# Approvals inbox

One place where people decide what agents asked to do: an inbox of requests from several agents, each with its blast radius, diff and rollback, a hash-bound decision, two different approvers for production, a history of every decision, and an agent that can only apply what's approved.

- Registry name: `approvals-inbox` (`@candor/approvals-inbox`)
- 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/approvals-inbox

## Install

```bash
npx candor-ui add approvals-inbox
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/approvals-inbox`.
It brings in: app-layout, app-sidebar, approval-queue, approval-request, audit-log, badge, blast-radius, button, card-registry, chat, command-palette, composer, entity-chip, message, metadata-diff, notification-center, page-header, page-state, prompt-suggestions, relative-time, standard-cards, team, thread, tooltip, use-candor-chat, use-shortcut, work.
npm packages: @ai-sdk/anthropic@^4.0.96, @ai-sdk/react@^4.0.166, ai@^7.0.162, 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/approvals-inbox/agent/agent-chat.tsx`
- `app/approvals-inbox/agent/page.tsx`
- `app/approvals-inbox/agents.ts`
- `app/approvals-inbox/api/agent/route.ts`
- `app/approvals-inbox/api/history/route.ts`
- `app/approvals-inbox/api/requests/route.ts`
- `app/approvals-inbox/api/rules/route.ts`
- `app/approvals-inbox/db/connect.ts`
- `app/approvals-inbox/db/drizzle-repo.ts`
- `app/approvals-inbox/db/schema.ts`
- `app/approvals-inbox/db/seed.ts`
- `app/approvals-inbox/history/page.tsx`
- `app/approvals-inbox/page.tsx`
- `app/approvals-inbox/repo.ts`
- `app/approvals-inbox/requests/[id]/page.tsx`
- `app/approvals-inbox/rules/page.tsx`
- `app/approvals-inbox/shell.tsx`
- `app/approvals-inbox/store.ts`

## <AgentChat>

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.

Props:

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

## <AgentPage>

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

Props:

- `params: Promise<{ 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.

Props:

- `active: Place`: The place in the sidebar this page belongs to, marked as the current page.
- `children: React.ReactNode`

## Functions

### inboxToolTitle

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

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

### connectInboxDb

```ts
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

```ts
inboxTx(db: InboxDb)
```

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

### drizzleInboxRepo

```ts
drizzleInboxRepo(db: InboxDb)
```

### seedInbox

```ts
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

```ts
demoWorld()
```

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

### memoryInboxRepo

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

```ts
go(href: string)
```

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

### post

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

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

### setInboxRepo

```ts
setInboxRepo(repo: InboxRepo)
```

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

### inboxRepo

```ts
inboxRepo()
```

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

### seedDemo

```ts
seedDemo(tx: InboxTx)
```

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

### resetStore

```ts
resetStore()
```

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

### viewerOf

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

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

### nameOf

```ts
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

```ts
listFor(ctx: Ctx)
```

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

### detailFor

```ts
detailFor(ctx: Ctx, id: string)
```

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

### history

```ts
history()
```

The whole log of decisions, for the History page.

### rulesView

```ts
rulesView()
```

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

### noticesFor

```ts
noticesFor(ctx: Ctx)
```

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

### markRead

```ts
markRead(ctx: Ctx, ids: string[])
```

### decide

```ts
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

```ts
withdraw(ctx: Ctx, id: string)
```

The person the agent asked for takes the request back.

### readSettings

```ts
readSettings(environment: Env)
```

### requestConfigChange

```ts
requestConfigChange(ctx: Ctx, input: { environment: Env; key: string; value: string; reason: string }, agentId = opsAgent.id)
```

### requestDeploy

```ts
requestDeploy(ctx: Ctx, input: { environment: Env; service: string; version: string; reason: string }, agentId = opsAgent.id)
```

### agentRequests

```ts
agentRequests(agentId = opsAgent.id)
```

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

### applyRequest

```ts
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

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

```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 }
```

### InboxDb

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

### World

What the agents act on: settings, deployed versions and orders. Releases and tables are fixed in store.ts.

```ts
{ config: Record<Env, Record<string, string>> deployed: Record<Env, Record<string, string>> orders: Record<string, { customer: string; amount: number; status: "paid" | "refunded" }> }
```

### Approval

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

### InboxRecord

One request, as stored. store.ts turns it into the ApprovalRequest the inbox shows.

```ts
{ /** 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

```ts
Pick<InboxRecord, "change" | "agentId" | "forId" | "required" | "reason" | "hash" | "createdAt" | "expiresAt">
```

### InboxPatch

```ts
Partial<Pick<InboxRecord, "state" | "decidedBy" | "decidedAt" | "note" | "staleReason" | "appliedBy" | "appliedAt">>
```

### Place

```ts
"inbox" | "agent" | "rules" | "history"
```

### Viewer

```ts
{ id: string; name: string; email: string; role: "owner" | "member" }
```

### Summary

```ts
{ viewer: Viewer; people: string[]; requests: ApprovalRequest[]; notices: Notice[]; needsYou: number }
```

### Env

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

### Person

```ts
{ id: string; name: string; email: string; role: "owner" | "member" }
```

### Change

```ts
| { 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

```ts
Change["kind"]
```

### Ctx

Who is acting, and the id of the HTTP request they acted in (it goes on every audit row).

```ts
{ viewer: Person; requestId: string }
```

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