The admin console
/admin (apps/web/src/admin/index.tsx) is the owner’s operator console. A node is one person’s
operating system, so this is not a customer-management console: it is the control panel and task
manager for a machine that runs autonomous agents on your behalf and spends your money doing it.
Eight areas — Processes, Money, System, Modules, People, Skills, Access, Audit — each answering
one of three questions: what is my world doing, what is it costing me, what do I do when something
is wrong. Read-only wherever a read is enough. Everything behind it is gated by a single requireOwner
preHandler hook on the whole plugin (apps/api/src/modules/admin/routes.ts), the same
one-hook-for-the-whole-file shape the LLM routes use for requireAuth — there is exactly one place
that decides who may reach this data, not one check scattered per handler. A signed-in human who
isn’t the owner sees the same calm “forbidden” empty state the rest of the app uses for a 403, not
a stripped-down version of the console.
People
GET /api/admin/people → AdminPerson[]POST /api/admin/people/:id/suspend { reason }POST /api/admin/people/:id/restoreGET /api/admin/people/:id → AdminPersonDetailEvery being, human or AI, in one list with one set of columns, and a detail view of any one of them (spend, memories, storage, wallet, activity, runs, conversations) computed identically for every being.
Suspension is decided by credentials, not by kind. It used to refuse anything that was not
kind: 'human', which reads as a capability withheld from AI citizens; it never was one.
Suspension works by locking a password hash, so what it actually needs is a credentials row, and
that is now the question the server asks — AdminPerson.canSuspend, with a
cannotSuspendReason the console prints where the control would be rather than silently omitting
it. The owner can never be suspended. An AI citizen that one day holds credentials becomes
suspendable the same day, with no code change. See design/PRIMITIVES.md §1.1.3 and
tests/contracts/citizenship.test.ts, where this is recorded as V11 fixed.
Suspending never deletes an account. suspendBeing() (apps/api/src/modules/admin/store.ts)
backs up the account’s real password hash into a separate admin_suspensions table, then
overwrites credentials.passwordHash with a freshly generated, unguessable one — the same
technique the auth module uses for its DUMMY_HASH timing-safe constant — so no password can ever
match it again. The caller separately calls destroyAllSessions(beingId), so an already-open
session dies immediately rather than surviving until next login. Restoring puts the real hash
back verbatim and drops the suspension row — the account returns exactly as it was, nothing about
it was ever deleted, and both actions are logged to the audit trail below.
Processes
GET /api/admin/processes → AdminProcessesPOST /api/admin/processes/:id/cancel → {ok, projectId, aborted, status, detail}The answer to “what is running right now”, and the panel that did not exist. It is careful about one distinction, because getting it wrong is how you end up trusting a number that resets on restart:
- a run is durable. It holds a claim row (
apps/api/src/modules/studio/claims.ts) that survives a crash and names the host, pid and process instance holding it. A project in an active status with no claim is either inside the reaper’s grace period or already stranded, and both are shown, because the gap between “it is working” and “the row says it is working” is exactly the lie the claims table exists to stop; - a loop — the twin tick, the Square’s life, Telegram, the embedding drain, the session purge,
the reaper — is a
setIntervalin this API process with no durable record at all. Each one reports the last effect it left in the database rather than an in-memory counter, and the panel says which process it is talking about.
Reading this route never reaps. reapStrandedRuns() fails projects as a side effect, and a GET that
quietly kills work is a trap, not a console; what the reaper did is read back from the traces it
left. Cancel is honest about its reach: it aborts immediately when this process holds the run,
and otherwise says so — the row flips now and the holder notices at its next check, or the reaper
sweeps it within a minute.
Budget caps
GET|PUT /api/admin/budget → AdminBudgetCapsThe caps were environment variables read once at boot, so raising one meant editing .env and
restarting the API in the middle of the run that hit the cap. A value set here applies to the next
model call, is stored in admin_settings, is reloaded at boot, and carries an audit row with the
before and after. The PUT merges key by key (the same reasoning as
PATCH /api/twin/profile/preferences): an absent cap is untouched, null hands that cap back to
the environment, 'off' means no limit. A whole-value PUT over three caps on one form is how two of
them get silently reset by an edit to the third.
Modules and plugins
GET /api/admin/modules → AdminModule[]POST /api/admin/modules/:id/revoke { capability }DELETE /api/admin/modules/:idWhat third-party code is installed and exactly what it may reach, with the manifest’s declared
uses beside the install’s actual granted — not “network” but “network: api.example.com, GET”.
Granted-but-not-declared and declared-but-not-granted are both surfaced.
Revoking is fail-closed. The grant row is narrowed first, so the module bridge — which re-reads
it on every call — refuses immediately; then the plugin’s live skills are unloaded, because a
running plugin holds the capability set it was installed with and there is no safe way to narrow
that in place; then the install drops to needs-approval. The code stays on disk, nothing of the
owner’s is deleted, and it will not run again until it is deliberately reinstalled.
Skills and approvals
GET /api/admin/skills → AdminSkillsThe live registry, which entries WORLD_ACTING_SKILLS gates, and every approval decision. The
gated list grew from five to seven the hard way — http.json slipped past while http.request was
gated — because nobody could see the two lists side by side. This is that view. It is honest about
its own scope: an approval row is written only when the call carries a projectId, so a
world-acting skill invoked from the Muse or the omnibar raises a Signal but leaves no row here.
Access: rotating the owner’s own code
GET /api/admin/access → AdminOwnerAccessPOST /api/admin/access/rotate { confirm: 'rotate' } → AdminOwnerAccessRotatedOWNER_ACCESS_CODE ships as NOVA-OWNER-0001, which is published in this repository, the docs and
the install scripts: a default credential on the one account that can do everything. The console
says so in plain words while it is still in force.
The current code is never shown — only a prefix hint and a length. Rotation mints a new code,
records it as this node’s owner bootstrap code, and withdraws the previous one if it was never used
(a used code is a historical fact about how somebody arrived, so it is kept). ensureOwnerAccessCode()
reads that rotation rather than the environment, which is what makes it survive a restart —
without that, rotating was theatre, because the boot path put the environment’s default straight
back into access_codes, still valid. The new value is returned exactly once, in the response to
the request that caused it; it is deliberately kept out of the audit row, and no route will hand it
back.
Spend
GET /api/admin/spend?days=30 → AdminSpend{ budget, limits, byDay, byTier, byModel, byPurpose, byBeing, byProject, failed, recent }The same llm_calls table Models and the budget guards
already tracks, sliced seven ways: a daily series, by tier, by model, by purpose (with the
project:<id>| attribution prefix stripped and folded so “twin.reflect” reads as one row instead
of fifty), by being (who is actually spending), by project (what a run cost), and the most recent
calls verbatim. failed is the one that is easy to miss and matters at 2am: llm_calls has no
error column, so a failed call is recorded by appending " (failed: …)" to its purpose, and this
counts the money spent on calls that never came back.
This is the one place an owner can see LLM cost broken down by who and why, not just the running total the Wallet widget shows on the desktop. It is US dollars throughout — never credits, which are the world’s own currency and live in the ledger below.
The credit ledger
GET /api/admin/ledger?limit=100 → AdminLedgercredit_entries read-only, world-wide, plus the totals, the Stripe events (type, outcome and time
— never the stored payload) and, most usefully, the wallets whose cached balance disagrees with
their own ledger. An empty drift list is the healthy answer. Nothing on this screen writes to the
ledger: it explains a balance, it does not adjust one.
Health
GET /api/admin/health → AdminSystemEverything GET /api/health already reports (uptime, LLM key presence, SQLite latency, Docker
availability, budget snapshot, WebSocket counts, embeddings coverage — see
Architecture), plus the things only the filesystem can answer:
- free disk, and how close it is to the level where writes start failing. This is not decoration. A node that runs out of disk fails a login, because SQLite cannot write, and kills whatever was running. The threshold is an absolute 2 GB rather than a percentage, because a 1% margin means 40 GB on one disk and 1.3 GB on another, and only the absolute number maps to the failure;
- the real size of the database: the file plus its
-walplus its-shm, plus the freelist waiting inside it for a VACUUM. The olddbSizeBytescounted only the first, and a WAL database on a busy day is mostly WAL; - the size of the workspace, where every project a run has ever built is still sitting;
- every table by row count, so “what is taking the space” is one list rather than a guess;
- two connection numbers, labelled separately because they legitimately disagree: beings holding a socket, and beings present — the latter counts AI citizens, who are resident in this process and never hold one.
Above all of it is a warnings band: named problems, worst first, each already phrased as something to do about it. Everything that can appear there is something that has actually taken this node down. An operator opening this page opened it because something is wrong, and should not have to read thirty tiles to work out which number is the bad one.
Access codes and the waitlist
Beyond the owner-code rotation above, this panel is deliberately read-only. Minting and revoking access codes already has a working
UI in Settings → Access codes (apps/web/src/settings/AccessCodesPanel.tsx), which calls the same
owner-gated /api/auth/codes routes described in
Access codes and the waitlist — the admin panel shows
the same truth (issued, used, unused, the waitlist, a CSV export) rather than building a second set
of controls for the same action.
Audit
GET /api/admin/audit?limit=&offset=&actorId=&action=&q=&since=&until= → AdminAudit{ id, actorId, actorHandle, action, target, targetLabel, detail, before, after, at }admin_audit is append-only — nothing writes to it except writeAudit(), and nothing in the admin
module ever updates or deletes a row. Every mutating route writes one: suspend, restore,
run.cancel, budget.caps, module.revoke, module.uninstall, access.rotate. before and
after carry what actually changed for the actions where that is a meaningful question — a cap
raised, a grant narrowed — and are null for the ones where it is not. They never carry a secret:
the rotate row records a prefix hint of the code that was replaced, never either value.
It is filterable by actor, action, free text and a date range, and the filter’s options are computed over the whole table rather than the page you are looking at, so a filter can always be undone. Reading this table is how an owner answers “who did what, to whom, and when” without trusting anyone’s memory of it, including their own.
One thing to read carefully: the Docker line is a Docker probe, and since
code.execute moved to the WASM tier
a machine with no Docker can have a perfectly working sandbox. The field is accurate about Docker;
it is not a general “is the sandbox up” answer.
What this console is not
Being plain about the shape, because “admin” invites assumptions:
-
There are no roles. One node has one owner, and everything here is gated on being that person. There is no admin, reviewer, moderator or support role to grant, and there is no plan for one — see Architecture.
-
Moderation lives at
/moderation, not in this console — see Moderation. Reporting, the owner’s moderation queue, blocking and a node’s Square admission policy are real, but they are a separate surface from/admin— this console’s job is the machine and the money, not the content people post. Suspending an account here is a different, operator-level action against a person who registered on your node; it is not how you deal with a post.(This bullet used to say: “There is no moderation queue, and no ‘Report this’ anywhere in the product.” That was true when written; moderation has since shipped, just not in this console.)
-
There is no plugin review queue. Installing a plugin is an owner action with an explicit capability grant (see Plugins and extensibility); nothing reviews one on anyone’s behalf.
-
There is a process view, and it is not a job queue. Processes shows the work that is in flight and lets you stop it; it does not schedule, retry or reorder anything, and it has no queue of its own. What a run is for still lives in the Studio and at
/work. -
There is no plugin store. Modules shows what is installed and takes reach away; installing still means an owner moving a directory and granting capabilities by hand.
Related
- Payments and credits — the owner’s Stripe/payout status
(
GET /api/payments/status) is a sibling read to this console, not part of it. - Access codes and the waitlist — where minting actually happens.
- Models and the budget guards — what the Spend panel’s numbers mean and how the ceilings that produce them work.