NIS — Network Information System

By James Aspinwall — February 2026

Purpose

NIS tracks the people, companies, websites, and activities in your network. It answers: who should I contact, when, why, and what happened last time?

Use cases: remember birthdays, queue follow-ups (“call Giovanni about driver’s license”), store background before a meeting, log conference conversations, track prospect companies.


Architecture

Layer Module Role
Data Nis Functional module — CRUD, queries, cascade deletes, FTS5 search
Process NisServer GenServer — scheduled reminders, birthday alerts
Permission Permissions.Nis @permission 50_001, use AccessControlled
REST NisRest JSON param bridge (string keys in, maps out)
Web NisWeb HTML pages — entity views, note forms, search
MCP my_mcp_server.ex LLM tools mirroring REST, prefixed nis_

User Data Segregation

Each user gets their own SQLite database file: nis_james.db, nis_jimmy.db. Table names stay the same across all databases — isolation happens at the database level, not the table level.

The Nis module resolves the correct Sqler instance by username. Each user’s database is registered as {:nis_db, username} and created on first access via setup_database/1.

# Database resolution
defp db(username), do: {:nis_db, username}

# Usage — same table names, different databases
Nis.create_contact("james", %{name: "Giovanni", priority: 85})
Nis.list_contacts("james", %{tag: "friend"})

The REST and Web layers extract the username from the session. MCP tools receive it from the authenticated user context. The username never appears in URLs.


Data Schema

Conventions (apply to all tables):

Common Columns

Four entity tables (contacts, companies, websites, activities) share these tracking columns:

Column Type Description
tags text (JSON array) Searchable tags
last_contact_at integer When you last interacted
last_contact_note_id integer FK Note describing last interaction
next_contact_at integer When to reach out next
next_contact_note_id integer FK Note describing planned action

nis_contacts

Column Type Description
name text, required Full name
nickname text How to address them
company_id integer FK, nullable Links to nis_companies.id — primary company affiliation
background text Interests, context, personality
location text Where they live or where you met
address text Mailing / physical address
contact_medium text (JSON) {"email":"...","whatsapp":"...","phone":"...","telegram":"..."}
relation text How and when you met
birthday text "MM-DD" format — annual reminders only
priority integer, default 0 0 = minimal, 100 = closest
stage text, nullable Prospect pipeline stage: "first_contact", "research", "outreach", "follow_up", "demo", "closed_won", "closed_lost" (free-form, not enum)
contact_every text, nullable Auto-reschedule next_contact_at after interaction: "weekly", "biweekly", "monthly", "quarterly". When last_contact_at is updated, next_contact_at is set to last_contact_at + interval.
referred_by text, nullable Who or what introduced this contact — a name, “ElixirConf EU”, “cold LinkedIn outreach”. FTS5 searchable
interests text (JSON array) ["elixir developer","mcp expertise","relative:sister"]

Plus common columns.

Index: company_id — for “who works at this company?” queries.

nis_companies

Column Type Description
name text, required Company name
location text HQ or relevant office
purpose text Products, services
interest text Why you’re tracking them
priority integer, default 0 0 = minimal, 100 = highest — ranks companies independently of deal value
stage text, nullable Pipeline stage (same values as contacts — tracks the deal, not the person)
deal_value integer, nullable Expected deal value in cents
expected_close_at integer, nullable Expected close date, Unix seconds

Plus common columns.

nis_websites

Column Type Description
url text, required Site URL
purpose text Why you’re tracking this site
company_id integer FK, nullable Links to nis_companies.id — associates the website with a company
contact_id integer FK, nullable Links to nis_contacts.id — associates the website with a person (e.g. their LinkedIn, personal blog)

Plus common columns.

Index: company_id, contact_id — for “what websites are associated with this company/contact?” queries.

nis_activities

Column Type Description
title text, required “Call Giovanni about license”
description text Details
status text, default "open" "open", "done", "cancelled"
completed_at integer, nullable Unix seconds — set automatically when status changes to "done". Enables “what did I accomplish today/this week?” queries
recurrence text, nullable "daily", "weekly", "monthly", "yearly" — when done, auto-create next instance

Plus common columns.

Note: Activities no longer use inline entity_type/entity_id. Entity links are stored in nis_activity_links (see below), allowing one activity to link to multiple entities.

nis_activity_links

Join table — links an activity to one or more entities.

