# Agent ops console (Claude Agent SDK)

An app section for running one agent: an overview of today's runs, spend and failures; runs with their log, trace, receipt and retry, skip or stop; a monthly budget the agent stops at; admin-only settings for its tools, model and versioned instructions; and a chat that drives runs. The agent routes enforce the settings and the budget.

- Registry name: `agent-ops-console-claude` (`@candor/agent-ops-console-claude`)
- Tier: Premium. Installing it needs a Premium or Team licence key: the person runs `npx candor-ui key <key>` themselves; never ask for the key in chat or write it to a file.
- Docs: https://candor.themethodic.com/docs/agent-ops-console-claude

## Install

```bash
npx candor-ui add agent-ops-console-claude
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/agent-ops-console-claude`.
It brings in: app-layout, app-sidebar, button, card-registry, chat, claude-agent-sdk, claude-agent-server, command-palette, composer, entity-chip, guardrail-notice, input, instructions-editor, know, locked, log-stream, message, model-settings, notification-center, ops, page-header, page-state, prompt-suggestions, relative-time, run-log, run-receipt, run-recovery, settings-layout, spend-breakdown, standard-cards, stat-card, status, team, thread, tool-permissions, trace-viewer, usage-meters, use-claude-agent, use-shortcut, work.
npm packages: @anthropic-ai/claude-agent-sdk@^0.3.318, drizzle-orm@^0.45.4, lucide-react@^1.67.0, postgres@^4.0.0, zod@^4.6.7.

It installs under your app's `app/` folder and runs with a server and an agent: set `ANTHROPIC_API_KEY`, and read the comment at the top of its `store.ts` for storage and auth. Its files:

- `app/agent-ops-console/agent.ts`
- `app/agent-ops-console/api/approve/route.ts`
- `app/agent-ops-console/api/ops/route.ts`
- `app/agent-ops-console/api/route.ts`
- `app/agent-ops-console/api/runs/route.ts`
- `app/agent-ops-console/api/settings/route.ts`
- `app/agent-ops-console/chat/page.tsx`
- `app/agent-ops-console/db/connect.ts`
- `app/agent-ops-console/db/drizzle-repo.ts`
- `app/agent-ops-console/db/schema.ts`
- `app/agent-ops-console/db/seed.ts`
- `app/agent-ops-console/layout.tsx`
- `app/agent-ops-console/page.tsx`
- `app/agent-ops-console/repo.ts`
- `app/agent-ops-console/runs/page.tsx`
- `app/agent-ops-console/settings/page.tsx`
- `app/agent-ops-console/spend/page.tsx`
- `app/agent-ops-console/store.ts`
- `app/agent-ops-console/ui.tsx`

## <ChatPage>

No props.

## <Layout>

Every page of the section sits in the same shell: sidebar, ⌘K palette, notices, and the runs and spend they read.

Props:

- `children: React.ReactNode`

## <OverviewPage>

Today's runs, the month's spend against the budget, and the failures waiting on someone.

No props.

## <RunsPage>

Every run: the list, and for the one you open its recovery (if it stopped), trace, receipt and log.

No props.

## <SettingsPage>

The admin's side: what the agent may do, how it thinks and what it's told. Everyone else sees it locked, with why.

No props.

## <SpendPage>

The month's budget, spend against it, and spend over time by model, agent and what it was for.

No props.

## <OpsShell>

The section's frame: the sidebar with its places, a ⌘K palette to jump to a page or a run or to ask the agent for one, the notices, and the runs and spend every page reads.

Props:

- `children: React.ReactNode`

## <OpsChat>

The chat that drives runs. The Runs page shows them; the agent runs with its admin's settings and budget.

Props:

- `chat: OpsChatState`: The chat state from useCandorChat (AI SDK) or useClaudeAgent (Claude Agent SDK); the page works with either.

## Functions

### opsToolTitle

```ts
opsToolTitle(tool: string, input: unknown)
```

Plain-words titles for the agent's tools, the same for both agent SDKs.

### connectOpsDb

```ts
connectOpsDb(url: string)
```

Open the section's database from a Postgres URL with the postgres-js driver. Swap the driver here (node-postgres, Neon's websocket Pool, …) if your app already uses another; it must support interactive transactions.

### opsTx

```ts
opsTx(db: OpsDb)
```

One transaction's view of the tables. Exported for ./seed.ts.

### drizzleOpsRepo

```ts
drizzleOpsRepo(db: OpsDb)
```

### seedOps

```ts
seedOps(db: OpsDb)
```

