Headless CRM Reference

Headless CRM

v1.0.0 · MCP

A CRM built around one thing: a customer contact, and the full history of everything that has ever happened with them. Four tables, no interface, no REST API — reachable only through two MCP tools: discover to learn the API, execute to do everything else.

2tools
125operations
6tables
134columns
9enumerations
18filter operators
01 — The rule

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 discover from, 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.
02 — Connect

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

networked
PORT=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>.

VariableDefaultMeaning
DATABASE_URLunsetPostgreSQL connection string. Set it and the CRM runs on Postgres; leave it unset for local SQLite.
CRM_DB_PATH./data/crm.dbSQLite file, used only without DATABASE_URL
CRM_API_TOKENunsetBearer token required on /mcp
CRM_ACTORmcpDefault audit actor
PORT / HOST8765 / 127.0.0.1HTTP bind address (0.0.0.0 on Railway)
PLINTH_API_KEYunsetEnables outbound email
PLINTH_API_URLunsetPlinth app base URL
PLINTH_FROM_EMAILunsetDefault From address
03 — Tool one · read only

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
ParameterTypeNotes
scopeenumWhich view to return. Defaults to overview, or is inferred from the other arguments.
entitystringEntity name, for scope: "entity" or to narrow the operation catalog.
operationstringOperation name, for scope: "operation".
categoryenumcrud · workflow · reporting · meta
querystringFree-text match over operation names, summaries, entities and field names.
namestringWith scope: "enums", return a single enumeration.
verbosebooleanWith scope: "operations", include full signatures instead of summaries.

The nine scopes

scopeReturnsReach for it when
overviewEntity list with live record counts, calling conventions, operation groupsFirst contact with the server
entitiesAll 23 entities: key fields, relations, record countsDeciding where data belongs
entityEvery field of one entity: type, enum, references, defaults, flagsBefore any create or update
operationsThe catalog, filtered by entity, category or queryLooking for the right call
operationOne signature in full, plus a runnable exampleImmediately before executing
enumsAll 9 enumerations and their allowed valuesFilling a status, stage or source field
filtersFilter operators, sort, pagination and projection rulesBuilding a query
examplesTwelve ready-to-run calls for common CRM jobsLearning the shape by example
searchFuzzy matches across operations, entities and field namesYou 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"
}
04 — Tool two · reads and writes

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
ParameterTypeNotes
operation requiredstringe.g. contact.profile, contact.log, contacts.create, report.pipeline.
paramsobjectArguments for that operation. Unknown keys are rejected with the accepted list.
actorstringWritten to created_by / updated_by and every audit entry.
dry_runbooleanRuns inside a transaction that is rolled back. You see the result; nothing persists.
idempotency_keystringA repeat call with the same key returns the first result instead of acting twice.
request_idstringCorrelation 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

CodeRaised when
unknown_operationNo such operation. details.suggestions lists the closest names.
unknown_param / missing_paramThe parameter set does not match the signature.
unknown_fieldA field name that is not on the entity; the message proposes the nearest one.
missing_requiredA required field or required custom field was omitted on create.
invalid_enumValue outside the enumeration. details.allowed carries every valid value.
readonly_fieldAn attempt to write a server-maintained column such as created_at.
reference_not_foundA *_id pointing at a record that does not exist or is deleted.
duplicate_valueA unique column collision; the conflicting record id is included.
version_conflictexpected_version did not match the stored version.
has_dependentsDelete refused because other records reference this one.
already_convertedA lead that has already been through lead.convert.
idempotency_conflictAn idempotency key reused for a different operation.
not_foundNo live record with that id.
05 — Operation catalog

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 tablelist get create update delete restore count aggregate describe, plus upsert on contacts and companies.
  • In bulkbulk_get bulk_create bulk_update bulk_delete bulk_restore on every table, and bulk.execute to run several different operations in one atomic round trip.
  • contact.* is where the work happens: profile for the whole customer in one call, log to record what happened, history to 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.
06 — Data model

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

07 — Query language

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

15
eq · ne
gt · gte · lt · lte
in · not_in · between
contains · not_contains
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; id always 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"
  }
}
08 — Semantics

What the engine guarantees

History writes itself

  • Adding a contact opens their history with a created event.
  • Editing a contact appends a field_change event carrying the before and after of every field that moved.
  • Moving a stage gets its own stage_change event, 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. delete marks the row and restore brings it back; deleting something others point at is refused unless you pass cascade or force.
  • Concurrent edits are caught. Pass expected_version to get a version_conflict instead of a lost update.
  • Duplicates merge cleanly. contact.merge moves all history to the survivor, fills only the fields it was missing, and records the merge.
  • Retries are safe. idempotency_key replays the first result; dry_run executes 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 contact

History 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.

CallPostgreSQL 16
One page from the index0.4 ms
contact.history, any page1–2 ms
contact.profile — contact, company, owner, summary, first page~15 ms
Paging 10,000 events deep (20 × 500)~56 ms
09 — Recipes

Common jobs, end to end

The same twelve calls discover({ scope: "examples" }) returns, ready to paste into execute.

10 — Enumerations

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.