A tool call is not a chat bubble
BuilderHelp and RuleCaddie do not answer from a single completion. They call tools: look up a rule, fetch an owner packet, check a quote. Those calls take time, return structured results, and sometimes need a human to approve before they run. If the UI treats every tool event as more text in the assistant bubble, the product loses the only surface that can show progress, failure, and approval. A tool call is a first-class UI object with a lifecycle — not a paragraph the model happened to emit.
Stream events, not prose about tools
Modern agent streams separate text tokens from tool lifecycle events. Protocols like AG-UI emit start, argument chunks, end, and result for each call, keyed by a stable toolCallId. The frontend's job is to map those events onto UI state — not to concatenate them into the chat transcript as if the model were narrating what it is doing.
When tool activity lands as markdown inside the assistant message, three things go wrong immediately. Partial argument JSON looks like a broken answer. A failed lookup looks like the model changed its mind mid-sentence. And there is nowhere to put Approve or Cancel that is not fighting the prose layout.
Keep the transcript for language. Keep a separate card — or a short timeline of cards — for each toolCallId. The card owns status. The bubble owns words.
The states that actually matter
A useful card has a small, named state machine: queued or starting, arguments still streaming, running, succeeded, failed, cancelled, and awaiting approval. You do not need a dozen micro-states. You do need those seven to be distinct, because the user action differs for each.
Arguments still streaming is not running. Showing a green check while argument JSON is still arriving teaches people the tool already finished when it has not even started. Running is not succeeded. A spinner that vanishes on socket close is the same failure mode as an unlabeled truncated answer — the UI went quiet without saying what happened.
Failed and cancelled are not the same either. Failed means the tool returned an error the agent may recover from; cancelled means someone (or a timeout) stopped the work on purpose. Awaiting approval is a pause with an owner, not a spinner with better copy. If your card can only show busy or done, you will mislabel every interesting case.
Correlate by id, not by order
Agents often start a second tool before the first result returns, or retry the same capability with different arguments. Ordering cards by arrival time without a stable id is how you overwrite the wrong row when TOOL_CALL_RESULT finally shows up.
Key every card on toolCallId from the first START event. Append argument deltas to that card. Attach the result to that card. If the stream reconnects and you see a duplicate START for the same id, update the existing card — do not create a twin.
Parent message ids help you nest a card under the turn that triggered it, but they are not a substitute for the tool id. Nesting without correlation still collapses when two tools run in the same turn.
Approval is a card state, not a second chat
Human-in-the-loop for purchase orders is an accountability decision: someone has to own the commitment before it ships. Tool-call approval in an agent UI is a narrower cousin of that idea. The card already knows the tool name and arguments; the approval control belongs on that card, not in a new user message that pretends the human typed a reply.
When the workflow pauses for approval, put the card in awaiting approval, show the arguments the agent intends to send, and resume by posting the decision as a tool result — approve, reject, or edit. Do not invent a parallel conversation thread just because the transport is chat-shaped.
That keeps the audit trail attached to the action. Six months later you can see which tool ran, with which args, after whose approval — without scraping prose from the bubble.
A failed tool is not a failed answer
RuleCaddie can miss a retrieval and still decline cleanly. BuilderHelp can time out on one lookup and succeed on the next. If a red tool card forces the whole assistant turn into an error state, you punish recoveries the model already handled.
Show tool failure on the card. Let the assistant message continue if the run finishes with a coherent reply. Only escalate to a turn-level error when the run itself ends in failure or the stream closes without a terminal event — the same rule we use for truncated text.
The practical test: can a user tell, at a glance, which tool failed, whether the agent recovered, and whether anything is still waiting on them? If the only signal is a half-finished paragraph, the UI is lying by omission.