Tool call UI with the Vercel AI SDK

· checked against the Vercel AI SDK docs · 6 min read

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 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 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.

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.

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 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.

Sources#