Approve from anywhere
Premium?id=AP-002: one approval by the id from the email, for the approval page. ?runId=…: a run's approvals, for the person who started it. Decisions go to the agent route (PUT), which resumes the run.
Preview
It runs with a server and an agent, so there’s no preview here: install it to run it in your app.
Install
First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/approve-from-anywhere
Brings in: approval-request, button, change-hash, chat, composer, email-layout, entity-chip, input, message, prompt-suggestions, thread, use-candor-chat, work · npm: @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
<GET>
No props.
Also takes the props of Request.
<GET>
The demo's inbox: emails sent to whoever is signed in. Delete this route once a real mailer sends the outbox.
No props.
Also takes the props of Request.
<GET>
The run's saved messages, for the person who started it. Resumes any run still owed a decision first.
No props.
Also takes the props of Request.
<POST>
One turn: { runId, message }. The run's messages are kept here, so the browser sends only the new one.
No props.
Also takes the props of Request.
<PUT>
A decision on a paused write, bound to the change's hash: { id, hash, approved, note?, edited? }. The approver gets the answer as soon as the decision is recorded; the run carries on after, without holding their request open for a model turn. On a serverless host, hand the promise to your platform's waitUntil or Next.js's after(); either way the run page's GET carries on any run still owed a decision.
No props.
Also takes the props of Request.
<ApprovePage>
The approver's side, made for a phone: they type the id from their email (it has no link), see the change in brief with its hash, open it in full to edit or to decide a production change, and decide. The run that asked carries on by itself.
No props.
<Page>
No props.
<SignedInAs>
The demo's "signed in as" switch: sets the cookie viewerOf() reads.
| Prop | Type | Default | Description |
|---|---|---|---|
| viewer | string | undefined | — | — |
| onChange | (() => void) | undefined | — | — |
<ApproveFromAnywhere>
The person who asks: a chat with an agent that changes feature-flag rollouts. Its one write pauses the run and emails someone else an id; while it waits, the run's approval shows here, read-only, and the page can be closed.
| Prop | Type | Default | Description |
|---|---|---|---|
| chatrequired | RolloutChat | — | — |
Functions
connectAnywhereDb(url: string)Open the block'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.
drizzleAnywhereRepo(db: AnywhereDb)seedAnywhere(db: AnywhereDb)Load the demo's flags into the database, leaving any that already exist alone. Run it once after migrating, e.g. from a script: await seedAnywhere(connectAnywhereDb(process.env.DATABASE_URL!)).
getApprovals(request: Request)?id=AP-002: one approval, with the percentage it starts from (the approval page needs it to hash an edit). ?runId=…: the run's approvals, for the person who started it.
postDecision(request: Request){ id, hash, approved, note?, edited?: { input, hash } }. Returns the response and, when it was recorded, the decision.
memoryAnywhereRepo()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.
rolloutChange(input: Pick<RolloutInput, "environment" | "flag" | "percent">, from: number)The change an input makes, starting from the flag's value when it was asked for.
rolloutHash(change: RolloutChange)The hash an approval is bound to. Recomputed on the server for every decision, and again before the change applies.
setAnywhereRepo(repo: AnywhereRepo)Use your own storage (or a database you opened yourself). Call it once, before the routes run.
anywhereRepo()The storage in use: whatever setAnywhereRepo() was given, else Postgres when DATABASE_URL is set, else memory.
viewerOf(request: Request)Who is asking. Replace with your auth; the demo reads a cookie the pages set.
readRollouts(environment: Environment)refusal(input: unknown)Why a proposed rollout can't be asked for, or undefined when it can. Checked before the run pauses for it.
requestApproval(a: { sdkId: string; runId: string; requester: string; tool: string; callId: string; input: unknown; request?: unknown })Record a write that waits on a person, and email the people who can approve it. Called by the SDK's approval request: the AI SDK route's onFinish, or the Claude ApprovalStore's put.
approvalEmail(r: ApprovalRecord)The email an approver gets: the change in words, the id to type and the hash to check. Never a link to click.
toApprovalRequest(r: ApprovalRecord, viewer: string)The stored approval as the approval page shows it, for one viewer: the requester can't decide their own.
approvalFor(id: string, viewer: string)One approval for the approval page, by the id from the email. Undefined when there's none.
expireOverdue()Pending approvals past their expiry become expired, so nobody can decide them and the run is told.
decide(d: Decision, viewer: string)Approve or reject exactly the change the approver saw, identified by its hash. An edit must keep the environment and the flag, and its hash is worked out again here: the approval binds to the edited change only if it matches the one the approver was shown.
applyApproved(id: string)Apply an approved write, once, exactly as approved, and only if the flag is still where it was when it was asked for.
outcomeOf(id: string)What the write tool returns to the agent once the run has a decision on it, in a sentence: applied (and how), rejected (and the approver's note), or expired. Applies the change when it's approved.
approvalForCall(runId: string, input: unknown)The approved write that covers this tool call, for the Claude tool handler, which gets the input but not the approval's id.
claimResume(id: string)Take the right to resume a run with this decision. One process gets it; a claim left unfinished for minutes is taken again.
markResumed(id: string)The run has the decision.
runOf(id: string)startRun(id: string, owner: string, sdk: RunRecord["sdk"])Start a run, or check the viewer owns the one they're continuing. Returns why not, when they can't.
keepAlive(runId: string, everyMs = 5_000)Beat while this process runs the run, so another can tell when it went away. Returns the function that stops it.
endRun(runId: string)waitingToResume()Approvals decided (or expired) that their run doesn't have yet, with the run's record.
resetStore()For tests: start from the seed data again. On a database this deletes the block's tables' rows.
rolloutToolTitle(tool: string, input: unknown)Plain-words titles for the block's tools, the same for both agent SDKs.
useRunApprovals(runId: string | undefined, poll: boolean)A run's approvals as its owner sees them, checked every few seconds while one is waiting.
useSavedRun(key: string)The run this browser last started, kept so a reload (or coming back tomorrow) picks it up again.
Types
- AnywhereDb
PgDatabase<PgQueryResultHKT, any>- ApprovalRecord
- One write waiting for, or given, a person's decision.
{ /** AP-001, AP-002, …: the id in the email, typed on the approval page. */ id: string /** The SDK's own id for the request: the AI SDK's approval id, or the Claude ApprovalStore's. */ sdkId: string runId: string /** Who started the run. They can't approve their own run's writes. */ requester: string /** The tool call: its name, the SDK's id for the call, and the input as the agent proposed it. *- NewApprovalRecord
Omit<ApprovalRecord, "id" | "state">- ApprovalPatch
Partial<Omit<ApprovalRecord, "id" | "sdkId" | "runId" | "requester" | "tool" | "callId" | "input" | "hash" | "createdAt">>- RunRecord
- A run: who started it, which SDK, and what it has said so far.
{ id: string owner: string sdk: "ai-sdk" | "claude" /** AI SDK: the whole conversation (UIMessage[]), saved after every step. */ messages?: unknown[] | undefined /** Claude: the SDK's session, so a run can be resumed after a restart. */ sessionId?: string | undefined startedAt: string /** Updated every few seconds by the process running it. A run not ended whose heartbeat stopped was cut off. */ h- OutboxEmail
{ seq: number; to: string; approvalId: string; notice: EmailNotice; html: string; at: string }- Environment
(typeof environments)[number]- RolloutInput
- The write tool's input: a feature flag's rollout, as a percentage of users, in one environment.
{ environment: Environment; flag: string; percent: number; reason?: string | undefined }- RolloutChange
- What a rollout change does, with the value it starts from. The hash covers exactly this, not the reason.
{ environment: Environment; flag: string; from: number; to: number }- Decision
{ id: string /** The hash the approver saw: the proposed change's. */ hash: string approved: boolean note?: string | undefined /** The input as the approver edited it, with the hash they saw for it. */ edited?: { input: Record<string, unknown>; hash: string } | undefined }- RolloutChat
- What either page gives the chat: the adapter hook's messages and composer, the run's approvals, and starting over.
{ messages: MessageData[] composer: React.ComponentProps<typeof Composer> error?: string | undefined /** The run's approvals, from the server. */ approvals: ApprovalData[] onNewRun: () => void }