Skip to content

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/restore
GET /api/admin/people/:id → AdminPersonDetail

Every 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 → AdminProcesses
POST /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 setInterval in 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 → AdminBudgetCaps

The 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/:id

What 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 → AdminSkills

The 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 → AdminOwnerAccess
POST /api/admin/access/rotate { confirm: 'rotate' } → AdminOwnerAccessRotated

OWNER_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 → AdminLedger

credit_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 → AdminSystem

Everything 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 -wal plus its -shm, plus the freelist waiting inside it for a VACUUM. The old dbSizeBytes counted 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.