# AI Agent Tool Call UI: Steps, Approvals and Errors

> How to build an AI agent tool call UI in React: queued, running, failed and approval states, readable inputs and outputs, and wiring it to AI SDK tool parts.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 16, 2026
- Category: [Building UI with AI agents](https://wingo-ui.com/blog/category/ai-agents)
- Reading time: 11 min
- Canonical: https://wingo-ui.com/blog/ai-agent-tool-call-ui

## TL;DR

A good AI agent tool call UI shows every call as one row with a status (queued, running, done, failed, waiting for approval), a human title, the tool's id, how long it took, and its input and output as readable JSON on demand. Failed calls open by themselves on their error, calls that need a person show Approve and Reject next to the exact arguments that will run, and each status change is announced once to screen readers. Map your AI SDK tool part states to those statuses and the same rows work in a chat message, a side panel or an agent page.

An AI agent tool call UI has one job: let a person see what the agent did, step by step, and step in when it matters. Most first versions fall short in the same places. A running call renders nothing, so the screen looks idle while the agent works. A failed call vanishes into a generic "Something went wrong". An approval prompt says "Send email?" without showing the address it will go to. This guide covers the states a tool call UI needs, how to make inputs and outputs readable, how to wire approvals to the AI SDK, and where the rows go in a chat, a side panel or an agent page. The examples use the agent components of Wingo UI, the library we build, so we are not neutral; outside facts link to their sources.

## What states does an AI agent tool call need?

Six: queued, running, done, failed, canceled and waiting for approval. Each one needs a look of its own, because a person scanning a run reads the markers before the words.

If you use the AI SDK, its tool parts already carry a `state`. The [chatbot tool usage docs](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-tool-usage) list `input-streaming`, `input-available`, `approval-requested`, `approval-responded`, `output-available`, `output-error` and `output-denied` (as of October 2026). Here is how they map to what a person should see:

| AI SDK state | Status | What the row shows |
|---|---|---|
| `input-streaming` | `pending` | a dashed ring, "Queued" |
| `input-available` | `running` | a spinner, a shimmering title, a time that ticks |
| `approval-requested` | `awaiting-approval` | a hand that pings, "Needs approval", Reject and Approve |
| `approval-responded` (approved) | `running` | the spinner again while the tool runs |
| `output-available` | `complete` | a check that pops once, the final time |
| `output-error` | `error` | a cross, the row opened on its error, Retry when you pass `onRetry` |
| `output-denied` | `canceled` | a gray marker, "Canceled" |

The middle column is the `ToolCallStatus` type from `lib/ai-chat`, which [Tool Call Timeline](https://wingo-ui.com/components/tool-call-timeline) and [Agent Status Card](https://wingo-ui.com/components/agent-status-card) share. Marker colors come from the status alone, so the `tone` prop only paints the Approve button and the header: running is info blue, done is green, failed is red and waiting is amber.

Two rules make these states hold up in any tool call UI in React. Show a running call the moment its input arrives, so real work never looks like a frozen screen. And keep failed calls in the list with their error, because a failure that disappears is the hardest one to debug.

## How do you make tool inputs and outputs readable?

Lead with a human title, keep the tool's id one tap away, and show the JSON only when someone asks for it.

Each row in the timeline reads like a sentence: "Finding overdue invoices", then `search_invoices` in a small mono chip, then "0.8 s". Click or tap the row (or press Enter or Space on it) and it opens the input the model sent and the output the tool returned as highlighted JSON. Text results wrap instead of turning into one long string. Every block has a copy button, and a block taller than `codeMaxHeight` (240px by default) scrolls inside itself, so a 200-line response does not push the rest of the run off screen.

When JSON is the wrong view, swap it. `renderOutput` and `renderInput` take the call and return any React node: invoices as a small table, an email draft as text, a map. Return `undefined` to keep the JSON and `null` to hide the section.

> Live demo (Tool Call Timeline): Watch the agent find invoices, fetch contacts and draft the email, then stop at Sending the email. Open a row to read its input and output, then press Approve or Reject. Try it at [Tool Call Timeline](https://wingo-ui.com/components/tool-call-timeline) and install it with `npx wingo-ui@latest add tool-call-timeline`.

The shape behind each row is plain data:

```ts
import type { ToolCall } from "@/components/ui/tool-call-timeline";

export const search: ToolCall = {
  id: "call-search",
  name: "search_invoices",
  title: "Finding overdue invoices",
  status: "complete",
  input: { status: "overdue", dueFrom: "2026-09-01", dueTo: "2026-09-24" },
  output: { total: 3, sum: 22775.95, currency: "USD" },
  durationMs: 820,
};
```

`startedAt` and `endedAt` work too; `durationMs` wins when your server already measured the call. `steps` holds sub-steps, which render indented under their parent, and `logs` holds plain log lines.

## How do you build a tool call UI in React with the AI SDK?

Convert each assistant message's tool parts into `ToolCall` objects, pass them to the timeline, and send approvals back with `addToolApprovalResponse`. That adapter is all an AI SDK tool UI needs on top of the component.

```bash
npx wingo-ui@latest add tool-call-timeline
```

The adapter keeps the AI SDK on one side and the component on the other. The structural `ToolPart` type keeps it independent of your tool definitions:

```ts
// lib/agent-tool-calls.ts
import type { UIMessage } from "ai";
import type { ToolCall, ToolCallStatus } from "@/components/ui/tool-call-timeline";

type ToolPart = {
  type: string;
  toolCallId: string;
  toolName?: string;
  state: string;
  input?: unknown;
  output?: unknown;
  errorText?: string;
  approval?: { id: string; approved?: boolean; isAutomatic?: boolean };
};

const STATUS: Record<string, ToolCallStatus> = {
  "input-streaming": "pending",
  "input-available": "running",
  "approval-requested": "awaiting-approval",
  "approval-responded": "running",
  "output-available": "complete",
  "output-error": "error",
  "output-denied": "canceled",
};

// what people read; the tool id stays in the chip
const TITLES: Record<string, string> = {
  searchInvoices: "Finding overdue invoices",
  sendEmail: "Sending the email",
};

function toolParts(message: UIMessage): ToolPart[] {
  return message.parts
    .filter((part) => part.type.startsWith("tool-") || part.type === "dynamic-tool")
    .map((part) => part as unknown as ToolPart);
}

export function toToolCalls(message: UIMessage): ToolCall[] {
  return toolParts(message).map((part) => {
    const name = part.type === "dynamic-tool" ? (part.toolName ?? "tool") : part.type.slice(5);
    const denied = part.state === "approval-responded" && part.approval?.approved === false;
    // a server-side policy decided: nothing for a person to press
    const automatic = part.state === "approval-requested" && part.approval?.isAutomatic === true;
    return {
      id: part.toolCallId,
      name,
      title: TITLES[name],
      status: denied ? "canceled" : automatic ? "running" : (STATUS[part.state] ?? "pending"),
      input: part.input,
      output: part.output,
      error: part.errorText,
    };
  });
}

export function approvalIdFor(message: UIMessage, callId: string): string | undefined {
  return toolParts(message).find((part) => part.toolCallId === callId)?.approval?.id;
}
```

Then render one timeline per assistant message:

```tsx
// app/agent/agent-run.tsx
"use client";

import { useMemo } from "react";
import { useChat } from "@ai-sdk/react";
import { lastAssistantMessageIsCompleteWithApprovalResponses, type UIMessage } from "ai";
import { ToolCallTimeline, type ToolCall } from "@/components/ui/tool-call-timeline";
import { approvalIdFor, toToolCalls } from "@/lib/agent-tool-calls";

function RunSteps({
  message,
  respond,
}: {
  message: UIMessage;
  respond: (call: ToolCall, approved: boolean) => void;
}) {
  const calls = useMemo(() => toToolCalls(message), [message]);
  if (calls.length === 0) return null;
  return (
    <ToolCallTimeline
      variant="card"
      title="Collections agent"
      calls={calls}
      onApprove={(call) => respond(call, true)}
      onReject={(call) => respond(call, false)}
      locale="en-US"
    />
  );
}

export function AgentRun() {
  const { messages, addToolApprovalResponse } = useChat({
    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
  });

  return (
    <div className="flex flex-col gap-4">
      {messages
        .filter((message) => message.role === "assistant")
        .map((message) => (
          <RunSteps
            key={message.id}
            message={message}
            respond={(call, approved) => {
              const id = approvalIdFor(message, call.id);
              if (id) addToolApprovalResponse({ id, approved });
            }}
          />
        ))}
    </div>
  );
}
```

`useChat` hands you a new object for the message that is streaming, so `useMemo` rebuilds the calls of that one message while older runs keep theirs. Your chat input stays as it is. The route handler in the next section adds the approval gate.

One pitfall: an `approval-requested` part is not always a question for a person. When a server-side policy approves or denies a call on its own, the AI SDK sets `approval.isAutomatic` to `true` and expects no response. The adapter shows those calls as running, so nobody gets Approve and Reject buttons that do nothing.

## How should human in the loop approval UI work?

Pause before the side effect, show the exact arguments that will run, and keep the decision one tap away. A human in the loop approval UI that hides the input asks people to approve something they cannot see.

On the server, the gate goes on `streamText`. The [chatbot tool usage docs](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-tool-usage) set a tool to `"user-approval"` in the `toolApproval` option and mark the older `needsApproval` tool property as deprecated (as of October 2026). The [streamText reference](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) also accepts a function per tool that receives the input, so only risky calls stop. The tool keeps its `execute`; the SDK waits for the answer before running it:

```ts
// lib/agent-tools.ts
import { tool } from "ai";
import { z } from "zod";
import { invoices } from "@/lib/invoices"; // your data layer
import { mailer } from "@/lib/mailer"; // your mail client

export const searchInvoices = tool({
  description: "Find invoices by status and due date",
  inputSchema: z.object({ status: z.enum(["open", "overdue", "paid"]), dueFrom: z.string(), dueTo: z.string() }),
  execute: async (input) => invoices.search(input),
});

export const sendEmail = tool({
  description: "Send a payment reminder to a client",
  inputSchema: z.object({ to: z.string().email(), subject: z.string() }),
  execute: async ({ to, subject }) => mailer.send({ to, subject }),
});
```

```ts
// app/api/chat/route.ts
import { convertToModelMessages, streamText, type UIMessage } from "ai";
import { model } from "@/lib/ai"; // your provider's model
import { searchInvoices, sendEmail } from "@/lib/agent-tools";

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const result = streamText({
    model,
    system: "When a tool execution is not approved by the user, do not retry it.",
    messages: await convertToModelMessages(messages),
    tools: { searchInvoices, sendEmail },
    toolApproval: { sendEmail: "user-approval" },
  });

  return result.toUIMessageStreamResponse();
}
```

The AI SDK's [human in the loop recipe](https://ai-sdk.dev/cookbook/next/human-in-the-loop) still shows `needsApproval: true` on the tool. Both paths produce the same `approval-requested` parts, so the client code above does not change, and the streamText reference says `toolApproval` wins when both are set. Tool keys become part types (`tool-sendEmail`), which is why the adapter's `TITLES` map uses the same camelCase names.

On the client, a call with status `awaiting-approval` shows Reject and Approve under its title, and opening the row shows the arguments right above them. To open waiting calls by themselves, control `expandedKeys` and add their ids as they arrive. If `onApprove` returns a promise, Approve spins and Reject stays disabled until it settles; when the buttons go away, focus moves back to the row, so a keyboard user never lands on the page body. On a phone the two buttons are 44px tall and share the row's width.

Rejecting is a real outcome. Show the call as `canceled` (the adapter above does it from `approval.approved === false`). Keep the `system` line from the route above: the AI SDK recipe adds that instruction so the model does not retry a denied call and ask the person the same question twice.

## Where do tool calls go: a chat message, a panel or an agent page?

All three, with a different variant for each place.

- **In a chat message**, use `variant="inline"`: a one-line summary like "Used 4 tools, 2.8 s" that opens while a call runs or waits for approval and closes 1.2 s after everything finished. It stays open if the person opened it or focus is inside it. [Chat Message](https://wingo-ui.com/components/chat-message) renders this on its own when the message has `toolCalls`, and `toolCallProps` passes `onApprove` and the rest through.
- **In a side panel**, use `variant="timeline"` with `size="sm"`. Rows lay out by their own width, so in a narrow column the tool id moves under the title.
- **On an agent page**, use `variant="card"`. The header shows the run's state in words, its progress while it runs ("2 of 5 done"), the total time and a button that opens or closes every row.

## How do you show an agent run's overall status?

Put a status card above the agent steps timeline. When an agent runs in the background, people need an answer to "is it still working?" before anyone opens a row.

[Agent Status Card](https://wingo-ui.com/components/agent-status-card) takes the same `ToolCall[]` as `steps` and derives the progress ("Step 3 of 5"), the current step and the elapsed time from them. Only the controls that matter right now appear: Pause and Stop while it runs, Approve and Reject in an amber box while it waits, Retry in a red box when it failed, View result when it is done. Stop asks first in a dialog, so mount `AlertDialogProvider` once.

```tsx
import { AlertDialogProvider } from "@/components/ui/alert-dialog";
import { AgentStatusCard, type ToolCall } from "@/components/ui/agent-status-card";

export function CollectionsAgent({ steps, onPause, onStop }: {
  steps: ToolCall[];
  onPause: () => Promise<void>;
  onStop: () => Promise<void>;
}) {
  return (
    <>
      <AlertDialogProvider />
      <AgentStatusCard
        name="Collections agent"
        task="Reminders for September's overdue invoices"
        status="running"
        steps={steps}
        startedAt={new Date(2026, 8, 24, 16, 38, 2)}
        logs={[]}
        onPause={onPause}
        onStop={onStop}
      />
    </>
  );
}
```

> Live demo (Agent Status Card): The run advances step by step. Open Steps and Logs, press Pause, then try Stop to see the confirmation dialog. Try it at [Agent Status Card](https://wingo-ui.com/components/agent-status-card) and install it with `npx wingo-ui@latest add agent-status-card`.

Use `variant="compact"` for a grid of agents and `variant="row"` for a 64px list line with a progress ring. Below 400px the orb moves above the text and the buttons share the width.

## How do reasoning and tool calls fit together?

Reasoning comes before the first call and between calls, and it should fold the same way. [Reasoning Indicator](https://wingo-ui.com/components/reasoning-indicator) shows a shimmering label and a live timer while the model thinks, then settles on "Thought for 12 s" that opens on a tap. Its `label` prop replaces the text during a phase, for example "Searching the web", so a thinking step and a tool step read alike. Chat Message stacks the reasoning row above the tool summary, then the answer below both.

## What makes a tool call UI accessible on a phone?

It needs three things: one announcement per status change, 44px touch targets and long content that scrolls inside its own block.

- **Announce once per change.** The list is not a live region; a screen reader reading every ticking duration is unusable. A separate polite region says "Sending the email: Needs approval" once when the status changes.
- **Say the status in words.** Failed and waiting calls say their status next to the title. The others carry it in the marker and a screen-reader-only word.
- **Targets.** On touch screens rows grow to 44px, and small rows keep their look with a 44px hit area.
- **Keep scrolling inside the row.** Code blocks scroll sideways inside themselves, so a long URL in a JSON output never widens the page at 360px. Agent logs stop at 40% of the screen height.
- **Reduced motion.** Springs become quick fades, and the shimmer and the ping stop.

## When is AI Elements the better choice?

AI Elements is the better choice when your app already runs on the AI SDK and you want free components under the [Apache 2.0 license](https://github.com/vercel/ai-elements/blob/main/LICENSE). Vercel's AI Elements has a [Tool](https://elements.ai-sdk.dev/components/tool) component whose header, input and output take the fields of the SDK's tool part (its `state`, `input`, `output` and `errorText`) and a [Confirmation](https://elements.ai-sdk.dev/components/confirmation) component that takes a part's `approval` and `state`, all built on shadcn/ui. You skip the adapter above. Its [docs](https://elements.ai-sdk.dev/docs) also say the project is moving to shadcn/ui and the current site will be deprecated at the end of November 2026, so new projects should install it from there (checked October 16, 2026). Wingo UI's timeline fits when you want the rail, durations, sub-steps, retry, the run-level card and the phone layouts built in, or when your agent does not use the AI SDK at all, since `ToolCall` is plain data. For the chat around it, see our guide to the [React AI chat UI](https://wingo-ui.com/blog/react-ai-chat-ui).

## What should you try next?

Open the [Tool Call Timeline](https://wingo-ui.com/components/tool-call-timeline) docs, switch between the timeline, card and inline variants in the playground, then install it with `npx wingo-ui@latest add tool-call-timeline`. It and the other two components in this guide are part of [Wingo UI Pro](https://wingo-ui.com/pricing). If a coding agent builds your screens, the [component library MCP guide](https://wingo-ui.com/blog/component-library-mcp-guide) shows how to let it read these props directly, and more posts like this one are in [Building UI with AI agents](https://wingo-ui.com/blog/category/ai-agents).

## Components in this post

- [Tool Call Timeline](https://wingo-ui.com/components/tool-call-timeline) (Pro): A React tool call timeline for AI agents: each step with its status, time, input and output, plus approval and retry when a call needs them. Install: `npx wingo-ui@latest add tool-call-timeline`
- [Agent Status Card](https://wingo-ui.com/components/agent-status-card) (Pro): A React AI agent status card: orb, name, status, current step, progress, elapsed time, steps, logs and the controls that matter right now. Install: `npx wingo-ui@latest add agent-status-card`
- [Reasoning Indicator](https://wingo-ui.com/components/reasoning-indicator) (Pro): A React reasoning indicator for AI chat: a shimmering live timer while the model thinks, then a quiet "Thought for 12 s" row to expand. Install: `npx wingo-ui@latest add reasoning-indicator`

## FAQ

### What states does a tool call UI need?

Six cover real agents: queued, running, done, failed, canceled and waiting for approval. The AI SDK's tool part states (input-streaming, input-available, approval-requested, approval-responded, output-available, output-error, output-denied) map onto them one to one or two to one.

### How do I add human in the loop approval to an AI SDK tool?

Set the tool to 'user-approval' in the toolApproval option of streamText (the older needsApproval tool property is marked deprecated in the AI SDK docs as of October 2026). On the client, render Approve and Reject when the tool part's state is approval-requested and call addToolApprovalResponse with the approval id and approved true or false. Pass lastAssistantMessageIsCompleteWithApprovalResponses as sendAutomaticallyWhen so the chat continues once every approval is answered.

### Should tool call details be open or collapsed by default?

Collapsed for calls that worked and open for calls that failed. People read a successful call's title and time; they need the error, the input and the logs of a failed one right away.

### How do I show tool calls inside a chat message?

Use a one-line summary such as Used 4 tools, 2.8 s that opens while a call runs or waits for approval and folds away after the run finishes. Wingo UI's Chat Message does this when you pass toolCalls on the message.

### Are the Wingo UI agent components free?

No. Tool Call Timeline, Agent Status Card and Reasoning Indicator are part of Wingo UI Pro. Free items they build on, such as Button, Badge, Code Block and Progress, install without a paid plan.

---

Source: https://wingo-ui.com/blog/ai-agent-tool-call-ui
