# The module contract

> The four interfaces a module implements — ModuleDescriptor, ModuleAppProps, HostIntegration, HostRuntime — field by field, and what each one is for.

Page: https://sdk.chatfuel.com/docs/reference/module-contract
Markdown: https://sdk.chatfuel.com/docs/reference/module-contract.md

A module contributes `apps/shell/src/modules/<id>/` with an `index.tsx` exporting `moduleDescriptor`
under that exact name, because the wizard regenerates the registry from it at scaffold time.

## `ModuleDescriptor` [#moduledescriptor]

<TypeTable
  type="{
  id: {
    description: 'The module.json id, and the first path segment that routes to it.',
    type: 'string',
    required: true,
  },
  title: {
    description: 'Sidebar tooltip and topbar label.',
    type: 'string',
    required: true,
  },
  icon: {
    description: 'Rendered in the rail and the topbar. An element, not a component.',
    type: 'ReactNode',
    required: true,
  },
  Component: {
    description:
      'The module’s root. React.lazy in every registered module: the descriptor is what the rail needs at startup, and the module itself is a chunk fetched on the first visit.',
    type: 'ComponentType<ModuleAppProps>',
    required: true,
  },
  hidden: {
    description:
      'Not a rail item and never routed as &#x22;/<id>&#x22;. The auth module is the only one: it contributes a host integration instead of a page.',
    type: 'boolean',
    default: 'false',
  },
  railHidden: {
    description:
      'Routed as &#x22;/<id>&#x22; like any module, and never a rail item. For a surface reached deliberately and by address only — the admin panel is the only one.',
    type: 'boolean',
    default: 'false',
  },
  host: {
    description: 'Shell integration for a module that wraps the host. Only auth uses it.',
    type: 'HostIntegration',
    default: 'none',
  },
}"
/>

`Component` has to be `React.lazy`. Ten modules imported eagerly put ten modules' code — and ten
modules' generated GraphQL documents — into one seven-megabyte first load for a person who opened one
of them. The shell mounts it under a `Suspense` whose fallback is the same spinner a module shows on
its own first load, so a cold visit is one spinner and not two, and keys it on `(module, bot)` so a
bot switch remounts with fresh subscriptions and fresh state.

The two invisibility flags are applied in one place, `railModules`, and they are not the same thing.
`hidden` takes the route away too; `railHidden` keeps it. The descriptor's `hidden` is checked against
the manifest's, so the two cannot drift.

## `ModuleAppProps` [#moduleappprops]

Everything a module gets. It imports React, `~ui`, `~api`, the contract files and its own subtree,
and receives the rest through these props — it never touches `window.location`, because routing is
the shell's.

<TypeTable
  type="{
  botId: {
    description: 'The Chatfuel bot this mount is for. The component is keyed on it, so it never changes under you.',
    type: 'string',
    required: true,
  },
  client: {
    description: 'The API client, narrowed to what a module may do: query, mutate, subscribe, onReconnect.',
    type: 'ModuleClient',
    required: true,
  },
  params: {
    description: 'This module’s deep-link params — the part after &#x22;?&#x22; in &#x22;/<moduleId>?…&#x22;.',
    type: 'URLSearchParams',
    required: true,
  },
  setParams: {
    description: 'Replace them. history.replaceState, so no nav-stack entry.',
    type: '(next: URLSearchParams) => void',
    required: true,
  },
  view: {
    description:
      'The path segment after the module id — the module’s own view, &#x22;&#x22; at its root. More than one screen means the screen goes here and the state goes in params: &#x22;/contacts/fields?density=compact&#x22;.',
    type: 'string',
    required: true,
  },
  setView: {
    description: 'Move within this module. A view change is a place, so it pushes unless you pass { replace: true }.',
    type: '(view: string, params?: URLSearchParams, options?: { replace?: boolean }) => void',
    required: true,
  },
  navigate: {
    description:
      'Somewhere else in the app, as an app-relative path (&#x22;/livechat?c=42&#x22;). This is how a module crosses to another one — where the app is mounted is the shell’s business.',
    type: 'Navigate',
    required: true,
  },
  installedModules: {
    description:
      'The ids in THIS deployment’s registry. A module may not import another module and must not guess what a scaffold took: knowledge-base shows services read-only with a link into bookings when bookings is installed, and edits them itself when it is not.',
    type: 'readonly string[]',
    default: 'undefined',
  },
  selectBot: {
    description:
      'Point the whole app at another bot — the topbar switcher’s move, asked for from inside a module. Absent in an embed, where there is no app around the module to re-point.',
    type: '(botId: string, workspaceId?: string) => void',
    default: 'undefined',
  },
}"
/>

<Callout type="warn">
  `selectBot` ignores a bot the shell does not know about rather than half-opening it, and says
  nothing when it does. The workspace argument matters without the `auth` module, where a bot lives in
  one of several workspaces the account owns and moving to it has to move both levels at once.
