Deals
A sales pipeline over contacts — the three views, the six fixed stages, and why a deal is a contact rather than a record of its own.
There is no Deal entity in the Chatfuel API. A deal is a Contact whose salesStageV2 is
set, so this module is three views over your contacts grouped by six fixed stages — and
everything that looks like a deal field (amount, close date, company) is a custom contact
attribute the module writes by name.
That is also why deals requires contacts: the record you are dragging around the board is
the same record the CRM edits.
Installed as
| Wizard id | deals |
| Flag | --modules deals |
| Selection | Default — --yes installs it |
| Requires | contacts |
| Recommends | — |
| Skill | chatfuel-deals |
Routes and views
The view is a path segment; everything else is a query parameter. board is DEFAULT_VIEW,
so it has no segment of its own.
| Path | What it shows |
|---|---|
/deals | The kanban board: six columns, one query per column, drag to change stage. |
/deals/table | The same deals as rows — search over name and phone, a stage subset, attribute conditions, sortable columns. |
/deals/forecast | Deals per stage over a window, win rate, pipeline and weighted pipeline, CSV export, saved views. |
The parameters the module owns:
| Parameter | Value |
|---|---|
deal | Contact id of the open deal. Opens beside the view — a drawer below 1280px, an inline second column above it. |
assignee | any, unassigned, ai, or u:<UserAccountID> for a person. |
q | Search text. |
stage | Comma list of SalesStageV2 members. |
unread | 1, or absent. |
sort | <attributeName>:asc or :desc. |
collapsed | Comma list of the columns collapsed on the board. |
density | comfortable (the default) or compact. |
The filter keys are shared across the three views, but the board cannot express most of them: its query takes an assignee and nothing else, which is why the board's only filter control is assignee. The table is where the rest apply.
Attribute predicates are deliberately not in the URL — they are unbounded in size and would
make a link unshareable, so they live in saved views instead. Neither is a default: a mount
that wrote the whole schema back would turn every shared link into six parameters of noise. A
?view= from an older link is still read once and dropped on the next write, and an
unrecognised value anywhere renders the default rather than throwing.
The model
Six stages, fixed by the enum, in board order:
SalesStageV2: New | Sorting | Ready | WorkingOn | Won | LostWorkingOn renders as "Working on". The first four are OPEN_STAGES — everything still in
play, which is what a pipeline rollup covers.
The seven deal fields are custom contact attributes, and contactAttributeUpdate is
documented "Create or update custom contact attribute" — writing a value is what creates the
attribute. lib/dealFields.ts is the whole convention in one file:
| Field | Attribute name | Kind |
|---|---|---|
| Amount | deal amount | Plain decimal, no symbol and no separators |
| Currency | deal currency | ISO-4217, upper case; EUR when a deal has an amount and no currency |
| Close date | deal close date | Millisecond timestamp |
| Company | deal company | Text |
| Probability | deal probability | Bare integer per cent |
| Source | deal source | Text |
| Lost reason | deal lost reason | Text |
Amount and close date are CARD_FIELDS: shown on the card and summed in the column rollup.
The rest live in the panel and in the table.
There is no rename mutation for a custom attribute. Change an attributeName in
lib/dealFields.ts after data exists and the old values are orphaned — and nothing errors:
a name that does not exist yet is silently omitted from contact.attributes(names:) rather
than reported, so the field reads empty on every deal. Add the old name to that
field's aliases instead, which is what they are for.
The one timestamp the API keeps per deal is lastSalesStageUpdateTime, and it is the last
transition. The card's ageing bar is therefore "days since it last moved", never "days in this
stage" — two days in New, three in Sorting, five in Ready, fourteen in Working on. Won and
Lost have no threshold at all: a closed deal sitting still is not rot.
Configuration
| Variable | Notes |
|---|---|
CHATFUEL_TOKEN | Secret. Server-side only. |
VITE_CHATFUEL_WORKSPACE_ID | The wizard's workspace step writes it. |
CHATFUEL_API_BASE | Defaults to https://panel.chatfuel.com. |
Nothing here is specific to this module. Environment variables has what each one is and which side reads it.
Permissions
| Object | Action | Required for |
|---|---|---|
| People | View | Reading the deals board |
| People | Edit | Moving cards between stages, editing deal fields, name, note and owner |
Limits
A segment cannot be narrowed by stage. SegmentInput has no sales-stage predicate. That
one gap costs two things: the table's attribute engine loses deal isolation (it returns
contacts, and a stage filter can only be applied client-side to the rows that loaded), and the
segment CSV export is every contact on the bot rather than the deals on screen. The other
export start, csvContactExportStartByIDsList, means "these deals" — and can only carry the
ids that are loaded, which is why the forecast offers to load the rest beside the export.
There is no aggregation of any attribute — no SUM, no AVG. Every money figure is a
client-side sum over the loaded rows and always renders its coverage (60 of 128). Mixed
currencies refuse to sum rather than print a wrong total.
Four analytics are absent from the data, not from the roadmap. With one timestamp per deal and no stage-change history: no time-in-stage, no funnel conversion, no sales velocity or average days to close, no cohorts. A window selects deals whose last update falls in it, which is not the set that entered the pipeline in it.
Within-column order cannot be persisted. The board's sort is fixed to
lastSalesStageUpdateTime desc and contactDealsConnection has no orderBy, so a drop
indicator anywhere but the top of the target column would be a lie the next render corrects.
There is no bulk mutation. A multi-card drag is one contactSetSalesStage per card, a
partial failure is an ordinary outcome, and past 25 cards the drag is refused rather than
firing that many requests at a rate-limited bot.
Only attribute-backed columns sort. The server orders by an attribute name; stage, owner,
unread and last message have none. On the attribute engine every custom attribute reports
dataType: string, so orderBy sorts as text — "9" comes after "1000".
Saved views are per signed-in user. setUserStorageItem is the only persistence this API
offers a client, so a teammate on the same bot sees none of yours.
The module's own ceilings, each picked against something the API does:
| Cards in a column page | 20 |
| Pages the board auto-loads before a button | 3 |
| Rows in a table page | 25 |
| Cards in one drag | 25 |
| Undo window | 60 seconds, one move deep |
| Saved views | 50 |
| Saved view name | 60 characters |
| Search kept in a saved view | 200 characters |
| Conditions in a saved filter | 20 |
| Values on a condition | 20 |
Deals, as a product
What the board, the table and the forecast do, and the questions this API cannot answer.
Contacts
Required alongside this one — the record a deal actually is.
Inbox
Where the conversation behind a deal happens; an edit there lands in the open panel.
Your agent's notes
The wizard installs .claude/skills/chatfuel-deals/ (Codex: .agents/skills/chatfuel-deals/) — the board queries, the table's engines, and the analytics this API cannot support.