Skip to content
ChatfuelSDK
Modules

Admin

The operator's panel over the whole Chatfuel account behind the token — reached by a password in the server environment, never by an identity, and never from the rail.

What it is

The panel is opened by a password sitting in the server environment, and by nothing else. Not a Chatfuel login: the deployment holds one master token for one account, and asking the operator to sign in to Chatfuel from inside their own app would only prove they hold an account the token already speaks for. Not a Supabase role either: the accounts module is opt-in, a deployment without it has none at all, and an operator with one would be sitting in somebody's workspace.

What it shows is the whole Chatfuel account behind that token — every workspace and bot, one bot's details and channels, who in this app reaches which bot, and whether the deployment itself is healthy. The module makes no GraphQL call of its own: every question it asks needs the master token, so all of them are asked on the server, through routes the vendored proxy adds under /chatfuel/admin.

Installed as

Wizard idadmin
Install itnpx @chatfuel/wizard --modules admin
Selected byOpt-in--yes never installs it
RequiresNothing, beyond the implicit core
Recommendsauth
Skillchatfuel-admin

Opt-in for the same reason auth is: it needs a credential the wizard has to invent, and one that a run should not write silently. It is not hidden — it has a real /admin route — but it is railHidden, which is the module contract's other flag: routed like any other module, and in the rail like no other module, which is to say never. A test pins that rather than trusting the comment: railHidden true, hidden falsy, absent from the rail list and from the built nav groups.

auth is recommended because the Access tab needs the accounts it creates. The admin migration adds no tables of its own — it reads the ones auth created, and applying it without them fails. Every function in it is granted to service_role and to nobody else, because the panel has no Supabase session at all.

Routes and views

The view is a path segment; the workspace and the open bot are query parameters. bots is the default, so it has no segment of its own.

PathWhat it shows
/adminBots: the workspaces on a rail with their bot counts against the plan, one workspace's bots beside it, and a drawer over one bot.
/admin/accessWho in this app reaches which bot, granted and revoked per person. Mounted only where the deployment has a database.
/admin/healthWhether the token is still accepted, which fence is in force, whether the database answers, and what is missing.
ParameterWhat it does
wThe workspace selected on the rail. Absent means the first one worth opening.
bThe bot whose drawer is open.

Whether the Access tab exists at all is decided on the serverauthMode: 'on' plus a service-role key — rather than guessed from env in the browser, and the header simply does not draw a tab the deployment cannot back. Health is asked when it is opened, because checking the token is a real round trip.

The bot drawer carries name (editable in place), bot id, workspace, created, time zone, country, industry, the connected channels, the Chatfuel team on that bot, and what this token may do to it. The health grid is six tiles — Chatfuel token, Account, Bot fence, Sign-in, Database, Publish queue — over four plain lines: Chatfuel API, Token variable, Home workspace, Outbound.

The model

