# Support copilot (Claude Agent SDK)

A support desk where an agent drafts answers from the help docs, with citations, and a person approves, edits or rejects every draft before the customer sees it. The agent hands threads to a person when it should, and reviewers' decisions feed a quality view.

- Registry name: `support-copilot-claude` (`@candor/support-copilot-claude`)
- 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/support-copilot-claude

## Install

```bash
npx candor-ui add support-copilot-claude
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/support-copilot-claude`.
It brings in: app-layout, app-sidebar, badge, button, card-registry, chat, citation, claude-agent-server, command-palette, composer, eval-scorecard, failure-clusters, handoff-card, inbox, locked, message, ops, page-header, page-state, prompt-suggestions, quality-trend, radio-group, relative-time, standard-cards, status, status-dot, team, textarea, thread, use-claude-agent, use-shortcut.
npm packages: @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.

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/support-copilot/agent.ts`
- `app/support-copilot/api/approve/route.ts`
- `app/support-copilot/api/customer/route.ts`
- `app/support-copilot/api/desk/route.ts`
- `app/support-copilot/api/quality/route.ts`
- `app/support-copilot/api/route.ts`
- `app/support-copilot/api/thread/route.ts`
- `app/support-copilot/customer/page.tsx`
- `app/support-copilot/db/connect.ts`
- `app/support-copilot/db/drizzle-repo.ts`
- `app/support-copilot/db/schema.ts`
- `app/support-copilot/db/seed.ts`
- `app/support-copilot/handoffs/page.tsx`
- `app/support-copilot/page.tsx`
- `app/support-copilot/quality/page.tsx`
- `app/support-copilot/repo.ts`
- `app/support-copilot/shell.tsx`
- `app/support-copilot/store.ts`
- `app/support-copilot/thread-screen.tsx`
- `app/support-copilot/thread/[id]/page.tsx`
- `app/support-copilot/ui.tsx`

## <CustomerPage>

The customer's side: they write in their thread and see only replies a person approved or wrote. The copilot's drafts never show here.

No props.

## <HandoffsPage>

Threads handed to people, by what each needs from you. Open one to take it over.

No props.

## <QueuePage>

The queue: every thread, by where it is. Drafts waiting for review come first.

No props.

## <QualityPage>

How good the copilot's drafts are, from reviewers' decisions only: pass rate by topic and the failure themes.

No props.

## <SupportShell>

The support desk's frame: sidebar with the places and their counts, a page header, ⌘K to jump to any page or thread, and an optional side panel (the copilot on a thread).

Props:

- `active?: Place | undefined`: The place in the sidebar this page belongs to, marked as the current page. None on a thread.
- `title: string`: The page's title, in its header.
- `description?: React.ReactNode | undefined`: A line under the title.
- `breadcrumb?: Crumb[] | undefined`: Where the page sits, e.g. Queue › T-1042.
- `actions?: React.ReactNode | undefined`: Buttons at the right of the header.
- `tabs?: PageTab[] | undefined`: Tabs under the header, each with an optional count.
- `activeTab?: string | undefined`: The id of the tab that's showing.
- `onTabChange?: ((id: string) => void) | undefined`: Called with a tab's id when it's chosen.
- `panel?: React.ReactNode | undefined`: A side panel beside the page, e.g. the copilot's chat.
- `panelOpen?: boolean | undefined`: Whether the side panel is open.
- `onPanelOpenChange?: ((open: boolean) => void) | undefined`: Called when the side panel opens or closes.
- `panelLabel?: string | undefined`: The side panel's name, for screen readers and its resize handle.
- `children: React.ReactNode`

## <ThreadScreen>

One support thread for the support team: what the customer said, the copilot's draft waiting for review beside it, and the copilot itself in the side panel. Once the thread is handed off, a person takes it over and replies.

Props:

- `id: string`: The thread to show, e.g. "T-1042".
- `chat: CopilotChat`: The chat state from useCandorChat (AI SDK) or useClaudeAgent (Claude Agent SDK); the screen works with either.

## <Page>

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 thread id, e.g. "T-1042".

## <ThreadLine>

One line of the thread. A sent copilot reply carries its sources and says who checked it before it went out; people on the team use Message's person author so they never read as the agent.

Props:

- `line: Line`: One line of the thread as sent: from the customer, the copilot (with who checked it), a person on the team, or the system.
- `customerLabel?: string | undefined`: What to call the customer instead of their name, e.g. "You" on the customer’s own page.

## <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 copilot sent it; the thread’s live handoff replaces it once the same handoff moves on.
- `onAccept?: (() => void) | undefined`: Lets a person take the thread while the handoff is pending; called when they do.

## Functions

### connectCopilotDb

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

### drizzleCopilotRepo

```ts
drizzleCopilotRepo(db: CopilotDb)
```

### seedCopilotData

```ts
seedCopilotData(db: CopilotDb)
```

Load the demo's customers, invoices, threads and earlier reviews into the database, leaving any that already exist alone: a thread's handoffs, lines and draft are added only when the thread itself is new, and the earlier reviews only into an empty reviews table. Run it once after migrating, e.g. from a script: `await seedCopilotData(connectCopilotDb(process.env.DATABASE_URL!))`.

### demoCopilotData

```ts
demoCopilotData()
```

The demo's customers, invoices, threads and earlier reviews, for the in-memory store and for seeding a database (./db/seed.ts). Ids are assigned in this order (H-1, H-2; L-1…L-10; D-1; R-1…R-13). Times are relative to now.

### memoryCopilotRepo

```ts
memoryCopilotRepo()
```

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.

### setCopilotRepo

```ts
setCopilotRepo(repo: CopilotRepo)
```

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

### copilotRepo

```ts
copilotRepo()
```

The storage in use: whatever setCopilotRepo() 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 shell's viewer switch sets.

### customerOf

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

Which customer is asking. Replace with your customers' sign-in; the demo is Jordan unless a cookie says otherwise.

### agentBlocked

```ts
agentBlocked(threadId: string)
```

Whether the copilot may work on this thread: null if it may, or why not, in one sentence.

### agentContext

```ts
agentContext(threadId: string)
```

What the copilot should know beyond the chat: the customer, and the thread so far, oldest first.

### searchDocs

```ts
searchDocs(query: string)
```

Help docs matching a query, best first. Plain keyword matching; replace with your docs search.

### lookUpAccount

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

### issueRefund

```ts
issueRefund(threadId: string, invoiceId: string)
```

Refund one invoice in full, within the copilot's limit. Over the limit it refuses and says to hand off.

### saveDraft

```ts
saveDraft(threadId: string, input: { text: string; docIds: string[] })
```

Save the copilot's reply as a draft for review. It never reaches the customer from here: a person approves, edits or rejects it. Every doc must exist and every citation must point at one of them.

### 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 }, by: string = copilot.name)
```

