ToolCall
What an agent shows when it calls a tool — the tool's name, its arguments as they stream in, a live status, the result or the error, and Approve / Deny when a person has to say yes first. A card on its own, or quiet rows under one header in a transcript.
pnpm add @tessinaui/uiWhen to use
- An agent calls a tool and the reader should see what it asked for and what came back.
- A call needs a person's approval first: sending, deleting, publishing, spending. Approve and Deny sit outside the disclosure, so a pending decision can never be hidden.
- Arguments that stream in token by token. The card stays valid under every prefix of them, and a row that has arrived never moves.
- Approvals that scale with what a call can do. Set
riskand a high-risk call states itseffects, says it isirreversibleand waits for an acknowledgement before Approve works. - A status the reader can trust. Six statuses, each with its own word and glyph, and the word is part of the card's accessible name.
When not to use
- A run of steps the model already took, inside a chat bubble. Use
ChatBubbleSteps: a collapsible trace, not a card per call, with no approval. - A plan of steps. Use PlanList. A plan is an ordered list of operations; a tool call is one operation with inputs and an output.
- A read-only call that is safe to run. Ask for approval where a call writes, deletes, spends or sends; a read usually needs none.
- An action you can simply undo. A Toast with an Undo action is often better than an approval, and approving every call teaches people to approve without reading.
- Code or a long output on its own. Use CodeBlock; a JSON result already renders in one.
Installation
pnpm add @tessinaui/uiUsage
import { ToolCall } from "@tessinaui/ui/tool-call";
<ToolCall
name="send_email"
args={{ to: "finance@acme.com", subject: "Q3 revenue report" }}
status={status}
result="Sent."
onApprove={() => setStatus("running")}
onDeny={() => setStatus("denied")}
/>The card is a snapshot: pass the call as it stands on every render — the
arguments parsed so far, the status, the result once there is one. It owns no
timer and no state machine. Approve and Deny call your handlers, and your
handlers move status on.
Examples
Default
In a transcript
A turn's calls read as quiet rows under one header. ToolCallGroup counts
them while the agent works ("Using 3 tools"), says when one waits for you, and
sums the turn once every call has finished: "Used 3 tools · 1 failed · 3.4s",
with each tool's mark stacked once. It folds itself when the turn is done, so
the answer reads right under it — unless you have opened or closed it, which
wins from then on. Run it again to watch the rows arrive.
Rows
variant="row" is the transcript look: the tool's mark, the call's phrase,
its status as a glyph and a word, and a short summary with how long it took.
A row has no frame until it needs you. Calls inside a group are rows by
default.
Write the phrase the way the call reads at each moment: "Searching issues" while it runs, "Searched issues" once it is done.
Chips
variant="chip" is a call inside an answer, drawn as a pill. While the call
runs, fails or stops it is a static pill that reads as its status. Once it is
done the pill opens to what the call did: a search lists its queries (pass args
as an array of strings) and its sources, with the first two marks stacked on
the pill; any other call shows its input as text, so pass args as a string, and
its result. A chip
never announces anything itself, because the answer around it already speaks.
Use marksClassName to hide the stacked marks in a narrow column.
Retry
Pass onRetry and a failed call offers Retry in place, named for the call.
Move status on in the handler; focus goes back to the call's header. A row
shows the reason under its header, so neither hides when the row is closed.
Copy
copyable adds Copy input and Copy output beside the open call's sections.
Text copies as it is, anything else as indented JSON. The button turns to a
check and the result is read out once.
Approval in a transcript
A row that needs your decision takes a frame, the group header says it is waiting, and the group opens if it was folded. Risk tiers work the same as on the card.
In a narrow column
In a narrow column the meta wraps under the name and Retry drops under its reason. Nothing scrolls sideways.
States
Six statuses, and none of them is carried by colour alone: each has its own word and its own glyph, and the word is part of the card's accessible name.
Approval
Risk tiers
risk scales the approval with what the call can do. Each tier is a word and
a glyph with its own shape — a shield, a triangle, a diamond — so the tiers
stay apart in greyscale and in forced-colors mode; the colour, taken from the
house intents, is the third signal and never the only one. Nothing is focused
for you on any tier.
Choosing a tier
| Tier | Use it for | What the card does |
|---|---|---|
| none | the plain card | Deny · Approve, as without risk |
low | reversible, contained writes: a draft, a label, a scratch file | one click; "Low risk" with a shield |
medium | writes other people see, or that take effort to undo: an edited record, a restarted service | one click; "Medium risk" with a warning triangle |
high | deleting data, spending money, sending on someone's behalf | "High risk" with a diamond, a destructive Approve, and an acknowledgement that must be ticked first |
A read-only call usually needs no approval at all, and a reversible action you can simply undo is often better as a toast with an Undo action than as an approval.
High risk
A high-risk call says plainly what will happen (effects) and that it cannot
be undone (irreversible), and Approve waits for the acknowledgement. Name
Approve with a verb through strings.approve ("Delete 3 files"); the tier word
never goes into a button's name, because the approval group already carries
it.
With a parameter diff
When a call edits something, show the edit. A
ParameterDiff in effects lists each value
before and after, sits beside Deny and Approve, and describes Approve, so the
change is what a reader approves.
Streaming arguments
Results
On color
The tiers on a coloured plate: the badge, the acknowledgement and the destructive Approve take the plate's recipes. On the error, info and success plates every intent ink is the plate's own ink, so the word and the glyph carry the tier there.
Right to left
dir="rtl" mirrors the card and swaps in the Arabic strings: the status words,
the section labels, the buttons, the risk tier, the acknowledgement and every
announcement. dir does not set a language: pass lang="ar" with it, or a
screen reader reads the Arabic in the page's voice. An inherited direction
mirrors the layout only; pass strings for another language.
Argument values, a text result and an error each read in their own direction, so a path, a URL or an address stays left to right inside an Arabic card.
Loading
Streaming
The card follows the streaming-stable contract every AI-kit component shares (CONVENTIONS §7c):
- Any prefix renders. No props at all, an object cut mid-key, a raw JSON
string cut mid-value, a status cut mid-word (
"runn"reads aspending) — each renders, none throws. - Arrived rows never move. A plain-object
argsrenders as one row per key, keyed by the key, so a row that has arrived keeps its DOM node while the next one streams in below it. - Streaming is never announced. While the call is
pendingthe arguments group isaria-busywitharia-live="off", so an assistive technology that honours an ancestorrole="log"still reads no argument token.
Pass the arguments parsed — a partial object, the shape the AI SDKs hand over — and each value renders on its own row. A string renders as text, and any other structure as JSON.
API Reference
ToolCall
| Prop | Type | Default | Description |
|---|---|---|---|
name | ReactNode | "Tool call" | The tool's name |
toolName | string | — | The tool's own name, read before the call's phrase in a row's accessible name |
nameText | string | name when it is a string | Plain text for announcements and the action buttons' names; required when name is a node |
args | unknown | — | The arguments so far. A plain object renders as rows; a string as text; any other structure as JSON |
status | ToolCallStatus | "pending" | pending · awaiting-approval · running · done · failed · denied. An unknown value reads as pending |
result | unknown | — | Shown once done: a string as text, a React node as itself, anything else as JSON |
error | ReactNode | — | Shown once failed. A string is also read out |
onApprove | () => void | — | Renders Approve while awaiting-approval |
onDeny | () => void | — | Renders Deny while awaiting-approval. Escape inside the approval controls calls it too |
risk | "low" | "medium" | "high" | — | The approval's risk tier. Unset keeps the plain card; an unrecognised value reads as "high" |
effects | ReactNode | — | What approving will do, shown beside the buttons while the call waits. An array renders as a list |
irreversible | boolean | false | Adds "This can't be undone." and names it in the acknowledgement |
icon | ReactNode | wrench | Replaces the glyph before the name. In a group it also joins the header's stack of marks |
variant | "card" | "row" | "chip" | "card", "row" in a group | row is the quiet transcript look; it takes a frame only while the call waits for approval. chip is a pill inside an answer that opens to what the call did |
sources | ToolCallSource[] | — | A search's sources (id, title, url, domain, icon): listed in the chip's panel, the first two marks stacked on the pill |
marksClassName | string | — | Classes for the chip's stacked marks, say to hide them in a narrow column |
durationMs | number | — | How long the call took. Shown after the name ("1.2s", <1s under a second) and added to the group's total |
summary | ReactNode | — | A short line on what came back ("12 issues"), shown after the name |
onRetry | () => void | — | Renders Retry on a failed call. Move status on in the handler |
copyable | boolean | false | Adds Copy input and Copy output to the open call |
copyResultText | string | — | What Copy output writes when result is a node (a list, a table) |
open | boolean | — | Controlled disclosure |
defaultOpen | boolean | open when it mounts waiting | Uncontrolled starting state |
onOpenChange | (open: boolean) => void | — | Called when the disclosure opens or closes |
tone | "default" | "on-color" | inherited | Inherited from a wrapping <Surface>, else "default" |
dir | "ltr" | "rtl" | — | Text direction; "rtl" also picks the Arabic strings |
strings | Partial<ToolCallStrings> | — | Per-entry string overrides |
Everything else lands on the root.
ToolCallGroup
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | derived | Replaces "Using 3 tools" / "Used 3 tools". The meta still follows it |
autoFold | boolean | true | Fold once every call has settled, unless the reader has opened or closed the group |
open | boolean | — | Controlled disclosure |
defaultOpen | boolean | true | Uncontrolled starting state |
onOpenChange | (open: boolean) => void | — | Called when the group opens or closes |
tone | "default" | "on-color" | inherited | Inherited from a wrapping <Surface>, else "default" |
dir | "ltr" | "rtl" | — | Text direction; "rtl" also picks the Arabic strings |
strings | Partial<ToolCallGroupStrings> | — | Per-entry string overrides, with plural variants (used_one, …) |
Calls inside report their status, duration and mark to the group, so the header never needs its own copy of their state.
ToolCallSkeleton
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "card" | "row" | "card" | The look it stands in for |
chars | number | 24 | Length of the name the placeholder stands in for |
label | string | — | What AT announces while the placeholder is up. Silent without it |
dir | "ltr" | "rtl" | — | Text direction |
AgentStatusGlyph
The one glyph set every agent list draws state with: ToolCall rows,
PlanList rows and the agent templates. It is
exported from @tessinaui/ui/tool-call for a list of your own, so your states
read the same way.
| Prop | Type | Default | Description |
|---|---|---|---|
status | "pending" | "running" | "done" | "failed" | "waiting" | "denied" | "skipped" | "paused" | "stopped" | — | The state. Each is a different shape, so none rests on colour |
shape | "ring" | "plain" | "ring" | ring is the outlined set a plan's steps use. plain is the bare marks a transcript row uses, such as a check and an alert |
The glyph sits in a 16px box and is decorative (aria-hidden): say the state's
word yourself. Size the box with className. agentStatusGlyph(status, shape)
returns the bare icon node.
DOM contract
| Attribute | Where | Values |
|---|---|---|
data-slot | every part | tool-call, tool-call-trigger, tool-call-name, tool-call-status, tool-call-panel, tool-call-args, tool-call-arg, tool-call-arg-value, tool-call-arg-reveal, tool-call-text, tool-call-json, tool-call-result, tool-call-error, tool-call-actions, tool-call-deny, tool-call-approve, tool-call-announcer, tool-call-skeleton, tool-call-approval, tool-call-risk, tool-call-effects, tool-call-irreversible, tool-call-acknowledge, tool-call-meta, tool-call-failure, tool-call-retry, tool-call-copy, tool-call-arrival, tool-call-group, tool-call-group-trigger, tool-call-group-label, tool-call-group-meta, tool-call-group-marks, tool-call-group-panel, tool-call-group-announcer, tool-call-marks |
data-variant | the root | card · row · chip |
data-settled / data-waiting | the group root | present once every call has settled / while a call waits |
data-status | the root | pending · awaiting-approval · running · done · failed · denied |
data-risk | the root, the approval block | low · medium · high; absent without a tier |
data-tone | the root | default · on-color |
Motion
Every movement explains a change, and nothing moves when a call first appears.
- A call that arrives in a group that has already painted grows into its space on the panel curve.
- A changed phrase or status fades in place.
- The group folds on the panel curve once the turn is done, a moment after the last call settles.
With reduced motion every state stays: calls appear at full height, labels swap at once, and the group still folds.
Accessibility
There is no APG pattern for a tool call. The card is a disclosure (the house
Collapsible) with real buttons for the decision, and the contract is:
- The status is in the name. The header is a
<button aria-expanded>whose accessible name is the tool and its status together — "delete_file, Awaiting approval" — so the status is heard with the name, not after a pause and never only as a colour or an icon. The badge's glyph isaria-hidden. - The decision is two real buttons, in one order. Deny first, Approve last — the order approval dialogs in the field use — and neither is focused for the reader, so the destructive choice is never the one an extra keypress lands on. Each button's name carries the tool ("Approve delete_file"), so a page with several waiting calls stays unambiguous.
- The decision is reachable first. The buttons sit outside the disclosure and before the result in the DOM, so Tab goes from the header straight to Deny, then Approve, and collapsing the arguments can never hide a pending decision.
- You see what you approve. A call that starts waiting opens its arguments; the card never closes on its own.
- Focus is never lost. After a decision the buttons go, and focus moves to the header, whose name now carries the new status.
- Escape cancels. Escape pressed on the acknowledgement, Deny or Approve denies the call, like Deny, and stops there, so a Drawer or Modal around the card stays open. Escape on the header does nothing, and nothing ever decides on a timer.
- A tier is never only a colour. With
risk, the approval block is a group named for the tier and the tool — "High risk: delete_file" — so entering the buttons announces it once. Approve is described by the effects and the irreversibility note, so the consequence is heard on the control that causes it. In forced-colors mode the word and the glyph's shape carry the tier. - High risk cannot be approved by reflex. Approve stays focusable but
reports
aria-disableduntil the acknowledgement is ticked; pressing it earlier moves focus to the checkbox, which says what is being acknowledged. A double click or a double Enter does nothing. - Announcements are transitions, once, politely. A call that starts
waiting is announced — "delete_file needs your approval.", with the tier
when there is one ("… High risk.") — and so is a
failure, with its reason. A call that mounts already waiting is announced
too, so an approval that arrives in one piece is not lost. Nothing else
speaks: not the arguments, not
running, notdone. Standalone, the card mounts one empty polite region; inside aChatBubbleListit reports to the list's region instead of adding a second. - Long values stay reachable. A value past 160 characters cuts with a
"Show more" toggle (a real button with
aria-expanded); a text result scrolls in a named, focusable region; a JSON result is aCodeBlock, which is copyable and keyboard-scrollable. - Reduced motion: the only animation is the running glyph's pulse, gated
with
motion-safe:.
AT matrix: not yet screen-reader tested, hence beta.
Rows and groups
- A row's name carries its whole line: the phrase, the status, the summary and the duration ("Searched issues, Done, 12 issues, 1.2s"). The mark and the separator dots are hidden from assistive tech.
- The group header is a disclosure named by its summary ("Used 3 tools, 1 failed, 3.4s"). The stacked marks are decoration. Closed rows are out of the tab order with the panel.
- One region speaks for the turn. Calls inside a group report approvals and failures to the group's polite region, or to the conversation's when the group sits in one. A copy result is read once.
- Retry is a real button named for the call ("Retry Query deploys"). Once the status moves on, focus goes to the call's header, never to the page.
Notes
deniedis a status, not a failure. It is the reader's own answer, so it keeps full ink and its own glyph. A call that failed after it was approved isfailed.- No lock after a decision. The card does not disable its buttons once
one is pressed — a card waiting on a status you never send would stay
locked. Move
statusin the handler. - Buttons need a handler.
awaiting-approvalwith noonApproveand noonDenyshows the status and no buttons — for a call someone else is approving.