Generative UI multi-step tool calls patterns let you turn opaque LLM reasoning into visible, interactive steps that users can inspect, interrupt, or redirect. The Vercel AI SDK provides the streaming primitives, but composing them into reliable multi-step workflows requires deliberate architecture. This guide walks through patterns that work in production, with code you can adapt directly.
The core challenge: visibility without blocking
When an agent executes five tools sequentially — search, fetch, calculate, format, notify — the user sees nothing until the final answer arrives. Generative UI solves this by rendering each tool call and its result as a discrete UI component in real time. The SDK’s streamText and useChat hooks handle the streaming; your job is structuring the tool definitions and the component mapping so the stream becomes a navigable timeline.
Start with a tool schema that carries enough metadata for the frontend to render meaningfully:
// lib/tools.ts
import { tool } from 'ai';
import { z } from 'zod';
export const searchTool = tool({
parameters: z.object({
query: z.string(),
recencyDays: z.number().optional(),
}),
execute: async ({ query, recencyDays = 7 }) => {
const results = await searchApi(query, { recencyDays });
return { results, query, timestamp: Date.now() };
},
});
export const fetchTool = tool({
parameters: z.object({ url: z.string().url() }),
execute: async ({ url }) => {
const content = await fetchContent(url);
return { url, content, fetchedAt: Date.now() };
},
});
export const calculateTool = tool({
parameters: z.object({
expression: z.string(),
context: z.record(z.unknown()).optional(),
}),
execute: async ({ expression, context }) => {
const result = await safeEval(expression, context);
return { expression, result, computedAt: Date.now() };
},
});
Each tool returns structured data, not just a string. That structure becomes the props for your generative UI components.
Basic pattern: streaming tool calls with component mapping
The simplest working pattern maps each tool call to a React component rendered inline in the message stream. The useChat hook exposes toolInvocations on each message — use that to drive rendering.
// components/ToolCallTimeline.tsx
'use client';
import { Message } from 'ai';
import { SearchCard } from './SearchCard';
import { FetchCard } from './FetchCard';
import { CalculateCard } from './CalculateCard';
const componentMap: Record<string, React.ComponentType<{ invocation: any }>> = {
searchTool: SearchCard,
fetchTool: FetchCard,
calculateTool: CalculateCard,
};
export function ToolCallTimeline({ message }: { message: Message }) {
const invocations = message.toolInvocations ?? [];
return (
<div className="space-y-3">
{invocations.map((invocation, index) => {
const Component = componentMap[invocation.toolName];
if (!Component) return null;
return (
<Component
key={`${invocation.toolCallId}-${index}`}
invocation={invocation}
/>
);
})}
</div>
);
}
Each card component handles its own loading, success, and error states based on invocation.state:
// components/SearchCard.tsx
'use client';
import { ToolInvocation } from 'ai';
interface SearchCardProps {
invocation: ToolInvocation<{ query: string; recencyDays?: number }, { results: any[] }>;
}
export function SearchCard({ invocation }: SearchCardProps) {
const { state, args, result, error } = invocation;
if (state === 'calling') {
return (
<div className="border-l-2 border-blue-500 pl-3 text-sm text-muted-foreground">
Searching for "{args.query}"…
</div>
);
}
if (state === 'result') {
return (
<details className="group border rounded p-3">
<summary className="font-medium cursor-pointer flex items-center gap-2">
<span>Search results for "{args.query}"</span>
<span className="text-xs text-muted-foreground">{result?.results.length} results</span>
</summary>
<ul className="mt-2 space-y-1">
{result?.results.slice(0, 5).map((r, i) => (
<li key={i} className="text-sm">
<a href={r.url} target="_blank" rel="noopener" className="text-blue-600 hover:underline">
{r.title}
</a>
<p className="text-muted-foreground">{r.snippet}</p>
</li>
))}
</ul>
</details>
);
}
if (state === 'error') {
return (
<div className="border-l-2 border-red-500 pl-3 text-sm text-red-600">
Search failed: {error?.message}
</div>
);
}
return null;
}
This pattern works for linear chains. The UI updates automatically as each tool completes because useChat re-renders messages when toolInvocations change.
Intermediate pattern: structured tool call chains with dependencies
Real workflows often have dependencies — tool B needs output from tool A. The SDK doesn’t enforce ordering; the model decides. But you can design tools that require prior context, then render the dependency chain explicitly.
Define a tool that accepts a dependsOn field referencing previous tool call IDs:
// lib/tools/chainable.ts
export const chainableFetchTool = tool({
parameters: z.object({
url: z.string().url(),
dependsOn: z.string().optional(), // toolCallId of the search result
reason: z.string(),
}),
execute: async ({ url, dependsOn, reason }) => {
const content = await fetchContent(url);
return { url, content, dependsOn, reason, fetchedAt: Date.now() };
},
});
On the frontend, render the dependency graph:
// components/ChainView.tsx
'use client';
import { ToolInvocation } from 'ai';
interface ChainNode {
invocation: ToolInvocation;
children: ChainNode[];
}
function buildTree(invocations: ToolInvocation[]): ChainNode[] {
const byId = new Map(invocations.map(i => [i.toolCallId, i]));
const roots: ChainNode[] = [];
invocations.forEach(inv => {
const dependsOn = (inv.args as any)?.dependsOn;
const node: ChainNode = { invocation: inv, children: [] };
if (dependsOn && byId.has(dependsOn)) {
const parent = findNode(roots, dependsOn);
parent?.children.push(node);
} else {
roots.push(node);
}
});
return roots;
}
function findNode(nodes: ChainNode[], id: string): ChainNode | null {
for (const n of nodes) {
if (n.invocation.toolCallId === id) return n;
const found = findNode(n.children, id);
if (found) return found;
}
return null;
}
export function ChainView({ invocations }: { invocations: ToolInvocation[] }) {
const roots = buildTree(invocations);
function renderNode(node: ChainNode, depth = 0) {
const Component = componentMap[node.invocation.toolName];
return (
<div key={node.invocation.toolCallId} className="ml-4 border-l border-gray-200 pl-3">
{Component && <Component invocation={node.invocation} />}
{node.children.map(c => renderNode(c, depth + 1))}
</div>
);
}
return <div>{roots.map(r => renderNode(r))}</div>;
}
This makes the reasoning trace visible. Users see why a fetch happened — which search result triggered it.
Advanced pattern: human-in-the-loop with generative UI
The most powerful pattern inserts approval gates. Before a mutating tool runs (send email, create record, charge card), render a confirmation component that pauses the stream until the user acts.
The SDK supports this through onToolCall callbacks and streaming control, but the cleanest approach uses a special tool that returns a UI component instead of data. The model calls requestApproval, the frontend renders a confirmation dialog, and the user’s response feeds back into the stream.
// lib/tools/approval.ts
export const requestApprovalTool = tool({
parameters: z.object({
action: z.string(),
details: z.record(z.unknown()),
riskLevel: z.enum(['low', 'medium', 'high']),
}),
execute: async ({ action, details, riskLevel }) => {
// This never actually executes — the frontend intercepts it
return { approved: false, pending: true };
},
});
On the server, detect this tool and yield a special stream part:
// app/api/chat/route.ts
import { streamText, tool } from 'ai';
import { requestApprovalTool } from '@/lib/tools/approval';
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai('gpt-4o'),
tools: {
searchTool,
fetchTool,
requestApprovalTool,
sendEmailTool,
},
messages,
onToolCall: async ({ toolName, args, toolCallId }) => {
if (toolName === 'requestApprovalTool') {
// Yield a custom data part the frontend understands
return {
type: 'approval_request',
toolCallId,
action: args.action,
details: args.details,
riskLevel: args.riskLevel,
};
}
},
});
return result.toDataStreamResponse();
}
The frontend renders an approval card that, when confirmed, sends a new message with the tool result:
// components/ApprovalCard.tsx
'use client';
import { useChat } from 'ai/react';
import { ToolInvocation } from 'ai';
interface ApprovalCardProps {
invocation: ToolInvocation<{ action: string; details: Record<string, unknown>; riskLevel: string }, any>;
}
export function ApprovalCard({ invocation }: ApprovalCardProps) {
const { append } = useChat();
const { args, toolCallId } = invocation;
const handleApprove = async () => {
// Simulate the tool result the model expects
await append({
role: 'tool',
content: JSON.stringify({ approved: true }),
toolCallId,
toolName: 'requestApprovalTool',
});
};
const handleReject = async () => {
await append({
role: 'tool',
content: JSON.stringify({ approved: false, reason: 'User rejected' }),
toolCallId,
toolName: 'requestApprovalTool',
});
};
return (
<div className="border-2 border-amber-500 rounded-lg p-4 bg-amber-50">
<div className="font-medium flex items-center gap-2">
<AlertTriangle className="w-5 h-5" />
Approval required: {args.action}
</div>
<pre className="mt-2 text-sm bg-white p-2 rounded">{JSON.stringify(args.details, null, 2)}</pre>
<div className="mt-3 flex gap-2">
<button onClick={handleApprove} className="btn btn-primary">Approve</button>
<button onClick={handleReject} className="btn btn-secondary">Reject</button>
</div>
</div>
);
}
The model then continues with the actual action tool (e.g., sendEmailTool) only after receiving approved: true. This pattern keeps the LLM in the loop while giving users veto power.
Error handling and recovery patterns
Multi-step chains fail. Tools time out, APIs return errors, models hallucinate parameters. Build recovery into the UI, not just the backend.
Retry with context
When a tool fails, render a retry button that re-invokes the same tool with the same arguments — but include the error message so the model can adjust:
// components/ToolCardWithRetry.tsx
export function ToolCardWithRetry({ invocation }: ToolCardProps) {
const { append } = useChat();
const { state, error, toolCallId, toolName, args } = invocation;
const handleRetry = () => {
append({
role: 'tool',
content: JSON.stringify({ error: error?.message, retry: true }),
toolCallId,
toolName,
});
};
if (state === 'error') {
return (
<div className="border-l-2 border-red-500 pl-3">
<div className="flex items-center gap-2">
<span className="text-red-600">Failed: {error?.message}</span>
<button onClick={handleRetry} className="btn btn-sm btn-ghost">Retry</button>
</div>
</div>
);
}
// ... normal rendering
}
On the server, catch the retry flag and let the model decide whether to retry or try a different approach:
// In your tool execute function
execute: async ({ query, retry, previousError }) => {
if (retry && previousError) {
// Adjust query based on error, or try alternative API
return searchApi(adjustQuery(query, previousError));
}
return searchApi(query);
}
Partial result rendering
If a tool returns partial data before failing (e.g., fetched 3 of 5 URLs), render what you have. Design tool results to be incrementally useful:
execute: async ({ urls }) => {
const results = [];
for (const url of urls) {
try {
const content = await fetchWithTimeout(url, 5000);
results.push({ url, content, status: 'success' });
} catch (e) {
results.push({ url, error: e.message, status: 'failed' });
}
}
return { results, partial: results.some(r => r.status === 'failed') };
}
The frontend shows successful fetches immediately, failed ones with retry buttons.
Performance: streaming large tool results
Tool results can be large — full HTML pages, JSON datasets, log files. Streaming them directly into the chat message bloats the client state and slows re-renders. Two patterns help:
1. Server-side summarization with reference IDs
Store large results server-side, return a reference ID and summary to the stream:
// Server-side tool execution
execute: async ({ url }) => {
const content = await fetchContent(url);
const summary = await summarize(content); // cheap model call
const refId = await storeResult(content); // Redis, DB, object store
return { refId, summary, url, length: content.length };
}
Frontend renders the summary with an “Expand” button that fetches the full content on demand:
function FetchCard({ invocation }) {
const [expanded, setExpanded] = useState(false);
const [fullContent, setFullContent] = useState(null);
const handleExpand = async () => {
const res = await fetch(`/api/tool-results/${invocation.result.refId}`);
const data = await res.json();
setFullContent(data.content);
setExpanded(true);
};
return (
<details className={expanded ? 'open' : ''}>
<summary>{invocation.result.summary}</summary>
{expanded && <pre>{fullContent}</pre>}
{!expanded && <button onClick={handleExpand}>Load full content</button>}
</details>
);
}
2. Incremental JSON streaming
For structured data the model needs to process incrementally (e.g., a 10,000-row CSV), use a streaming JSON parser on the server and yield chunks as separate tool result parts. The SDK’s streamText supports multiple tool result messages per tool call if you manage the stream manually — but simpler: have the tool return a streamable cursor, and the model calls a fetchNextChunk tool repeatedly.
export const fetchChunkTool = tool({
parameters: z.object({ cursor: z.string() }),
execute: async ({ cursor }) => {
const { rows, nextCursor } = await db.queryLargeResult(cursor);
return { rows, nextCursor, hasMore: !!nextCursor };
},
});
The model loops until hasMore is false. Each chunk renders as a row in a virtualized table.
Common pitfalls and tradeoffs
Pitfall: assuming tool call order matches execution order. The model may emit tool calls in parallel. toolInvocations reflects emission order, not completion order. Always key components by toolCallId, not array index.
Pitfall: mutating shared state across tool calls. Each tool execution is isolated. If tool B needs tool A’s output, pass it explicitly via arguments or the model’s context. Don’t rely on closure variables.
Pitfall: over-streaming. Not every tool needs a UI component. Internal tools (token counting, caching, routing) should be visible: false in the tool definition or filtered on the frontend. Show only what the user can act on.
Tradeoff: approval granularity. Per-tool approval is safest but creates fatigue. Batch approvals (approve “all fetches for this research task”) reduce friction but increase blast radius. Start per-tool, add batching when patterns stabilize.
Tradeoff: client vs server rendering of tool results. Server-rendered components (RSC) can’t easily update incrementally from a stream. Client components with useChat handle streaming natively but lose SEO and initial paint speed. Hybrid: stream into a client island within an RSC page.
Tradeoff: model-controlled vs deterministic flow. Letting the model decide tool order enables flexibility but makes debugging harder. For critical paths (payment, deletion), use a deterministic orchestrator that calls the model for decisions but executes tools in a fixed sequence.
Putting it together: a complete workflow
Here’s how these patterns compose in a research assistant that searches, fetches, synthesizes, and emails a report — with approval before sending:
// app/api/chat/route.ts
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai('gpt-4o'),
system: `You are a research assistant. Use tools in sequence:
1. searchTool for initial queries
2. fetchTool for promising results (set dependsOn)
3. calculateTool for data analysis
4. requestApprovalTool before sendEmailTool
5. sendEmailTool only after approval`,
tools: {
searchTool,
fetchTool: chainableFetchTool,
calculateTool,
requestApprovalTool,
sendEmailTool,
},
messages,
maxSteps: 10,
onToolCall: ({ toolName, toolCallId }) => {
if (toolName === 'requestApprovalTool') {
return { type: 'approval_request', toolCallId };
}
},
});
return result.toDataStreamResponse();
}
The frontend composes ChainView for the research phase, ApprovalCard for the gate, and a final EmailSentCard for confirmation. Each component is testable in isolation; the stream stitches them together.
Generative UI for multi-step tool calls isn’t a single component — it’s a set of conventions: structured tool outputs, component maps, dependency rendering, approval interception, and incremental loading. Start with the basic streaming pattern, add dependency visualization when chains grow, insert approval gates at mutation boundaries, and optimize payload size when results exceed a few KB. The SDK gives you the stream; these patterns make it usable.