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.
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:
{
"$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:
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:
| 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 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:
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-smokepnpm 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 devThe 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.
What to read next
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.
Add a module to an app you already made
The wizard has one door into a project that exists — --embed — and it was written for somebody else's app. What it writes, what it never overwrites, and the four things you finish by hand.
Change how it looks
One file of tokens moves every component in both themes. The duplicate-block rule, the two places motion lives, the fonts that are the app's rather than the system's, and the edits that generate nothing.