Tool chip
A tool call in a transcript: pending, running, done or failed, with its input and output.
Let me pull that invoice up for you.
import { AgentAvatar } from "@oration/canon/components/agent-avatar";import { ToolChip } from "@oration/canon/components/ai/tool-chip";import * as React from "react";export function Hero() { const tools = [ { name: "find_supplier", label: "Matched Northwind Freight", input: { phone: "+13125550142", company_name: "Northwind Freight" }, output: { vendor_id: "V-10482", company: "Northwind Freight LLC", match: "caller_id", }, duration: 320, }, { name: "lookup_invoice", label: "Found invoice INV-20931", input: { vendor_id: "V-10482", invoice_number: "INV-20931" }, output: { invoice_number: "INV-20931", amount: 20625, status: "approved", due: "2026-10-02", }, duration: 540, }, { name: "get_payment_status", label: "Payment PMT-58213 is scheduled", input: { invoice_number: "INV-20931" }, output: { payment_id: "PMT-58213", status: "scheduled", method: "ACH", scheduled_run: "2026-09-29", }, duration: 610, }, ]; const [done, setDone] = React.useState(0); React.useEffect(() => { if (done >= tools.length) return; const id = window.setTimeout(() => setDone((n) => n + 1), 1100); return () => window.clearTimeout(id); }, [done, tools.length]); return ( <div className="flex w-full max-w-xl flex-col gap-2 rounded-xl bg-card p-4 text-left shadow-border"> <div className="flex items-center gap-1.5 text-13 font-medium text-foreground"> <AgentAvatar agent={{ id: "ag_payment_status", name: "Payments desk agent", }} size={16} /> Payments desk agent <span className="font-normal text-muted-foreground tabular-nums"> 0:18 </span> </div> <p className="text-sm text-foreground"> Let me pull that invoice up for you. </p> <div className="flex min-w-0 flex-col gap-1.5"> {tools.map((tool, index) => index > done ? null : ( <ToolChip key={tool.name} name={tool.name} label={tool.label} status={index < done ? "done" : "running"} input={tool.input} output={index < done ? tool.output : undefined} duration={tool.duration} /> ), )} </div> {done >= tools.length ? ( <p className="text-sm text-pretty text-foreground"> INV-20931 is approved and scheduled for Tuesday's ACH run, September 29. You'll get the remittance by email the same day. </p> ) : null} </div> );}Usage#
Tool chip shows one tool call inside a transcript or an assistant turn: its status, an optional plain-words summary, the tool's machine name in Geist Mono and how long it took. When there is an input, output or error, the chip is a disclosure that opens a tint well of copyable payloads. It lets people audit what an agent actually did (lookup_invoice, get_payment_status) without reading JSON by default. The common mistake is leading with the payload: keep chips closed and let the summary and status carry the story.
When to use
- Under an agent's turn in a call transcript, one chip per tool call, in the order they ran.
- In a Copilot answer, where tool calls appear one at a time as they run and flip to done.
- When someone debugging an agent needs the exact arguments and result of a call.
- When a failed call needs its error message next to the turn that triggered it.
When not to use
- For a plan of steps that tick through queued, running and done. Use Task list
- For the model's reasoning between tool calls. Use Thinking
- For an action a person has to approve before it runs. Use Approval card
- For a long code sample or a response body worth reading in full. Use Code block
- For editing a tool's parameters. Use JSON schema builder
- For a background job's status in a table row. Use Status label
The Machine Mono Rule
The Tint Well Rule
The Label-Beside-Color Rule
Anatomy#
Arrow keys move between rows and open or close them. C copies a value, P copies its path.
Arrow keys move between rows and open or close them. C copies a value, P copies its path.
- Status icon. 14px: a dashed circle for queued, a spinner for running, a green check for done, a red alert for failed.
- Label. Optional plain-words summary in 13px ink: Found payment PMT-58213. It shimmers while running.
- Tool name. The registered name in 12px Geist Mono. Slate Meta when a label leads, ink when it stands alone.
- Duration. Tabular figures after it finishes: 540 ms, 1.34 s.
- Chevron. Only when there is a payload. Turns 180° while open.
- Payload well. Input, output and error sections, split by hairlines. Input and output are bare JSON viewers with a 240px scroll cap: fold, search, and copy any value or its path. The error is red 12px mono text.
- Copy button. One per section, named Copy input, Copy output or Copy error. Rows inside the input and output copy their own value or path.
Examples#
Statuses
Queued, running, done and failed. The label shimmers while running, and the duration appears once the call finishes.
import { ToolChip } from "@oration/canon/components/ai/tool-chip";export function Statuses() { return ( <div className="flex flex-col items-start gap-2"> <ToolChip name="resend_remittance" label="Resend remittance to Halcyon" status="pending" input={{ payment_id: "PMT-58190", email: "ap@halcyon.example" }} /> <ToolChip name="get_payment_status" label="Checking payment for INV-20931" status="running" input={{ invoice_number: "INV-20931" }} /> <ToolChip name="lookup_invoice" label="Found invoice INV-20931" status="done" duration={540} input={{ invoice_number: "INV-20931" }} output={{ amount: 20625, status: "approved" }} /> <ToolChip name="get_remittance" label="Couldn't read the remittance" status="error" duration={2004} input={{ payment_id: "PMT-58213" }} error="ERP timeout after 2000 ms" /> </div> );}Name only, or a label first
Call transcripts show the tool name alone, for people debugging the agent. Copilot leads with a plain-words label and keeps the name as a quieter reference.
import { ToolChip } from "@oration/canon/components/ai/tool-chip";export function LabelOrName() { return ( <div className="grid w-full max-w-3xl gap-6 sm:grid-cols-2"> <div className="flex flex-col gap-2"> <span className="text-xs text-muted-foreground"> Name only, in a call transcript </span> <ToolChip name="get_payment_status" status="done" duration={610} input={{ invoice_number: "INV-20931" }} output={{ status: "scheduled", scheduled_run: "2026-09-29", }} /> </div> <div className="flex flex-col gap-2"> <span className="text-xs text-muted-foreground"> Label and name, in Copilot </span> <ToolChip name="get_payment_status" label="Payment is scheduled" status="done" duration={610} input={{ invoice_number: "INV-20931" }} output={{ status: "scheduled", scheduled_run: "2026-09-29", }} /> </div> </div> );}Input, output and error
Objects are pretty-printed as JSON, strings render as written, and an error gets its own red section. Each section has a copy button.
Arrow keys move between rows and open or close them. C copies a value, P copies its path.
Arrow keys move between rows and open or close them. C copies a value, P copies its path.
Arrow keys move between rows and open or close them. C copies a value, P copies its path.
ERP timeout after 2000 ms. The agent transferred after one failed lookup.
import { ToolChip } from "@oration/canon/components/ai/tool-chip";export function Payloads() { return ( <div className="flex w-full max-w-xl flex-col gap-4"> <ToolChip name="lookup_invoice" label="Found invoice INV-20931" status="done" duration={540} input={{ vendor_id: "V-10482", invoice_number: "INV-20931" }} output={{ invoice_number: "INV-20931", supplier: "Northwind Freight LLC", amount: 20625, currency: "USD", status: "approved", due: "2026-10-02", }} defaultOpen /> <ToolChip name="search_knowledge" label="Searched knowledge" status="done" duration={690} input="early payment discount remittance" output="Remittance advice FAQ, 0.80 match" /> <ToolChip name="get_remittance" label="Couldn't read the remittance" status="error" duration={2004} input={{ payment_id: "PMT-58213" }} error="ERP timeout after 2000 ms. The agent transferred after one failed lookup." defaultOpen /> </div> );}In a call transcript
Chips sit under the agent turn that made the calls, in the order they ran, capped at max-w-xl so wide screens don't stretch them.
- 0:06Aisha, Orchard Street
Hi, I'm calling about invoice OS-4471. We haven't seen the payment yet.
- 0:11Payments desk agent
Thanks, Aisha. I can see OS-4471 was paid by ACH on Friday. I've sent the remittance to your AP inbox.
import { AgentAvatar } from "@oration/canon/components/agent-avatar";import { ToolChip } from "@oration/canon/components/ai/tool-chip";export function InTranscript() { const turns = [ { id: "t1", at: "0:06", speaker: "Aisha, Orchard Street", agent: false, text: "Hi, I'm calling about invoice OS-4471. We haven't seen the payment yet.", }, { id: "t2", at: "0:11", speaker: "Payments desk agent", agent: true, text: "Thanks, Aisha. I can see OS-4471 was paid by ACH on Friday. I've sent the remittance to your AP inbox.", calls: [ { id: "c1", name: "get_payment_status", input: { vendor_id: "V-20417", invoice_number: "OS-4471" }, output: { status: "sent", method: "ACH", sent: "2026-09-25", trace: "091000019284113", }, duration: 480, }, { id: "c2", name: "resend_remittance", input: { payment_id: "PMT-58177", email: "ap@orchardstreet.example", }, output: { delivered: true }, duration: 390, }, ], }, ]; return ( <ol className="flex w-full max-w-2xl flex-col gap-4 rounded-xl bg-card p-4 text-left shadow-border"> {turns.map((turn) => ( <li key={turn.id} className="grid grid-cols-[2.5rem_minmax(0,1fr)] gap-3" > <span className="pt-0.5 text-xs text-muted-foreground tabular-nums"> {turn.at} </span> <div className="flex min-w-0 flex-col gap-1.5"> <span className="flex items-center gap-1.5 text-13 font-medium text-foreground"> {turn.agent ? ( <AgentAvatar agent={{ id: "ag_payment_status", name: "Payments desk agent", }} size={16} /> ) : null} {turn.speaker} </span> <p className="text-sm text-pretty text-foreground"> {turn.text} </p> {turn.calls?.map((call) => ( <ToolChip key={call.id} name={call.name} status="done" input={call.input} output={call.output} duration={call.duration} className="max-w-xl" /> ))} </div> </li> ))} </ol> );}States#
import { ToolChip } from "@oration/canon/components/ai/tool-chip";import { cn } from "@oration/canon/lib/utils";export function StatesMatrix() { const columns = [ { label: "Rest", className: "" }, { label: "Hover", className: "[&_[data-slot=collapsible-trigger]]:bg-muted", }, { label: "Focus visible", className: "[&_[data-slot=collapsible-trigger]]:ring-3 [&_[data-slot=collapsible-trigger]]:ring-ring/40", }, ]; const rows = [ { label: "Queued", status: "pending" as const }, { label: "Running", status: "running" as const }, { label: "Done", status: "done" as const }, { label: "Failed", status: "error" as const }, ]; return ( <div className="grid w-full min-w-0 grid-cols-[4.5rem_repeat(3,minmax(0,1fr))] items-center gap-x-3 gap-y-3 overflow-x-auto"> <span /> {columns.map((column) => ( <span key={column.label} className="text-xs text-muted-foreground" > {column.label} </span> ))} {rows.map((row) => ( <div key={row.label} className="contents"> <span className="text-13 text-muted-foreground"> {row.label} </span> {columns.map((column) => ( <ToolChip key={column.label} name="lookup_invoice" status={row.status} duration={540} input={{ invoice_number: "INV-20931" }} className={cn( "pointer-events-none", column.className, )} /> ))} </div> ))} </div> );}| State | Treatment |
|---|---|
| Queued | A Faint Slate dashed circle. status="pending". |
| Running | A spinning loader in Slate Meta, the label (or the name) shimmering, no duration yet. |
| Done | A Ledger Green check and the duration. |
| Failed | A Signal Red alert and the duration. Pass error to show the message. status="error". |
| Hover | An expandable chip deepens from Well Gray at 70% to 100% over 150ms. |
| Focus visible | A 3px Focus Indigo ring at 40% around the chip. |
| Open | The chip keeps the deeper fill, the chevron turns and the payload well slides open. |
| Not expandable | With no input, output or error it renders as a plain row with no chevron and no hover. |
| Reduced motion | The spinner and shimmer stop and the panel opens without animating height. |
Behavior#
- It is a Base UI Collapsible: the chip is the trigger button, and it sets
aria-expandedand toggles the panel with Enter or Space. defaultOpensets the initial state only; after that the person controls it. There is no controlledopenprop.- The panel's height animates over 200ms on the house ease-out through the shared collapsible styles, and snaps under reduced motion.
- Objects and arrays open as a JSON viewer tree, fully expanded up to 80 values. A string that holds a JSON document is parsed into the tree; any other string renders as written.
- Duration shows only once the status is done or failed. Under a second it reads in ms; under ten seconds with two decimals (1.34 s); beyond that with one.
- The chip is at most the width of its container and truncates the label and name, so a long name never breaks the row.
Do and don't#
Arrow keys move between rows and open or close them. C copies a value, P copies its path.
ERP timeout after 2000 ms
Arrow keys move between rows and open or close them. C copies a value, P copies its path.
Arrow keys move between rows and open or close them. C copies a value, P copies its path.
Arrow keys move between rows and open or close them. C copies a value, P copies its path.
Arrow keys move between rows and open or close them. C copies a value, P copies its path.
Content#
nameis the tool's registered name, exactly:lookup_invoice,get_payment_status,resend_remittance.labelis optional. When you add it, write the result, not the action: Found payment PMT-58213, Sent remittance to Northwind Freight.- If the label shows while running, write it so it works before the result exists, or change it when the call finishes: Looking up INV-20931 then Found invoice INV-20931.
erroris the message a developer would want, in plain words: ERP timeout after 2000 ms, not Error: undefined.
Accessibility#
- The chip is a
<button>named by its label, tool name and status, such as Found payment PMT-58213 lookup_payment_status, Done. - Status icons are
aria-hidden; a visually hidden word carries the status. - Status changes aren't announced. If a sequence of calls matters to a screen reader user, announce progress in a polite live region nearby.
- The spinner and label shimmer stop under reduced motion; the status word still changes.
- Copy buttons are icon buttons with names and tooltips. Payloads scroll inside a 240px box and keep their text selectable.
| Keys | Action |
|---|---|
| Tab | Moves to an expandable chip, then into its copy buttons when open. |
| Enter | Opens or closes the payload. |
| Space | Opens or closes the payload. |
Design tokens#
| Token | Used for |
|---|---|
--muted | Chip and payload fill at 70%; hover and open at 100% |
--success | Done check |
--destructive | Failed icon and the error payload text |
--muted-foreground | Running spinner, tool name beside a label, duration, section titles |
--subtle-foreground | Queued dashed circle |
--border | Hairlines between payload sections |
--ring | Focus ring at 40% |
--radius-lg | 10px corners on the chip and the well |
font-mono | Tool name and payloads |
text-shimmer | Running label |
API reference#
ToolChip
One tool call. Also exported: formatDuration(ms) and the ToolStatus type.
Other props spread onto Nothing. Only the props below are read..
| Prop | Type | Default | Description |
|---|---|---|---|
nameRequired | string | No default | The tool's machine name, e.g. lookup_invoice. |
label | string | No default | Plain-words summary shown before the name. |
statusRequired | "pending" | "running" | "done" | "error" | No default | Queued, running, done or failed. |
input | unknown | No default | Arguments. Strings as written, anything else as JSON. |
output | unknown | No default | Result. Strings as written, anything else as JSON. |
error | string | No default | Error message, shown in red. |
duration | number | No default | Milliseconds. Shown once done or failed. |
defaultOpen | boolean | false | Opens the payload on mount. |
className | string | No default | Merged onto the root. |
formatDuration
Formats milliseconds the way the chip does.
| Prop | Type | Default | Description |
|---|---|---|---|
msRequired | number | No default | Milliseconds. |
returns | string | No default | 540 ms, 1.34 s or 12.4 s. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
The product never shows the queued state: Copilot chips appear only once they start running, and transcripts only have done calls.
Transcript mock data always marks tool calls as successful, so the failed chip and its error section never render in the app today.
There is no controlled open, so the app can't open a chip whose call fails after it mounted. Only defaultOpen applies, at mount.
Status is shown by icon and color, with the word only for screen readers. A sighted user reads Failed from a red icon.
formatDuration shares its name with the duration picker's formatDuration, which takes different arguments.