Column Type Description
activity_id integer FK, required FK to nis_activities.id
entity_type text, required "contact", "company", "website"
entity_id integer, required FK to the linked entity

Indexes: (activity_id), (entity_type, entity_id) composite.

No id or updated_at — this is a pure join table. Rows are created/deleted with the activity, never updated independently.

nis_notes

Polymorphic — attaches to any entity via entity_type + entity_id. Both fields are nullable — a note with no parent is a standalone note (ideas, general thoughts, things that don’t belong to any entity yet).

Column Type Description
entity_type text, nullable "contact", "company", "website", "activity", or nil for standalone
entity_id integer, nullable FK to the parent entity, or nil for standalone
content text Note body (FTS5 searchable)
reason text Why this note was saved
url text Reference URL for the note
channel text (JSON) How the interaction happened: {"mode":"whatsapp"}
tags text (JSON array) Searchable tags
is_next_contact integer (boolean) 1 = pinned as the next action for its parent

Indexes: (entity_type, entity_id) composite, is_next_contact, FTS5 virtual table on content.

nis_fts — Full-Text Search Index

A single FTS5 virtual table indexing searchable text across all entity types:

Column Type Description
entity_type text "contact", "company", "website", "activity", "note"
entity_id integer FK to the source entity
text text Indexed content (see below)

What gets indexed:

The Nis module maintains this index — inserts on create, updates on update, deletes on delete. Search queries hit one table and return results across all entity types.

This replaces the notes-only FTS5 index. /api/nis/search?q=Fintech now finds the company by name, contacts by background, and notes by content.

Delete Behavior

Handled in the Nis module, not SQL constraints.

“Set as Next Action” Behavior

When a note is saved with is_next_contact = 1, the Nis module clears is_next_contact on any existing next-action note for the same parent entity before setting the new one. One next action per entity at a time — the new note silently replaces the old one.


Web UI

Entity Page — /nis/contacts/:id

┌─────────────────────────────────────────────┐
│  👤 Giovanni Rossi                 [✏️] [🗑️] │
│  📍 Miami, FL  ·  Priority: 85              │
│  🏷️ friend, client prospect                 │
│  📅 Birthday: Mar 15                        │
│  📞 Last contact: 3 days ago                │
├─────────────────────────────────────────────┤
│  📌 Next Action                              │
│  ┌─────────────────────────────────────┐    │
│  │ Check if driver's license arrived   │    │
│  │ at his house. Call to confirm.      │    │
│  └─────────────────────────────────────┘    │
│  Due: Feb 25                                │
├─────────────────────────────────────────────┤
│  📝 Latest Note                     Feb 20  │
│  Called about address change for IRS.        │
│  He confirmed new address is on file.        │
│                                              │
│  📋 View all notes (7)                       │
├─────────────────────────────────────────────┤
│  ➕ New Note                                 │
│  ┌─────────────────────────────────────┐    │
│  │                                     │    │
│  └─────────────────────────────────────┘    │
│  Channel: [WhatsApp ▾]  ☐ Set as next action  │
│  Due: [____date picker____]   [Save Note]     │
└─────────────────────────────────────────────┘

Entity List — /nis/contacts

┌──────────────────────────────────────────────────────┐
│  👤 Contacts                    [🔍 Search] [➕ New] │
├──────────────────────────────────────────────────────┤
│  Giovanni Rossi      ⭐85  📅 3d ago   📌 Feb 25    │
│  Jimmy German        ⭐90  📅 1d ago   📌 tomorrow  │
│  Karen Aspinwall     ⭐70  📅 2w ago   —             │
├──────────────────────────────────────────────────────┤
│  Sort: [Priority ▾]  Filter: [All tags ▾]           │
└──────────────────────────────────────────────────────┘

Default sort: priority descending, then next_contact_at ascending. Search uses FTS5 across names, tags, and note content.

Notes Page — /nis/contacts/:id/notes

Chronological list. Each note shows content, channel, tags, timestamp. Inline editable. Delete per note.

Same three-page pattern applies to companies, websites, and activities.


REST API

All endpoints under /api/nis/. Updates require updated_at for optimistic locking. Username resolved from session — never in the URL.

CRUD (same pattern for each entity)

Method Path Action
GET /api/nis/contacts List (see filter params below)
GET /api/nis/contacts/:id Get with latest + next-contact notes
POST /api/nis/contacts Create
PUT /api/nis/contacts/:id Update
DELETE /api/nis/contacts/:id Cascade delete

