# Tool call UI with the Vercel AI SDK

> Render AI SDK 7 tool parts as tool call rows: map each part state to running, done or failed, write a past-tense summary, and keep input and output one click away.

Published 2026-11-04. Facts checked 2026-11-04. Source: https://pixilui.com/guides/tool-call-ui-vercel-ai-sdk

In the AI SDK, every tool call arrives in `message.parts` as a part typed `tool-<name>` (or `dynamic-tool`), and its `state` moves from `input-streaming` to `output-available` or `output-error`. Map those states to a tool call row: running, done or failed. Write the summary from the input and output ("Searched 14 tickets"), pass both as JSON for the fold-out, and wrap one message's calls in a group that counts them.

## How the AI SDK exposes tool calls

The [chatbot tool usage](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-tool-usage) page explains that tool calls and results live inside the assistant message as typed tool parts: a part starts as a call and becomes a result when the tool runs. The SDK exports `isToolUIPart` and `getToolName`, so one renderer can handle every tool, static or dynamic. The page also asks you to handle the approval states (`approval-requested`, `approval-responded`, `output-denied`) even when a tool does not need approval, so a switch on `state` stays exhaustive.

What the parts do not carry is timing. The row's live timer needs a start time, so the code below notes when each call first appears and when it settles.

## From part state to row

| AI SDK tool part state | Tool call row `status` | What the row shows |
| --- | --- | --- |
| `input-streaming` | `running` | The tool name and a timer; input still arriving |
| `input-available` | `running` | Present-tense summary ("Searching tickets") |
| `approval-requested`, `approval-responded` | `running` | Waiting on a person: render an [approval card](/guides/human-approval-vercel-ai-sdk) for it |
| `output-available` | `done` | Past-tense summary with the count, final duration, input and output |
| `output-error` | `error` | `errorText` in place, with Retry |
| `output-denied` | `error` | "Not approved", with the reason when there is one |

## The code

The server is a plain `streamText` route with tools; nothing in it is specific to the row.

```ts
// app/api/chat/route.ts
import { convertToModelMessages, createUIMessageStreamResponse, stepCountIs, streamText, toUIMessageStream, tool, type UIMessage } from "ai";
import { z } from "zod";

const tools = {
  searchTickets: tool({
    description: "Search Zendesk tickets",
    inputSchema: z.object({ query: z.string(), status: z.enum(["open", "pending", "solved"]) }),
    execute: async ({ query }) => ({ query, count: 14, ids: [48211, 48207, 48190] }),
  }),
};

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();
  const result = streamText({
    model: "openai/gpt-5.5",
    messages: await convertToModelMessages(messages),
    tools,
    stopWhen: stepCountIs(5),
  });
  return createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }) });
}
```

The client keeps the tool parts of each message in order and turns each one into a row.

```tsx
// components/message-tools.tsx
"use client";

import { useRef } from "react";
import { getToolName, isToolUIPart, type UIMessage } from "ai";
import { ToolCallRow, ToolCallRowGroup, type ToolCallRowStatus, type ToolCallRowValue } from "@/components/pixilui/tool-call-row";

type ToolPart = Extract<UIMessage["parts"][number], { toolCallId: string }>;

const json = (value: unknown) => (value === undefined ? undefined : (JSON.parse(JSON.stringify(value)) as ToolCallRowValue));

const statusOf = (part: ToolPart): ToolCallRowStatus =>
  part.state === "output-available" ? "done" : part.state === "output-error" || part.state === "output-denied" ? "error" : "running";

// Present tense while it runs, past tense with the evidence once done. Write one line per tool you own.
function summary(name: string, part: ToolPart) {
  const output = part.state === "output-available" ? (part.output as { count?: number } | undefined) : undefined;
  if (name === "searchTickets") return output?.count !== undefined ? `Searched ${output.count} tickets` : "Searching tickets";
  return part.state === "output-available" ? `Ran ${name}` : `Running ${name}`;
}

export function MessageTools({ message, onRetry }: { message: UIMessage; onRetry: () => Promise<void> }) {
  // The parts carry no timing: note when each call first shows up and when it settles.
  const times = useRef(new Map<string, { start: number; end?: number }>());
  const parts = message.parts.filter(isToolUIPart);
  if (!parts.length) return null;

  return (
    <ToolCallRowGroup>
      {parts.map((part) => {
        const name = getToolName(part);
        const status = statusOf(part);
        const time = times.current.get(part.toolCallId) ?? { start: Date.now() };
        if (status !== "running" && time.end === undefined) time.end = Date.now();
        times.current.set(part.toolCallId, time);

        return (
          <ToolCallRow
            key={part.toolCallId}
            name={name}
            status={status}
            summary={summary(name, part)}
            startedAt={time.start}
            durationMs={time.end !== undefined ? time.end - time.start : undefined}
            input={json(part.input)}
            output={part.state === "output-available" ? json(part.output) : undefined}
            error={part.state === "output-error" ? part.errorText : part.state === "output-denied" ? "Not approved" : undefined}
            onRetry={part.state === "output-error" ? onRetry : undefined}
          />
        );
      })}
    </ToolCallRowGroup>
  );
}
```

