# The shell API

> The two hooks a module can reach beyond its props, what the shell does with what they publish, and the three files behind them that a module may not import.

Page: https://sdk.chatfuel.com/docs/reference/shell-api
Markdown: https://sdk.chatfuel.com/docs/reference/shell-api.md

Beyond its props, a module can reach two hooks and their types, and nothing else.

## What a module can call [#what-a-module-can-call]

Everything below is exported from `apps/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. |
| `ScreenValue`, `ScreenDetail`                     | Types      | What you may publish.                                                |
| `ScreenSnapshot`                                  | Type       | What the bridge answers with.                                        |
| `ShellAction`, `ShellActionResult`, `ShellBridge` | Types      | The bridge's own shapes.                                             |
| `ScreenContextProvider`, `ShellBridgeProvider`    | Components | The shell's, not yours. It mounts them around the module.            |

Neither hook lets a module reach another module. Publishing is write-only into a sink the shell owns,
and the bridge only ever resolves against the module registry.

<Callout type="warn">
  Both 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.
</Callout>

## Publishing what the operator is looking at [#publishing-what-the-operator-is-looking-at]

```ts
usePublishScreenContext(detail: ScreenDetail | null): void
```

`ScreenDetail` is `Record<string, ScreenValue>`, and `ScreenValue` is the whole JSON grammar —
strings, numbers, booleans, null, arrays, nested objects. Not a flat string map: the live pass proved
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 [#reading-and-moving-the-app]

```ts
useShellBridge(): ShellBridge | null
```

`ShellBridge` 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 [#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.

## The shell's own half [#the-shells-own-half]

Three files implement all of the above. 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                                                                                                                                                                                                                                                                                                   |
| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `apps/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. |
| `apps/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()`.                                                                                                                        |
| `apps/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.

The third file on the allowlist, `apps/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.
