# Human handoff

A support agent answers a customer and hands the thread to a person when it should; the agent stops replying, and a person claims it in an inbox, answers, and can hand it back.

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

## Install

```bash
npx candor-ui add human-handoff
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/human-handoff`.
It 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 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/human-handoff/agent.ts`
- `app/human-handoff/api/inbox/route.ts`
- `app/human-handoff/api/route.ts`
- `app/human-handoff/api/thread/route.ts`
- `app/human-handoff/db/connect.ts`
- `app/human-handoff/db/drizzle-repo.ts`
- `app/human-handoff/db/schema.ts`
- `app/human-handoff/db/seed.ts`
- `app/human-handoff/inbox/page.tsx`
- `app/human-handoff/page.tsx`
- `app/human-handoff/repo.ts`
- `app/human-handoff/store.ts`
- `app/human-handoff/ui.tsx`

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

Props:

- `card: 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.

Props:

- `chat: SupportChat`: The chat state from useCandorChat (AI SDK) or useClaudeAgent (Claude Agent SDK); the block works with either.
- `threadId: string`: The customer thread this chat belongs to.
- `threadApi: string`: The thread API route, polled for who owns the thread and what people wrote, e.g. "/human-handoff/api/thread".
- `inboxHref: string`: Where the team’s handoff inbox lives, linked for the person who picks it up.

## Functions

### connectHandoffDb

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

```ts
drizzleHandoffRepo(db: HandoffDb)
```

### seedHandoffData

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

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

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

```ts
setHandoffRepo(repo: HandoffRepo)
```

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

### handoffRepo

```ts
handoffRepo()
```

The storage in use: whatever setHandoffRepo() was given, else Postgres when DATABASE_URL is set, else memory.

### viewerOf

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

Which support person is asking. Replace with your auth; the demo reads a cookie the inbox page sets.

### customerOf

```ts
customerOf(request: Request)
```

Which customer is asking. Replace with your auth; the demo is always Jordan unless a cookie says otherwise.

### agentBlocked

```ts
agentBlocked(threadId: string, customerId?: string)
```

Whether the agent may answer in this thread: null if it may, or why not, in one sentence.

### agentContext

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

```ts
lookUpAccount(threadId: string)
```

### recordCustomer

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

```ts
recordAgent(threadId: string, text: string)
```

Record the agent's reply, so the person who picks the thread up can read it.

### issueRefund

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

```ts
handoffCard(threadId: string, handoffId?: string)
```

The card for a handoff, as the service describes it.

### handOff

```ts
handOff(threadId: string, input: { reason: HandoffReason; summary: string })
```

Hand the thread to a person. The agent stops replying in it from now on.

### claim

```ts
claim(threadId: string, viewer: string)
```

A person takes the thread over. Only a thread that was handed off and nobody has yet.

### reply

```ts
reply(threadId: string, viewer: string, text: string)
```

The person who has the thread answers the customer.

### handBack

```ts
handBack(threadId: string, viewer: string)
```

The person who has the thread gives it back to the agent, which may reply again.

### writeAsCustomer

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

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

```ts
staffView(threadId: string, viewer: string)
```

The thread as a support person sees it, with why each control is locked for them.

### inbox

```ts
inbox(viewer: string)
```

The support inbox: every thread that was handed off, by what it needs from this viewer.

### visit

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

```ts
resetStore()
```

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

### supportToolTitle

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

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

### summarizeCard

```ts
summarizeCard(name: string, data: unknown)
```

The text version of the handoff card, for copy and whenever it can't be drawn.

## Types

### HandoffDb

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

### CustomerRecord

```ts
{ id: string; name: string; email: string; account: string; plan: string }
```

### InvoiceRecord

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

```ts
{ id: string; subject: string; customerId: string; owner: Owner; handoffs: Handoff[]; lines: Line[] }
```

### NewHandoff

```ts
Pick<Handoff, "reason" | "summary" | "at">
```

### NewLine

```ts
Omit<Line, "id">
```

### HandoffReason

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

### Handoff

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

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

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

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

```ts
Awaited<ReturnType<typeof staffView>>
```

### CustomerThread

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

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

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