Skip to content
Tier

Copilot panel

Pro

An embeddable copilot for an existing product: a launcher button with the app's unread count, and a panel with the conversation, built from the free chat items (Thread, Message, Composer, AiDisclosure, PromptSuggestions). The panel slides in from the end edge on a wide screen and fills the screen on a narrow one. It names the agent the app names, says it's an AI system, and shows whether the person is signed in or a guest; nothing it shows claims more than the app told it (no "saved", "private" or "a person will reply" unless the app says so in viewer.note or disclosure). The host's brand reaches it through the theme: it draws with the theme's tokens, so a brand file styles it like the rest of the app.

The app owns the conversation: it passes messages, sends in onSend, and reports state. The unread count is the app's too: clear it in onOpenChange when the panel opens, if that's what "read" means in your product.

Markdown
Version 5.2.0· other versions

This page is for 5.2.0, the current release. An earlier release’s page is the Markdown that release published, so the docs match the version you have installed (candor-lock.json records it).

Not in the docs of 1.3.0, 1.4.0, 1.5.0, 1.5.1, 1.6.0, 1.7.0, 1.8.0, 1.8.1, 1.9.0, 1.9.1, 2.0.0, 2.1.0, 2.2.0, 2.3.0, 2.4.0, 2.5.0, 2.5.1, 2.6.0, 2.6.1 and 2.6.2: the page came later, or had another name then.

Docs pages weren’t kept for 1.0.0, 1.0.1, 1.1.0, 1.1.1, 1.2.0 and 1.2.1: they are kept from 1.3.0 on, and before that only llms.txt was published.

Preview

Loading the preview

Install

With the Candor CLI

First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/copilot-panel

Brings in: ai-disclosure, bidi, button, chat, composer, locale, message, motion, prompt-suggestions, sheet, thread, tokens, utils · npm: lucide-react@^1.67.0

Usage

TSX · The example above
"use client"

import * as React from "react"
import { CopilotPanel, type CopilotViewer } from "@/components/candor/copilot-panel"
import type { MessageData } from "@/lib/candor/chat"

const agent = { id: "help", name: "Help assistant", role: "assistant" }

const text = (id: string, author: MessageData["author"], words: string): MessageData => ({
  id,
  author,
  status: "complete",
  blocks: [{ kind: "paragraph", nodes: [{ kind: "text", text: words }] }],
})

export default function Example() {
  const [messages, setMessages] = React.useState<MessageData[]>([])
  const [unread, setUnread] = React.useState(2)
  const [signedIn, setSignedIn] = React.useState(false)
  const viewer: CopilotViewer = signedIn
    ? { kind: "signed-in", name: "sam@keel.dev" }
    : { kind: "guest", note: "This conversation isn't kept after you leave.", onSignIn: () => setSignedIn(true) }
  return (
    <div className="flex h-24 items-center text-sm text-muted-foreground">
      <p>The launcher sits in the page; here it is inline rather than pinned to the corner.</p>
      <CopilotPanel
        launcher="inline"
        className="ms-auto"
        agent={agent}
        viewer={viewer}
        unread={unread}
        // In this product, opening the panel counts as reading it.
        onOpenChange={(open) => open && setUnread(0)}
        disclosure="Run by Keel. Ask for a person at any time."
        suggestions={[
          { id: "s1", text: "How do I invite my team?" },
          { id: "s2", text: "Why did my last export fail?" },
        ]}
        messages={messages}
        onSend={(words) =>
          setMessages((m) => [...m, text(`u${m.length}`, { role: "user" }, words), text(`a${m.length}`, { role: "agent", agent }, "I’ll look into that for you.")])
        }
      />
    </div>
  )
}

<CopilotPanel>

PropTypeDefaultDescription
agentrequiredAgentIdentity | string—The agent, named by the app: an AgentIdentity or a plain name. Never model text.
messagesrequiredMessageData[]—The conversation so far, oldest first.
onSendrequired(text: string) => void—Called with the text when the person sends. The draft is cleared after.
stateComposerState | undefined"ready"The composer's state: ready, sending, streaming, locked, held or offline, as for Composer.
onStop(() => void) | undefined—Shows Stop while a reply streams; called when it's pressed.
valuestring | undefined—The draft, when the app keeps it. Left out, the panel keeps its own.
onValueChange((value: string) => void) | undefined—Called with the draft as it changes, when the app keeps it.
suggestionsPromptSuggestion[] | undefined[]Prompts to start from, shown before the first message. Choosing one puts it in the composer to edit, never sends it.
viewerrequiredCopilotViewer—Who is using it: a guest, or a signed-in person named by the app.
unreadnumber | undefined0How many replies the person hasn't seen, as the app counts them. Shown on the launcher when above 0.
openboolean | undefined—Whether the panel is open, when the app controls it.
defaultOpenboolean | undefinedfalseWhether the panel starts open, when the app doesn't control it.
onOpenChange((open: boolean) => void) | undefined—Called when the panel opens or closes.
disclosurestring | undefined—The app's own plain text about the system (who runs it, how to reach a person), shown with the AI notice.
onLearnMore(() => void) | undefined—Opens the app's own page about its AI use; shows "Learn more" in the notice.
emptyReact.ReactNode | undefined—Shown before the first message, under the suggestions, in place of "Ask a question to start."
renderMessage((message: MessageData, options: { nameShownAbove: boolean }) => React.ReactNode) | undefined—Draws one message, to pass Message's own props (ratings, retry). Defaults to `<Message message={m} nameShownAbove={…} />`. The second argument says whether the message is the named agent's, whose name the panel's title already shows: pass it on as Message's `nameShownAbove`.
launcher"floating" | "inline" | undefined"floating""floating" (the default) pins the launcher to the bottom end corner; "inline" leaves it in the page's flow, for a header.
side"end" | "start" | undefined"end"The edge the panel comes from on a wide screen: "end" (the default) or "start".
classNamestring | undefined—Classes, merged with Candor's.