Replace contacts with companies, websites, or activities.

List Filter Params

All list endpoints (GET /api/nis/contacts, etc.) accept optional query params:

Param Type Description
q string FTS5 search across entity fields
tag string Filter by tag — comma-separated for multiple (e.g. tag=idea,pricing requires all tags present)
sort string Sort field — priority, next_contact_at, last_contact_at, name, title (default: priority desc for contacts, next_contact_at asc for activities)
limit integer Max results to return (default: 100)
stage string Filter by pipeline stage (contacts and companies only)
status string Filter by status (activities only — open, done, cancelled)
priority_min integer Minimum priority (contacts and companies)
last_contact_before integer Unix seconds — contacts not spoken to since this date
next_contact_before integer Unix seconds — entities with next action due before this date
company_id integer Filter by company (contacts and websites)
contact_id integer Filter websites by contact (websites only)
entity_type + entity_id string + integer Filter activities by linked entity (activities only — queries nis_activity_links)
completed_after integer Unix seconds — activities completed after this date (activities only)
completed_before integer Unix seconds — activities completed before this date (activities only)

Activity Entity Links

Activity create and update endpoints accept an optional links array to associate the activity with contacts, companies, or websites. On update, the existing links are replaced entirely by the new set.

POST /api/nis/activities
{
  "title": "Send proposal to Sarah at Fintech Corp",
  "links": [
    {"entity_type": "contact", "entity_id": 42},
    {"entity_type": "company", "entity_id": 7}
  ]
}

On create, each link is inserted into nis_activity_links. On update, existing links for the activity are deleted and replaced with the provided set. If links is omitted on update, existing links are left unchanged.

Notes

Method Path Action
GET /api/nis/notes List notes (see note filter params below)
GET /api/nis/notes/:id Get a single note by ID
POST /api/nis/notes Create (optionally sets next-contact on parent)
PUT /api/nis/notes/:id Update
DELETE /api/nis/notes/:id Delete (clears orphaned parent references)

Note filter params:

Param Type Description
entity_type + entity_id string + integer Notes for a specific entity (both required together)
tag string Filter by tag — comma-separated for multiple (e.g. tag=idea,pricing requires all tags present)
limit integer Max results (default: 50)
since integer Unix seconds — notes created after this date

When neither entity_type/entity_id nor tag is provided, returns the most recent notes across all entities (up to limit).

Each note in the response includes parent_next_contact_at — the next_contact_at value from the parent entity (or null for standalone notes). This lets “show me all delegated notes” also show when each delegation is due without a second round trip per entity.

Queries

Method Path Action
GET /api/nis/search?q=driver+license FTS5 across all entity types and notes
GET /api/nis/due Entities with overdue or due-today next_contact_at
GET /api/nis/birthdays Contacts with birthdays in the next 7 days
GET /api/nis/pipeline Contacts and companies with a stage set, grouped by stage, with per-stage total_value summed from deal_value
GET /api/nis/company/:id/contacts Contacts linked to a company via company_id

Log Interaction

Convenience endpoint that atomically logs an interaction with a contact: creates a note, updates last_contact_at (triggering contact_every rescheduling), and optionally sets a next action.

Method Path Action
POST /api/nis/log Log interaction (see params below)

Params:

Param Type Required Description
contact_id integer yes The contact to log against
content string yes Note content
channel string (JSON) no {"mode":"email"}, {"mode":"whatsapp"}, etc.
tags string (JSON array) no Tags for the note
next_action string no Content for the next-action note (creates a second note with is_next_contact=1)
next_action_due integer no Unix seconds — sets next_contact_at on the contact (required if next_action provided)

Atomically: creates the interaction note, sets last_contact_at to now on the contact, and if next_action is provided, creates a next-action note with the due date.

Stats

Activity summary for a time window. No new data — computed from existing tables.

Method Path Action
GET /api/nis/stats Activity summary (see params below)

Params:

Param Type Description
since integer Unix seconds — start of time window (default: 7 days ago)
until integer Unix seconds — end of time window (default: now)

Response:

{
  "notes_created": 23,
  "activities_completed": 8,
  "contacts_added": 3,
  "overdue_count": 2,
  "stage_changes": [
    {"entity_type": "contact", "entity_id": 42, "name": "Sarah Chen", "from": "outreach", "to": "demo"},
    {"entity_type": "company", "entity_id": 7, "name": "Fintech Corp", "from": "outreach", "to": "demo"}
  ]
}

