# Copilot panel

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.

- Registry name: `copilot-panel` (`@candor/copilot-panel`)
- Tier: Pro. Installing it needs a Pro, 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/copilot-panel

## Install

```bash
npx candor-ui add copilot-panel
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/copilot-panel`.
It brings in: ai-disclosure, button, chat, composer, locale, message, motion, prompt-suggestions, sheet, thread, tokens, utils.
npm packages: lucide-react@^1.67.0.

## Example

```tsx
"use client"

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

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

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

export default function Example() {
  const [messages, setMessages] = React.useState<Message[]>([])
  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>

Props:

- `agent: AgentIdentity | string`: The agent, named by the app: an AgentIdentity or a plain name. Never model text.
- `messages: MessageData[]`: The conversation so far, oldest first.
- `onSend: (text: string) => void`: Called with the text when the person sends. The draft is cleared after.
- `state?: ComposerState | undefined` (default `"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.
- `value?: string | 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.
- `suggestions?: PromptSuggestion[] | undefined` (default `[]`): Prompts to start from, shown before the first message. Choosing one puts it in the composer to edit, never sends it.
- `viewer: CopilotViewer`: Who is using it: a guest, or a signed-in person named by the app.
- `unread?: number | undefined` (default `0`): How many replies the person hasn't seen, as the app counts them. Shown on the launcher when above 0.
- `open?: boolean | undefined`: Whether the panel is open, when the app controls it.
- `defaultOpen?: boolean | undefined` (default `false`): Whether the panel starts open, when the app doesn't control it.
- `onOpenChange?: ((open: boolean) => void) | undefined`: Called when the panel opens or closes.
- `disclosure?: string | 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.
- `empty?: React.ReactNode | undefined`: Shown before the first message, under the suggestions, in place of "Ask a question to start."
- `renderMessage?: ((message: MessageData) => React.ReactNode) | undefined`: Draws one message, to pass Message's own props (ratings, retry). Defaults to `<Message message={m} />`.
- `launcher?: "floating" | "inline" | undefined` (default `"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` (default `"end"`): The edge the panel comes from on a wide screen: "end" (the default) or "start".
- `className?: string | undefined`

## Strings (`copilotPanel`)

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

- `launcher`: Ask {name}
- `launcherUnread`: { one: "Ask {name}, {count} unread reply", other: "Ask {name}, {count} unread replies" }
- `description`: A conversation with {name}, an AI system.
- `signedInAs`: Signed in as {name}
- `guest`: You're not signed in.
- `signIn`: Sign in
- `guestLocked`: Sign in to ask {name}.
- `placeholder`: Ask {name}
- `empty`: Ask a question to start.

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

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

Import: `import type { AgentIdentity } from "@/lib/candor/chat"` (item `chat`, https://candor.themethodic.com/docs/chat.md)

```ts
{
  id: string;
  /** The displayed name, e.g. "Architect agent". Never derived from model output. */
  name: string;
  role: AgentRole;
  specialty?: string | undefined;
}
```

### Message

Import: `import type { Message } from "@/lib/candor/chat"` (item `chat`, https://candor.themethodic.com/docs/chat.md)

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

Import: `import type { PromptSuggestion } from "@/lib/candor/chat"` (item `chat`, https://candor.themethodic.com/docs/chat.md)

```ts
{ id: string; text: string }
```

### AgentRole

Import: `import type { AgentRole } from "@/lib/candor/chat"` (item `chat`, https://candor.themethodic.com/docs/chat.md)

What an agent does: "assistant", "coder", "reviewer"… The app registers each role's glyph and hue (AgentRolesProvider).

```ts
string
```

### MessageAuthor

Import: `import type { MessageAuthor } from "@/lib/candor/chat"` (item `chat`, https://candor.themethodic.com/docs/chat.md)

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

Import: `import type { MessageStatus } from "@/lib/candor/chat"` (item `chat`, https://candor.themethodic.com/docs/chat.md)

```ts
"streaming" | "complete" | "interrupted" | "failed"
```

### MessageBlock

Import: `import type { MessageBlock } from "@/lib/candor/chat"` (item `chat`, https://candor.themethodic.com/docs/chat.md)

```ts
{
  kind: "paragraph";
  nodes: InlineNode[];
}
/**
 * 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: ToolCall[];
}
/** 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

Import: `import type { ReasoningEntry } from "@/lib/candor/chat"` (item `chat`, https://candor.themethodic.com/docs/chat.md)

```ts
| (ReasoningBase & { basis: "verified"; evidence: string })
  | (ReasoningBase & { basis: "believed" | "assumed"; assumption: string })
```

### MessageSource

Import: `import type { MessageSource } from "@/lib/candor/chat"` (item `chat`, https://candor.themethodic.com/docs/chat.md)

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

## Motion

- **unread** (pop, scale): The 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.

## 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`: its fixed strings in `strings`, its nouns in `nouns`; entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
