Support copilot

Premium

The signed-in customer's thread: what they wrote, the replies people approved or wrote, and who has it.

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/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).

PropTypeDefaultDescription
activePlace | undefined—The place in the sidebar this page belongs to, marked as the current page. None on a thread.
titlerequiredstring—The page's title, in its header.
descriptionReact.ReactNode | undefined—A line under the title.
breadcrumbCrumb[] | undefined—Where the page sits, e.g. Queue › T-1042.
actionsReact.ReactNode | undefined—Buttons at the right of the header.
tabsPageTab[] | undefined—Tabs under the header, each with an optional count.
activeTabstring | undefined—The id of the tab that's showing.
onTabChange((id: string) => void) | undefined—Called with a tab's id when it's chosen.
panelReact.ReactNode | undefined—A side panel beside the page, e.g. the copilot's chat.
panelOpenboolean | undefined—Whether the side panel is open.
onPanelOpenChange((open: boolean) => void) | undefined—Called when the side panel opens or closes.
panelLabelstring | undefined—The side panel's name, for screen readers and its resize handle.
childrenrequiredReact.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.

PropTypeDefaultDescription
idrequiredstring—The thread to show, e.g. "T-1042".
chatrequiredCopilotChat—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).

PropTypeDefaultDescription
paramsrequiredPromise<{ 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.

PropTypeDefaultDescription
linerequiredLine—One line of the thread as sent: from the customer, the copilot (with who checked it), a person on the team, or the system.
customerLabelstring | 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.

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