One password, one cookie, twelve hours. POST /chatfuel/admin/session compares timingSafeEqual over the SHA-256 of both sides — hashed first so the buffers are always the same length, because a length check in front of a constant-time compare gives away how long the real password is. On success it sets exactly one cookie, cf_admin, Path=/, HttpOnly, SameSite=Strict, and Secure whenever the request arrived over TLS. The HMAC key is derived from the password itself, so nothing is stored and a deployment answering each request from a fresh instance works unchanged. Every /chatfuel/admin/* call must carry that cookie and an x-cf-admin header: a form posted from another origin can send a cookie but cannot set a header.

There is no other revocation. A stolen cookie is good until it expires or the password changes. Rotating ADMIN_PASSWORD invalidates every open session at once, and that is the whole revocation story — stated rather than hidden, because it is the cost of a stateless session.

ADMIN_PASSWORD has a sixteen-character floor, and it exists because the throttle cannot be relied on. Wrong-password counters live in one process's memory, and a host that answers each request from a fresh instance has nothing to share them in. On the platform this is most likely to run on, rate limiting is best-effort and the length of the secret is what actually stands between the panel and a guess. Below the floor every route answers 500 AdminMisconfigured and the module names the reason; unset, the routes are not claimed at all and the host answers its own 404.

The name is unprefixed on purpose. A VITE_ one would land in the browser bundle.

The routes deliberately bypass the fences. They never call the proxy's admission sequence, never consult the auth gate, and never apply the workspace fence. Those exist to keep a request away from bots that are not the caller's — and this panel's entire purpose is the account-wide view they are built to withhold. It is the only place in the proxy where a fence is skipped, and the route module says so at the top. requireAdmin runs before every route but /session.

Chatfuel deletes a workspace when its last bot goes. Verified live, twice — and nothing warns you. For the workspace this deployment names that is unrecoverable from inside the app: the id in the environment would point at something that no longer exists, and on a deployment with sign-up nobody could create an account again, so it is refused outright and no flag gets past it. Any other workspace is refused once, with a sentence saying what it costs, and allowed when the same delete arrives again with force=1. And when Chatfuel cannot be asked which workspace a bot is in, the delete is refused: a delete that says "try again in a moment" is recoverable and a workspace that is gone is not.

Two more things about deletes are worth knowing before you write one. Asked about a bot it has already deleted, Chatfuel answers NotEnoughPermissions — not "no such bot" — so a client that treats only BotDoesNotExist as "already gone" leaves a half-finished delete that can never complete. And the order is Chatfuel first, the database second (the opposite of a rename): a row without its bot is a dead entry in everybody's switcher, while a bot without its row is merely out of reach and the next attempt finishes the job.

After a create or a delete the panel clears both caches whole: the workspace fence holds its bot set for sixty seconds and the auth gate holds each session's for thirty, so without that a bot the panel just made is refused by the proxy for up to a minute after the screen says it exists.

Configuration

VariableNotes
CHATFUEL_TOKENSecret. Server-side only.
VITE_CHATFUEL_WORKSPACE_IDThe workspace you picked during the wizard run.
CHATFUEL_API_BASEDefaults to https://panel.chatfuel.com.
ADMIN_PASSWORDSecret, unprefixed, at least 16 characters. The only thing that opens /admin. Unset means the routes are not mounted; changing it signs every open session out.

The wizard has three ways to fill the last one — a flag, the environment, or one it generates — and generated is the default, because the thing being chosen is a secret nobody has to remember. It is 24 random bytes as base64url, so 32 characters, and deliberately not hex: the wizard's own log scrubber masks any 64-hex string, and a hex secret would be invisible in exactly the output somebody needs to read it out of. It goes into .env and no other file — not the handoff, not the agent instructions, not a README, because those are checked into repositories — and it is printed once at the end, only when this run invented it.

Every variable the app reads, and which side reads it, is on environment variables.

Permissions

ObjectActionRequired for
BotViewListing the account's workspaces and bots, and reading one bot's details.
BotEditCreating, renaming and deleting bots.

These are the token's own permissions, not a person's. There is no identity here to attach them to — whoever can read the environment file already holds the token, so the panel grants nothing the credential did not.

Limits

/admin is the whole way in, before unlocking and after. There is no Admin item in the rail and there never will be: the rail is the list of places a product's users go, and an item that appeared once the password was accepted would still be an item anybody could see the moment they borrowed the machine. Bookmark the URL.

Nothing here ever shows a secret. The health page reports CHATFUEL_TOKEN and the service-role key as present or absent; a value would make one browser screen as sensitive as the environment file. The variable's name is on screen, because an operator fixing a deployment needs to know which name to set. A bot's apiToken is never selected in any query — it opens that bot's public API.

Two things cannot be done from the panel by design. The admin password cannot be rotated here: a deployment that could rewrite its own environment from a browser would be a worse door than the one it replaced. And there is no session revocation, because the cookie is stateless — which is what makes it work on per-request functions.

Workspace members are not in the overview query. Asked for Workspace.members, the accounting subgraph answers InternalServerError for every workspace on the account, and because the field is non-nullable that one field turns the whole answer into data: null and the panel loses the workspaces it was drawing. The people on a bot come back fine, and that is what the drawer shows.

A bot created outside this app has no row in the database. It can be renamed and deleted here, but the Access page will not list it.

The panel's own numbers:

Password floor16 characters
Generated password32 characters (24 random bytes, base64url)
Session12 hours
Flat delay on every attempt250 ms, right or wrong
Wrong answers before the wait starts3
The waitDoubles from 1 second, up to 5 minutes
Throttle entries kept1,000
Bot id accepted^[A-Za-z0-9_-]{1,64}$
Bot name120 characters
Health token ping10-second timeout

On this page