Two doors, and no others
Clients never memorise this API. They call discover to read the schema and the exact signature of whatever they need, then call execute to run it. Every operation — every create, every report, every bulk write — arrives through the same two entry points.
- No REST surface. The only non-MCP route is
GET /health, which returns liveness and exposes no CRM data. - The catalog is the documentation. Everything on this page is generated from the same registry the server answers
discoverfrom, so the two cannot drift apart. - Errors teach. A wrong field name comes back with the right one; a wrong enum comes back with the allowed values; a wrong operation comes back with the closest matches.
Running the server
stdio
local# install, optionally seed demo data, run
npm install
npm run seed
npm run stdio
Point any MCP client at the entry script:
{
"mcpServers": {
"crm": {
"command": "node",
"args": ["/path/to/headlesscrm/src/stdio.js"]
}
}
}
Streamable HTTP
networkedPORT=8765 npm run http
# MCP endpoint: POST http://127.0.0.1:8765/mcp
Sessions are negotiated on initialize and carried in the mcp-session-id header. Set CRM_API_TOKEN to require Authorization: Bearer <token>.
| Variable | Default | Meaning |
|---|---|---|
| DATABASE_URL | unset | PostgreSQL connection string. Set it and the CRM runs on Postgres; leave it unset for local SQLite. |
| CRM_DB_PATH | ./data/crm.db | SQLite file, used only without DATABASE_URL |
| CRM_API_TOKEN | unset | Bearer token required on /mcp |
| CRM_ACTOR | mcp | Default audit actor |
| PORT / HOST | 8765 / 127.0.0.1 | HTTP bind address (0.0.0.0 on Railway) |
| PLINTH_API_KEY | unset | Enables outbound email |
| PLINTH_API_URL | unset | Plinth app base URL |
| PLINTH_FROM_EMAIL | unset | Default From address |
discover
Returns the live catalog: entities, fields, enum values, relations, the query language, and the exact parameter signature of any operation. Call it before guessing a name — it is cheaper than a failed write.
discover(…)
readOnlyHint| Parameter | Type | Notes |
|---|---|---|
| scope | enum | Which view to return. Defaults to overview, or is inferred from the other arguments. |
| entity | string | Entity name, for scope: "entity" or to narrow the operation catalog. |
| operation | string | Operation name, for scope: "operation". |
| category | enum | crud · workflow · reporting · meta |
| query | string | Free-text match over operation names, summaries, entities and field names. |
| name | string | With scope: "enums", return a single enumeration. |
| verbose | boolean | With scope: "operations", include full signatures instead of summaries. |
The nine scopes
| scope | Returns | Reach for it when |
|---|---|---|
| overview | Entity list with live record counts, calling conventions, operation groups | First contact with the server |
| entities | All 23 entities: key fields, relations, record counts | Deciding where data belongs |
| entity | Every field of one entity: type, enum, references, defaults, flags | Before any create or update |
| operations | The catalog, filtered by entity, category or query | Looking for the right call |
| operation | One signature in full, plus a runnable example | Immediately before executing |
| enums | All 9 enumerations and their allowed values | Filling a status, stage or source field |
| filters | Filter operators, sort, pagination and projection rules | Building a query |
| examples | Twelve ready-to-run calls for common CRM jobs | Learning the shape by example |
| search | Fuzzy matches across operations, entities and field names | You know the word, not the name |
Ask what a field expects
discover{
"scope": "entity",
"entity": "history"
}
Find the call by concept
discover{
"scope": "search",
"query": "follow up"
}
execute
Runs any of the 125 operations. operation names the call, params carries its arguments, and four optional modifiers control identity, safety and retries.
execute(…)
destructiveHint| Parameter | Type | Notes |
|---|---|---|
| operation required | string | e.g. contact.profile, contact.log, contacts.create, report.pipeline. |
| params | object | Arguments for that operation. Unknown keys are rejected with the accepted list. |
| actor | string | Written to created_by / updated_by and every audit entry. |
| dry_run | boolean | Runs inside a transaction that is rolled back. You see the result; nothing persists. |
| idempotency_key | string | A repeat call with the same key returns the first result instead of acting twice. |
| request_id | string | Correlation id, echoed back and stored on the audit entry. |
Success envelope
every call{
"ok": true,
"operation": "contacts.create",
"request_id": "req_9f2c1a",
"took_ms": 3,
"result": { "record": { "id": "con_…", … } }
}
Error envelope
isError{
"ok": false,
"operation": "contacts.update",
"error": {
"code": "unknown_field",
"message": "contacts: unknown field 'titel'. Did you mean: title?",
"details": { "entity": "contacts", "field": "titel" }
},
"hint": "Call discover({ scope: 'entity', entity: 'contacts' })…"
}
Error codes
| Code | Raised when |
|---|---|
| unknown_operation | No such operation. details.suggestions lists the closest names. |
| unknown_param / missing_param | The parameter set does not match the signature. |
| unknown_field | A field name that is not on the entity; the message proposes the nearest one. |
| missing_required | A required field or required custom field was omitted on create. |
| invalid_enum | Value outside the enumeration. details.allowed carries every valid value. |
| readonly_field | An attempt to write a server-maintained column such as created_at. |
| reference_not_found | A *_id pointing at a record that does not exist or is deleted. |
| duplicate_value | A unique column collision; the conflicting record id is included. |
| version_conflict | expected_version did not match the stored version. |
| has_dependents | Delete refused because other records reference this one. |
| already_converted | A lead that has already been through lead.convert. |
| idempotency_conflict | An idempotency key reused for a different operation. |
| not_found | No live record with that id. |
All 125 operations
Each table gets the same plain CRUD; the contact, company, report, email and meta calls sit on top. Names in teal only read; names in vermilion can write. Open a row for its signature.
- Per table —
listgetcreateupdatedeleterestorecountaggregatedescribe, plusupserton contacts and companies. - In bulk —
bulk_getbulk_createbulk_updatebulk_deletebulk_restoreon every table, andbulk.executeto run several different operations in one atomic round trip. - contact.* is where the work happens:
profilefor the whole customer in one call,logto record what happened,historyto page back through it,find,set_stage,due,complete_follow_up,merge. - Reports answer the questions a small team actually asks — what is in the pipeline, how much is happening, who has gone quiet, who is carrying what.
Five tables, 134 columns
A contact is the key record; history is everything that ever happened with them, one row per event. companies is where they work and users is who on your side is dealing with them. Everything belongs to an organisation, and nothing is visible across that boundary. Every row carries id, created_at, updated_at, created_by, updated_by, deleted_at and version. Ids are prefixed per table, so an id always announces its own type.
accounts
Reading data
The same vocabulary works on list, count, aggregate, bulk_update and bulk_delete. Values are coerced to the field's type and enums are validated, so a typo fails loudly rather than matching nothing.
filters
three forms, freely mixed{
"stage": "proposal", // equality
"value": { "gte": 10000, "lt": 250000 }, // operators
"owner_id": ["usr_a", "usr_b"], // list = IN
"next_step_date": { "between": ["2026-09-01", "2026-09-30"] },
"$or": [
{ "source": "referral" },
{ "value": { "gte": 100000 } }
]
}
Operators
15gt · gte · lt · lte
in · not_in · between
starts_with · ends_with
is_null · not_null
- search
- Substring match across an entity's searchable fields.
- sort
"-amount,last_name"— a leading minus sorts descending.- limit / offset
- Up to 500 per page; responses carry
pagination.next_offset. - fields
- Return only the columns you name;
idalways comes back. - include
["account_id"]expands a parent,["contacts"]embeds a child collection.
A filtered, projected, expanded page
execute{
"operation": "contacts.list",
"params": {
"filters": { "stage": "proposal", "value": { "gte": 10000 } },
"sort": "-value",
"fields": ["full_name", "value", "next_step_date"],
"include": ["company_id"],
"limit": 25
}
}
Grouped aggregation
execute{
"operation": "contacts.aggregate",
"params": {
"group_by": ["stage"],
"metrics": [
{ "fn": "sum", "field": "value", "as": "pipeline" },
{ "fn": "count", "field": "id", "as": "contacts" }
],
"sort": "-pipeline"
}
}
What the engine guarantees
History writes itself
- Adding a contact opens their history with a
createdevent. - Editing a contact appends a
field_changeevent carrying the before and after of every field that moved. - Moving a stage gets its own
stage_changeevent, so you can see how a relationship progressed without reading every note. - Conversations — calls, emails, meetings, messages — update
last_contacted_at. Notes and edits deliberately do not. - Emails sent through the CRM land in the timeline with the provider's response attached.
Guarantees
- Deletes are recoverable.
deletemarks the row andrestorebrings it back; deleting something others point at is refused unless you passcascadeorforce. - Concurrent edits are caught. Pass
expected_versionto get aversion_conflictinstead of a lost update. - Duplicates merge cleanly.
contact.mergemoves all history to the survivor, fills only the fields it was missing, and records the merge. - Retries are safe.
idempotency_keyreplays the first result;dry_runexecutes in a transaction that is rolled back. - One engine, two databases. The same operations run on PostgreSQL (set
DATABASE_URL) and on SQLite locally.
Built to stay fast
measured on 100,000 events for one contactHistory is the table that grows, so it is the one that is indexed for the way it is read: partial indexes matching the exact ordering — (contact_id, COALESCE(occurred_at, created_at) DESC, id DESC) WHERE deleted_at IS NULL — and cursor paging instead of offsets, so page 200 costs what page 1 costs.
| Call | PostgreSQL 16 |
|---|---|
| One page from the index | 0.4 ms |
contact.history, any page | 1–2 ms |
contact.profile — contact, company, owner, summary, first page | ~15 ms |
| Paging 10,000 events deep (20 × 500) | ~56 ms |
Common jobs, end to end
The same twelve calls discover({ scope: "examples" }) returns, ready to paste into execute.
42 controlled vocabularies
Enum fields accept these values, case-insensitively, and store the canonical spelling. A rejected value comes back with the full list attached.