Approvals inbox (Claude Agent SDK)

Premium

The Ops agent: it files requests for you and applies one only after people approved it in the inbox. The chat asks you before it applies anything too; your go-ahead here is not an approval, and the server still refuses anything the inbox didn't approve.

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/approvals-inbox-claude

Brings in: app-layout, app-sidebar, approval-queue, approval-request, audit-log, badge, blast-radius, button, card-registry, chat, claude-agent-server, command-palette, composer, entity-chip, message, metadata-diff, notification-center, page-header, page-state, prompt-suggestions, relative-time, standard-cards, team, thread, tooltip, 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

<AgentChat>

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

<AgentPage>

No props.

<POST>

The person's answer to the agent's permission request: { id, approved, reason? }. Only who started the run can answer.

No props.

Also takes the props of Request.

<POST>

The Ops agent, on the Claude Agent SDK. Set ANTHROPIC_API_KEY. Runs on one Node server, not spread over instances.

No props.

Also takes the props of Request.

<GET>

Every decision: requests, approvals, rejections, expiries, withdrawals, applies and refusals. Ids and codes, with names.

No props.

<GET>

The inbox for whoever is asking: every request, what waits on them and their notices. With ?id=CR-001, one request with its blast radius, diff, approvals and history.

No props.

Also takes the props of Request.

<POST>

One thing a person did: decide { kind: "decide", id, hash, approved, note? }, withdraw, or mark notices read.

No props.

Also takes the props of Request.

<GET>

The approval rules per environment, and where each pending production request stands against them.

No props.

<HistoryPage>

Every decision, written by the server as it happened: requests, approvals, rejections, expiries, applies and refusals.

No props.

<InboxPage>

The inbox: every request agents filed, by what it needs from you. Production is flagged; soonest to expire first.

No props.

<RequestPage>

The route: reads its params inside Suspense, so Next.js can prerender the page shell (Cache Components).

PropTypeDefaultDescription
paramsrequiredPromise<{ id: string }>—The route’s params from the framework: the request id, e.g. "CR-002".

<RulesPage>

The approval rules, enforced by the store, and where each production request stands against the two-level rule.

No props.

<Shell>

The app frame every page of the template shares: the sidebar with what needs you, ⌘K to search or jump, the notifications bell, and (for the demo) who you're signed in as.

PropTypeDefaultDescription
activerequiredPlace—The place in the sidebar this page belongs to, marked as the current page.
childrenrequiredReact.ReactNode—What goes inside.

Functions

inboxToolTitle(tool: string, input: unknown)

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

connectInboxDb(url: string)

Open the template'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.

inboxTx(db: InboxDb)

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

drizzleInboxRepo(db: InboxDb)
seedInbox(db: InboxDb)

Load the demo inbox (Acme web's settings, versions and orders, and a week of agents' requests) into the database, once: it does nothing when there are requests already. Run it after migrating, e.g. from a script: await seedInbox(connectInboxDb(process.env.DATABASE_URL!)).

demoWorld()

Acme web as the demo starts, for the in-memory store and for seeding a database (./db/seed.ts).

memoryInboxRepo()

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.

go(href: string)

Moves to another page. Full-page navigation keeps the template free of a router; swap in useRouter().push.

post(body: object, init?: { keepalive?: boolean | undefined })

Sends one action to the inbox API and returns its error, if any, as a sentence.

setInboxRepo(repo: InboxRepo)

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

inboxRepo()

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

seedDemo(tx: InboxTx)

The demo's requests, written through the repo: the inbox as it looks after a busy week.

resetStore()

For tests: start from the seed data again. On a database this deletes the template's tables' rows.

viewerOf(request: Request)

Who is asking. Replace with your auth; the demo reads a cookie the viewer switch sets.

nameOf(id: string, requests: InboxRecord[] = [])

A name for an id on an audit row or a request, when it's known. Unknown ids stay ids. Request ids need the requests.

listFor(ctx: Ctx)

The inbox for one viewer: every request, each saying whether they can decide it.

detailFor(ctx: Ctx, id: string)

Everything the request page shows, or null when there's no such request.

history()

The whole log of decisions, for the History page.

rulesView()

The rules, and where each pending production request stands against the two-level rule.

noticesFor(ctx: Ctx)

What waits on the viewer, and what happened to requests made for them. Read state is per person.

markRead(ctx: Ctx, ids: string[])
decide(ctx: Ctx, id: string, hash: string, approved: boolean, note?: string)

Approve or reject exactly the change the person was shown, identified by its hash. Production needs two different approvals; until then it stays pending and waits on someone else. Every refusal is written to the log.

withdraw(ctx: Ctx, id: string)

The person the agent asked for takes the request back.

readSettings(environment: Env)
requestConfigChange(ctx: Ctx, input: { environment: Env; key: string; value: string; reason: string }, agentId = opsAgent.id)
requestDeploy(ctx: Ctx, input: { environment: Env; service: string; version: string; reason: string }, agentId = opsAgent.id)
agentRequests(agentId = opsAgent.id)

The requests one agent filed, for that agent to check.

applyRequest(ctx: Ctx, id: string, agentId = opsAgent.id)

Apply an approved request: only the agent that filed it, only once, and only if nothing moved since.

attempt(run: () => Promise<T>)

For the agent routes: turn a refusal into a plain sentence for the model instead of an exception.

Types

InboxChat
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 }
InboxDb
PgDatabase<PgQueryResultHKT, any>
World
What the agents act on: settings, deployed versions and orders. Releases and tables are fixed in store.ts.
{ config: Record<Env, Record<string, string>> deployed: Record<Env, Record<string, string>> orders: Record<string, { customer: string; amount: number; status: "paid" | "refunded" }> }
Approval
{ by: string; at: string }
InboxRecord
One request, as stored. store.ts turns it into the ApprovalRequest the inbox shows.
{ /** CR-001, CR-002, … assigned by the repo. */ id: string change: Change agentId: string /** The person the agent asked for. They can't approve it. */ forId: string /** Approvals needed, fixed when the request was filed. */ required: number reason: string /** sha256 of the canonical change; a decision is bound to it. */ hash: string state: ApprovalState createdAt: string expiresAt: string /** Ol
NewInboxRecord
Pick<InboxRecord, "change" | "agentId" | "forId" | "required" | "reason" | "hash" | "createdAt" | "expiresAt">
InboxPatch
Partial<Pick<InboxRecord, "state" | "decidedBy" | "decidedAt" | "note" | "staleReason" | "appliedBy" | "appliedAt">>
Place
"inbox" | "agent" | "rules" | "history"
Viewer
{ id: string; name: string; email: string; role: "owner" | "member" }
Summary
{ viewer: Viewer; people: string[]; requests: ApprovalRequest[]; notices: Notice[]; needsYou: number }
Env
(typeof environments)[number]
Person
{ id: string; name: string; email: string; role: "owner" | "member" }
Change
| { kind: "config"; environment: Env; key: string; from: string; to: string } | { kind: "deploy"; environment: Env; service: string; from: string; to: string } | { kind: "export"; environment: Env; table: string; columns: string[]; to: string } | { kind: "refund"; environment: Env; order: string; amount: number; customer: string }
Kind
Change["kind"]
Ctx
Who is acting, and the id of the HTTP request they acted in (it goes on every audit row).
{ viewer: Person; requestId: string }