Hand the thread to a person. The copilot stops drafting in it, and a draft waiting for review is withdrawn unsent.

### review

```ts
review(threadId: string, viewer: string, input: ReviewInput)
```

A reviewer's decision on the waiting draft. Approve sends it as written; edit sends the reviewer's text; reject sends nothing. Each is recorded for the quality page: approve is a pass, edit and reject are fails with a reason.

### claim

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

A person takes a handed-off thread over.

### reply

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

The person who has the thread answers the customer directly.

### handBack

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

The person who has the thread gives it back to the copilot, which may draft again.

### handOffByPerson

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

A reviewer hands the thread to the team without the copilot, e.g. when its draft shows it should have.

### writeAsCustomer

```ts
writeAsCustomer(customerId: string, text: string)
```

The customer writes in their thread. It waits for a draft, or for the person who has it.

### askForPerson

```ts
askForPerson(customerId: string)
```

The customer asks for a person: the thread goes to the team at once, without asking the copilot.

### customerView

```ts
customerView(customerId: string)
```

The thread as its customer sees it: their messages, replies people approved or wrote, and what happened. Drafts are never part of it.

### staffView

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

The thread as a support person sees it: the draft beside it, and why each control is locked for them.

### queue

```ts
queue()
```

Every thread, newest activity first, with where it is and what it needs.

