Support copilot
PremiumThe signed-in customer's thread: what they wrote, the replies people approved or wrote, and who has it.
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/support-copilot
Brings in: app-layout, app-sidebar, badge, button, card-registry, chat, citation, 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-candor-chat, use-shortcut · 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>
The customer writes ({ action: "write", text }) or asks for a person ({ action: "ask-for-person" }).
No props.
Also takes the props of Request.
<GET>
The desk for whoever is asking: every thread with where it is, the handoffs by what they need, and the sidebar's counts.
No props.
Also takes the props of Request.
<GET>
Quality from reviewers' decisions on drafts: pass rate by topic and failure themes, the last 7 days against the 7 before.
No props.
Also takes the props of Request.
<POST>
The copilot, on the Vercel AI SDK. The person chatting is the reviewer, not the customer: the copilot's drafts go to the review queue, never to the customer. Set ANTHROPIC_API_KEY, and CANDOR_APPROVAL_SECRET so approvals can't be forged.
No props.
Also takes the props of Request.
<GET>
One thread as the signed-in support person sees it, with the draft waiting for review: ?id=T-1042.
No props.
Also takes the props of Request.
<POST>
What a person does with a thread: { id, action } where action is review (decision: approve | edit | reject, text for an edit, theme and note for edit or reject), claim, reply (text), hand-back, or hand-off (reason, summary).
No props.
Also takes the props of Request.
<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).
| Prop | Type | Default | Description |
|---|---|---|---|
| active | Place | undefined | — | The place in the sidebar this page belongs to, marked as the current page. None on a thread. |
| titlerequired | 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. |
| childrenrequired | React.ReactNode | — | What goes inside. |
<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.
| Prop | Type | Default | Description |
|---|---|---|---|
| idrequired | string | — | The thread to show, e.g. "T-1042". |
| chatrequired | 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).
| Prop | Type | Default | Description |
|---|---|---|---|
| paramsrequired | 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.
| Prop | Type | Default | Description |
|---|---|---|---|
| linerequired | 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.
| Prop | Type | Default | Description |
|---|---|---|---|
| cardrequired | 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(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(db: CopilotDb)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()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()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(repo: CopilotRepo)Use your own storage (or a database you opened yourself). Call it once, before the routes run.
copilotRepo()The storage in use: whatever setCopilotRepo() 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 shell's viewer switch sets.
customerOf(request: Request)Which customer is asking. Replace with your customers' sign-in; the demo is Jordan unless a cookie says otherwise.
agentBlocked(threadId: string)Whether the copilot may work on this thread: null if it may, or why not, in one sentence.
agentContext(threadId: string)What the copilot should know beyond the chat: the customer, and the thread so far, oldest first.
searchDocs(query: string)Help docs matching a query, best first. Plain keyword matching; replace with your docs search.
lookUpAccount(threadId: string)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(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(threadId: string, handoffId?: string)The card for a handoff, as the service describes it.
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(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(threadId: string, viewer: string)A person takes a handed-off thread over.
reply(threadId: string, viewer: string, text: string)The person who has the thread answers the customer directly.
handBack(threadId: string, viewer: string)The person who has the thread gives it back to the copilot, which may draft again.
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(customerId: string, text: string)The customer writes in their thread. It waits for a draft, or for the person who has it.
askForPerson(customerId: string)The customer asks for a person: the thread goes to the team at once, without asking the copilot.
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(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()Every thread, newest activity first, with where it is and what it needs.
handoffInbox(viewer: string)Threads handed to people, by what each needs from this viewer.
counts(viewer: string)The counts the sidebar shows.
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()For tests: start from the seed data again. On a database this deletes the template's tables' rows.
copilotToolTitle(tool: string, input: unknown)Plain-words titles for the copilot's tools, the same for both agent SDKs.
summarizeCard(name: string, data: unknown)The text version of the template's cards, for copy and whenever one can't be drawn.
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(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(url: string, body: unknown)POST JSON and read the answer; a failed request comes back as one plain sentence.
Types
- CopilotDb
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 }- LineRecord
- One line as stored: the help docs an approved draft cited, by id; store.ts turns them into numbered sources.
Omit<Line, "sources"> & { docIds?: string[] | undefined }- DraftRecord
- The copilot's draft waiting for review.
{ id: string; text: string; docIds: string[]; at: string }- DraftOutcome
- What happened to a draft: a reviewer's decision, or withdrawn unsent by a handoff.
Decision | "withdrawn"- ThreadRecord
- One support thread, as stored: its handoffs and lines oldest first, and the draft waiting for review, if any.
{ 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.
{ id: string; threadId: string; subject: string; topic: Topic; decision: Decision; theme?: Theme | undefined; note?: string | undefined; by: string; at: string }- NewHandoff
Pick<Handoff, "reason" | "summary" | "at">- NewLine
Omit<LineRecord, "id">- NewDraft
Omit<DraftRecord, "id">- NewReview
Omit<ReviewRecord, "id">- Desk
- What GET /api/desk returns.
{ viewer: string; threads: QueueRow[]; handoffs: InboxItem[]; counts: { drafts: number; needsDraft: number; handoffs: number } }- HandoffReason
(typeof handoffReasons)[number]- Theme
keyof typeof themes- Topic
(typeof topics)[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 copilot (through reviewed drafts), nobody yet (handed off, not taken), or one person.
{ 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.
{ id: string from: "customer" | "agent" | "person" | "system" name: string text: string at: string sources?: MessageSource[] | undefined review?: { by: string; edited: boolean } | undefined }- Decision
"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.
{ 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.
"needs-draft" | "draft-waiting" | "handed-off" | "sent"- DraftCardData
- What the draft card in the copilot chat shows.
{ threadId: string; draftId: string; text: string; sources: MessageSource[] }- ReviewInput
{ decision: "approve" | "edit" | "reject"; text?: string | undefined; theme?: string | undefined; note?: string | undefined }- CustomerThread
Awaited<ReturnType<typeof customerView>>- StaffThread
Awaited<ReturnType<typeof staffView>>- QueueRow
Awaited<ReturnType<typeof queue>>[number]- Quality
Awaited<ReturnType<typeof quality>>- CopilotChat
- What either adapter hook returns (useCandorChat or useClaudeAgent): the template 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 }