# Contacts

> The CRM over Chatfuel contacts — its three views, the two list engines behind one table, and the CRM features this API cannot support.

Page: https://sdk.chatfuel.com/docs/modules/contacts
Markdown: https://sdk.chatfuel.com/docs/modules/contacts.md

## What it is [#what-it-is]

A contact has no `createdAt`. It has no tags, no change history, and no delete, merge, block or
unsubscribe mutation anywhere in this API — so a great deal of ordinary CRM UI is not buildable
here, and this module ships none of it.

What it does ship is three surfaces behind one header — the record table with filters, saved
views, inline editing and bulk actions; **Fields**, the bot's attribute catalog; and
**Audience**, the breakdown by stage, channel and owner — plus a full record page. A contact is
a channel-specific type behind one interface, its custom fields are contact attributes that
exist because some contact has a value for one, and every filter is an inline segment carried on
the query rather than a segment stored on the server.

## Installed as [#installed-as]

|             |                                           |
| ----------- | ----------------------------------------- |
| Wizard id   | `contacts`                                |
| Install it  | `npx @chatfuel/wizard --modules contacts` |
| Selected by | Default — `--yes` installs it             |
| Requires    | Nothing, beyond the implicit `core`       |
| Recommends  | Nothing                                   |

Other modules point at it: `deals` declares `contacts` in its `requires`, so picking Deals
installs this whether you named it or not, and `livechat` recommends it for the contact card
beside the thread.

## Routes and views [#routes-and-views]

Three views, and the default one has no path segment of its own — `/contacts` *is* the list.

| Path                 | What it shows                                                                                                     |
| -------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `/contacts`          | The record table: filters, saved views, inline editing, bulk actions.                                             |
| `/contacts/fields`   | The bot's attribute catalog — what exists, how many contacts carry it, which flows read it, what it is stored as. |
| `/contacts/audience` | Counts by channel, by sales stage and by owner, every figure a count the server returned.                         |

The filter travels in the URL, so a filtered list is a link you can send:

| Parameter        | What it does                                                                                                 |
| ---------------- | ------------------------------------------------------------------------------------------------------------ |
| `contact`        | Opens that contact as a full record page over the view.                                                      |
| `tab`            | Which record tab — `overview`, `fields` or `activity`. Written only while a record is open.                  |
| `density`        | `compact`, `cozy` or `comfortable`. `cozy` is the default and is omitted.                                    |
| `q`              | Free-text search.                                                                                            |
| `assignee`       | `unassigned`, `ai`, or `u:<userAccountId>`. `any` is the default and is omitted.                             |
| `stage`          | Comma-separated sales stages. All six means no filter, so it is written as nothing.                          |
| `unread`         | `1` for unread only.                                                                                         |
| `since`, `until` | The last-message window, as ISO instants. A value `Date` cannot read is dropped rather than sent.            |
| `platform`       | Comma-separated channels. The full set means no filter.                                                      |
| `sort`           | `<attribute name>:asc` or `:desc`. Only the last colon splits, because an attribute name may contain spaces. |

Filter **groups** are deliberately absent from the URL: they are unbounded in size, and a link
carrying twenty predicates is not a link. They live in saved views instead. An unknown value in
any parameter falls back to the default in silence, so a stale link renders the list rather than
a white screen.

## The model [#the-model]

**Two server queries can list contacts, and they see different sets.**

`contactsConnection` with an inline `SegmentInput` sees **every contact** and can express any
attribute predicate, nested, in either sort direction — and it has no live feed.
`contactChatsConnection` is live and searches server-side over name and phone, filters by
assignee, unread and stage, and lists **only contacts that have a conversation**. A contact
created by a CSV import, or by the API's one create mutation, comes back with `conversation:
null` and never appears there at all.

That is not a small gap. On one live bot `contactsCount` said **67** while `contactChatsCountV2`
said **44**, and the missing 23 were exactly the contacts who had never messaged. Searching the
chat engine for one of them returned nothing while the same contact sat on the segment engine's
first page.

So the segment engine is the floor. The chat engine is an opt-in you reach by asking for
something only it can answer — unread, owner, stage, a last-message window — and the module
routes between them in one pure function, `lib/queryPlan.ts`, whose caveat strings are unit-test
assertions rather than promises. Two consequences to build around: under the segment engine the
list is a snapshot, because no subscription exists for that shape; and under the chat engine,
channel, sort and attribute predicates are gone from the server and applied to the rows that
happened to load.

<Callout type="warn">
  An attribute name the bot does not carry matches nobody, silently. No error, no warning, an
  empty page — and a field disappears the moment its last value is deleted, so a saved view or a
  stored column list can go stale without anyone doing anything wrong. Validate names against
  the catalog before the query goes out. The same applies to the filter branches the schema
  advertises and the API does not honour: `byTag`, `byStoredSegment` and `dateStrategy` are all
  in the SDL and all fail live.
</Callout>

## Configuration [#configuration]

| Variable                     | Notes                                           |
| ---------------------------- | ----------------------------------------------- |
| `CHATFUEL_TOKEN`             | Secret. Server-side only.                       |
| `VITE_CHATFUEL_WORKSPACE_ID` | The workspace you picked during the wizard run. |
| `CHATFUEL_API_BASE`          | Defaults to `https://panel.chatfuel.com`.       |