### handoffInbox

```ts
handoffInbox(viewer: string)
```

Threads handed to people, by what each needs from this viewer.

### counts

```ts
counts(viewer: string)
```

The counts the sidebar shows.

### quality

```ts
quality(at = Date.now())
```

Quality from reviewers' decisions only, for the last 7 days against the 7 before: a draft sent as written passes; edited or rejected fails. Drafts still waiting aren't counted. Nothing here is a model's opinion.

### resetStore

```ts
resetStore()
```

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

### copilotToolTitle

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

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

### summarizeCard

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

The text version of the template's cards, for copy and whenever one can't be drawn.

### withCitations

```ts
withCitations(text: string, sources: MessageSource[])
```

A reply's plain text as inline nodes: "[1]" becomes a citation of the first source. A marker with no source stays a plain bracket (Citation shows it as unknown); nothing is ever rendered as HTML.

### replyMessage

```ts
replyMessage(id: string, text: string, sources: MessageSource[], at?: string, status: MessageData["status"] = "complete")
```

A reply as a Candor message: paragraphs of text with citations, and its sources.

### send

```ts
send(url: string, body: unknown)
```

POST JSON and read the answer; a failed request comes back as one plain sentence.

## Types

### CopilotDb

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

### LineRecord

One line as stored: the help docs an approved draft cited, by id; store.ts turns them into numbered sources.

```ts
Omit<Line, "sources"> & { docIds?: string[] | undefined }
```

### DraftRecord

The copilot's draft waiting for review.

```ts
{ id: string; text: string; docIds: string[]; at: string }
```

### DraftOutcome

What happened to a draft: a reviewer's decision, or withdrawn unsent by a handoff.

```ts
Decision | "withdrawn"
```

### ThreadRecord

One support thread, as stored: its handoffs and lines oldest first, and the draft waiting for review, if any.

```ts
{ id: string; subject: string; topic: Topic; customerId: string; owner: Owner; handoffs: Handoff[]; lines: LineRecord[]; draft?: DraftRecord | undefined }
```

### ReviewRecord

A reviewer's decision on a draft: what the quality page counts.

```ts
{ id: string; threadId: string; subject: string; topic: Topic; decision: Decision; theme?: Theme | undefined; note?: string | undefined; by: string; at: string }
```

### NewHandoff

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

### NewLine

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

### NewDraft

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

### NewReview

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

### Desk

What GET /api/desk returns.

```ts
{ viewer: string; threads: QueueRow[]; handoffs: InboxItem[]; counts: { drafts: number; needsDraft: number; handoffs: number } }
```

### HandoffReason

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

### Theme

```ts
keyof typeof themes
```

### Topic

```ts
(typeof topics)[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 copilot (through reviewed drafts), nobody yet (handed off, not taken), or one person.

```ts
{ kind: "agent" } | { kind: "waiting" } | { kind: "person"; name: string }
```

### Line

One line of the thread, as sent. An agent line is a draft a person approved; `review` says who and whether they edited it.

```ts
{ id: string from: "customer" | "agent" | "person" | "system" name: string text: string at: string sources?: MessageSource[] | undefined review?: { by: string; edited: boolean } | undefined }
```

### Decision

```ts
"approved" | "edited" | "rejected"
```

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

### QueueStatus

Where a thread is, for the queue.

```ts
"needs-draft" | "draft-waiting" | "handed-off" | "sent"
```

### DraftCardData

What the draft card in the copilot chat shows.

```ts
{ threadId: string; draftId: string; text: string; sources: MessageSource[] }
```

### ReviewInput

```ts
{ decision: "approve" | "edit" | "reject"; text?: string | undefined; theme?: string | undefined; note?: string | undefined }
```

### CustomerThread

```ts
Awaited<ReturnType<typeof customerView>>
```

### StaffThread

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

### QueueRow

```ts
Awaited<ReturnType<typeof queue>>[number]
```

### Quality

```ts
Awaited<ReturnType<typeof quality>>
```

### CopilotChat

What either adapter hook returns (useCandorChat or useClaudeAgent): the template 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`.
