The shell API
The three capabilities a module can reach beyond its props, what the shell does with what they publish, and the files behind them that a module may not import.
Beyond its props, a module can reach three capabilities and their types, and nothing else.
What a module can call
Everything below is exported from content/shell/src/modules/shellApi.ts, the one runtime file on
the module import allowlist.
| Export | Kind | For |
|---|---|---|
usePublishScreenContext(detail) | Hook | Publish what the operator is looking at. |
useShellBridge() | Hook | Read or move the app around this module. Returns null in an embed. |
registerDeviceCache(sweep) | Function | Hand the shell a way to drop what this module keeps on the device. Returns the unregister. |
clearDeviceCaches() | Function | Run every registered sweep. |
ScreenValue, ScreenDetail | Types | What you may publish. |
ScreenSnapshot | Type | What the bridge answers with. |
ShellAction, ShellActionResult, ShellBridge | Types | The bridge's own shapes. |
CacheSweep | Type | What registerDeviceCache takes. |
ScreenContextProvider, ShellBridgeProvider | Components | The shell's, not yours. It mounts them around the module. |
None of them lets a module reach another module. Publishing is write-only into a sink the shell owns, the bridge only ever resolves against the module registry, and a sweep hands the shell a function and learns nothing back.
The two hooks are no-ops in an embed. There is no provider above the module, so
usePublishScreenContext publishes into a sink that discards, and useShellBridge() answers null
— a module mounted inside somebody else's product has no business reading or rewriting their address
bar. Handle the null; the one caller in the tree degrades to saying it could not move rather than
pretending it did.
Publishing what the operator is looking at
usePublishScreenContext(detail: ScreenDetail | null): voidScreenDetail is Record<string, ScreenValue>, and ScreenValue is the whole JSON grammar —
strings, numbers, booleans, null, arrays, nested objects. Not a flat string map: the assistant's
submit mutation round-trips nested structures to the model verbatim, so flattening would only lose
information.
Call it wherever the answer actually lives — the workspace that knows the view, or the view that
knows the row count — as many times as you like. The shell merges every live entry, keyed by
useId(), and a later mount wins a key clash, which is the right precedence: the view knows more
than the workspace hosting it. Pass null to publish nothing.
The value is keyed by its own JSON, so an inline object literal is fine and needs no memoising — re-publishing happens only when the content changes.
Five modules publish today: livechat, deals, bookings, flow-builder and automations.
Reading and moving the app
useShellBridge(): ShellBridge | nullShellBridge has two methods.
snapshot(): ScreenSnapshot answers { moduleId, moduleTitle, url, params, detail, destinations } —
the address bar verbatim (the model quotes it back and it has to be real), this module's params, the
merged detail from every live publish, and where else the app could be sent. It is read at call
time through a ref, so a snapshot taken ten minutes after the bridge was built is still current.
run(action: ShellAction): ShellActionResult takes { actionType, parameters } — free strings on the
wire, because the vocabulary is the server's — and answers { ok, label, undo? }. Four outcomes:
| Outcome | ok | label |
|---|---|---|
actionType is not navigate | false | I don't know how to "<actionType>" in this dashboard |
The pathKey resolves to nothing | false | There is no "<name>" page here, or That navigation had no destination when it was not a string |
| Already at that URL | true | Already on <title> |
| Moved | true | Opened <title>, with undo restoring the address it left |
undo is always available on a successful move, because everything the bridge can do is a route
change and a route change is reversible — which is also why executing one needs no approval gate.
Anything that changes account data is a server-side tool and goes through the server's own
manual-approval batch instead.
The vocabulary run accepts
pathKey is a named destination, never a URL. resolveDestination normalises both sides
(lowercase, non-alphanumerics stripped) and matches title first, then id, then an alias table — title
first because the model has only ever seen the product's page names, id because that is what somebody
writing the parameters by hand reaches for. First match in registry order wins, which is the order
the rail shows.
| Alias | Module |
|---|---|
livechat, inbox, chats, conversations | livechat |
leads, pipeline | deals |
calendar, appointments | bookings |
flows, flowbuilder | flow-builder |
catalog, faq, businessinfo | knowledge-base |
channelsettings, aisetup | automations |
assistant | coworker |
Those are Chatfuel's own page names for things this shell calls something else. Names the model uses that exist in neither the table nor the registry — Billing, API, teammates — resolve to nothing and are reported as not being in this dashboard, which is the truth.
parameters.params becomes the URL query, capped on both axes: at most 12 keys, each value
truncated to 200 characters. Objects, arrays, null and undefined are dropped rather than
stringified. This is model output landing in the address bar and a module reads it as its own deep
link.
Dropping what a module keeps on the device
registerDeviceCache(sweep: CacheSweep): () => voidA module that caches its data in localStorage so a screen paints before the network answers has a
copy that belongs to one signed-in person looking at one bot — and nothing in the browser retires it.
Signing out ends the session, switching bots changes the subject, and the copy outlives both. The
flow builder is the case: it keeps whole flows there, message texts and WhatsApp templates included.
Register the sweep from the module's descriptor module, which is loaded whether or not the screen behind it ever opens — the cache outlives the screen, so the thing that registers has to as well.
The shell calls clearDeviceCaches() in three places: on sign-out, before the server call; when the
topbar's stored bot changes; and when the auth session moves to another bot. A sweep that throws is
swallowed and its answer is not read — one module's cache is not worth another module's sign-out, and
storage itself throws where site data is switched off.
The deployment's own settings
src/config/ holds what is true of this deployment rather than of the product. defaults.ts declares
the shape and the upstream values; app.ts holds the overrides and is yours — the wizard writes it
once and records it as generated, which is what makes update skip it for the life of the app.
Leaving a setting out of app.ts is how you keep getting the upstream's improvements to it.
| Setting | Default | What it is |
|---|---|---|
currency | 'EUR' | The currency a money amount is shown in when the record itself does not say — a deal with an amount and no deal currency attribute. An ISO 4217 code, handed to Intl.NumberFormat, so a code it does not know throws where the number would have been. |
calendarScrollMinute | 480 | Where a day or week calendar opens, in minutes past midnight, when there is nothing in it to scroll to. 480 is 08:00 — an hour before the working day the defaults assume. |
The file is short on purpose. A value is a setting only when the code cannot work it out and the person using the app should not have to; the week's first day is a preference and falls back to the locale, a specialist's working hours are edited in the bookings UI, and which attribute holds a deal's amount is bound against what the bot really carries.
src/config/index.ts merges the two and freezes the result, so nothing that reads a setting can also
set one. config/ sits outside every module subtree, so a module reads ../shellConfig, which
re-exports APP_CONFIG and the AppConfig type.
The shell's own half
Three files implement the hooks and the bridge. None of them is on the import allowlist, so a module that reaches for one fails the boundary pass rather than compiling.
| File | Exports | What it is |
|---|---|---|
content/shell/src/lib/screenContext.ts | ScreenSink, createScreenSink() | The sink behind the hook: publish, read, size. Deliberately not React state — publishing happens on every filter change and every page of rows, and nobody re-renders when it does. It is a plain Map the shell holds in a ref, and read() is called at the one moment the assistant's tool fires. |
content/shell/src/lib/shellBridge.ts | Destination, resolveDestination, actionParams, MAX_ACTION_PARAMS, MAX_ACTION_PARAM_LENGTH, BridgeDeps, createShellBridge | The bridge itself. createShellBridge takes the destinations, a currentRoute() and currentUrl() read at call time, the shell's own navigate and restore, and readDetail(). |
content/shell/src/lib/botSelection.ts | WorkspaceOption, Selection, resolveSelection, workspaceOptions, readStoredSelection, writeStoredSelection | Which workspace and which bot the app is looking at. Nothing to do with modules — the topbar's, and React-free so the rules can be tested on their own. |
botSelection answers from three sources in this order of authority: what the person last chose
(localStorage, under chatfuel.workspace and chatfuel.bot), what the wizard wrote as the starting
point (VITE_CHATFUEL_WORKSPACE_ID), and what the account actually has right now — the last being the
referee, so a stored id whose bot has since been deleted is a stale note to drop rather than an error
to show. With no stored workspace, the remembered bot names where it lives now, because a bot can be
moved between workspaces and following it is the more faithful restore.
workspaceOptions exists because Chatfuel does not require workspace titles to be unique and makes
them collide by itself: creating a bot without naming a workspace leaves a throwaway "My Workspace"
behind. Same-named entries get their bots appended, and identical-all-the-way-down ones get the last
six characters of the id.
Both localStorage calls are wrapped: a browser with site data switched off throws on the property
access itself, and losing the memory of a choice is not worth a blank screen. A workspace holding no
bots is still remembered; an empty bot id is removed rather than written, so the next visit does not
read it back as a real one.
The fourth file on the allowlist, content/shell/src/modules/testClient.ts, is not an API at all — it
is an inert ModuleClient for the render smoke tests, where effects never run. A request made from it
never answers, rather than answering with a lie.
The module contract
The four interfaces a module implements — ModuleDescriptor, ModuleAppProps, HostIntegration, HostRuntime — field by field, and what each one is for.
The proxy
Every route the proxy mounts, which of them exist only when a module or a variable does, the ceilings and fences with their values, the options that move them, and the three hosts that serve the same core.