Working with contacts — reference
The CRM service owns the contact rolodex behind your site: leads captured from forms, plus anything you import or sync from another system. This article covers how to create and reconcile contacts, search them, move them through their lifecycle, and read back what happened — both over the internal HTTP API and through the crm_* MCP tools an agent calls.
A contact carries lifecycle state (stage, status), an owner, tags, UTM/source attribution, two AI scores (score_fit, score_intent), and an append-only activity timeline. email is the natural key used to reconcile rows on upsert, but it is not a hard uniqueness constraint: a plain POST /contacts (or crm_create_contact) never deduplicates, so two creates with the same email produce two rows. Companies and deals also exist but are deliberately thin — see the last section.
[!NOTE] The CRM service runs on loopback only and every route requires an internal token issued by the gateway. You don't call it directly from a browser or a public site. In practice you reach it one of two ways: an agent calls the
crm_*MCP tools, or an internal service (e.g. the CMS form-submission forwarder) calls the HTTP routes. This article shows both. The two surfaces overlap but are not identical: the MCP tools expose a curated subset of the HTTP fields, and a few HTTP-only fields (company_name,classify,external_ids,custom,message, the extra UTM fields) have no MCP equivalent. Where it matters, each section says which path a field belongs to.
The contact schema, and how to discover it
The stage, status, and field set are not something you should hard-code from this doc — they can drift. The service exposes its own machine-readable schema:
GET /contacts/schema
It returns a stable JSON document describing the object: its primary key, natural keys, enum values, every field with its type, an advertised filter_ops list per field, and an endpoint catalogue. This is the Attio/Twenty pattern — schema is part of the API surface, so an agent can cache it once rather than reading source.
{
"object": "contact",
"primary_key": "id",
"natural_keys": ["email", "external_ids"],
"enums": {
"stage": ["lead","mql","sql","customer","disqualified"],
"status": ["new","working","contacted","qualified","unqualified","nurture","converted"],
"activity_kind": ["stage_change","status_change", "..."],
"actor_kind": ["user","system","agent"]
},
"fields": [
{ "name": "email", "type": "string", "required": true,
"filter_ops": ["eq","ne","in","ilike","is_null","not_null"] },
{ "name": "score_fit", "type": "integer", "min": 0, "max": 100,
"filter_ops": ["eq","ne","gt","gte","lt","lte","is_null","not_null"] },
{ "name": "tags", "type": "string[]",
"filter_ops": ["contains","not_contains","is_empty","is_not_empty"] }
],
"endpoints": { "search": "POST /contacts/search", "...": "..." }
}
[!WARNING] The schema's
filter_opsis advisory and can under-report what the runtime actually accepts. The runtime validator (validateFilter) checks against a separateOPS_FOR_TYPEtable keyed by field type, and that table is broader than some fields advertise:
- boolean fields (
do_not_contact,email_opted_out) advertise only["eq"]but the runtime also acceptsne.stage/statusadvertise["eq","ne","in"]but the runtime enum type also acceptsnin.source_form_idadvertises["eq","is_null","not_null"]but the runtimeuuid_or_nulltype also acceptsne,in,nin.So if you cache
filter_opsand refuse to send anything outside it, you're being stricter than the service. And when a filter is rejected, the400message lists the allowed ops fromOPS_FOR_TYPE— which can name ops the schema'sfilter_opsdidn't. The two surfaces derive from different tables; treat the schema as a starting hint, not a contract.
There is no MCP tool that wraps /contacts/schema — it's an HTTP endpoint for internal/agent introspection. The crm_search_contacts tool description embeds an operator list inline instead.
Creating contacts
Create
crm_create_contact (HTTP: POST /contacts, returns 201) creates a new lead. email is required and validated with a basic shape check — the regex is ^[^\s@]+@[^\s@]+\.[^\s@]+$, i.e. something@something.tld — so an obvious typo fails fast with 400 email is not a valid email address rather than hitting Postgres. Everything else is optional.
// crm_create_contact
{
"email": "ada@example.com",
"first_name": "Ada",
"last_name": "Lovelace",
"source": "website",
"tags": ["webinar", "inbound"],
"utm_source": "linkedin",
"utm_campaign": "spring-launch"
}
Defaults applied on insert: stage → lead, status → new, tags → [], custom → {}, external_ids → {}.
A few things worth knowing:
- Owner assignment depends on who's calling. A human/agent create assigns the caller (or an explicit
owner_user_id) as owner. A service caller — e.g. the CMS forwarding a public form submission, wherereq.rolestarts withservice:— is forced to leave the lead unowned so sales can pick it up from the unowned queue. You can't override that as a service caller. score_fit/score_intentare silently dropped on create — including via the MCP tool. Thecrm_create_contacttool advertises them in its input schema (and its description even says "you can set them manually"), butcreateContactfilters the payload throughCREATE_ALLOWED_FIELDS, which does not include either score. They are only ever written by the classification worker. Treat any value you pass as discarded.company_nameis a convenience — HTTP only. OverPOST /contacts, pass acompany_namestring and it's resolved to acrm.companiesrow by name match within the tenant (creating one if none exists) and linked viacompany_id. Empty/blank clears the link. Thecrm_create_contactMCP tool does not exposecompany_name, so an agent using the tool can't trigger this — it has to pass an already-resolvedcompany_id. (Note the match is a SQLilikeon the name, so%/_in the string are treated as wildcards.)- Auto-classify — HTTP only, and only on this route. Pass
classify: truetoPOST /contactsand acrm.classify-contactjob is enqueued after insert. The CMS form path uses this. It's a best-effort enqueue — if it fails, the contact is still created (the error is logged and swallowed). Two caveats:classifyis not honoured byPOST /contacts/upsertor/batch(an import that sets it does nothing), and the enqueue is a no-op whenSERVICE_JOBS_URLis unset, so in an unconfigured environment leads are created but never queued.
Upsert (idempotent)
POST /contacts/upsert (returns 200) is the call to use for imports and sync workers — "create if new, otherwise reconcile". It looks up an existing row, then PATCHes or INSERTs:
- If the body has both
external_id_systemandexternal_id_value, it matches onexternal_ids->>system = value. - Otherwise it matches on
emailvia a SQLilike(so the match is case-insensitive, per tenant — but%/_in the email string would be interpreted as wildcards). - If neither is present, it's a
400.
When external_id_system + external_id_value are both present and an existing row is found by either path (external-id match, or the lookup fell through to email), the supplied id is merged into external_ids (existing keys preserved, the supplied system overwritten), so a contact can carry { hubspot: "...", salesforce: "..." } simultaneously. The merge path does not emit a field_update activity — the sync worker is expected to record its own sync_in if it wants one.
// POST /contacts/upsert — reconcile by HubSpot id
{
"external_id_system": "hubspot",
"external_id_value": "100234",
"email": "ada@example.com",
"first_name": "Ada",
"company_name": "Analytical Engines Ltd"
}
[!NOTE] There is no MCP tool for
upsertorbatch— they're HTTP-only, aimed at import/sync code rather than conversational agents.crm_create_contactalways inserts.
Batch
POST /contacts/batch (returns 200) runs up to 200 upserts in one call. Each item follows upsert semantics (match by email or external id). The body is { items: [...] }; over 200 items returns a 400. Results come back per item, in order, so a bad row doesn't sink the batch:
// Response
{ "data": [
{ "ok": true, "data": { "id": "…", "email": "ada@example.com" } },
{ "ok": false, "error": { "message": "email is not a valid email address", "status": 400 },
"ref": "broken-email" }
] }
ref is the item's email (else external_id_value, else null), so you can map a failure back to its input — here the caller sent "email": "broken-email", which failed validation. The per-item status is the thrown error's status (400 for a bad email, otherwise 500).
Searching
There are two read paths.
Quick list — crm_list_contacts (HTTP GET /contacts) — for one or zero simple filters: a free-text search (substring ilike on email/first_name/last_name), plus optional stage, status, owner_user_id. Default page size 50. A limit here is clamped with Math.min(limit, 200), so the MCP tool's advertised max of 500 is silently capped to 200 rather than rejected. Results are newest-first and embed the linked company name.
Structured search — crm_search_contacts (HTTP POST /contacts/search) — for anything with more than one condition or any operator other than equality.
The search DSL
The body is a list of { field, op, value } predicates plus optional sort, limit, offset:
// crm_search_contacts — hot inbound leads, best fit first
{
"filters": [
{ "field": "stage", "op": "eq", "value": "lead" },
{ "field": "score_fit", "op": "gte", "value": 70 },
{ "field": "tags", "op": "contains", "value": ["inbound"] },
{ "field": "created_at", "op": "gt", "value": "2026-06-01T00:00:00Z" }
],
"sort": { "field": "score_fit", "dir": "desc" },
"limit": 50,
"offset": 0
}
Filters are ANDed. Max 20 filter clauses; sort defaults to created_at desc. Unknown fields, unknown sort fields, and operators a field doesn't support all return a 400 naming the allowed ops.
[!WARNING] On
crm_search_contacts,limitis validated by Zod asmin(1).max(200)at the route — but the MCP tool advertisesmax(500). Unlike the quick-list route, the search route does not clamp: alimitover 200 (which the MCP tool will happily accept) failssafeParseand returns400 invalid search body. Keep searchlimitat 200 or below regardless of what the tool schema allows.
Operators
| Op | Meaning | Value | Field types |
|---|---|---|---|
eq / ne |
equals / not-equals | scalar | string, uuid, enum, integer, boolean |
in / nin |
in / not in a set | array | string, uuid, enum |
gt / gte / lt / lte |
numeric & date comparison | scalar | integer, timestamp |
ilike |
case-insensitive substring (wrapped in %…%) |
string | string fields |
is_null / not_null |
presence check | (omit) | most fields |
contains / not_contains |
array membership | string or array | tags (string[]) |
is_empty / is_not_empty |
literal '{}' comparison |
(omit) | tags |
[!WARNING]
inandninrequire an array value — a scalar there is a400. Foris_null,not_null,is_empty,is_not_empty, omitvalue(any value you pass is tolerated but ignored).Timestamps are range-only:
created_at,updated_at,last_activity_at,stage_changed_atdo not supporteq/ne— usegt/gte/lt/lte(andis_null/not_nullwhere advertised).
is_empty/is_not_emptyare implemented as literal string comparisons against the array column:= '{}'and!= '{}'.is_emptyworks for an emptytext[].is_not_emptyis!= '{}'against the array — a string-literal inequality, not a true "has ≥1 element" cardinality check, so don't rely on it as one. (The MCP tool also describes these as "empty string or empty array check," which is looser than what the code does.)
Filterable fields
Not every column is filterable. The filterable set (and the type that governs which ops apply):
| Field | Type | Notes |
|---|---|---|
email, first_name, last_name, phone |
string | |
source |
string | lead source label |
utm_source / utm_medium / utm_campaign |
string | |
ip_country |
string | eq / ne / in |
company_id |
uuid | |
owner_user_id, source_form_id |
uuid (nullable) | use is_null to find unassigned / no-form leads |
stage, status |
enum | |
score_fit, score_intent |
integer 0–100 | range-filterable |
tags |
string[] | contains / is_empty family only |
do_not_contact, email_opted_out |
boolean | |
created_at, updated_at, last_activity_at, stage_changed_at |
timestamp | ISO 8601, range-only |
custom (tenant-defined extension fields), message, utm_term, utm_content, referrer_url, and landing_url exist on the contact but are not filterable. score_* are settable only by the classifier, never by clients.
Lifecycle: stage, status, owner, tags, notes
These mutations have dedicated routes/tools rather than going through PATCH, because each one writes a richer row to the activity timeline. Do not try to set stage/status/owner_user_id/tags via crm_update_contact (PATCH /contacts/:id) — those keys aren't in the patch allow-list and will be silently dropped.
Stage — crm_set_contact_stage (POST /contacts/:id/stage)
Lifecycle stage. Values: lead → mql → sql → customer, plus disqualified for out-of-funnel. Body { stage, reason? }. Emits a stage_change activity recording from/to/reason, and stamps stage_changed_at. Setting the stage it's already in is a no-op (returns the row, no activity). A change into customer or disqualified also cancels any active nurture enrollments (best-effort side effect) — but because the no-op returns before that block, re-setting a contact that is already customer/disqualified to the same stage does not cancel nurture. The cancel only fires on an actual stage change.
Status — crm_set_contact_status (POST /contacts/:id/status)
Disposition within the stage. Values: new → working → contacted → qualified / unqualified → nurture / converted. Body { status, reason? }. Emits status_change. When status is unqualified and you pass a reason, it's persisted on the contact column disqualified_reason (kept in sync with the activity row), and active nurture is cancelled. Other transitions intentionally leave nurture running — a lead can move to working while a drip continues.
[!NOTE]
disqualified_reasonis populated by status =unqualified, not by the lifecycle stage =disqualified. The column name and the stage name are similar but unrelated — moving a contact to stagedisqualifieddoes not writedisqualified_reason.
Owner — crm_set_contact_owner (POST /contacts/:id/owner)
Body { owner_user_id } (a workspace member's uuid, or null to unassign). Emits owner_change. No-op if the new owner strictly equals the current one. The value is normalised with || null before writing, so an empty string is stored as null — but it won't match null by strict equality, so passing "" against an already-unowned contact is not a no-op and emits an owner_change from null to null.
Tags — crm_update_contact_tags (POST /contacts/:id/tags)
Body { add?: [], remove?: [] } — applied atomically against the current tag set. Adds that already exist and removes that aren't present are skipped. Emits a tag_add and/or tag_remove activity for whatever actually changed. At least one of add/remove must be non-empty.
Notes — crm_add_contact_note (POST /contacts/:id/notes, returns 201)
Body { body } — a non-empty string, markdown supported, trimmed to 4000 characters. Appends a note activity. Use it for call summaries and freeform context; notes are permanent and visible to all workspace members. There is no edit or delete for activities — the timeline is append-only.
Plain field edits — crm_update_contact (PATCH /contacts/:id)
The HTTP PATCH allow-list covers scalar fields that don't warrant a dedicated transition: email, name, phone, company_id (or company_name), message, source, source_form_id, all UTM fields, referrer_url/landing_url, ip_country, do_not_contact, email_opted_out, external_ids, custom, disqualified_reason. Pass only what you're changing. It emits one field_update activity listing the changed keys. Changing email re-runs the validity check. Setting email_opted_out or do_not_contact to true cancels in-flight nurture (flipping back to false does not re-enroll).
[!WARNING] The
crm_update_contactMCP tool exposes a narrower set than the HTTP route: onlyfirst_name,last_name,phone,company_id,source,score_fit,score_intent,utm_source/utm_medium/utm_campaign,do_not_contact,email_opted_out. Socompany_name,external_ids,custom,message,disqualified_reason,source_form_id,referrer_url,landing_url,ip_country,utm_term,utm_contentare HTTP-only on update — an agent using the tool can't patch them. (And as on create,score_fit/score_intentare advertised on the tool but dropped by the HTTP allow-list, so updating them does nothing either.) Anything in this section beyond the tool's field list applies to the HTTPPATCHpath only.
Activities and classifications (reading back)
Every mutation above lands on the contact's timeline. Read it newest-first with crm_get_contact_activities (GET /contacts/:id/activities, default 50 / max 200). Each row has a kind, a body, and an actor_kind of user, system, or agent.
Activity kinds: stage_change, status_change, owner_change, tag_add, tag_remove, note, classification, form_submission, sync_in, sync_out, field_update, plus three nurturing kinds emitted by the newsletters service (nurture_match_result, nurture_send, nurture_cancel).
[!TIP]
actor_kindtells you who did something. The default is computed inemitActivity: anyservice:*caller (form forwarders, the newsletters nurture writes, etc.) getssystemunless it explicitly passes a kind; a human action through the dashboard getsuser. The classifier is the one path that explicitly passesagent. Filter on it to separate machine activity from human activity.
Manual classification
Lead scoring is AI-driven, but you can trigger it on demand. POST /contacts/:id/classify enqueues the crm.classify-contact job. It is mostly fire-and-forget, with two edges to know:
- It first loads the contact and returns
404if the id doesn't exist. - On success it returns
{ job_id }(HTTP202). WhenSERVICE_JOBS_URLis unset, the enqueue is a no-op: you still get202butjob_idisnulland nothing was actually queued.
The pipeline loads the contact plus its last 20 activities, pulls the tenant's grounding docs by kind (profile, industry, product, icp, usp) from the context service, then calls the LLM (model claude-sonnet-4-6 by default, temperature 0.2) for strict JSON. It writes a crm.contact_classifications audit row, denormalises score_fit/score_intent onto the contact, and emits a classification activity (actor_kind: agent).
score_fitis leftnullwhenever ICP/company context is unavailable — both the per-contact "no grounding docs" case and the whole-service case whereSERVICE_CONTEXT_URLis unset (then every contact comes back withscore_fit = nulland explanatory reasoning).- Out-of-range or wrong-typed values from the model become
nullrather than throwing, and only numeric scores are denormalised onto the contact. - Read the full audit trail with
GET /contacts/:id/classifications(default 20 / max 100, newest first — a different default page size from activities). - The classify endpoint is the manual counterpart to the
classify: trueflag on create — both end up in the same queue. There is nocrm_*MCP tool for classify; it's triggered from the dashboard's "Classify" button or by calling the route.
Companies and deals: basic for now
Both objects exist and are tenant-scoped, but only the bare minimum is implemented.
Companies — crm_list_companies / crm_create_company (HTTP GET/POST /companies). Fields: name (required), domain (citext, unique per tenant — use it to dedupe on sync), industry, size. Contacts link to a company via company_id. Over HTTP, the contact create/update path can auto-create a company from a company_name string — but company_name is not exposed on crm_create_contact or crm_update_contact, so an agent using those tools can't trigger auto-creation. There's no company GET-by-id, update, or delete tool.
Deals — crm_list_deals / crm_create_deal (HTTP GET/POST /deals). Fields: name (required), amount, currency (ISO 4217, default EUR; the MCP tool enforces a 3-character code), stage (freeform label — not an enum), contact_id, company_id, expected_close (YYYY-MM-DD). That's the whole surface: GET/POST only, no update, no delete, no pipeline logic. Treat deals as a place to record an opportunity, not as a managed pipeline yet.
FAQ
What's the difference between stage and status?
stage is the lifecycle position (lead/mql/sql/customer/disqualified); status is the working disposition within that journey (new/working/contacted/qualified/unqualified/nurture/converted). They're independent fields with independent transition routes.
How do I update stage/status/owner/tags?
Use their dedicated tools/routes — not crm_update_contact. Those keys are excluded from the PATCH allow-list and dropped silently if you send them there, precisely so every lifecycle change produces a timeline activity.
Create vs. upsert — which do I use?
crm_create_contact (and HTTP POST /contacts) always inserts and never deduplicates — two creates with the same email give you two rows. For "create if new, else reconcile" — imports, HubSpot/Salesforce sync — use POST /contacts/upsert (single) or POST /contacts/batch (≤200). Reconciliation is keyed on email or external_id_system+external_id_value.
Why did my lead come back unowned?
If the creator is a service (e.g. the CMS form forwarder, req.role starting with service:), the owner is forced to null so the lead lands in the unowned queue. Assign it explicitly afterward with crm_set_contact_owner.
Can I filter on a custom field?
No. custom is stored as JSONB on the contact but isn't in the filterable set. Only the fields listed under "Filterable fields" can be used in the search DSL.
How do I find every operator a field accepts?
Fetch GET /contacts/schema and read filter_ops as a hint — but it's advisory and can under-report (booleans accept ne, enums and uuid_or_null fields accept nin, etc.). If you pass an unsupported op, the 400 lists the actually allowed ops for that field's type, which is the authoritative set.