Strings

The fixed words, in English by default. Change any of them under copilotPanel in <LocaleProvider strings={{ … }}>; the table is exported as copilotPanelStrings.

KeyEnglish
launcherAsk {name}
launcherUnread{ one: "Ask {name}, {count} unread reply", other: "Ask {name}, {count} unread replies" }
descriptionA conversation with {name}, an AI system.
signedInAsSigned in as {name}
guestYou're not signed in.
signInSign in
guestLockedSign in to ask {name}.
placeholderAsk {name}
emptyAsk a question to start.

Motion

MomentFamilyWhat it does
unreadpop, scaleThe launcher's unread count pops in when it goes from none to some while the launcher is on screen, and again each time the number changes. A count already there on first draw is still.

Set with --cd-motion-copilot-panel-<moment>-* in your brand file; the families are in the design reference.

Types

CopilotViewer
Who is using the copilot, as the app knows it. A guest sees that they aren't signed in, with the app's own note (what happens to the conversation) only when the app gives one, and a Sign in button when `onSignIn` is given.
{ kind: "guest"; /** The app's own plain text about guest use ("This conversation isn't kept after you leave."). Nothing is said without it. */ note?: string | undefined; /** Shows Sign in in the panel; called when it's pressed. */ onSignIn?: (() => void) | undefined; /** Whether a guest may send. On by default; when off, the composer stays visible and says to sign in. */ canSend?: boolean | undefined; } | { kind: "signed-in"; /** The person's name or email, from the app. */ name: string; }

Types from other items

The props above take these. Each is defined by another item; import it from the path shown.

AgentIdentity from @/lib/candor/chat
{ id: string; /** The displayed name, e.g. "Architect agent". Never derived from model output. */ name: string; role: AgentRole; specialty?: string | undefined; }
MessageData from @/lib/candor/chat
{ id: string; author: MessageAuthor; /** When it was sent. Absent when the source doesn't say; then no time is shown. */ createdAt?: string | undefined; status: MessageStatus; blocks: MessageBlock[]; reasoning?: ReasoningEntry[] | undefined; /** How long the agent spent before answering, e.g. "6s". Only once known. */ thoughtFor?: string | undefined; /** * When the request for this reply went out, as the app measured it: an ISO time or milliseconds since the epoch. * While the reply streams and nothing of it has arrived, its working line counts up from here ("Working · 4.2s"), * and so does its reasoning's. Left out, no time is shown. */ startedAt?: string | number | undefined; sources?: MessageSource[] | undefined; /** Shown under an agent's reply: model, cost. Only values the service measured. */ attribution?: { model?: string | undefined; cost?: string | undefined; } | undefined; }
PromptSuggestion from @/lib/candor/chat
{ id: string; text: string }
AgentRole from @/lib/candor/chat
What an agent does: "assistant", "coder", "reviewer"… The app registers each role's glyph and hue (AgentRolesProvider).
string
MessageAuthor from @/lib/candor/chat
{ role: "user"; name?: string | undefined; } | { role: "agent"; agent: AgentIdentity; } /** A person on the app's own team (support staff, a reviewer), not the user: shown by name, with their team. */ | { role: "person"; name: string; team?: string | undefined; } /** System lines are the app's own strings, never model text. */ | { role: "system"; }
MessageStatus from @/lib/candor/chat
"streaming" | "complete" | "interrupted" | "failed"
MessageBlock from @/lib/candor/chat
{ kind: "paragraph"; nodes: InlineNode[]; /** * The "\n" in its text are line breaks, drawn as such: set by `markdownBlocks(text, { breaks: true })` on a * paragraph it joined from several lines. Left out, a "\n" is drawn as a space, as HTML draws it. */ breaks?: true | undefined; } /** * Text exactly as someone typed it: line breaks and spacing kept, nothing parsed. For a person's own words, so * `*this*` stays `*this*` and a pasted list keeps its lines. */ | { kind: "plain"; text: string; } /** A heading inside a reply. Levels are relative to the message, 1 the largest; the page's own headings sit above. */ | { kind: "heading"; level: 1 | 2 | 3; nodes: InlineNode[]; } | { kind: "list"; ordered?: boolean | undefined; items: InlineNode[][]; } | { kind: "payload"; language: PayloadLanguage; text: string; title?: string | undefined; } | { kind: "tools"; calls: ToolCallData[]; } /** Something the agent could not do, and what is missing because of it. */ | { kind: "degraded"; reason: string; missing?: string | undefined; } /** * A typed card (a result table, a plan, an approval) rendered by the app's * card registry. `summary` is the service's plain-text version, used for copy * and whenever the card can't be shown. */ | { kind: "card"; card: string; props: unknown; summary: string; } /** Files sent with the message, shown as tiles. New in 1.1: a `switch` over kinds needs a `default` branch. */ | { kind: "attachments"; files: Attachment[]; }
ReasoningEntry from @/lib/candor/chat
| (ReasoningBase & { basis: "verified"; evidence: string }) | (ReasoningBase & { basis: "believed" | "assumed"; assumption: string })
MessageSource from @/lib/candor/chat
{ id: string; /** The [n] shown inline. Assigned by the service, so it is stable across renders. */ marker: number; kind: SourceKind; label: string; /** An inert excerpt shown in the popover. Text only. */ snippet?: string | undefined; /** Where it came from in a human sense: "Docs · Sales process v3", "Query on orders". */ origin?: string | undefined; provisional?: boolean | undefined; }