AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  AlertCircle,
  Ban,
  Check,
  ChevronRight,
  CircleAlert,
  CircleCheck,
  Copy,
  CopyX,
  LoaderCircle,
  RefreshCcw,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/tool-call-card.json

Prompt

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 derives now − startedAt from 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.

On This Page