The module contract
The four interfaces a module implements — ModuleDescriptor, ModuleAppProps, HostIntegration, HostRuntime — field by field, and what each one is for.
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
Prop
Type
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
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.
Prop
Type
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.
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.
types.ts is this page. shellApi.ts is the runtime half — the 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
One module may wrap the shell instead of being a page in it. Only auth does.
Prop
Type
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
What create returns. Its components close over their own adapter, so the shell never passes one
around.
Prop
Type
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.
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. |