Every variable the app reads, and which side reads it, is on
[environment variables](/docs/deploy/environments).

## Permissions [#permissions]

| Object   | Action | Required for                                                                                            |
| -------- | ------ | ------------------------------------------------------------------------------------------------------- |
| `People` | `View` | Reading the contacts table, the record page, the field catalog, the audience counts and any CSV export. |
| `People` | `Edit` | Inline editing, name, note, sales stage and owner changes, bot-wide field defaults, and the CSV import. |

## Limits [#limits]

Every omission below is forced by the API, and building any of them means faking it.

* **No delete, no merge, no block, no unsubscribe.** No such mutation exists.
* **No tags.** `byTag` is in the schema, fails live, and there is no tag mutation to write one with anyway.
* **No stored segments.** `byStoredSegment` fails live; saved views are per-user client storage standing in for it, so nobody's views are shared with the team.
* **No date-typed filters.** `dateStrategy` fails on every attribute, including genuine `datetime` ones, so dates go through the default strategy — which compares the value as text, number and date at once and keeps the row if any reading matches. Greater-than and less-than are approximate here, and the module says so beside them.
* **No "created this week".** `Contact` has no `createdAt`, and reading it off a signed-up attribute would be a different number wearing that name.
* **No change history and no audit trail.** Nothing in the API records who changed what.
* **No aggregation over attribute values.** Counts per sales stage are the only server-computed numbers; anything else is a client sum over the rows that loaded, and has to be printed with its coverage.
* **No exact count under a client-side filter.** The header count counts the segment, so when the list narrows rows itself the two numbers legitimately differ. Both are shown rather than one wrong one.
* **No live filtered list.** The engine that can express a filter has no subscription, and the one with a subscription cannot express the filter.

**No bulk mutation, either.** Every write takes exactly one contact id, so setting a stage on 120
contacts is 120 requests: the cap is stated up front, a partial result is the normal outcome
rather than an error, and undo writes the old value back one row at a time. Some rows cannot be
undone at all — a contact that had no stage cannot be returned to none — and those are dropped
from the undo rather than skipped quietly.

**A field exists because a contact has a value for it.** Writing a value on a name that does not
exist creates the attribute; deleting the last value destroys it. Clearing a field is a delete,
not an empty write — saving an empty box as a write stores an empty string, which is a *value*,
and the field stays counted and stays non-empty. And a bot-wide default makes the field
non-empty on every contact at once, which silently changes what every "is empty" filter means.

**Sorting is by attribute only.** The server orders by an attribute name, and there is no
attribute name for a stage, an unread count or a last message — so none of those columns can
carry a sort header. Custom attributes all report as strings, so they sort as text: `"1000"`
before `"9"`.

**CSV goes through REST and reports no total.** GraphQL never accepts bytes, so the upload is a
REST call; and the import stream carries created, updated and declined counts with no total of
any kind, so the wizard counts up rather than drawing a progress bar at an invented rate. A row
whose phone matches an existing contact updates it instead of creating a second one — which is
what makes the import usable as a recurring sync.

| What                                          | Number                                        |
| --------------------------------------------- | --------------------------------------------- |
| Rows in a page                                | 50                                            |
| Pages pulled before the list asks for a click | 6                                             |
| Rows one query may ask for                    | 500 (`first: 500` works, `first: 1000` fails) |
| Rows one bulk run touches                     | 500                                           |
| Contacts in an export by selection            | 100                                           |
| Conditions in a filter                        | 20                                            |
| Groups in a filter                            | 10                                            |
| Saved views                                   | 40, with a 60-character name                  |
| Fields read from the catalog                  | 500 (100 × 5 pages)                           |

<Cards>
  <Card title="Contacts" href="/modules/contacts" description="What this module does, from the product side: the table, the filters, the views, the CSV." />

  <Card title="Deals" href="/docs/modules/deals" description="The same contacts by sales stage — the module that declares this one in its requires." />

  <Card title="Inbox" href="/docs/modules/inbox" description="Where a conversation with one of these contacts happens, and where the contact card is borrowed from." />

  <Card title="Your agent's notes" description="The wizard installs the module's skill as .claude/skills/chatfuel-contacts/ for Claude Code, or .agents/skills/chatfuel-contacts/ for Codex CLI. references/guide.md is the model and the two engines, references/filters.md is the segment input in full, references/import-export.md is the CSV story end to end." />
</Cards>
