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 id | admin |
| Install it | npx @chatfuel/wizard --modules admin |
| Selected by | Opt-in — --yes never installs it |
| Requires | Nothing, beyond the implicit core |
| Recommends | auth |
| Skill | chatfuel-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.
| Path | What it shows |
|---|---|
/admin | Bots: 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/access | Who in this app reaches which bot, granted and revoked per person. Mounted only where the deployment has a database. |
/admin/health | Whether the token is still accepted, which fence is in force, whether the database answers, and what is missing. |
| Parameter | What it does |
|---|---|
w | The workspace selected on the rail. Absent means the first one worth opening. |
b | The bot whose drawer is open. |
Whether the Access tab exists at all is decided on the server — authMode: '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
| Variable | Notes |
|---|---|
CHATFUEL_TOKEN | Secret. Server-side only. |
VITE_CHATFUEL_WORKSPACE_ID | The workspace you picked during the wizard run. |
CHATFUEL_API_BASE | Defaults to https://panel.chatfuel.com. |
ADMIN_PASSWORD | Secret, 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
| Object | Action | Required for |
|---|---|---|
Bot | View | Listing the account's workspaces and bots, and reading one bot's details. |
Bot | Edit | Creating, 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 floor | 16 characters |
| Generated password | 32 characters (24 random bytes, base64url) |
| Session | 12 hours |
| Flat delay on every attempt | 250 ms, right or wrong |
| Wrong answers before the wait starts | 3 |
| The wait | Doubles from 1 second, up to 5 minutes |
| Throttle entries kept | 1,000 |
| Bot id accepted | ^[A-Za-z0-9_-]{1,64}$ |
| Bot name | 120 characters |
| Health token ping | 10-second timeout |
Admin, as a product
The three tabs, the door, and what an operator can and cannot do from inside their own deployment.
Accounts
Recommended alongside this one — the app's own accounts, which the Access tab grants bots to.
The auth gate
The fence these routes deliberately skip, and what it is doing for every other request.
Your agent's notes
The wizard installs .claude/skills/chatfuel-admin/ (Codex: .agents/skills/chatfuel-admin/) — references/access.md for the door, references/bots.md for the last-bot rule and the cache clears, references/health.md for what the health page may report.