Human handoff (Claude Agent SDK)
PremiumThe customer's answer to the agent's permission request (a refund): { id, approved, reason? }.
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/human-handoff-claude
Brings in: away-digest, button, card-registry, chat, claude-agent-server, composer, handoff-card, inbox, locked, message, ops, prompt-suggestions, relative-time, standard-cards, team, thread, use-claude-agent · 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>
The support inbox for whoever is asking. ?thread=T-1039 adds that thread in full; ?visit=1 (once, when the page opens) adds what happened since their last visit.
No props.
Also takes the props of Request.
<POST>
What a person does with a thread: { id, action: "claim" | "reply" | "hand-back", text? }.
No props.
Also takes the props of Request.
<POST>
The 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>
The customer's thread: who has it, the latest handoff, and what people wrote. ?id=T-1042.
No props.
Also takes the props of Request.
<POST>
The customer writes to the person who has the thread: { id, text }.
No props.
Also takes the props of Request.
<InboxPage>
The support team's side: threads the agent handed off. Take one over, answer the customer, hand it back.
No props.
<Page>
No props.
<LiveHandoffCard>
A handoff card that follows the thread: pending until a person takes it, then theirs, then handed back.
| Prop | Type | Default | Description |
|---|---|---|---|
| cardrequired | HandoffCardData | — | The handoff as the agent sent it; the live thread’s handoff replaces it once the same handoff moves on. |
| onAccept | (() => void) | undefined | — | Lets a person take the handoff while it’s pending; called when they do. |
<HumanHandoff>
A customer's support thread. The agent answers until it hands the thread to a person; from then on the customer writes to the support team here, and the agent stays out until a person hands the thread back.
| Prop | Type | Default | Description |
|---|---|---|---|
| chatrequired | SupportChat | — | The chat state from useCandorChat (AI SDK) or useClaudeAgent (Claude Agent SDK); the block works with either. |
| threadIdrequired | string | — | The customer thread this chat belongs to. |
| threadApirequired | string | — | The thread API route, polled for who owns the thread and what people wrote, e.g. "/human-handoff/api/thread". |
| inboxHrefrequired | string | — | Where the team’s handoff inbox lives, linked for the person who picks it up. |
Functions
connectHandoffDb(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.
drizzleHandoffRepo(db: HandoffDb)seedHandoffData(db: HandoffDb)Load the demo's customers, invoices and threads into the database, leaving any that already exist alone: a thread's handoffs and lines are added only when the thread itself is new. Run it once after migrating, e.g. from a script: await seedHandoffData(connectHandoffDb(process.env.DATABASE_URL!)).
demoHandoffData()The demo's customers, invoices and threads, for the in-memory store and for seeding a database (./db/seed.ts). Handoff and line ids are assigned in this order (H-1, H-2; L-1…L-7). Times are relative to now.
memoryHandoffRepo()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.
setHandoffRepo(repo: HandoffRepo)Use your own storage (or a database you opened yourself). Call it once, before the routes run.
handoffRepo()The storage in use: whatever setHandoffRepo() was given, else Postgres when DATABASE_URL is set, else memory.
viewerOf(request: Request)Which support person is asking. Replace with your auth; the demo reads a cookie the inbox page sets.
customerOf(request: Request)Which customer is asking. Replace with your auth; the demo is always Jordan unless a cookie says otherwise.
agentBlocked(threadId: string, customerId?: string)Whether the agent may answer in this thread: null if it may, or why not, in one sentence.
agentContext(threadId: string)What the agent should know beyond the chat: who the customer is, and what was said while a person had the thread.
lookUpAccount(threadId: string)recordCustomer(threadId: string, text: string, key?: string)Record what the customer said in the agent chat. key (the chat message's id) keeps a resent message from counting twice.
recordAgent(threadId: string, text: string)Record the agent's reply, so the person who picks the thread up can read it.
issueRefund(threadId: string, invoiceId: string)Refund one invoice in full, within the agent's limit. Over the limit it refuses and says to hand off.
handoffCard(threadId: string, handoffId?: string)The card for a handoff, as the service describes it.
handOff(threadId: string, input: { reason: HandoffReason; summary: string })Hand the thread to a person. The agent stops replying in it from now on.
claim(threadId: string, viewer: string)A person takes the thread over. Only a thread that was handed off and nobody has yet.
reply(threadId: string, viewer: string, text: string)The person who has the thread answers the customer.
handBack(threadId: string, viewer: string)The person who has the thread gives it back to the agent, which may reply again.
writeAsCustomer(threadId: string, customerId: string, text: string)The customer writes while people have the thread. While the agent has it, they write in the agent chat instead.
customerView(threadId: string, customerId: string)The thread as its customer sees it: who has it, the latest handoff, and what was said since people had it.
staffView(threadId: string, viewer: string)The thread as a support person sees it, with why each control is locked for them.
inbox(viewer: string)The support inbox: every thread that was handed off, by what it needs from this viewer.
visit(viewer: string)What happened to handoffs since this viewer last opened the inbox, and mark them as here now. Null on a first visit: there's nothing to compare against.
resetStore()For tests: start from the seed data again. On a database this deletes the block's tables' rows.
supportToolTitle(tool: string, input: unknown)Plain-words titles for the block's tools, the same for both agent SDKs.
summarizeCard(name: string, data: unknown)The text version of the handoff card, for copy and whenever it can't be drawn.
Types
- HandoffDb
PgDatabase<PgQueryResultHKT, any>- CustomerRecord
{ id: string; name: string; email: string; account: string; plan: string }- InvoiceRecord
{ id: string; customerId: string; amount: number; date: string; description: string; refunded?: { at: string; by: string } | undefined }- ThreadRecord
- One support thread, as stored, with its handoffs and lines oldest first.
{ id: string; subject: string; customerId: string; owner: Owner; handoffs: Handoff[]; lines: Line[] }- NewHandoff
Pick<Handoff, "reason" | "summary" | "at">- NewLine
Omit<Line, "id">- HandoffReason
(typeof handoffReasons)[number]- Handoff
{ id: string; reason: HandoffReason; summary: string; at: string; claimedBy?: string | undefined; claimedAt?: string | undefined; returnedBy?: string | undefined; returnedAt?: string | undefined }- Owner
- Who answers the customer: the agent, nobody yet (handed off, not claimed), or one person.
{ kind: "agent" } | { kind: "waiting" } | { kind: "person"; name: string }- Line
- One line of the thread. `via` says where it was written: the agent chat, or the thread once people have it.
{ id: string; from: "customer" | "agent" | "person" | "system"; name: string; text: string; at: string; via: "agent" | "thread"; key?: string | undefined }- HandoffCardData
- What the handoff card shows. Built here, so what moves and what stays is the service's word, not the model's.
{ threadId: string handoffId: string run: string reason: string from: string to: string note: string at: string moves: string[] stays: string[] state: "pending" | "accepted" returnedBy?: string | undefined }- StaffThread
Awaited<ReturnType<typeof staffView>>- CustomerThread
{ id: string; subject: string; owner: Owner; handoff: HandoffCardData | null; lines: Omit<Line, "key">[] }- SupportChat
- What either adapter hook returns (useCandorChat or useClaudeAgent): the block 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 }