Tool UI patterns
When an AI agent calls tools, the user needs to see it: pending, streaming, result, error, and a human-in-the-loop approval before anything consequential runs.
In one line: when an AI agent reaches for a tool, the interface has to narrate it — pending, streaming, a structured result or a clear error — and stop to ask before anything consequential actually happens.
What it is
A tool call is the moment an AI agent stops talking and starts acting: it decides to invoke a function — search a database, draft an email, book a flight — and waits for the result. Tool UI patterns are the interface conventions that surface that action to the user instead of leaving it inside a black box.
Without them, the agent goes quiet for a few seconds and then a result appears with no explanation of where it came from. With them, the user watches the model decide, sees which tool ran with which arguments, follows the output as it streams, and — for anything that changes the world — gets asked first.
Why it matters
An agent that silently calls tools is impossible to trust and miserable to debug. Surfacing the call buys you three things at once:
- Trust. Users believe an answer more when they can see the steps that produced it. A flight price means more next to "called
book_flight(SFO → JFK)" than as a bare number. - Debuggability. When the model calls the wrong tool or passes bad arguments, a visible call is the difference between "the agent is broken" and "ah, it searched the wrong date."
- Safety. Some tools spend money, send messages, or delete data. Those must never fire on the model's say-so alone — the user has to see the proposed action and approve it.
See it
Tweak it3
Step through each state from the rail: idle, pending, streaming, result, error, and needs-approval. Notice that the state is always spelled out in text, and that the approval step exposes real Approve and Deny buttons before the booking runs.
How it works
A tool call moves through a small lifecycle, and each stage gets its own UI:
- idle — the tool exists in the agent's toolbox but hasn't been called. Show it muted, if at all.
- pending — the model has decided to call the tool. Show the tool name and arguments plus a "calling…" affordance so the wait feels intentional, not frozen.
- streaming — partial output is arriving. Render rows as they come in rather than blocking on the full payload.
- result — success. Render the output as a structured card — a flight option with a price, a table, a map — not a wall of raw JSON. The model returns data; your UI gives it shape.
- error — the call failed. Say so in words ("Error: the service is unavailable"), never by color alone, and offer a real Retry button.
Layered on top of the happy path is human-in-the-loop approval. Before a destructive or consequential tool runs, the agent pauses in a needs-approval state and renders the proposed action with explicit Approve and Deny controls. The tool only executes on an affirmative, keyboard-operable confirmation.
Build it
The cleanest way to model this is a switch over the tool-call state — one branch per stage, each rendering a different piece of UI.
type ToolState =
| "pending"
| "streaming"
| "result"
| "error"
| "needs-approval";
function ToolCall({
state,
result,
onApprove,
onRetry,
}: {
state: ToolState;
result?: { airline: string; price: number };
onApprove: () => void;
onRetry: () => void;
}) {
switch (state) {
case "pending":
return <p>Calling book_flight…</p>;
case "streaming":
return <p>Searching flights… (streaming)</p>;
case "result":
// Structure the result — don't dump raw JSON.
return (
<div className="result-card">
<strong>{result?.airline}</strong>
<span>${result?.price}</span>
</div>
);
case "error":
// Text-first error, plus a real retry control.
return (
<div role="group">
<p>Error: the flight service is unavailable.</p>
<button type="button" onClick={onRetry}>
Retry
</button>
</div>
);
case "needs-approval":
// Gate the consequential action behind explicit confirmation.
return (
<div role="group">
<p>Book this flight for $284?</p>
<button type="button" onClick={onApprove}>
Approve
</button>
<button type="button">Deny</button>
</div>
);
}
}Make it yours
Use the controls beside the demo above to change tool-call state, tool, and call latency — each change updates the example live.
Experiment in the playground- Swap
book_flightfor a tool from your own product and decide which states it actually needs — a read-only search may never need an approval gate, while a "send invoice" tool always should. - Design the result card for your data: a structured summary beats raw JSON every time, and it's where most of the perceived quality lives.
- Make the error state recoverable — a retry that re-runs the same call, plus enough context for the user to know whether retrying is worth it.
Reproduce it with an LLM
Reproduce it with an LLM
You are a product engineer designing the UI for an AI agent that calls tools. For one tool — 'book_flight(from, to, date)' — design the full set of UI states the user sees: idle, pending (the model decided to call it), streaming (partial output arriving), result (success, rendered as a structured card not raw JSON), error (with a retry), and needs-approval (a human-in-the-loop confirm/deny BEFORE a consequential action runs). For each state, specify what's shown, what the user can do, and the accessibility treatment (announce state changes with a live region, keep approve/deny as real keyboard-operable buttons, never auto-run a destructive tool without explicit confirmation). Return a state-by-state spec.
Pitfalls & accessibility
- Keep focus sensible as content streams in: don't yank focus to newly arrived rows, and make sure the Approve, Deny, and Retry controls have visible focus rings and a sane tab order.