Install the row with `npx shadcn@latest add @pixilui/tool-call-row`. Pass `regenerate` from `useChat` as `onRetry` to run the last turn again.

## What the row adds to the part

- **A summary, not a signature.** The person reads "Searched 14 tickets"; the developer still sees `searchTickets` in mono.
- **A timer, then a duration.** Slow tools explain slow answers.
- **Folded JSON.** Input and output are one click away, formatted, each with Copy, instead of printed into the thread.
- **A group line.** `ToolCallRowGroup` reads each row and says "Used 4 tools" or "2 of 4 done", so ten calls do not push the answer off screen.

## When the framework already ships a UI

The AI SDK ships hooks, not components; its docs render tool parts as plain divs and `JSON.stringify`. AI Elements, Vercel's component set for the AI SDK, has a `Tool` component for tool parts; its site says the project moves into shadcn/ui at the end of November 2026, and the [AI Elements migration doc](/docs/migrate-from-ai-elements) maps `Tool` to the tool call row. If you need a tool's result drawn as its own card (a weather card, a chart), render that card and keep the row above it as the record of the call.

## When not to use this

- **Tools the person should not see.** Retrieval and ranking plumbing can be noise; show the call that changed the answer.
- **Generative UI.** When a tool's job is to render a component, the component is the message; a row would repeat it.
- **Tracing.** Token counts and spans per call belong in your tracing tool, not the chat.

## Questions

### How do I render tool calls with useChat?

Loop over `message.parts` and keep the parts where `isToolUIPart(part)` is true. Each has a `toolCallId`, a `state`, its `input` and, once done, its `output` or `errorText`. Map the state to running, done or failed and render one row per part.

### Why do my tool parts have type tool-searchTickets?

Static tools get a part type of `tool-` plus the tool's name, which lets TypeScript type `input` and `output` per tool. Tools defined at runtime arrive as `dynamic-tool`. `getToolName` returns the name for both.

### How do I show how long a tool call took?

The AI SDK's tool parts do not include timing. Note the time when a part first appears and when its state becomes `output-available` or `output-error`, or measure inside `execute` and return the duration with the result.

## Related

- [How to show tool calls and their results in an AI chat](/guides/show-tool-calls-in-ai-chat): what a tool row needs, framework aside.
- [Human approval with the Vercel AI SDK](/guides/human-approval-vercel-ai-sdk): when a tool call needs a person first.
- [Tool call UI with LangGraph](/guides/tool-call-ui-langgraph): the same row from `stream.toolCalls`.
- [Tool call UI with the OpenAI Agents SDK](/guides/tool-call-ui-openai-agents-sdk): the same row from run item events.

## Sources

- [AI SDK UI: Chatbot Tool Usage](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-tool-usage), read 4 November 2026
- [AI SDK UI: Chatbot](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot), read 4 November 2026
- [ai on npm](https://www.npmjs.com/package/ai) (version 7.0.162, its `isToolUIPart` and `getToolName` exports), read 4 November 2026
- [AI Elements](https://elements.ai-sdk.dev), read 4 November 2026
