Skip to content

Tool chip

A tool call in a transcript: pending, running, done or failed, with its input and output.

Status
Beta
Category
AI
Adoption
Not used yet
import { ToolChip } from "@oration/canon/components/ai/tool-chip";
packages/canon/src/components/ai/tool-chip.tsx
Payments desk agent0:18

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 tool name and every payload are Geist Mono, because a machine wrote them. The summary label, the duration and the section titles stay in Geist Sans.

The Tint Well Rule

The chip and its payload panel are 70% Well Gray with 10px corners. They sit inside a message or card without becoming a second bordered card.

The Label-Beside-Color Rule

Each status has its own icon shape (dashed circle, spinner, check, alert), not only a color, and a screen-reader label: Queued, Running, Done, Failed.

Anatomy#

Input1 key
"invoice_number": "INV-20931"

Arrow keys move between rows and open or close them. C copies a value, P copies its path.

Output2 keys
"id": "PMT-58213",
"status": "scheduled"

Arrow keys move between rows and open or close them. C copies a value, P copies its path.

  1. Status icon. 14px: a dashed circle for queued, a spinner for running, a green check for done, a red alert for failed.
  2. Label. Optional plain-words summary in 13px ink: Found payment PMT-58213. It shimmers while running.
  3. Tool name. The registered name in 12px Geist Mono. Slate Meta when a label leads, ink when it stands alone.
  4. Duration. Tabular figures after it finishes: 540 ms, 1.34 s.
  5. Chevron. Only when there is a payload. Turns 180° while open.
  6. 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.
  7. 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.

Name only, in a call transcript
Label and name, in Copilot
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.

Input2 keys
"vendor_id": "V-10482",
"invoice_number": "INV-20931"

Arrow keys move between rows and open or close them. C copies a value, P copies its path.

Output6 keys
"invoice_number": "INV-20931",
"supplier": "Northwind Freight LLC",
"amount": 20625,
"currency": "USD",
"status": "approved",
"due": "2026-10-02"

Arrow keys move between rows and open or close them. C copies a value, P copies its path.

Input1 key
"payment_id": "PMT-58213"

Arrow keys move between rows and open or close them. C copies a value, P copies its path.

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

  1. 0:06
    Aisha, Orchard Street

    Hi, I'm calling about invoice OS-4471. We haven't seen the payment yet.

  2. 0:11
    Payments 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#

RestHoverFocus visible
Queued
Running
Done
Failed
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>    );}
States
StateTreatment
QueuedA Faint Slate dashed circle. status="pending".
RunningA spinning loader in Slate Meta, the label (or the name) shimmering, no duration yet.
DoneA Ledger Green check and the duration.
FailedA Signal Red alert and the duration. Pass error to show the message. status="error".
HoverAn expandable chip deepens from Well Gray at 70% to 100% over 150ms.
Focus visibleA 3px Focus Indigo ring at 40% around the chip.
OpenThe chip keeps the deeper fill, the chevron turns and the payload well slides open.
Not expandableWith no input, output or error it renders as a plain row with no chevron and no hover.
Reduced motionThe 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-expanded and toggles the panel with Enter or Space.
  • defaultOpen sets the initial state only; after that the person controls it. There is no controlled open prop.
  • 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#

Found payment PMT-58213lookup_payment_status540 ms, Done
Do. Write the label as the outcome in plain words and let the mono name say which tool: Found payment PMT-58213, then lookup_payment_status.
Called lookup_payment_status tool successfullylookup_payment_status540 ms, Done
Don't. Repeat the tool name in the label or narrate the call: Called lookup_payment_status tool successfully.
Input1 key
"payment_id": "PMT-58213"

Arrow keys move between rows and open or close them. C copies a value, P copies its path.

Error
ERP timeout after 2000 ms
Do. Keep chips closed so the transcript reads as a conversation. Open the one that failed.
Input1 key
"phone": "+13125550142"

Arrow keys move between rows and open or close them. C copies a value, P copies its path.

Output1 key
"vendor_id": "V-10482"

Arrow keys move between rows and open or close them. C copies a value, P copies its path.

Input1 key
"invoice_number": "INV-20931"

Arrow keys move between rows and open or close them. C copies a value, P copies its path.

Output1 key
"status": "approved"

Arrow keys move between rows and open or close them. C copies a value, P copies its path.

Don't. Open every chip by default. The turn turns into a wall of JSON and the words get lost.

Content#

  • name is the tool's registered name, exactly: lookup_invoice, get_payment_status, resend_remittance.
  • label is 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.
  • error is 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.
Keyboard interactions
KeysAction
TabMoves to an expandable chip, then into its copy buttons when open.
EnterOpens or closes the payload.
SpaceOpens or closes the payload.

Design tokens#

Design tokens
TokenUsed for
--mutedChip and payload fill at 70%; hover and open at 100%
--successDone check
--destructiveFailed icon and the error payload text
--muted-foregroundRunning spinner, tool name beside a label, duration, section titles
--subtle-foregroundQueued dashed circle
--borderHairlines between payload sections
--ringFocus ring at 40%
--radius-lg10px corners on the chip and the well
font-monoTool name and payloads
text-shimmerRunning 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..

Props of ToolChip
PropTypeDefaultDescription
nameRequiredstringNo defaultThe tool's machine name, e.g. lookup_invoice.
labelstringNo defaultPlain-words summary shown before the name.
statusRequired"pending" | "running" | "done" | "error"No defaultQueued, running, done or failed.
inputunknownNo defaultArguments. Strings as written, anything else as JSON.
outputunknownNo defaultResult. Strings as written, anything else as JSON.
errorstringNo defaultError message, shown in red.
durationnumberNo defaultMilliseconds. Shown once done or failed.
defaultOpenbooleanfalseOpens the payload on mount.
classNamestringNo defaultMerged onto the root.

formatDuration

Formats milliseconds the way the chip does.

Props of formatDuration
PropTypeDefaultDescription
msRequirednumberNo defaultMilliseconds.
returnsstringNo default540 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.