Appearance
Admin & Ops Console
pocket agent ships with an internal operator console at /admin — a self-contained dashboard for support and incident response. It is where an operator reads the state of the system (users, agents, cards, transactions, money ops, the funnel) and takes a small, carefully-chosen set of safe, audited actions: freeze a card, decide KYC, suspend an account, deny a pending approval, sweep the treasury to an allowlisted destination, and — the big red button — pause all withdrawals.
The design principle throughout: every write action reuses an existing protected path and appends an immutable audit row. An operator can stop money and reverse-safe controls, but can never invent a new, unaudited way to move funds.

Enabling the console
The console is fail-closed. It is mounted only when ADMIN_PASSWORD is set; with it unset, the entire /admin surface returns 404, and a second in-app guard refuses every request even if the route is reached (defense-in-depth).
bash
ADMIN_PASSWORD=<a strong secret> # mounts /admin (adminConfigured)
# A real signing secret is then MANDATORY — it HMACs the admin session cookie:
REAP_AUTH_SECRET=… # or REAP_WEBHOOK_SECRETEnabling the admin panel makes a real signing secret mandatory at boot: the server refuses to start with a default secret whenever the admin panel is on, because that same AUTH_SECRET signs operator session cookies — a default would let anyone mint a valid session. See Infrastructure & Feature Flags for the boot guard.
Authentication model
Operator auth is completely decoupled from the consumer Privy auth. An operator signs in with the shared ADMIN_PASSWORD and receives a short-lived, signed, httpOnly session cookie.
The security properties, exactly as implemented (src/admin/auth.ts, src/admin/router.ts):
| Property | How |
|---|---|
| No stored password | The password is never persisted; the compare is constant-time (both sides hashed to a fixed 32 bytes so length never leaks via early return). |
| Signed session | A padm_session cookie carries {name, exp} HMAC-signed with the server AUTH_SECRET; tampering or expiry → 401. |
| Cookie hardening | httpOnly, SameSite=Strict, Secure in production, scoped to /admin, 12h TTL. |
| Brute-force protection | Login is rate-limited to 8 attempts / 10 minutes per IP, and the IP is the rightmost X-Forwarded-For entry (appended by the trusted edge) so a client cannot spoof a fresh bucket per request. |
| Failed-login alerting | Every failed login fires alert("admin.login.fail", …). |
What the console shows (read endpoints)
All read endpoints live under /admin/api/* behind the operator session guard. The dashboard is a single-page shell (ui.ts) that branches on session state.
| Endpoint | Shows |
|---|---|
GET /overview | The home dashboard — total/new/active users (real cohorts, not updated_at), KYC breakdown, agent and card counts, total card balance, frozen cards, task statuses, pending approvals, a 30-day transaction grid by kind × status, pending fund/redeem ops, suspended-user count, and the withdrawals-paused flag. |
GET /growth | The funnel + CAC + gross-margin screen — reads the events spine and the cost_events COGS ledger (defaults to 30 days, caps at 90). |
GET /treasury | Treasury address(es), live on-chain balances, the total card-balance liability it backs, "free to withdraw" liquidity above that liability, and the configured payout allowlist. |
GET /users, GET /users/:id | User search/list and a per-user detail (profile, KYC, agents, suspend state). |
GET /agents | Agent search/list. |
GET /transactions | The unified ledger, filterable by kind, status, and user. |
GET /cards | Cards, optionally only frozen. |
GET /approvals | All approvals, filterable by status. |
GET /tasks | Autonomous agent tasks, filterable by status. |
GET /ops | Unresolved money ops — fund_ops not yet credited/failed/expired, and redeem_ops not yet done/failed/released. This is the operator's "what is stuck?" view. |
GET /audit | The recent immutable admin audit log. |
GET /session | The current operator's display name. |
"Free to withdraw" is a solvency guardrail
The treasury view computes freeUsd = max(0, totalUsd − liabilityUsd), where the liability is the sum of all card balances the treasury backs. Sweeping more than freeUsd would under-collateralize user cards — the number is there so an operator can see, before a payout, exactly how much is actually free.
Actions (every one audited)
The console deliberately exposes a minimal set of write actions. Each reuses a path that already exists for users or the money engine, and each appends an admin_audit row (operator, action, target, detail, ip) before returning.
| Action | Endpoint | What it does | Reuses |
|---|---|---|---|
| Freeze / unfreeze card | POST /cards/:agentId/freeze | Flips the ledger frozen flag and best-effort tells Reap. Reversible. | The user-facing /cards/:cardId/freeze path |
| Decide KYC | POST /users/:id/kyc | Sets kyc_status to one of NONE / IN_REVIEW / APPROVED / REJECTED. | store.upsertUser (same write as the KYC endpoint) |
| Suspend / unsuspend | POST /users/:id/suspend | Sets kv admin:suspend:<id> (what the money path checks) and mirrors users.data.suspended for visibility. A suspended user cannot move money or fund cards. | The kv flag operatorBlock reads |
| Deny approval | POST /approvals/:id/deny | Flips a pending approval to denied. Operators can deny but never approve on a user's behalf. | The approvals table |
| Treasury payout | POST /treasury/payout | Sweeps treasury funds to an env-allowlisted destination only. Most dangerous — serialized, kill-switch-aware, alerted. | treasuryPayout → the audited sendWithdrawal(owner:'treasury') path |
| Kill-switch | POST /flags/kill-withdrawals | Sets/clears kv admin:kill_withdrawals to pause/resume all withdrawals without a redeploy. | The kv flag operatorBlock reads |
| Funnel backfill | POST /growth/backfill | Seeds the funnel from historical activity. Analytics-only — writes to events with ON CONFLICT DO NOTHING, never touches funds. | — |
Operators can stop a spend, never authorize one
The console intentionally has no "approve on behalf of a user" action. Approving a payment is the user's consent, and that consent cannot be delegated to an operator. An operator can only deny a proposed spend.
The kill-switch, in depth
The withdrawals kill-switch is the primary incident-response control. It sets a single kv key, admin:kill_withdrawals, which the money engine reads on every money operation. Flipping it pauses all withdrawals instantly, with no redeploy.
Coverage. operatorBlock(userId) runs inside withMoneyGuard, the wrapper around every money operation — card funding, p2p transfer, swap, and card redeem — and is also called directly in executeWithdraw (the single protected wallet-send path). So one kv flip pauses card funding, transfers, swaps, redeems, and wallet withdrawals alike. The same wrapper then enforces the per-tx and daily velocity caps (WITHDRAW_MAX_USD and friends) before the send runs.
This closes a gap called out in earlier audits
Earlier the kill-switch and suspend flag only covered executeWithdraw (wallet withdrawals). Today they are checked in withMoneyGuard, so they cover card redeem, transfer, and funding too — alongside the velocity caps, which are enforced on every money-out path and are on by default even while ENABLE_WITHDRAWALS is off. The kill-switch sits on top of that env flag: it is a live, no-redeploy emergency stop.
Fail-open on read error. operatorBlock deliberately fails open if the kv read itself errors — a database hiccup must never block all money movement by accident. The hard money gate (ENABLE_WITHDRAWALS) and the velocity caps remain in force regardless, so failing open on the kv read does not open a hole.
Treasury payout, in depth
The treasury payout is the single most dangerous action in the whole product — it signs a real on-chain send of pooled funds. It is defended in depth so that even a fully compromised admin session cannot drain funds to an attacker.
The layered defenses:
- Destination is not caller-chosen. The operator supplies only
{chain, asset, amount}. The destination is resolved from the env allowlist (TREASURY_PAYOUT_ADDRESS/TREASURY_PAYOUT_SOL) only. A stolen session cannot sweep to an arbitrary wallet. If no allowlist entry exists, the action is disabled. - Serialized + kill-switch-aware — it runs under a Redis lock and respects the withdrawals kill-switch.
- Live-balance check — it verifies on-chain liquidity before sending.
- Same audited send path — it reuses
sendWithdrawal(owner:'treasury'), the exact path every other money send uses, so all the no-phantom-credit and confirm-before-success guarantees apply. - Audited + alerted — a
treasury.payoutaudit row is written and an alert fires, every time.
There is also a boot-time guard: if TREASURY_EVM/TREASURY_SOL is an address-only override (which cannot sign) and ENABLE_WITHDRAWALS is on, the server refuses to start — because every redeem/payout would otherwise fail.
KYC queue, freeze & suspend
These are the day-to-day support controls.
- KYC decision writes through
store.upsertUserexactly like the user-facing KYC endpoint, so the operator decision and the automated flow converge on one code path. Valid states:NONE,IN_REVIEW,APPROVED,REJECTED. - Freeze mirrors the user's own freeze: it flips the ledger
frozenflag and best-effort calls ReapfreezeCard/unfreezeCard. A frozen card declines authorizations and blocks redeem. - Suspend sets
admin:suspend:<id>=1— the exact kv flagoperatorBlockchecks — so a suspended user is blocked from every money path, and mirrorsusers.data.suspendedso the state is visible in the user detail view.
Audit log
Every operator action — including login — appends an immutable row to admin_audit:
| Column | Content |
|---|---|
operator | The display name from the signed session |
action | e.g. treasury.payout, flags.withdrawals.pause, kyc.decide, user.suspend, card.freeze, approval.deny, login |
target | The affected user/agent/approval id (or the payout destination) |
detail | A JSON blob (amounts, tx hash, status, etc.) |
ip | The trusted rightmost X-Forwarded-For entry |
The audit table is append-only; the console offers no delete. The most sensitive actions (treasury payout, kill-switch) also fire a real-time alert() to ALERT_WEBHOOK_URL, so a payout or an emergency pause pages a human in addition to being recorded.
Security posture, summarized
In short: the console is off unless deliberately enabled, protected by a hardened login, limited to a minimal set of reversible-or-allowlisted actions, and fully audited. For the flags that govern the money engine underneath it, see Infrastructure & Feature Flags.