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):
-
id— integer PK, Sqler millisecond timestamp -
updated_at— integer, optimistic locking -
Time fields (
*_at) — Unix seconds -
JSON fields (
tags,interests,contact_medium,channel) — stored as text, encoded/decoded at module boundary - One database per user — tables are identical across databases
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:
-
Contacts:
name,nickname,background,relation,referred_by,tags -
Companies:
name,purpose,interest,tags -
Websites:
url,purpose,tags -
Activities:
title,description,tags -
Notes:
content,reason,tags
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
-
Delete contact → delete all its notes, remove from
nis_activity_links, setcontact_id = nilon linked websites, delete FTS entries, then delete the contact -
Delete company → set
company_id = nilon linked contacts and websites, delete all its notes, remove fromnis_activity_links, delete FTS entries, then delete the company -
Delete website → delete all its notes, remove from
nis_activity_links, delete FTS entries, then delete the website -
Delete activity → delete all its notes, delete its
nis_activity_linksrows, delete FTS entries, then delete the activity -
Delete note → clear any
last_contact_note_idornext_contact_note_idreferencing it on the parent, delete FTS entry
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] │
└─────────────────────────────────────────────┘
- Next Action textarea is directly editable, saves on blur
- Delete confirms, cascade-deletes notes, redirects to list
-
“Set as next action” checkbox reveals the date picker — the selected date sets
next_contact_aton the parent entity
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. |