Steeren/Working with contacts — referencelive from the platform← site
Capture & manage leads

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, email-on-update, 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_ops is advisory and can under-report what the runtime actually accepts. The runtime validator (validateFilter) checks against a separate OPS_FOR_TYPE table 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 accepts ne.
  • stage/status advertise ["eq","ne","in"] but the runtime enum type also accepts nin.
  • source_form_id advertises ["eq","is_null","not_null"] but the runtime uuid_or_null type also accepts ne, in, nin.

So if you cache filter_ops and refuse to send anything outside it, you're being stricter than the service. And when a filter is rejected, the 400 message lists the allowed ops from OPS_FOR_TYPE — which can name ops the schema's filter_ops didn'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: stagelead, statusnew, 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, where req.role starts with service: — 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_intent are silently dropped on create — including via the MCP tool. The crm_create_contact tool advertises them in its input schema (and its description even says "you can set them manually"), but createContact filters the payload through CREATE_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_name is a convenience — HTTP only. Over POST /contacts, pass a company_name string and it's resolved to a crm.companies row by name match within the tenant (creating one if none exists) and linked via company_id. Empty/blank clears the link. The crm_create_contact MCP tool does not expose company_name, so an agent using the tool can't trigger this — it has to pass an already-resolved company_id. (Note the match is a SQL ilike on the name, so % / _ in the string are treated as wildcards.)
  • Auto-classify — HTTP only, and only on this route. Pass classify: true to POST /contacts and a crm.classify-contact job 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: classify is not honoured by POST /contacts/upsert or /batch (an import that sets it does nothing), and the enqueue is a no-op when SERVICE_JOBS_URL is 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:

  1. If the body has both external_id_system and external_id_value, it matches on external_ids->>system = value.
  2. Otherwise it matches on email via a SQL ilike (so the match is case-insensitive, per tenant — but % / _ in the email string would be interpreted as wildcards).
  3. 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 upsert or batch — they're HTTP-only, aimed at import/sync code rather than conversational agents. crm_create_contact always 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 listcrm_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 searchcrm_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, limit is validated by Zod as min(1).max(200) at the route — but the MCP tool advertises max(500). Unlike the quick-list route, the search route does not clamp: a limit over 200 (which the MCP tool will happily accept) fails safeParse and returns 400 invalid search body. Keep search limit at 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] in and nin require an array value — a scalar there is a 400. For is_null, not_null, is_empty, is_not_empty, omit value (any value you pass is tolerated but ignored).

Timestamps are range-only: created_at, updated_at, last_activity_at, stage_changed_at do not support eq/ne — use gt/gte/lt/lte (and is_null/not_null where advertised).

is_empty / is_not_empty are implemented as literal string comparisons against the array column: = '{}' and != '{}'. is_empty works for an empty text[]. is_not_empty is != '{}' 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_reason is 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 stage disqualified does not write disqualified_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_contact MCP tool exposes a narrower set than the HTTP route: only first_name, last_name, phone, company_id, source, score_fit, score_intent, utm_source/utm_medium/utm_campaign, do_not_contact, email_opted_out. So email, company_name, external_ids, custom, message, disqualified_reason, source_form_id, referrer_url, landing_url, ip_country, utm_term, utm_content are HTTP-only on update — an agent using the tool can't patch them. (And as on create, score_fit/score_intent are 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 HTTP PATCH path 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_kind tells you who did something. The default is computed in emitActivity: any service:* caller (form forwarders, the newsletters nurture writes, etc.) gets system unless it explicitly passes a kind; a human action through the dashboard gets user. The classifier is the one path that explicitly passes agent. 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 404 if the id doesn't exist.
  • On success it returns { job_id } (HTTP 202). When SERVICE_JOBS_URL is unset, the enqueue is a no-op: you still get 202 but job_id is null and 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_fit is left null whenever ICP/company context is unavailable — both the per-contact "no grounding docs" case and the whole-service case where SERVICE_CONTEXT_URL is unset (then every contact comes back with score_fit = null and explanatory reasoning).
  • Out-of-range or wrong-typed values from the model become null rather 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: true flag on create — both end up in the same queue. There is no crm_* 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.

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

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

Served live from the platform · /docs/working-with-contacts