Skip to content
ChatfuelSDK
Modules

Contacts

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

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

Wizard idcontacts
Install itnpx @chatfuel/wizard --modules contacts
Selected byDefault — --yes installs it
RequiresNothing, beyond the implicit core
RecommendsNothing

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

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

PathWhat it shows
/contactsThe record table: filters, saved views, inline editing, bulk actions.
/contacts/fieldsThe bot's attribute catalog — what exists, how many contacts carry it, which flows read it, what it is stored as.
/contacts/audienceCounts 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:

ParameterWhat it does
contactOpens that contact as a full record page over the view.
tabWhich record tab — overview, fields or activity. Written only while a record is open.
densitycompact, cozy or comfortable. cozy is the default and is omitted.
qFree-text search.
assigneeunassigned, ai, or u:<userAccountId>. any is the default and is omitted.
stageComma-separated sales stages. All six means no filter, so it is written as nothing.
unread1 for unread only.
since, untilThe last-message window, as ISO instants. A value Date cannot read is dropped rather than sent.
platformComma-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

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.

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.

Configuration

VariableNotes
CHATFUEL_TOKENSecret. Server-side only.
VITE_CHATFUEL_WORKSPACE_IDThe workspace you picked during the wizard run.
CHATFUEL_API_BASEDefaults to https://panel.chatfuel.com.

Every variable the app reads, and which side reads it, is on environment variables.

Permissions

ObjectActionRequired for
PeopleViewReading the contacts table, the record page, the field catalog, the audience counts and any CSV export.
PeopleEditInline editing, name, note, sales stage and owner changes, bot-wide field defaults, and the CSV import.

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.

WhatNumber
Rows in a page50
Pages pulled before the list asks for a click6
Rows one query may ask for500 (first: 500 works, first: 1000 fails)
Rows one bulk run touches500
Contacts in an export by selection100
Conditions in a filter20
Groups in a filter10
Saved views40, with a 60-character name
Fields read from the catalog500 (100 × 5 pages)

Contacts

What this module does, from the product side: the table, the filters, the views, the CSV.

Deals

The same contacts by sales stage — the module that declares this one in its requires.

Inbox

Where a conversation with one of these contacts happens, and where the contact card is borrowed from.

Your agent's notes

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.

On this page