Skip to content
ChatfuelSDK
Guides

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.

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.

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.

index.tsx
AdminApp.tsx
AdminContext.ts
AdminWorkspace.tsx
types.ts

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.

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:

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.

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.

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:

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 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.

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:

FileWhat it is
lib/<name>Store.tsa pure reducer — no React, no clock, no fetching
hooks/use<Name>Store.tsthe useReducer binding, and the effects that feed it
<Name>Context.tsa 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 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:

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.

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.

Run the gates

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:

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 and click into your module from the rail. That is the check: the rail item, the address, and a screen that is not blank.

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.

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

On this page