Render AI tool calls as custom React components — show loading, result, and interactive states for each tool invocation in assistant-ui.
Create custom UI components for AI tool calls, providing visual feedback and interactive experiences when tools are executed.
Overview
Tool UIs in assistant-ui allow you to create custom interfaces that appear when AI tools are called. These generative UI components enhance the user experience by:
- Visualizing tool execution with loading states and progress indicators
- Displaying results in rich, formatted layouts
- Enabling user interaction through forms and controls
- Providing error feedback with helpful recovery options
This guide demonstrates building tool UIs with the Vercel AI SDK.
For composing UI from a JSON spec and component allowlist (display-only layouts), see the Generative UI primitive guide.
Creating Tool UIs
Tool UI is registered on toolkit entries. The same entry can define a browser-executed frontend tool, a human tool that completes through addResult, or a render-only backend tool whose schema and execution live on the server.
1. Client-Defined Tools
If you're creating tools on the client side, register them in a toolkit with Tools({ toolkit }).
"use generative";
import { defineToolkit } from "@assistant-ui/react";
import { z } from "zod";
export default defineToolkit({
getWeather: {
description: "Get current weather for a location",
parameters: z.object({
location: z.string(),
unit: z.enum(["celsius", "fahrenheit"]),
}),
execute: async ({ location, unit }) => {
"use client";
return fetchWeatherAPI(location, unit);
},
render: ({ args, result, status }) => {
if (status.type === "running") {
return <div>Checking weather in {args.location}...</div>;
}
return (
<div className="weather-card">
<h3>{args.location}</h3>
<p>
{result.temperature}°{args.unit === "celsius" ? "C" : "F"}
</p>
<p>{result.description}</p>
</div>
);
},
},
});import { AssistantRuntimeProvider, Tools, useAui } from "@assistant-ui/react";
import { useChatRuntime } from "@assistant-ui/react-ai-sdk";
import toolkit from "./toolkit";
function MyRuntimeProvider({ children }: { children: React.ReactNode }) {
const runtime = useChatRuntime({ api: "/api/chat" });
const aui = useAui({ tools: Tools({ toolkit }) });
return (
<AssistantRuntimeProvider aui={aui} runtime={runtime}>
{children}
</AssistantRuntimeProvider>
);
}Frontend toolkit entries can be passed to your backend using the
frontendTools utility.
Learn more about creating tools in the Tools Guide.
2. UI-Only for Existing Tools
If your tool is defined elsewhere (e.g., in your backend API, MCP server, or LangGraph), register a backend toolkit entry with just render:
const toolkit = {
getWeather: {
type: "backend",
render: ({ args, result, status }) => {
// UI rendering logic only
},
},
} satisfies Toolkit;Quick Start Example
This example shows how to implement the UI-only approach with a backend toolkit entry:
Create a Tool UI Renderer
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { z } from "zod";
type WeatherArgs = {
location: string;
unit: "celsius" | "fahrenheit";
};
type WeatherResult = {
temperature: number;
description: string;
humidity: number;
windSpeed: number;
};
const WeatherToolUI: ToolCallMessagePartComponent<
WeatherArgs,
WeatherResult
> = ({ args, status, result }) => {
if (status.type === "running") {
return (
<div className="flex items-center gap-2">
<Spinner />
<span>Checking weather in {args.location}...</span>
</div>
);
}
if (status.type === "incomplete" && status.reason === "error") {
return (
<div className="text-red-500">
Failed to get weather for {args.location}
</div>
);
}
return (
<div className="weather-card rounded-lg bg-blue-50 p-4">
<h3 className="text-lg font-bold">{args.location}</h3>
<div className="mt-2 grid grid-cols-2 gap-4">
<div>
<p className="text-2xl">
{result.temperature}°{args.unit === "celsius" ? "C" : "F"}
</p>
<p className="text-gray-600">{result.description}</p>
</div>
<div className="text-sm">
<p>Humidity: {result.humidity}%</p>
<p>Wind: {result.windSpeed} km/h</p>
</div>
</div>
</div>
);
};Register the Tool UI
Put the renderer on the matching backend toolkit entry:
const toolkit = {
getWeather: {
type: "backend",
render: WeatherToolUI,
},
} satisfies Toolkit;
function App({ runtime }: { runtime: AssistantRuntime }) {
const aui = useAui({ tools: Tools({ toolkit }) });
return (
<AssistantRuntimeProvider aui={aui} runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
);
}Define the Backend Tool (Vercel AI SDK)
When using the Vercel AI SDK, define the corresponding tool in your API route:
import { streamText, tool, zodSchema } from "ai";
import { z } from "zod";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("gpt-5.4-nano"),
messages: await convertToModelMessages(messages),
tools: {
getWeather: tool({
description: "Get current weather for a location",
inputSchema: zodSchema(
z.object({
location: z.string(),
unit: z.enum(["celsius", "fahrenheit"]),
}),
),
execute: async ({ location, unit }) => {
const weather = await fetchWeatherAPI(location);
return {
temperature: weather.temp,
description: weather.condition,
humidity: weather.humidity,
windSpeed: weather.wind,
};
},
}),
},
});
return result.toUIMessageStreamResponse();
}Tool UI Patterns
Component Pattern
Create standalone tool UI components:
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
export const WebSearchToolUI: ToolCallMessagePartComponent<
{ query: string },
{ results: SearchResult[] }
> = ({ args, status, result }) => {
return (
<div className="search-container">
<div className="mb-3 flex items-center gap-2">
<SearchIcon />
<span>Search results for: "{args.query}"</span>
</div>
{status.type === "running" && <LoadingSpinner />}
{result && (
<div className="space-y-2">
{result.results.map((item, index) => (
<div key={index} className="rounded border p-3">
<a href={item.url} className="font-medium text-blue-600">
{item.title}
</a>
<p className="text-sm text-gray-600">{item.snippet}</p>
</div>
))}
</div>
)}
</div>
);
};Register it on the toolkit:
const toolkit = {
webSearch: {
type: "backend",
render: WebSearchToolUI,
},
} satisfies Toolkit;Dynamic Toolkit Pattern
Use a toolkit hook in its own file when the renderer needs component state:
"use client";
import { type Toolkit, useInlineRender } from "@assistant-ui/react";
import { useMemo } from "react";
export function useAnalyzeDataToolkit(theme: "light" | "dark") {
const renderAnalyzeData = useInlineRender(({ result, status }) => {
return (
<DataVisualization
data={result}
theme={theme}
loading={status.type === "running"}
/>
);
});
return useMemo(
() =>
({
analyzeData: {
type: "backend",
render: renderAnalyzeData,
},
}) satisfies Toolkit,
[renderAnalyzeData],
);
}import { AuiProvider, Tools, useAui } from "@assistant-ui/react";
import { useState } from "react";
import { useAnalyzeDataToolkit } from "./analyze-data-toolkit";
function DynamicToolUI({ children }: { children: React.ReactNode }) {
const [theme] = useState<"light" | "dark">("light");
const toolkit = useAnalyzeDataToolkit(theme);
const aui = useAui({
tools: Tools({ toolkit }),
});
return <AuiProvider value={aui}>{children}</AuiProvider>;
}Inline Pattern
For tools that need access to parent component props:
Why useInlineRender? By default, a tool UI's render function is
static. Use useInlineRender when your UI needs access to dynamic component
props (for example, to pass in an id or other contextual data).
"use client";
import { type Toolkit, useInlineRender } from "@assistant-ui/react";
import { useMemo } from "react";
export function useInventoryToolkit(productId: string, productName: string) {
const renderInventory = useInlineRender(({ result }) => {
return (
<div className="inventory-status">
<h4>{productName} Inventory</h4>
<p>
Stock for {productId}: {result.quantity} units
</p>
<p>Location: {result.warehouse}</p>
</div>
);
});
return useMemo(
() =>
({
checkInventory: {
type: "backend",
render: renderInventory,
},
}) satisfies Toolkit,
[renderInventory],
);
}import { AuiProvider, Tools, useAui } from "@assistant-ui/react";
import { useInventoryToolkit } from "./inventory-toolkit";
function ProductPage({ productId, productName }) {
const toolkit = useInventoryToolkit(productId, productName);
const aui = useAui({
tools: Tools({ toolkit }),
});
return (
<AuiProvider value={aui}>
<div>Product details...</div>
</AuiProvider>
);
}Interactive Tool UIs
User Input Collection
Create tools that collect user input during execution:
Pro tip: Call addResult(...) exactly once to complete the tool call.
After it's invoked, the assistant will resume the conversation with your
provided data.
const toolkit = {
selectDate: {
type: "human",
description: "Ask the user to select a date.",
parameters: z.object({ prompt: z.string() }),
render: ({ args, result, addResult }) => {
if (result) {
return (
<div className="rounded bg-green-50 p-3">
✅ Selected date: {new Date(result.date).toLocaleDateString()}
</div>
);
}
return (
<div className="rounded border p-4">
<p className="mb-3">{args.prompt}</p>
<DatePicker
onChange={(date) => {
addResult({ date: date.toISOString() });
}}
/>
</div>
);
},
},
} satisfies Toolkit;Multi-Step Interactions
Build complex workflows with human-in-the-loop patterns for multi-step user interactions:
"use generative";
import { defineToolkit } from "@assistant-ui/react";
import { z } from "zod";
export default defineToolkit({
requestApproval: {
description: "Request user approval for an action",
parameters: z.object({
action: z.string(),
details: z.any(),
}),
execute: async ({ action, details }, { human }) => {
"use client";
// Request approval from user
const response = await human({ action, details });
return {
approved: response.approved,
reason: response.reason,
};
},
render: ({ args, result, interrupt, resume }) => {
const [reason, setReason] = useState("");
// Show result after approval/rejection
if (result) {
return (
<div className={result.approved ? "text-green-600" : "text-red-600"}>
{result.approved ? "✅ Approved" : `❌ Rejected: ${result.reason}`}
</div>
);
}
// Show approval UI when waiting for user input
if (interrupt) {
return (
<div className="rounded border-2 border-yellow-400 p-4">
<h4 className="font-bold">Approval Required</h4>
<p className="my-2">{interrupt.payload.action}</p>
<pre className="rounded bg-gray-100 p-2 text-sm">
{JSON.stringify(interrupt.payload.details, null, 2)}
</pre>
<div className="mt-4 flex gap-2">
<button
onClick={() => resume({ approved: true })}
className="rounded bg-green-500 px-4 py-2 text-white"
>
Approve
</button>
<button
onClick={() => resume({ approved: false, reason })}
className="rounded bg-red-500 px-4 py-2 text-white"
>
Reject
</button>
<input
type="text"
placeholder="Rejection reason..."
value={reason}
onChange={(e) => setReason(e.target.value)}
className="flex-1 rounded border px-2"
/>
</div>
</div>
);
}
return <div>Processing...</div>;
},
},
});Use tool human input (human() / resume()) for workflows that need to
pause tool execution and wait for user input. Use addResult() for "human
tools" where the AI requests a tool call but the entire execution happens
through user interaction.
Server-side approval gates
Some runtimes (notably AI SDK v6's needsApproval tools) pause on the server and emit an approval request that the client must acknowledge before the tool runs. assistant-ui surfaces this on the tool part as approval and exposes respondToApproval({ approved, reason? }) on the renderer:
const toolkit = {
deploy: {
type: "backend",
render: ({ args, approval, respondToApproval, result }) => {
if (approval?.approved === undefined) {
return (
<div>
<p>Approve deploy to {args.target}?</p>
<button onClick={() => respondToApproval({ approved: true })}>
Approve
</button>
<button
onClick={() =>
respondToApproval({ approved: false, reason: "user denied" })
}
>
Deny
</button>
</div>
);
}
if (approval?.approved === false) {
return <p>Denied{approval.reason ? `: ${approval.reason}` : ""}</p>;
}
if (result === undefined) return <p>Approved, running…</p>;
return <p>Deployed</p>;
},
},
} satisfies Toolkit;approval.approved is a three-state signal:
undefined: gate is open, the renderer should ask the user. This is the only state in whichrespondToApprovalis legal.true: decision recorded as allow. The server is producing the result (or has produced one, available onresult).false: decision recorded as deny.output-deniedfrom the runtime setsisErrorand exposesapproval.reason.
approval.isAutomatic is true when the runtime granted the decision from a server-side policy rather than the user; render a "auto-approved" badge instead of buttons in that case.
For the wire-side setup (needsApproval, sendAutomaticallyWhen), see AI SDK v6 server-side tool approval.
Advanced Features
Tool Status Handling
The status prop provides detailed execution state:
render: ({ status, args }) => {
switch (status.type) {
case "running":
return <LoadingState />;
case "requires-action":
return <UserInputRequired reason={status.reason} />;
case "incomplete":
if (status.reason === "cancelled") {
return <div>Operation cancelled</div>;
}
if (status.reason === "error") {
return <ErrorDisplay error={status.error} />;
}
return <div>Failed: {status.reason}</div>;
case "complete":
return <SuccessDisplay />;
}
};Deferred Rendering
This section applies when the model drives the component through a tool call (args arrive incrementally and you want to wait for the final shape). If your backend or orchestrator pushes the component instead, prefer Data-Part Generative UI with makeAssistantDataUI. Data parts arrive as terminal events, so the renderer only fires once with the final data, no deferred rendering needed.
Sometimes you want to capture a tool call's streaming arguments but only render the final UI once the call completes. This is useful when partial args would render misleading or jarring intermediate states (a chart that flashes through half-populated data), when the component is expensive to mount (heavy visualizations, embedded iframes, third-party widgets), or when the model controls whether the component appears at all.
Inline at the end of streaming
Return null from the tool UI's render until status.type === "complete". The streaming args still arrive in args as the model emits them, you just ignore them until the call is done:
const toolkit = {
renderChart: {
type: "backend",
render: ({ args, status }) => {
if (status.type !== "complete") return null;
return <Chart title={args.title} data={args.series} />;
},
},
} satisfies Toolkit;The chart mounts once, with the final args, after streaming finishes. No re-renders during the stream.
The same render shape works inside the Tools() toolkit's render field and with MessagePrimitive.Parts's inline tools.by_name overrides.
Below the message body
If the component should sit outside the message parts (for example, a card attached under the avatar block rather than inline with text), gate at the message level with AuiIf and read s.message.status:
import { MessagePrimitive, AuiIf, useAuiState } from "@assistant-ui/react";
function PostMessageCard() {
const parts = useAuiState((s) => s.message.parts);
const chartCall = parts.find(
(p) => p.type === "tool-call" && p.toolName === "renderChart",
);
if (!chartCall) return null;
return <Chart {...chartCall.args} />;
}
<MessagePrimitive.Root>
<MessagePrimitive.Parts />
<AuiIf
condition={(s) =>
s.message.role === "assistant" &&
s.message.status?.type === "complete"
}
>
<PostMessageCard />
</AuiIf>
</MessagePrimitive.Root>;The AuiIf predicate fires whenever the assistant state changes; children mount only when both checks pass. PostMessageCard then reads the captured tool-call part from s.message.parts and renders from its args.
For the opposite pattern (showing partial data as it streams in), see Field-Level Streaming State and Partial Results & Streaming below.
Field-Level Streaming State
Use useToolArgsStatus to react to per-field streaming state. The hook returns a propStatus map where each top-level key in the args object resolves from "streaming" to "complete" as the partial JSON arrives. Call it inside a tool-call message part context:
import { useToolArgsStatus } from "@assistant-ui/react";
const toolkit = {
submitForm: {
type: "backend",
render: ({ args }) => {
const { propStatus } = useToolArgsStatus<{
email: string;
phone: string;
}>();
return (
<form className="space-y-4">
<div>
<input
type="email"
value={args.email ?? ""}
className={propStatus.email === "streaming" ? "loading" : ""}
disabled
/>
</div>
<div>
<input
type="tel"
value={args.phone ?? ""}
className={propStatus.phone === "streaming" ? "loading" : ""}
disabled
/>
</div>
</form>
);
},
},
} satisfies Toolkit;Partial Results & Streaming
Display results as they stream in:
const toolkit = {
analyzeData: {
type: "backend",
render: ({ result, status }) => {
const progress = result?.progress || 0;
const insights = result?.insights || [];
return (
<div className="analysis-container">
{status.type === "running" && (
<div className="mb-4">
<div className="mb-1 flex justify-between">
<span>Analyzing...</span>
<span>{progress}%</span>
</div>
<div className="w-full rounded bg-gray-200">
<div
className="h-2 rounded bg-blue-500"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
<div className="space-y-2">
{insights.map((insight, i) => (
<div key={i} className="rounded bg-gray-50 p-2">
{insight}
</div>
))}
</div>
</div>
);
},
},
} satisfies Toolkit;Custom Tool Fallback
For tools that have no dedicated UI, add the ToolFallback shadcn component to your project. See the ToolFallback install guide for setup instructions and the ToolGroup guide for grouping consecutive tool calls into a collapsible container.
Execution Context
Generative UI components have access to execution context through props:
type ToolCallMessagePartProps<TArgs, TResult> = {
// Tool arguments
args: TArgs;
argsText: string; // JSON stringified args
// Execution status
status: ToolCallMessagePartStatus;
isError?: boolean;
// Tool result (may be partial during streaming)
result?: TResult;
// Tool metadata
toolName: string;
toolCallId: string;
// Interactive callbacks
addResult: (result: TResult | ToolResponse<TResult>) => void;
resume: (payload: unknown) => void;
// Interrupt state
interrupt?: { type: "human"; payload: unknown }; // Payload from context.human()
// Optional artifact data
artifact?: unknown;
};Human Input Handling
When a tool calls human() during execution, the payload becomes available in the render function as interrupt.payload:
const toolkit = {
confirmAction: {
type: "backend",
render: ({ args, result, interrupt, resume }) => {
// Tool is waiting for user input
if (interrupt) {
return (
<div className="confirmation-dialog">
<p>Confirm: {interrupt.payload.message}</p>
<button onClick={() => resume(true)}>Yes</button>
<button onClick={() => resume(false)}>No</button>
</div>
);
}
// Tool completed
if (result) {
return <div>Action {result.confirmed ? "confirmed" : "cancelled"}</div>;
}
return <div>Processing...</div>;
},
},
} satisfies Toolkit;Learn more about tool human input in the Tools Guide.
Best Practices
1. Handle All Status States
Always handle loading, error, and success states:
render: ({ status, result, args }) => {
if (status.type === "running") return <Skeleton />;
if (status.type === "incomplete") return <ErrorState />;
if (!result) return null;
return <ResultDisplay result={result} />;
};2. Provide Visual Feedback
Use animations and transitions for better UX:
<div
className={cn(
"transition-all duration-300",
status.type === "running" && "opacity-50",
status.type === "complete" && "opacity-100",
)}
>
{/* Tool UI content */}
</div>3. Make UIs Accessible
Ensure keyboard navigation and screen reader support:
<button
onClick={() => addResult(value)}
aria-label="Confirm selection"
className="focus:outline-none focus:ring-2"
>
Confirm
</button>4. Optimize Performance
Use useInlineRender to prevent unnecessary re-renders:
"use client";
import { type Toolkit, useInlineRender } from "@assistant-ui/react";
import { useMemo } from "react";
export function useHeavyComputationToolkit() {
const renderHeavyComputation = useInlineRender(({ result }) => {
return <ComplexVisualization data={result} />;
});
return useMemo(
() =>
({
heavyComputation: {
type: "backend",
render: renderHeavyComputation,
},
}) satisfies Toolkit,
[renderHeavyComputation],
);
}import { AuiProvider, Tools, useAui } from "@assistant-ui/react";
import type { ReactNode } from "react";
import { useHeavyComputationToolkit } from "./heavy-computation-toolkit";
function HeavyComputationToolProvider({ children }: { children: ReactNode }) {
const toolkit = useHeavyComputationToolkit();
const aui = useAui({ tools: Tools({ toolkit }) });
return <AuiProvider value={aui}>{children}</AuiProvider>;
}Generative UI components are only displayed in the chat interface. The actual tool execution happens on the backend. This separation allows you to create rich, interactive experiences while keeping sensitive logic secure on the server.
Data-Part Generative UI
Alongside tool-call rendering, assistant-ui supports a second generative UI mechanism based on DataMessagePart. Instead of attaching UI to a tool invocation, the backend (or the LangGraph graph) emits named data events that are appended as { type: "data", name, data } parts on the parent assistant message.
When to choose which:
- Tool UI: the model decides what to render by calling a tool whose args become the component's data. Register the renderer via the
Tools()toolkit'srenderfield. Args stream incrementally, so you observe partial state viastatus/useToolArgsStatusand may need Deferred Rendering for components that should only mount with final data. - Data UI (
makeAssistantDataUI): the backend or orchestrator decides what to render and pushes a named data event onto the assistant message. Data parts arrive as terminal events with no streaming partials, so the renderer naturally fires once with the final data.
If you want a component to appear only after the message is complete and you control the backend, Data UI is usually the more direct fit; reach for Tool UI's deferred pattern when the model itself must drive the choice.
Use makeAssistantDataUI to register a renderer for a named data part:
import { makeAssistantDataUI } from "@assistant-ui/react";
type ChartProps = { series: number[]; title: string };
export const ChartUI = makeAssistantDataUI<ChartProps>({
name: "chart",
render: ({ data }) => (
<div>
<h3>{data.title}</h3>
<Chart series={data.series} />
</div>
),
});Mount <ChartUI /> once inside the AssistantRuntimeProvider tree; it renders nothing itself and only registers the renderer.
For LangGraph-specific patterns (emitting UI from a Python/TypeScript graph node via push_ui_message / typedUi, dynamic loading with LoadExternalComponent, and the useLangGraphUIMessages escape hatch), see LangGraph Generative UI.
A fallback renderer for unmatched data parts is available internally but setFallbackDataUI is not yet a public API.
Related Guides
- Tools Guide - Learn how to create and use tools with AI models
- Multi-Agent - Render sub-agent conversations inside tool call UIs
- Tool Fallback - Default UI for tools without custom components
- API Reference - Detailed type definitions and component APIs
- Message Primitive - Complete Message component documentation