Note on stage_changes: Requires tracking previous stage values. The Nis module records stage transitions by writing a note with tags=["stage_change"] and reason="outreach → demo" on the entity whenever stage is updated. The stats endpoint queries these notes within the time window.

Bulk Stage Update

Update the stage on multiple entities in one call. Useful when closing a deal — move the company and all associated contacts to closed_won at once.

Method Path Action
POST /api/nis/bulk/stage Update stage on multiple entities

Params:

{
  "stage": "closed_won",
  "entities": [
    {"entity_type": "contact", "entity_id": 42},
    {"entity_type": "contact", "entity_id": 55},
    {"entity_type": "company", "entity_id": 7}
  ]
}

Each entity is updated individually. Returns per-entity success/error. Creates a stage_change note on each entity for stats tracking.

Import

Method Path Action
POST /api/nis/import/contacts Bulk create contacts from JSON array
POST /api/nis/import/companies Bulk create companies from JSON array

Accepts a JSON array of entity maps. Each item is validated and inserted individually — partial success returns created IDs and per-item errors. Designed for CSV-to-JSON conversion in the web UI or migration scripts.

MCP Tools

Mirror the REST API for LLM access. Tool names prefixed with nis_. All list tools accept the same filter params as their REST equivalents.

Entity CRUD: nis_contacts, nis_contact_get, nis_contact_create, nis_contact_update, nis_contact_delete, nis_companies, nis_company_get, nis_company_create, nis_company_update, nis_company_delete, nis_websites, nis_website_get, nis_website_create, nis_website_update, nis_website_delete, nis_activities, nis_activity_get, nis_activity_create, nis_activity_update, nis_activity_done.

Notes: nis_notes (with optional tag/entity filters), nis_note_get, nis_note_create, nis_note_update, nis_note_delete.

Queries: nis_search, nis_due, nis_birthdays, nis_pipeline, nis_company_contacts, nis_import_contacts.

Convenience: nis_log_interaction, nis_stats, nis_bulk_stage.

nis_activity_done marks an activity as done (sets completed_at), and if recurring, auto-creates the next instance.

nis_log_interaction atomically creates a note, updates last_contact_at, and optionally sets a next action — replacing 2–3 sequential tool calls with one.

nis_stats returns activity summary (notes created, activities completed, contacts added, overdue count, stage changes) for a time window.

nis_bulk_stage updates the stage on multiple entities in one call.


Implementation Plan

Phase 1 — Data Layer

# File What
1 lib/nis.ex Functional module: setup_database/1 (accepts username, starts a Sqler for nis_{username}, creates 6 tables + nis_activity_links join table + nis_fts FTS5 index + regular indexes). db/1 helper resolves {:nis_db, username}. CRUD for all tables with FTS index maintenance on every create/update/delete. Cascade delete per entity type. Follow TaskManager pattern.
2 lib/mcp/application.ex No Sqler at startup — databases are created per user on first access.
3 IEx Test: Nis.setup_database("james"), create a company, create a contact with company_id, create an activity linked to both, verify FTS search finds the company by name and the contact by background.

Phase 2 — Permission & REST

# File What
4 lib/permissions/nis.ex @permission 50_001, @module_name "NIS", use AccessControlled.
5 lib/nis_rest.ex JSON param bridge. Receives username from caller, passes to Nis. Includes import endpoints (bulk create with per-item error reporting).
6 lib/my_mcp_server_router.ex REST routes under /api/nis/. Extract username from session. Pipeline, company-contacts, and import routes.

Phase 3 — Web UI

# File What
7 lib/nis_web.ex Entity pages, list views, note forms. Contact page shows company link. Company page shows linked contacts. Activity form allows linking multiple entities. Pipeline view groups contacts/companies by stage.
8 lib/my_mcp_server_router.ex Web routes: /nis/contacts, /nis/contacts/:id, /nis/pipeline, etc.

Phase 4 — MCP & GenServer

# File What
9 lib/my_mcp_server.ex MCP tool definitions, routed through Permissions.Nis. Username from MCP session context. Includes nis_pipeline, nis_company_contacts, nis_import_contacts.
10 lib/nis_server.ex GenServer for scheduled reminders — birthday alerts, overdue follow-ups via Pushover. Auto-creates next instance for recurring activities when marked done. Iterates known user databases.