Copilot
The operator's own assistant, on a page of its own — what it can read, what it may move, and the approval gate in front of everything else.
What it is
The assistant is a page you go to, not a panel that follows you. There was a dock — the thread rendered beside every other module, with a button in the top bar — and it was removed: it put one module's chrome into every screen in the product. Nothing in the app draws one.
What survived the removal is the part that was never about the panel. Because the page runs inside the shell, the assistant can answer "what is the operator looking at" with the actual screen, and it can move the app — and both work from its own page. Everything that would change account data stops at a manual approval batch on the server first.
Installed as
| Wizard id | coworker |
| Install it | npx @chatfuel/wizard --modules coworker |
| Selected by | Default — --yes installs it |
| Requires | Nothing, beyond the implicit core |
| Recommends | livechat |
| Skill | chatfuel-coworker |
The module is neither hidden nor railHidden: it takes an ordinary rail item, in the Growth
group beside Publishing and Ads Optimization.
Routes and views
One path, one parameter. There is no view segment — everything the module shows is one thread.
| Path | What it shows |
|---|---|
/coworker | The conversation list on the left, the open thread beside it, the composer under it. |
| Parameter | What it does |
|---|---|
c | The conversation on screen. Absent means the newest, or an empty page on a fresh bot. |
That one parameter still goes through a codec, and the surface reads it on every render
rather than seeding state at mount. The assistant's own navigate writes the URL, and a
mount-seeded surface would ignore it in silence.
The model
It reads the screen by asking, and it blocks while it waits. The server sends
CoworkerFrontendStateRequested and waits a few seconds for
coworkerConversationFrontendStateSubmitReply. Answer from something already alive: on a cold
page whose first module is still loading, the reply misses the window and the tool comes back
{"success":false,…}. The reply is a Map! and not a string map — nested objects, arrays,
numbers and booleans all reach the model verbatim, so send structure rather than flattening it.
Exactly one client may answer a given requestID; a second one gets
FrontendStateRequestNotFound.
It navigates by page name, never by URL. CoworkerFrontendAction {actionType: 'navigate', parameters: {pathKey: 'Deals'}} is a named destination, and the vocabulary is the Chatfuel
dashboard's page list rather than this app's — Live Chat, Contacts, Leads, Calendar, Flows,
Billing, channel settings, automations, catalog, FAQ, API, teammates — several of which this
shell calls something else and several of which it does not have. So lib/shellBridge.ts
resolves a name against the module registry (title first, then id) with a sixteen-entry alias
table behind it, and builds the URL itself. A name that resolves to nothing is not an error; it
is a page this product does not include, and the thread says so.
A route change is reversible, which is why executing one needs no approval and why undo is
always offered. What guards it instead is when: an action runs only if it arrived on the live
socket, in the conversation on screen, has not run before, is not inside a two-second cooldown,
and is not landing while somebody is typing — unless they asked in the last two minutes, which
is the case the typing guard was built to allow. Every refusal shows a label instead of a
silent no-op: Already done, Was opened earlier, Waiting for you to open this chat, Ready when you are. The history rule is the one that ships as a bug if it is an afterthought: a
frontend action is a message, so every page of history contains every navigation the
assistant ever performed.
Every tool call costs two messages, and both have empty content. The request carries
toolCalls: []; the result carries one entry. Failed results and rejections arrive as fully
empty messages — no content, no tool calls. Filtering on "has no content" therefore hides the
whole run. The rule that works is the one the module ships: no content and no tool calls is
noise; a message carrying tool calls is a step.
Approval is conversation state rather than a message. pendingAction holds the whole batch,
one boolean resolves all of it, and none of it runs until you answer. Reads never reach the
gate — chatfuel_gql-list_catalog and chatfuel_gql-list_specialists run with no
pendingAction at all. There is no expiry: a pending batch blocks the conversation until
somebody decides. Sending an ordinary message while one is pending is a rejection, with that
text as the denial message.
The module classifies each tool by its leading verb — fifteen read verbs, eleven destroy verbs, everything else a write — and an unrecognised verb or an unknown tool id counts as a write. That is the safe direction to be wrong in.
The agent loop is rate-limited per user account, and past the limit a reply may simply never
arrive: the mutation already returned success, so a success is not proof the loop started.
isAgentLoopActive is a hint rather than a guarantee — it can still read true after a loop
has ended — so "still thinking" is not evidence either. Keep a timeout of your own. The module
treats fifteen seconds without an event as a reason to refetch, and two minutes as a reason to
stop showing a typing indicator and say the loop is stuck.
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.
Permissions
| Object | Action | Required for |
|---|---|---|
Bot | View | Creating conversations, chatting with the assistant, subscribing to updates. |
One permission, and it is the whole list. Everything conversation-scoped is ownership-checked on
top of it: conversations are per (user account, bot), and another operator's id answers
CoworkerConversationDoesNotExist rather than a permission error.
Limits
The conversation list has to name its bot. botID is optional on
currentUser.coworkerConversationsConnection, and left out the field lists every bot the
deploying account holds — so behind the auth gate the proxy refuses that form with 403 AccountScopeBlocked, along with the form that passes a variable arriving null. Always send it.
Reading one thread by id is allowed and checked by the same fence that covers every other
in-bot id.
Pagination cannot be trusted past the first page. Do not page on hasNextPage: page until
you receive fewer edges than you asked for. A cursor pointing at a row that has since been
deleted no longer resolves; restart from the top. Thirty conversations and fifty messages a
page.
The unread counter is approximate. It increments on every assistant message, invisible tool messages included, and resets all-or-nothing.
Attachments are images and documents only. At most fifteen files a message, fifty megabytes
each; png, jpeg, webp, gif, and PDF, Office and text documents. Video is refused
outright — the assistant cannot read it. Audio is not an attachment at all: a voice note takes
its own mutation, one file per message. Message attachments are server-side copies and are not
kept forever.
A conversation cannot be renamed, deleted or archived. None of the three exists in the
public API. The server generates the title from the first user message, so it is a whole
sentence, it can be long, and it is sometimes the literal string "null". What the module does
instead is keep a title and a pin in frontendStateStorage — server-persisted, so a rename at
the desk is a rename on the laptop. Worth knowing: the assistant can read that map. It is
the agent's own scratchpad, which is why the keys are plain words.
One tool call in flight shows no spinner. The wire never says which call is running, and with a single step there is no run group to hang the state on. The typing indicator and the composer's Stop cover it.
Four things in the API look usable and are not: ice breakers — both
coworkerConversationCreateIceBreakers and coworkerConversationClickIceBreaker — are not
usable from this API, coworkerIncognitoConversationCreate is not yours to call, the
WhatsApp-proxy plumbing is undocumented here, and the skill-scoped creates need ids this API
does not list.
The module's own ceilings:
| Conversations per page | 30 |
| Messages per page | 50 |
| Files in one message | 15 |
| One file | 50 MB |
| Conversation title kept in the rail | 52 characters |
| Params the assistant may put on a URL | 12 keys, 200 characters each |
| Navigation cooldown | 2 seconds |
| "They just asked" window | 2 minutes |
| Refetch after silence | 15 seconds |
| Loop declared stuck | 2 minutes |
Copilot, as a product
What the assistant does, from the operator's side: the tools, the approval card, and what it can see.
Inbox
Recommended alongside this one — the conversations the assistant is usually asked about.
AI Agent
The other assistant in the product, and not this one: how the bot answers customers, rather than how the operator is helped.
Your agent's notes
The wizard installs .claude/skills/chatfuel-coworker/ (Codex: .agents/skills/chatfuel-coworker/) — the async contract, the approval gate, screen context and frontend actions, and the don't-use list.
Knowledge Base
Everything the AI knows about a business, as one structured record — the eight sources, the character budget, and why there is nothing to upload.
Inbox
The operator inbox over five channels — its one route, the identity rule the whole module rests on, and the platform limits that arrive after a send.