Agent ops console (Claude Agent SDK)

Premium

The person's answer to the agent's permission request: { id, approved, reason? }.

Markdown

Preview

It runs with a server and an agent, so there’s no preview here: install it to run it in your app.

Install

With the Candor CLI

First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/agent-ops-console-claude

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: @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

<POST>

No props.

Also takes the props of Request.

<GET>

Everything the section's pages and shell show for this viewer: runs, spend, the budget and notices.

No props.

Also takes the props of Request.

<POST>

Mark notices read for this viewer: { read: string[] }.

No props.

Also takes the props of Request.

<POST>

The Ops agent, on the Claude Agent SDK, run with the settings an admin saved and stopped at the month's budget. Set ANTHROPIC_API_KEY. Runs on one Node server, not spread over instances.

No props.

Also takes the props of Request.

<POST>

A person's choice for a run stopped at a failed step, from the Runs page: { action: "retry" | "skip" | "stop", runId, stepId?, confirmed? }. A retry or skip that changes something is refused until the person confirmed it.

No props.

Also takes the props of Request.

<GET>

The agent's settings, as this viewer may see and change them.

No props.

Also takes the props of Request.

<POST>

One change, by an admin. The store checks the viewer's role and the value; the answer is the settings after it.

No props.

Also takes the props of Request.

<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.

PropTypeDefaultDescription
childrenrequiredReact.ReactNode—What goes inside.

<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.

PropTypeDefaultDescription
childrenrequiredReact.ReactNode—What goes inside.

<OpsChat>

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

PropTypeDefaultDescription
chatrequiredOpsChatState—The chat state from useCandorChat (AI SDK) or useClaudeAgent (Claude Agent SDK); the page works with either.

Functions

opsToolTitle(tool: string, input: unknown)

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

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(db: OpsDb)

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

drizzleOpsRepo(db: OpsDb)
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()

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(request: Request)

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

costOf(model: string, t: Tokens)

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

setOpsRepo(repo: OpsRepo)

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

opsRepo()

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

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({ 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(text: string, viewer: string)

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

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(id: string, permission: ToolPermission, viewer: string)
setModelSettings(value: ModelSettingsValue, viewer: string)
setBudget(usd: number, viewer: string)
budgetStop()

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

remainingBudget()

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

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(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(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()

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

runConfig(viewer: string)

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

budgetGate()

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

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(runId: string, stepId: string, by: string)

Retry the failed step, then go on.

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

Skip the failed step, then go on.

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(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(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(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()

Every run, newest first.

getRun(runId: string)
spendBreakdown()

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

budgetUsage()

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

noticesFor(viewer: string)

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

markNoticesRead(ids: string[], viewer: string)
snapshot(viewer: string)

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

settingsFor(viewer: string)

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

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()

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

Types

OpsDb
PgDatabase<PgQueryResultHKT, any>
Attempt
{ startedAt: number; endedAt?: number | undefined; error?: string | undefined }
RunStep
{ id: string; title: string; kind: SpanKind; writes: boolean; undo?: string | undefined; status: RunStatus; attempts: Attempt[] }
RunRecord
One run, as stored. Times are epoch milliseconds.
{ /** 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
Omit<RunRecord, "id" | "unmeasured">
RunPatch
What a step-by-step write replaces. `unmeasured` is written on its own (markUnmeasured).
Pick<RunRecord, "status" | "endedAt" | "steps" | "waits" | "log">
Basis
How a figure was measured. Both are estimates of the bill, from usage the SDK reported.
"tokens at list price" | "Claude Agent SDK estimate"
Tokens
{ input?: number | undefined; output?: number | undefined; cacheRead?: number | undefined; cacheWrite?: number | undefined }
LedgerEntry
One recorded model call (or the share of one), in USD.
{ seq: number; at: string; queryId?: string | undefined; runId?: string | undefined; model: string; feature: string; costUsd: number; tokens?: Required<Tokens> | undefined; basis: Basis }
NewLedgerEntry
Omit<LedgerEntry, "seq">
Version
{ id: string; text: string; by: string; at: string }
Stop
{ stopped: string; rule: string; at: string }
OpsSettings
The agent's one row of settings, and the budget's counters.
{ 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
keyof typeof templates
ToolId
(typeof toolIds)[number]
Recovery
{ title: string; steps: PlanStep[]; failedStepId: string; reason: string; retryWrites: boolean; skipWrites: boolean }
RunView
{ 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
Awaited<ReturnType<typeof snapshot>>
Settings
Awaited<ReturnType<typeof settingsFor>>
OpsChatState
What either adapter hook returns (useCandorChat or useClaudeAgent): the page works with both.
{ 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 }