Tool Call Card
One agent tool call inline in the conversation — a first-class approval gate with one-shot Approve/Decline, structured args and results, key-based redaction that never puts the secret in the DOM, announced truncation for huge payloads, and four data states.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/tool-call-card.jsonPrompt
Build a React + TypeScript + Tailwind "ToolCallCard" component with zod and
lucide-react, reusing an existing JSON tree viewer for structured payloads and a
copy-to-clipboard hook for text ones.
Contract
- A zod schema (`toolCallCardCallSchema` in a sibling contract file) is the single
source of truth for ONE call: { id, name, state, args?: unknown, result?:
unknown, error?: string, durationMs?: number, startedAt?: string|number|Date,
summary?: string, approvalPrompt?: string, denialReason?: string }.
- The call state union is FIVE values, not three: "awaiting-approval" | "pending"
| "success" | "error" | "denied". Approval is a state of the call, not a modal
bolted on top, and a refusal is an outcome the transcript has to keep — with
the reason the human gave.
- `args` / `result` are `unknown` on purpose: a tool's payload shape is the
tool's business. Never ask the caller to pre-stringify them. A string `result`
is legitimate and is rendered as TEXT (log output), not as a JSON row.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
card's own render state, independent of the call's state. `status="error"`
means the transcript row failed to load; the TOOL failing is `state: "error"`,
and the two read differently on screen.
- Props: call: Call | null; status; now? (injected clock); defaultOpen?;
onApprove?(id); onDeny?(id, reason?); onRetry?; sensitiveKeys? (defaults to a
built-in credential list); jsonMaxChildren? (50); jsonMaxStringLength? (200);
textPreviewChars? (1200); formatDuration?; emptyState?; errorMessage?; label?
("Tool call"); className plus the rest of the element props, ref forwarded to
the <article>.
- Every numeric prop is clamped (jsonMaxChildren >= 1, jsonMaxStringLength >= 16,
textPreviewChars >= 80) so a 0 or a NaN can never render an empty payload.
Behavior
- APPROVAL IS ONE-SHOT. The Approve handler fires at most once per approval
request no matter how fast the button is clicked, because the lock lives in a
ref that is read and written synchronously inside the click handler: five
clicks dispatched in a single task all read the same stale state, so a
state-only guard would let four through and run a destructive tool five times.
The lock expires by itself — a generation counter ticks whenever the card
re-enters "awaiting-approval", so a second, genuine approval request is
answerable while a duplicate click is not.
- Both decision buttons use aria-disabled, never the native `disabled`
attribute: `disabled` blurs the button the instant it flips, dropping focus to
<body> in the middle of the action, and removes it from the tab order.
- Declining is two steps: Decline reveals a reason textarea, "Send decline"
submits it, and the reason is passed to onDeny (undefined when blank) so the
agent learns WHY. If neither onApprove nor onDeny is passed, no buttons are
rendered at all — an Approve button that approves nothing is worse than none.
- Sensitive arguments are REDACTED BEFORE RENDER. Keys are matched
case/separator-insensitively but whole (`apiKey` = `api_key` = `API-KEY`, while
`max_tokens` is untouched), and a matched key's entire subtree is replaced by
the string "[redacted]" and never walked. The plaintext therefore never exists
in the DOM, in the accessibility tree or in the copy buffer. CSS masking (a
blur, a covering box, -webkit-text-security) would leave the secret in all
three. Redaction runs on the result too, and the section title states how many
values it replaced. Limitation to document: key matching cannot see a secret
embedded INSIDE another value (a password in a DSN) — redact those upstream.
- The redacted copy is memoized on the payload's identity and on a derived key
STRING, not on the keys array: an inline array literal changes identity every
render, and handing the JSON viewer a brand-new object each time would silently
collapse every row the reader had opened.
- Big payloads never truncate silently. An array/object renders jsonMaxChildren
entries and ends in an explicit "Show 4950 more…"; a text payload renders
textPreviewChars characters above the exact figure ("Showing 1,200 of 41,382
characters") and a "Show all" toggle. Copy always writes the FULL text, never
the visible preview.
- The error message is the one thing that is never capped: no max-height, no
line clamp, wrapped, with its own copy button. The tail of a stack trace is
usually the part that names the cause.
- The card is COLLAPSED by default, except when the call is in "error" or
"awaiting-approval" — you cannot approve what you cannot read, and an error one
click away from being seen is an error that gets missed. A call that later
fails opens itself; the reader can still close it.
- Disclosure is a real <button aria-expanded> whose panel is UNMOUNTED when
closed (a zero-height collapse leaves every JSON row and copy button reachable
by Tab — an invisible keyboard trap), and `aria-controls` is only set while
open so it never points at an id that is not in the document.
- The card owns NO CLOCK. A finished call shows `durationMs`; a pending call
shows `now - startedAt`, recomputed from the start instant every render and
never accumulated, so a re-render, a paused tab or a replayed stream cannot
drift. Omit `now` and a pending call simply shows no number. Calling Date.now()
inside the component would also desync server and client output.
- Identity changes reset the card (adjust state during render, no effect): a new
call id restores the default disclosure, closes the decline form and clears the
typed reason, so a recycled card can never submit the previous call's text.
Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
bg-primary + text-primary-foreground for Approve, bg-primary/5 with a
border-primary/50 card edge for the approval bar, bg-destructive with
text-background for the decline confirmation, text-destructive with
bg-destructive/5 for the error block. No hard-coded colours anywhere.
- Five states are five SHAPES — shield / spinner / check / alert / ban — each
carrying an sr-only word, so they survive a monochrome theme and a colour-blind
reader. The spinner stops under prefers-reduced-motion and still reads as "in
progress"; every transition is motion-reduce-guarded.
- The root is an <article aria-label="Tool call: <name>"> holding ONE persistent
sr-only role="status" line that spells out the state ("delete_files needs your
approval", "run_tests failed — ProcessExitError…"). It is mounted for the whole
ready branch, so a state CHANGE is announced from an element the screen reader
is already watching.
- Long tool names and long identifiers use `wrap-anywhere` (overflow-wrap:
anywhere) plus `min-w-0`, NOT `break-words`: only the former lowers the
element's min-content width, which is what actually stops an 80-character tool
name from making the card wider than the message it sits in.
- cn() merges the consumer's className into the root, which also spreads the
remaining element props and forwards its ref.
Customization levers
- Policy: `sensitiveKeys` replaces the built-in credential list wholesale (pass
[] to disable redaction, or add your own domain keys like `ssn`, `iban`).
- Disclosure: `defaultOpen` pins the panel open or closed for every state — set
it true in a debugging surface, false in a customer-facing transcript.
- Payload budget: `jsonMaxChildren` / `jsonMaxStringLength` / `textPreviewChars`
decide how much is drawn before the explicit reveal control. Raise them for
small payloads, lower them for a wall-of-JSON tool — but never make either
silent; the count is part of the reading.
- Units: `formatDuration` swaps the wording (seconds, tokens, a cost figure).
Unlike a trace panel's duration column, a single card's number is read as prose
and never ranked against a neighbour, which is why the default mixes units
("820 ms", "1.2 s", "1 m 2 s").
- Live time: pass a `now` you tick once per second in your app — one interval for
the whole conversation instead of one per card.
- Chrome: `label` names the region, `summary` is your own one-line scan aid,
`approvalPrompt` is where you spell out the blast radius in the user's words,
`emptyState` replaces the empty body, `errorMessage` + `onRetry` own the
envelope failure.Concepts
- Approval is a state, not a modal — an agent about to delete files, send mail or spend money parks the call in
awaiting-approval, and the card grows the two buttons that decide it, inline, where the reasoning that led there is still on screen. A dialog would rip the decision out of its context and block the rest of the conversation. - One-shot decision lock — the guard is a ref read and written synchronously inside the click handler, because five clicks dispatched in one task all observe the same stale state; a state-only guard would run a destructive tool five times. The lock is keyed to a generation that ticks whenever the card re-enters the gate, so it expires by itself instead of leaving a dead button behind.
- Declining carries a reason — "no" without a why sends the agent straight back to the same plan. The decline path is two steps (Decline → reason → send) and the text is handed to
onDeny, then kept on the card so the transcript still explains itself tomorrow. - Redaction happens before render, not with CSS — a matched key's whole subtree is replaced by
[redacted]in a copy of the payload, so the secret is absent from the DOM, from the accessibility tree and from the copy buffer. A blur or a covering box leaves the plaintext in all three, one "view source" away. - Truncation is always announced — a 5,000-row array ends in "Show 4950 more…", a 41,000-character log says exactly how many characters are on screen, and Copy always writes the whole payload. A silent cap is worse than no cap: it makes you debug the wrong thing.
- The error is the one thing never capped — no max-height, no line clamp, always copyable. Everything else on the card can be summarised; the failure is why the card is open.
- No clock inside the component — a finished call carries its
durationMs, a running one derivesnow − startedAtfrom an injected clock. Elapsed time is recomputed from the start instant, never incremented, so a pause, a re-render or a replay can't drift — and the server and the browser always render the same string.
Reasoning Block
A collapsible thinking panel whose open/closed state is driven by the stream's lifecycle — and permanently yields to the reader the moment they touch it.
Citation Chip
The inline [n] marker that trails a sentence in an AI answer — an inline box that cannot change the paragraph's line height, with a source card portalled past every clipping bubble.