</Callout>

## What a module may import [#what-a-module-may-import]

The boundary is enforced, not conventional. Inside `apps/shell/src/modules/<id>/`: `react` and
`react-dom`, `~ui`, `~api` — with generated documents from its own namespace or `core` and no other —
its own files, and exactly three files outside its subtree.

<Files>
  <Folder name="apps/shell/src/modules">
    <File name="types.ts" />

    <File name="shellApi.ts" />

    <File name="testClient.ts" />
  </Folder>
</Files>

`types.ts` is this page. `shellApi.ts` is the runtime half — [the shell
API](/docs/reference/shell-api). `testClient.ts` is an inert client the render smoke tests mount over:
a request made from it never answers, rather than answering with a lie.

The rule runs the other way too. A shell-level file may not reach into a module subtree; it goes
through the registry.

## `HostIntegration` [#hostintegration]

One module may wrap the shell instead of being a page in it. Only `auth` does.

<TypeTable
  type="{
  routes: {
    description:
      'First path segments this integration owns (&#x22;sign-in&#x22;, &#x22;team&#x22;, …). They are never treated as module ids.',
    type: 'readonly string[]',
    required: true,
  },
  create: {
    description:
      'Synchronous. Returns null when nothing is configured — no Supabase env — and the shell then runs exactly as it would without the module. Called once, before the API client, because the client needs its token getter.',
    type: '(input: { env; basePath; appLogo? }) => HostRuntime | null',
    required: true,
  },
}"
/>

`create` is given the env bag, `basePath` (where the app is mounted, for the absolute links the
integration mails out) and `appLogo` — a URL the host has already resolved, because a name is a string
and arrives in `env` while a logo is a location, and an embed host's assets are not in our `public/`.

## `HostRuntime` [#hostruntime]

What `create` returns. Its components close over their own adapter, so the shell never passes one
around.

<TypeTable
  type="{
  getAccessToken: {
    description: 'The caller’s session token for the proxy gate. Undefined means none.',
    type: '() => Promise<string | undefined>',
    required: true,
  },
  onSessionLost: {
    description: 'The shell’s client reports that the proxy rejected the session.',
    type: '(err: unknown) => void',
    required: true,
  },
  subscribeSession: {
    description:
      'Who is signed in, as a change signal — the subscribe half of a useSyncExternalStore pair. The two getters below are read through it, so the chrome follows a sign-in without the shell polling.',
    type: '(cb: () => void) => () => void',
    required: true,
  },
  getBotId: {
    description: 'The Chatfuel bot this session is working in. Null until the workspace resolves.',
    type: '() => string | null',
    required: true,
  },
  getBots: {
    description:
      'Every bot this session may open, oldest first — what the topbar switches between. The array is replaced only when it really changes, so it is safe to read through useSyncExternalStore.',
    type: '() => HostBot[]',
    required: true,
  },
  selectBot: {
    description: 'Move to another of them. A bot that is not in getBots() is ignored.',
    type: '(botId: string) => void',
    required: true,
  },
  getWorkspaceName: {
    description: 'That workspace’s name, for the topbar. The shell must not ask Chatfuel for it.',
    type: '() => string | null',
    required: true,
  },
  Gate: {
    description:
      'Wraps the whole shell: sign-in and no-access screens when needed, children otherwise. Without a host integration the shell renders unwrapped.',
    type: 'ComponentType<{ route; navigate; children }>',
    required: true,
  },
  TopbarItem: {
    description: 'Topbar right slot — the user menu. Rendered inside Gate.',
    type: 'ComponentType<{ route; navigate }>',
    default: 'none',
  },
  Page: {
    description: 'Pages for the integration’s own `routes` that render INSIDE the shell chrome (&#x22;team&#x22;).',
    type: 'ComponentType<{ route; navigate }>',
    default: 'none',
  },
}"
/>

`getAccessToken` and `onSessionLost` are what the shell builds its API client from, which is why the
runtime is created first. What `onSessionLost` receives, and which rejections the `auth` module
decides are not a lapse, is on [the errors page](/docs/reference/errors).

## Supporting types [#supporting-types]

| Type       | Shape                                                                                                                                                                                                       | Used by                                                          |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| `Navigate` | `(url: string, options?: { replace?: boolean }) => void`                                                                                                                                                    | `ModuleAppProps.navigate`, and every host-integration component. |
| `AppRoute` | `{ moduleId: string \| null; path: string; segments: readonly string[]; params: URLSearchParams }` — a parsed `/<seg>[/rest][?qs]` below the app's base path, where `path` is `seg/rest` without the query. | `Gate`, `TopbarItem`, `Page`.                                    |
| `HostBot`  | `{ id: string; botId: string \| null; name: string }` — `id` is the host's own id for the row, what it is asked to rename or delete by; `botId` is the Chatfuel bot, null while it is still being created.  | `HostRuntime.getBots`.                                           |
