# Write a module of your own

> A module is a directory, a manifest, a React tree and two registration tables. Copy the smallest one in the repository, keep the conventions the gates check, and finish on pnpm validate.

Page: https://sdk.chatfuel.com/docs/guides/build-a-module
Markdown: https://sdk.chatfuel.com/docs/guides/build-a-module.md

There is no plugin API to implement and nothing to register at runtime. A module is two
directories that share a name — a content half that describes it and teaches an agent about it,
and an app half the shell mounts — plus an entry in each of two tables. What makes it a module
rather than a folder is that the gates recognise the shape, so the fastest way to get one right
is to copy one that already passes them.

This is that shape, in the SDK repository, where both halves live. In an app the wizard
scaffolded there is only the app half: `src/modules/<id>/` with a descriptor in it is enough for
the shell to route and draw it, and the manifest and the skill are what a module needs in order
to travel to somebody else's project.

<Steps>
  <Step>
    ### Copy the smallest one [#copy-the-smallest-one]

    `admin` is the smallest module in the repository — twenty-five files under
    `apps/shell/src/modules/admin/` — and it carries the whole shape: a descriptor, an address file,
    a reducer, its hook, a context, three views and a components directory.

    <Files>
      <Folder name="apps/shell/src/modules/admin">
        <File name="index.tsx" />

        <File name="AdminApp.tsx" />

        <File name="AdminContext.ts" />

        <File name="AdminWorkspace.tsx" />

        <File name="types.ts" />

        <Folder name="components" />

        <Folder name="hooks" />

        <Folder name="lib" />

        <Folder name="views" />
      </Folder>
    </Files>

    Two things about it are unusual and come out on the way: it opens behind a password rather than
    any Chatfuel identity, and it carries `railHidden: true` so it never appears in the menu. If you
    would rather start from an ordinary rail module, `ads-optimization` is the next one up — forty-nine
    files, one surface, no lock screen.
  </Step>

  <Step>
    ### Write the content half [#write-the-content-half]

    ```
    modules/<id>/
      module.json      the manifest
      handoff.md       inlined into your coding agent's instructions file
      skill/           installed into .claude/skills/ or .agents/skills/
    ```

    `module.json` is validated against a JSON Schema that accepts no field it does not know, so a typo
    is a failed gate rather than a silent no-op. Here is a whole one:

    ```json title="modules/bookings/module.json"
    {
      "$schema": "../../packages/module-kit/module.schema.json",
      "id": "bookings",
      "name": "Bookings",
      "description": "Booking workspace over bookingsV2: day/week/month calendar with drag-and-drop, appointments list, staff with weekly hours and Google Calendar sync, services catalog, AI booking settings, insights; live updates and an availability-driven booking wizard.",
      "status": "ready",
      "requires": [],
      "recommends": ["knowledge-base", "contacts"],
      "skill": { "installAs": "chatfuel-bookings" },
      "app": {
        "env": [
          { "name": "CHATFUEL_TOKEN", "secret": true },
          { "name": "VITE_CHATFUEL_WORKSPACE_ID", "resolve": "workspacePick" },
          { "name": "CHATFUEL_API_BASE", "default": "https://panel.chatfuel.com" }
        ],
        "embed": {
          "roots": ["src/modules/bookings"],
          "entryComponent": "BookingsApp",
          "playbook": "playbooks/embed.md"
        }
      },
      "permissions": [
        { "object": "People", "action": "View", "requiredFor": "reading the calendar and appointments" },
        { "object": "People", "action": "Edit", "requiredFor": "creating, moving, resolving and deleting bookings" },
        { "object": "Ai", "action": "Edit", "requiredFor": "managing staff and services, the AI booking settings and the bot time zone" }
      ]
    }
    ```

    `id` has to equal the directory name on both sides, and `skill.installAs` has to be unique across
    every module — both are checked by name. `status: "ready"` is what puts a module in the picker;
    `selection: "opt-in"` keeps it out of what `--yes` takes. `app.embed` declares which shell
    directories `--embed` copies and which component a host mounts. Every field is on the
    [manifest reference](/docs/reference/module-manifest).

    `handoff.md` is prose, not configuration: what the module does, the address it lives at, its
    deep-link params, and the traps worth knowing before touching it. The wizard inlines it into the
    instructions file of whichever coding agent the user picked, so write it for them rather than for
    us — everything in these trees is copied onto somebody else's disk.
  </Step>

  <Step>
    ### Write the app half [#write-the-app-half]

    `apps/shell/src/modules/<id>/index.tsx` exports one thing, under a name that cannot vary — the
    wizard regenerates the registry by looking for it:

    ```tsx title="apps/shell/src/modules/admin/index.tsx"
    export const moduleDescriptor: ModuleDescriptor = {
      id: 'admin',
      title: 'Admin',
      icon: <IconShield />,
      railHidden: true,
      Component: lazy(() => import('./AdminApp').then((m) => ({ default: m.AdminApp }))),
    };
    ```

    `Component` is a `React.lazy` one in every registered module. The descriptor is what the rail
    needs at startup — an id, a title, an icon — and the module itself is a chunk fetched on the first
    visit; imported eagerly, ten modules put ten modules' generated GraphQL documents into one first
    load for somebody who opened one of them.

    Everything below `index.tsx` is yours, inside one boundary: module code may import React, `~ui`,
    `~api`, the three shell contract files beside your directory, and its own subtree. Nothing else —
    not another module, not a shell component. What the shell hands you instead arrives as props:
    `botId`, `client`, `params`, `setParams`, `view`, `setView` and `navigate`. A module never touches
    `window.location`, which is what [routing](/docs/concepts/routing) is about.

    If `hidden: true` is in the manifest it has to be on the descriptor as well, and the gate says so
    by name when they disagree.
  </Step>

  <Step>
    ### Split the state three ways, and put the address in its own file [#split-the-state-three-ways-and-put-the-address-in-its-own-file]

    There is no state library, and the convention that replaces one is three files with three jobs:

    | File                      | What it is                                                            |
    | ------------------------- | --------------------------------------------------------------------- |
    | `lib/<name>Store.ts`      | a pure reducer — no React, no clock, no fetching                      |
    | `hooks/use<Name>Store.ts` | the `useReducer` binding, and the effects that feed it                |
    | `<Name>Context.ts`        | a throwing accessor, so a component outside the provider fails loudly |

    Deep-link state gets a fourth, `lib/<name>Params.ts`, pure the same way: parse in, serialize out.
    Two rules hold it together — an unknown value falls back silently, because the link somebody is
    opening was minted three releases ago, and the default view writes no segment at all, so
    `/admin` **is** the bots list. [State](/docs/concepts/state) is the long version.

    The split that is easy to get wrong is the provider one. The exported root renders the providers
    and consumes none of them; an inner component does the consuming:

    ```tsx title="apps/shell/src/modules/admin/AdminApp.tsx"
    export function AdminApp({ client, view, setView, params, setParams, selectBot }: ModuleAppProps) {
      const store = useAdminStore(client);
      const context = useMemo(() => ({ client, store, selectBot }), [client, store, selectBot]);

      return (
        <ToastProvider>
          <AdminContext.Provider value={context}>
            <ModuleRoot className="relative">
              <AdminWorkspace view={view} setView={setView} params={params} setParams={setParams} />
            </ModuleRoot>
          </AdminContext.Provider>
        </ToastProvider>
      );
    }
    ```

    Calling the context hook inside the component that renders its provider throws at runtime — the
    hook runs while the provider is still only a return value — and `tsc` cannot see it. It has
    happened once, and there is now a pass that catches it.
  </Step>

  <Step>
    ### Register it in both tables [#register-it-in-both-tables]

    `src/modules/index.ts` is the registry, generated by the wizard from the modules a user picked and
    listing every ready module in the repository. Add the import under the fixed name and put the
    binding in `MODULES`.

    `src/modules/navGroups.tsx` is the menu, and it is the one place the information architecture
    lives. Put your id in a group's `items` array, in the order it should read. The registry's order
    is `readdirSync` order and would read differently in every deployment, which is why the menu is
    not built from it.

    Leave the second one out and your module still appears, under a heading called `More` — a menu
    that silently dropped a page would be worse than one with an extra heading in it.
  </Step>

  <Step>
    ### Run the gates [#run-the-gates]

    ```bash
    pnpm validate        # schema, manifests, references, boundaries, publishability, …
    pnpm check           # tsc across the workspace
    pnpm lint            # eslint + prettier --check
    pnpm test            # all suites
    pnpm --filter @chatfuel/wizard pack-smoke
    ```

    `pnpm validate` is the one that knows about modules. It reads your manifest against the schema and
    then the semantic rules ajv cannot express; it checks the descriptor exists, exports the fixed
    name, is in the registry and has a handoff note; it checks your embed roots resolve and your
    declared entry component is actually exported; it walks every import in your subtree against the
    boundary; and it refuses a cycle in your own file graph. If your module ships GraphQL, it also
    insists that `skill/examples/operations.graphql` exists and that your id is in the codegen list —
    and that a module with no operations is **not** in that list.

    Then the module itself:

    ```bash
    pnpm --filter chatfuel-shell-app test
    pnpm --filter chatfuel-shell-app dev
    ```

    The test to write first is the render smoke test every module carries. The suite runs without a
    browser, so nothing else in it can see a component that throws on its first render: it
    type-checks, it passes every gate, and it renders nothing. Rendering each of your surfaces to a
    string over an inert client and a store already in the state that surface draws from is what
    catches a white screen before a person does.

    Then open the dev server on [localhost:5173](http://localhost:5173) and click into your module
    from the rail. That is the check: the rail item, the address, and a screen that is not blank.
  </Step>
</Steps>

<Callout type="warn">
  Every module is installed in the repository, so a module that quietly assumes another one is
  there works perfectly here and breaks in the first scaffold that leaves the other one out. A
  module may not import another module, and it must not guess what a scaffold took either — the
  shell tells it, in `installedModules`, which is how `knowledge-base` links into `bookings` when
  bookings is installed and edits the same data itself when it is not.
</Callout>

## What to read next [#what-to-read-next]

The two halves have a reference page each: the [module manifest](/docs/reference/module-manifest)
for every field `module.json` accepts, and the [module contract](/docs/reference/module-contract)
for every prop the shell hands a module. [Modules](/docs/concepts/modules) is the concept behind
both, and [your first change](/docs/first-change) is the same shape at one tenth the size — a view
added to a module that already exists.