Load the demo (the agent's settings, tools and first instructions, two runs from earlier today and some spend) into the database, once: it does nothing when the settings row exists. Run it after migrating, e.g. from a script: `await seedOps(connectOpsDb(process.env.DATABASE_URL!))`. In real use, load your own settings instead.

### memoryOpsRepo

```ts
memoryOpsRepo()
```

In this server process's memory: gone on restart and not shared between instances. Transactions run one at a time, and a transaction that throws is rolled back to the state it started from. Starts empty; store.ts seeds it.

### viewerOf

```ts
viewerOf(request: Request)
```

Who is asking. Replace with your auth; the demo reads a cookie the settings page sets (default Riley).

### costOf

```ts
costOf(model: string, t: Tokens)
```

What one call cost at list price, or null when the model or the token counts are unknown.

### setOpsRepo

```ts
setOpsRepo(repo: OpsRepo)
```

Use your own storage (or a database you opened yourself). Call it once, before the routes run.

### opsRepo

```ts
opsRepo()
```

The storage in use: whatever setOpsRepo() was given, else Postgres when DATABASE_URL is set, else memory (seeded with the demo).

### seedDemo

```ts
seedDemo(tx: OpsTx, { history = true }: { history?: boolean | undefined } = {})
```

The demo, written through the repo: the agent's settings, tools and first instructions, and unless `history` is false, four earlier weeks of spend and two runs from earlier today.

### resetStore

```ts
resetStore({ pace = 1, empty = false }: { pace?: number | undefined; empty?: boolean | undefined } = {})
```

For tests: start from the seed data again, optionally with no waiting in steps and no seeded runs or spend. On a database this deletes the section's tables' rows.

### saveInstructions

```ts
saveInstructions(text: string, viewer: string)
```

Save the text as a new version. Every earlier version is kept and can be restored.

### restoreInstructions

```ts
restoreInstructions(id: string, viewer: string)
```

Bring back an earlier version by saving its text as the newest one, so the restore can be undone too.

### setToolPermission

```ts
setToolPermission(id: string, permission: ToolPermission, viewer: string)
```

### setModelSettings

```ts
setModelSettings(value: ModelSettingsValue, viewer: string)
```

### setBudget

```ts
setBudget(usd: number, viewer: string)
```

### budgetStop

```ts
budgetStop()
```

Why the agent can't start now, or null when it can.

### remainingBudget

```ts
remainingBudget()
```

What's left of the month's budget, for the Claude Agent SDK's own backstop.

### recordSpend

```ts
recordSpend(input: { model: string; tokens: Tokens; basis: Basis; queryId: string })
```

Record one model call's spend. Returns why the agent must stop when this call made it: the month's spend reached the budget, or the call's cost couldn't be measured (a guard that can't see spend can't guard). Runs with the settings row locked, so two calls recorded at once each see the other: they can't both pass a check only one fits under.

### attributeSpend

```ts
attributeSpend(queryId: string, runIds: Set<string>)
```

When a request ends, put what it spent on the run it worked on. Only when it worked on exactly one run: one request's tokens can't honestly be split between runs, so otherwise it stays as agent chat.

### settleClaudeQuery

```ts
settleClaudeQuery(queryId: string, sessionId: string, total: number, resumed: boolean)
```

The Claude Agent SDK reports each reply's cost (its own estimate) only when the reply ends. Replace what was recorded from tokens during that reply with the SDK's figure, keeping each entry's share. `total` is the SDK's cumulative `total_cost_usd` for the session.

### stopForSdkBudget

```ts
stopForSdkBudget()
```

The SDK stopped the reply at its own budget (`maxBudgetUsd`).

### runConfig

```ts
runConfig(viewer: string)
```

Everything an agent route needs to run the agent exactly as its admin configured it.

### budgetGate

```ts
budgetGate()
```

Refuse agent work once the budget is spent, even inside a reply that started before it was.

### startRun

```ts
startRun(template: TemplateId, by: string)
```

Start a run and carry it as far as it goes. `by` is who asked: the person, through the agent or the Runs page.

### retryStep

```ts
retryStep(runId: string, stepId: string, by: string)
```

Retry the failed step, then go on.

### skipStep

```ts
skipStep(runId: string, stepId: string, by: string)
```

Skip the failed step, then go on.

### stopRun

```ts
stopRun(runId: string, by: string)
```

Stop a failed run. What finished stays done; the answer says what that was and how to undo it.

### changesSomething

```ts
changesSomething(tool: string, input: unknown)
```

Whether a call changes something: starting a run with a writing step, or retrying or skipping when a writing step lies ahead. A call the store can't place counts as changing.

### needsApproval

```ts
needsApproval(tool: string, input: unknown)
```

Whether the agent must wait for the person: the admin set the tool to "ask", or the call changes something.

### recover

```ts
recover(input: { action: "retry" | "skip" | "stop"; runId: string; stepId?: string | undefined; confirmed?: boolean | undefined }, viewer: string)
```

A person's recovery choice from the Runs page. A choice that changes something needs `confirmed`.

### listRuns

```ts
listRuns()
```

Every run, newest first.

### getRun

```ts
getRun(runId: string)
```

### spendBreakdown

```ts
spendBreakdown()
```

Recorded spend per week for the last five weeks, by model, agent and what it was for.

### budgetUsage

```ts
budgetUsage()
```

The month's spend against the budget, as a meter.

### noticesFor

```ts
noticesFor(viewer: string)
```

What happened that someone should know about, newest first, with whether this viewer has opened it.

### markNoticesRead

```ts
markNoticesRead(ids: string[], viewer: string)
```

### snapshot

```ts
snapshot(viewer: string)
```

Everything the section's pages and shell show, for one viewer.

### settingsFor

```ts
settingsFor(viewer: string)
```

The settings page's view, for one viewer. Non-admins see every control locked, with why.

### useBrowserValue

```ts
useBrowserValue(read: () => T, server: T)
```

A value only the browser knows (the address, the viewer's clock), read without a hydration mismatch: the server render and the first client render get `server`, then the browser's value.

### useOps

```ts
useOps()
```

Runs, spend and notices for the signed-in person, shared by the shell and every page.

## Types

### OpsDb

```ts
PgDatabase<PgQueryResultHKT, any>
```

### Attempt

```ts
{ startedAt: number; endedAt?: number | undefined; error?: string | undefined }
```

### RunStep

```ts
{ id: string; title: string; kind: SpanKind; writes: boolean; undo?: string | undefined; status: RunStatus; attempts: Attempt[] }
```

### RunRecord

One run, as stored. Times are epoch milliseconds.

```ts
{ /** RUN-001, RUN-002, … assigned by the repo. */ id: string template: TemplateId title: string environment: string status: RunStatus startedBy: string startedAt: number /** Set whenever the run stops (done, failed at a step, or stopped); cleared when it goes on. */ endedAt?: number | undefined steps: RunStep[] /** Time the run spent waiting for a person after a failure. */ waits: { start: number
```

### NewRun

```ts
Omit<RunRecord, "id" | "unmeasured">
```

### RunPatch

What a step-by-step write replaces. `unmeasured` is written on its own (markUnmeasured).

```ts
Pick<RunRecord, "status" | "endedAt" | "steps" | "waits" | "log">
```

### Basis

How a figure was measured. Both are estimates of the bill, from usage the SDK reported.

```ts
"tokens at list price" | "Claude Agent SDK estimate"
```

### Tokens

```ts
{ input?: number | undefined; output?: number | undefined; cacheRead?: number | undefined; cacheWrite?: number | undefined }
```

### LedgerEntry

One recorded model call (or the share of one), in USD.

```ts
{ seq: number; at: string; queryId?: string | undefined; runId?: string | undefined; model: string; feature: string; costUsd: number; tokens?: Required<Tokens> | undefined; basis: Basis }
```

### NewLedgerEntry

```ts
Omit<LedgerEntry, "seq">
```

### Version

```ts
{ id: string; text: string; by: string; at: string }
```

### Stop

```ts
{ stopped: string; rule: string; at: string }
```

### OpsSettings

The agent's one row of settings, and the budget's counters.

```ts
{ model: ModelSettingsValue budgetUsd: number /** Model calls whose cost couldn't be measured in `month` (YYYY-MM): counted, never guessed. */ unpriced: { month: string; count: number } lastStop?: Stop | undefined }
```

### TemplateId

```ts
keyof typeof templates
```

### ToolId

```ts
(typeof toolIds)[number]
```

### Recovery

```ts
{ title: string; steps: PlanStep[]; failedStepId: string; reason: string; retryWrites: boolean; skipWrites: boolean }
```

### RunView

```ts
{ entry: { id: string; title: string; agent: AgentIdentity; environment: string; status: RunStatus; startedAt: string; duration?: number | undefined; cost?: number | undefined } status: RunStatus startedBy: string trace: { spans: TraceSpan[]; total: number } /** What the run used. A cost that wasn't measured is marked so on it, with why in costNote. */ receipt: RunReceipt log: LogItem[] recovery?:
```

### Snapshot

```ts
Awaited<ReturnType<typeof snapshot>>
```

### Settings

```ts
Awaited<ReturnType<typeof settingsFor>>
```

### OpsChatState

What either adapter hook returns (useCandorChat or useClaudeAgent): the page works with both.

```ts
{ messages: MessageData[] composer: React.ComponentProps<typeof Composer> approvals: { id: string; tool: string; input?: unknown; title?: string | undefined; reason?: string | undefined }[] respond: (id: string, approved: boolean, reason?: string) => unknown error?: string | undefined }
```

## Rules for using Candor UI

- Components take plain typed data. Never pass model output as HTML or render it as a link; messages are typed blocks.
- A control that can't be used says why: wrap it in `<Locked reason="…">`, don't just disable it.
- Don't show a value you don't have: leave it out (it renders as —) rather than guess. An estimate is labelled as one.
- Nothing is pre-selected in a decision a person makes, and the agent is named from your app's data, never from model output.
- Your app's words go in `LocaleProvider` (fixed strings) and `TermsProvider` (nouns); entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
