Human handoff

Premium

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.

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/human-handoff

Brings in: away-digest, button, card-registry, chat, composer, handoff-card, inbox, locked, message, ops, prompt-suggestions, relative-time, standard-cards, team, thread, use-candor-chat · 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.

<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 Vercel AI SDK. Set ANTHROPIC_API_KEY, and CANDOR_APPROVAL_SECRET so approvals can't be forged.

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.

PropTypeDefaultDescription
cardrequiredHandoffCardData—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.

PropTypeDefaultDescription
chatrequiredSupportChat—The chat state from useCandorChat (AI SDK) or useClaudeAgent (Claude Agent SDK); the block works with either.
threadIdrequiredstring—The customer thread this chat belongs to.
threadApirequiredstring—The thread API route, polled for who owns the thread and what people wrote, e.g. "/human-handoff/api/thread".
inboxHrefrequiredstring—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 }