The Vercel AI SDK’s useChat hook returns message parts that include tool invocations alongside text, but the documentation leaves the rendering logic as an exercise. This guide walks through building a production-ready component that handles streaming tool calls, displays results inline, and manages the awkward intermediate states that occur during execution.
Step 1: Understand the message parts structure
When you enable tools in your AI SDK route, each message in useChat’s messages array contains a parts array instead of a simple content string. Each part has a type discriminator: "text", "tool-invocation", or "tool-result".
// Simplified type from ai/react
type MessagePart =
| { type: "text"; text: string }
| { type: "tool-invocation"; toolInvocation: ToolInvocation }
| { type: "tool-result"; toolResult: ToolResult };
interface ToolInvocation {
state: "call" | "result";
toolCallId: string;
toolName: string;
args: any;
result?: any;
}
interface ToolResult {
toolCallId: string;
toolName: string;
args: any;
result: any;
}
The key insight: a single assistant message can contain multiple tool invocations interleaved with text. The state field on tool-invocation parts tells you whether the call is still streaming ("call") or has returned ("result").
Verify: Add console.log(messages.map(m => m.parts)) to your component and trigger a tool call. You’ll see the part array grow in real time.
Step 2: Build a part renderer component
Create a component that switches on part.type and delegates to specialized renderers. This keeps the main chat component clean and makes each part testable in isolation.
// components/MessageParts.tsx
"use client";
import { MessagePart } from "ai/react";
interface MessagePartsProps {
parts: MessagePart[];
}
export function MessageParts({ parts }: MessagePartsProps) {
return (
<div className="message-parts space-y-2">
{parts.map((part, index) => (
<MessagePartRenderer key={index} part={part} />
))}
</div>
);
}
function MessagePartRenderer({ part }: { part: MessagePart }) {
switch (part.type) {
case "text":
return <TextPart text={part.text} />;
case "tool-invocation":
return <ToolInvocationPart invocation={part.toolInvocation} />;
case "tool-result":
return <ToolResultPart result={part.toolResult} />;
default:
return null;
}
}
Verify: Replace your existing message rendering with <MessageParts parts={message.parts} /> and confirm text still renders correctly.
Step 3: Render streaming tool invocations
The "call" state means the model is still streaming arguments. You’ll receive partial JSON that may be invalid until complete. Handle this gracefully — don’t crash on parse errors.
// components/ToolInvocationPart.tsx
"use client";
import { ToolInvocation } from "ai/react";
import { format } from "date-fns";
interface ToolInvocationPartProps {
invocation: ToolInvocation;
}
export function ToolInvocationPart({ invocation }: ToolInvocationPartProps) {
const { state, toolCallId, toolName, args, result } = invocation;
const isStreaming = state === "call";
const displayArgs = isStreaming ? args : JSON.stringify(args, null, 2);
return (
<div
className={`tool-invocation rounded-lg border p-3 font-mono text-sm ${
isStreaming ? "border-amber-500 bg-amber-50" : "border-emerald-500 bg-emerald-50"
}`}
data-tool-call-id={toolCallId}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">
Tool call
</span>
<code className="px-1.5 py-0.5 rounded bg-gray-200">{toolName}</code>
{isStreaming && (
<span className="ml-auto text-xs text-amber-600 animate-pulse">
Streaming…
</span>
)}
{!isStreaming && (
<span className="ml-auto text-xs text-emerald-600">
Completed
</span>
)}
</div>
<div className="tool-args">
<details className="group">
<summary className="cursor-pointer select-none text-gray-600">
Arguments {isStreaming && "(partial)"}
</summary>
<pre className="mt-1 overflow-x-auto text-xs text-gray-800">
{displayArgs}
</pre>
</details>
</div>
{result !== undefined && !isStreaming && (
<ToolResultInline result={result} toolName={toolName} />
)}
</div>
);
}
function ToolResultInline({ result, toolName }: { result: any; toolName: string }) {
const isError = result?.error === true;
return (
<details className="mt-2 group" open>
<summary className="cursor-pointer select-none text-sm font-medium text-gray-600">
Result {isError ? "(error)" : ""}
</summary>
<pre className={`mt-1 overflow-x-auto text-xs ${
isError ? "text-red-700 bg-red-50" : "text-gray-800"
} rounded p-2`}>
{JSON.stringify(result, null, 2)}
</pre>
</details>
);
}
Verify: Trigger a tool that takes a few seconds (e.g., a web search). You should see the arguments populate character-by-character, then flip to “Completed” with the result nested inside.
Step 4: Handle tool results as separate parts
When the tool finishes, the SDK emits a "tool-result" part in addition to the "tool-invocation" part transitioning to "result" state. This duplication exists for compatibility with non-streaming consumers. Render the result once — prefer the tool-result part for final output since it’s the canonical completion signal.
// components/ToolResultPart.tsx
"use client";
import { ToolResult } from "ai/react";
interface ToolResultPartProps {
result: ToolResult;
}
export function ToolResultPart({ result }: ToolResultPartProps) {
const { toolCallId, toolName, args, result: toolResult } = result;
const isError = toolResult?.error === true;
return (
<div
className={`tool-result rounded-lg border p-3 font-mono text-sm ${
isError ? "border-red-500 bg-red-50" : "border-blue-500 bg-blue-50"
}`}
data-tool-call-id={toolCallId}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">
Tool result
</span>
<code className="px-1.5 py-0.5 rounded bg-gray-200">{toolName}</code>
{isError && (
<span className="ml-auto text-xs text-red-600 font-medium">Error</span>
)}
</div>
<details className="group" open>
<summary className="cursor-pointer select-none text-gray-600">
Output
</summary>
<pre className={`mt-1 overflow-x-auto text-xs ${
isError ? "text-red-700" : "text-gray-800"
}`}>
{JSON.stringify(toolResult, null, 2)}
</pre>
</details>
<details className="mt-2 group">
<summary className="cursor-pointer select-none text-gray-500 text-xs">
Invocation arguments
</summary>
<pre className="mt-1 overflow-x-auto text-xs text-gray-600">
{JSON.stringify(args, null, 2)}
</pre>
</details>
</div>
);
}
Verify: Check that you see exactly one result display per tool call. If you see duplicates, your MessagePartRenderer is rendering both the tool-invocation (with state: "result") and the tool-result part. Suppress the inline result in ToolInvocationPart when a corresponding tool-result exists — see Step 6.
Step 5: Render text parts with markdown support
Text parts arrive incrementally during streaming. Use a streaming-safe markdown renderer that doesn’t re-parse the entire string on every chunk.
// components/TextPart.tsx
"use client";
import { Markdown } from "react-markdown";
import remarkGfm from "remark-gfm";
interface TextPartProps {
text: string;
}
export function TextPart({ text }: TextPartProps) {
if (!text) return null;
return (
<div className="text-part prose prose-sm max-w-none text-gray-800">
<Markdown remarkPlugins={[remarkGfm]}>
{text}
</Markdown>
</div>
);
}
For production, consider streamdown or a custom streaming markdown parser that avoids layout shift during incremental updates. The AI SDK’s streamText already handles this server-side; the client just receives complete text chunks.
Verify: Stream a response with code blocks, tables, and lists. No flickering or broken rendering mid-stream.
Step 6: Deduplicate tool results
As noted in Step 4, you’ll get both a tool-invocation with state: "result" and a separate tool-result part. Track which tool calls have already rendered their final result to avoid showing the same output twice.
// components/MessageParts.tsx (updated)
"use client";
import { MessagePart, ToolInvocation, ToolResult } from "ai/react";
import { useMemo } from "react";
import { MessagePartRenderer } from "./MessagePartRenderer";
interface MessagePartsProps {
parts: MessagePart[];
}
export function MessageParts({ parts }: MessagePartsProps) {
// Collect toolCallIds that have a tool-result part
const completedToolCalls = useMemo(() => {
const ids = new Set<string>();
for (const part of parts) {
if (part.type === "tool-result") {
ids.add(part.toolResult.toolCallId);
}
}
return ids;
}, [parts]);
return (
<div className="message-parts space-y-2">
{parts.map((part, index) => (
<MessagePartRenderer
key={index}
part={part}
isToolCallCompleted={completedToolCalls.has(
part.type === "tool-invocation" ? part.toolInvocation.toolCallId : ""
)}
/>
))}
</div>
);
}
Then update ToolInvocationPart to skip the inline result when isToolCallCompleted is true:
// components/ToolInvocationPart.tsx (updated)
interface ToolInvocationPartProps {
invocation: ToolInvocation;
isToolCallCompleted?: boolean;
}
// Inside the component:
{result !== undefined && !isStreaming && !isToolCallCompleted && (
<ToolResultInline result={result} toolName={toolName} />
)}
Verify: Trigger a tool call and confirm the result appears exactly once — in the tool-result part, not duplicated in the invocation card.
Step 7: Add loading and error states for tool execution
Tools can fail, timeout, or return partial results. Surface these states clearly so users understand what happened.
// components/ToolInvocationPart.tsx (additions)
function ToolInvocationPart({ invocation, isToolCallCompleted }: ToolInvocationPartProps) {
const { state, toolCallId, toolName, args, result } = invocation;
const isStreaming = state === "call";
const isError = result?.error === true;
// Determine execution status
let executionStatus: "pending" | "running" | "success" | "error" = "pending";
if (isStreaming) executionStatus = "running";
else if (isToolCallCompleted || state === "result") {
executionStatus = isError ? "error" : "success";
}
const statusConfig = {
pending: { label: "Queued", color: "text-gray-500", bg: "bg-gray-100" },
running: { label: "Running…", color: "text-amber-600", bg: "bg-amber-100" },
success: { label: "Success", color: "text-emerald-600", bg: "bg-emerald-100" },
error: { label: "Failed", color: "text-red-600", bg: "bg-red-100" },
}[executionStatus];
return (
<div className={`tool-invocation rounded-lg border p-3 font-mono text-sm ${statusConfig.bg} border-gray-200`}>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">
Tool call
</span>
<code className="px-1.5 py-0.5 rounded bg-gray-200">{toolName}</code>
<span className={`ml-auto text-xs font-medium ${statusConfig.color}`}>
{statusConfig.label}
</span>
</div>
{/* ... rest of component */}
</div>
);
}
For the tool-result part, render errors with a retry affordance if your backend supports re-execution:
// components/ToolResultPart.tsx (additions)
{isError && (
<div className="mt-2 flex gap-2">
<button
className="text-xs px-2 py-1 rounded border border-red-300 text-red-700 hover:bg-red-50"
onClick={() => retryToolCall(toolCallId)}
>
Retry
</button>
<button
className="text-xs px-2 py-1 rounded border border-gray-300 text-gray-700 hover:bg-gray-50"
onClick={() => dismissToolError(toolCallId)}
>
Dismiss
</button>
</div>
)}
Verify: Force a tool error (throw in your tool implementation) and confirm the error renders with retry/dismiss options.
Step 8: Wire it into useChat
Your main chat component now composes cleanly. The messages array from useChat contains the parts structure automatically when tools are enabled on the server.
// app/chat/page.tsx
"use client";
import { useChat } from "ai/react";
import { MessageParts } from "@/components/MessageParts";
export default function Chat() {
const { messages, input, handleInputChange, handleSubmit, isLoading, error } = useChat({
api: "/api/chat",
});
return (
<div className="flex flex-col h-screen p-4 gap-4">
<div className="flex-1 overflow-y-auto space-y-6">
{messages.map((message) => (
<div
key={message.id}
className={`message flex gap-3 ${message.role === "assistant" ? "flex-row-reverse" : ""}`}
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
message.role === "assistant" ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-700"
}`}>
{message.role === "assistant" ? "🤖" : "👤"}
</div>
<div className="flex-1 min-w-0">
<MessageParts parts={message.parts} />
</div>
</div>
))}
{isLoading && <div className="text-center text-gray-500">Thinking…</div>}
</div>
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={input}
onChange={handleInputChange}
placeholder="Type a message…"
className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="rounded-lg bg-blue-600 px-4 py-2 text-white font-medium hover:bg-blue-700 disabled:opacity-50"
>
Send
</button>
</form>
{error && <div className="text-red-600 text-sm">Error: {error.message}</div>}
</div>
);
}
Verify: Send a message that triggers multiple tool calls in sequence. Each invocation should appear in order, stream arguments, show completion, then display results.
Step 9: Handle client-side tool routing (optional)
If you use a gateway like n4n.ai that supports provider fallback and routing directives, tool calls may execute on different providers mid-conversation. The message parts structure remains identical — the gateway normalizes tool calling across providers — but you may want to surface which provider executed a given tool.
// Add to ToolInvocationPart if your gateway returns provider metadata
{invocation.provider && (
<span className="ml-2 text-xs text-gray-400 font-normal">
via {invocation.provider}
</span>
)}
This requires your server to forward provider metadata in the tool invocation part. The AI SDK doesn’t standardize this field, so check your gateway’s documentation.
Verify: Route a conversation through multiple providers and confirm the provider badge appears on each tool call.
Step 10: Test edge cases
Run through these scenarios before shipping:
| Scenario | Expected behavior |
|---|---|
| Tool streams invalid JSON mid-call | Arguments display shows raw partial text, no crash |
| Tool returns massive result (100KB+) | Result collapses in <details>, doesn’t freeze UI |
| Multiple parallel tool calls | Each gets its own invocation card, results match by toolCallId |
| User navigates away during tool execution | No memory leaks, no errors on unmount |
| Tool call ID collision (buggy server) | Graceful degradation — log warning, render what you have |
| Empty tool result | Shows “null” or empty object, not blank |
Write unit tests for MessagePartRenderer with fixture data covering each part type and state combination. Integration test the full flow with a mock server that streams tool calls.
// __tests__/MessageParts.test.tsx
import { render, screen } from "@testing-library/react";
import { MessageParts } from "@/components/MessageParts";
const mockParts = [
{ type: "text", text: "I'll check the weather." },
{
type: "tool-invocation",
toolInvocation: {
state: "result",
toolCallId: "call_123",
toolName: "get_weather",
args: { location: "San Francisco" },
result: { temp: 62, conditions: "foggy" },
},
},
{
type: "tool-result",
toolResult: {
toolCallId: "call_123",
toolName: "get_weather",
args: { location: "San Francisco" },
result: { temp: 62, conditions: "foggy" },
},
},
];
test("renders text, invocation, and result without duplication", () => {
render(<MessageParts parts={mockParts} />);
expect(screen.getByText("I'll check the weather.")).toBeInTheDocument();
expect(screen.getByText("get_weather")).toBeInTheDocument();
// Result should appear once (in tool-result part)
expect(screen.getAllByText("foggy")).toHaveLength(1);
});
Verification checklist
- Text streams smoothly without layout shift
- Tool arguments appear incrementally during streaming
- Each tool call shows exactly one result (no duplicates)
- Errors render with retry/dismiss actions
- Large results are collapsed by default
- Parallel tool calls render independently
- Component unmounts cleanly during streaming
- Unit tests cover all part types and states
The pattern scales: add new part types (e.g., "file", "image", "reasoning") by extending the switch in MessagePartRenderer without touching the chat layout. This is the architecture the AI SDK expects you to build — the hook gives you structured data, you supply the presentation logic.