Render AI tool calls as custom React components that show loading, result, and interactive states for each tool invocation in assistant-ui.
Render a tool call as your own React component, with loading, result, error, and interactive states.
Overview
A tool UI is a component that renders in the thread when the model calls a tool. It can:
- show loading and progress states while the tool runs
- display the result
- collect input from the user through forms and controls
- report errors
The examples on this page use the Vercel AI SDK.
To let the model compose UI from a vocabulary instead of binding one component per tool, see Generative UI.
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, AuiConfig, Tools } from "@assistant-ui/react";
import { useChatRuntime } from "@assistant-ui/ai-sdk";
import toolkit from "./toolkit";
function MyRuntimeProvider({ children }: { children: React.ReactNode }) {
const runtime = useChatRuntime();
const config = AuiConfig({ tools: Tools({ toolkit }) });
return (
<AssistantRuntimeProvider runtime={runtime} config={config}>
{children}
</AssistantRuntimeProvider>
);
}useChatRuntime() targets /api/chat by default. To point at a different endpoint or customize requests, see Custom transport.
Tip
The model also needs the tool's schema. Wrap the same toolkit in
AISDKToolkit in your route handler; see Backend Tools.
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 only a render:
const toolkit = defineToolkit({
getWeather: {
type: "backend",
render: ({ args, result, status }) => {
// UI rendering logic only
},
},
});This type: "backend" shape is for a plain toolkit. In a "use generative" file, write execute: externalTool() instead; see Externally defined tools.
Quick Start Example
This example uses a backend toolkit entry for a tool that the server defines. The preview uses a shorter renderer than the steps below.
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 related backend toolkit entry.
Set display to "standalone" to keep the tool UI outside the collapsed tool group. A backend tool uses the inline display by default.
const toolkit = defineToolkit({
getWeather: {
type: "backend",
display: "standalone",
render: WeatherToolUI,
},
});
function App({ runtime }: { runtime: AssistantRuntime }) {
const config = AuiConfig({ tools: Tools({ toolkit }) });
return (
<AssistantRuntimeProvider runtime={runtime} config={config}>
<Thread />
</AssistantRuntimeProvider>
);
}Define the Backend Tool (Vercel AI SDK)
When using the Vercel AI SDK, define the corresponding tool in your API route:
import { openai } from "@ai-sdk/openai";
import { convertToModelMessages, streamText, tool, zodSchema } from "ai";
import { z } from "zod";
export async function POST(req: Request) {
const { messages } = await req.json();
const aiSDKTools = {
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,
};
},
}),
};
const result = streamText({
abortSignal: req.signal,
model: openai("gpt-6-luna"),
messages: await convertToModelMessages(messages, { tools: aiSDKTools }),
tools: aiSDKTools,
});
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 = defineToolkit({
webSearch: {
type: "backend",
render: WebSearchToolUI,
},
});Inline Pattern
When a renderer needs component props or state, build the toolkit in a hook and wrap the renderer in useInlineRender:
Tip
useInlineRender returns a component that keeps its identity while the
function you pass to it changes, so the tool UI reads the latest props and
state without remounting.
"use client";
import { defineToolkit, 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(
() =>
defineToolkit({
checkInventory: {
type: "backend",
render: renderInventory,
},
}),
[renderInventory],
);
}import { AuiConfig, AuiProvider, Tools, useAui } from "@assistant-ui/react";
import { useInventoryToolkit } from "./inventory-toolkit";
function ProductPage({ productId, productName }) {
const toolkit = useInventoryToolkit(productId, productName);
const aui = useAui();
const config = AuiConfig({ tools: Tools({ toolkit }) });
return (
<AuiProvider extends={aui} config={config}>
<div>Product details...</div>
</AuiProvider>
);
}Interactive Tool UIs
s.thread.capabilities.answerToolCall reports whether the thread can take an answer at all. It is false in a readonly thread, such as a sub-agent transcript or a conversation rendered through ReadonlyThreadProvider, where addResult, resume, and respondToApproval do nothing, and on an external store that has nothing to answer with: no onAddToolResult, onResumeToolCall, or onRespondToToolApproval, and no tool execution of its own. Render the request without its controls when it is false, as the kit's tool fallback does:
const canAnswer = useAuiState((s) => s.thread.capabilities.answerToolCall);User Input Collection
A human tool has no executor. Its render collects the input and passes it to addResult:
Tip
Call addResult(...) exactly once. It completes the tool call, and the
assistant continues with the data you passed.
const toolkit = defineToolkit({
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>
);
},
},
});Multi-Step Interactions
A frontend tool can pause during execute and wait for the user. Call human() with a payload; render receives it as interrupt, and the value passed to resume() is returned to execute:
"use generative";
import { defineToolkit } from "@assistant-ui/react";
import { useState } from "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>;
},
},
});Tip
Use human() and resume() when a tool's execute has to pause and wait
for the user. Use addResult() for a human tool, where the user supplies
the whole result.
Server-side approval gates
Some runtimes (notably AI SDK v7's toolApproval-gated 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:
import { useState } from "react";
import type { ToolApprovalResponse } from "@assistant-ui/react";
const toolkit = defineToolkit({
deploy: {
type: "backend",
render: ({ args, approval, respondToApproval, result }) => {
const [error, setError] = useState<string | null>(null);
if (approval?.approved === undefined) {
// `try`/`await` rather than `.catch`: a rejected response and a
// rejected precondition (an unknown option, an answer the request does
// not take) reach the caller by different routes.
const answer = async (response: ToolApprovalResponse) => {
setError(null);
try {
await respondToApproval(response);
} catch (failure) {
setError(failure instanceof Error ? failure.message : String(failure));
}
};
return (
<div>
<p>Approve deploy to {args.target}?</p>
<button onClick={() => void answer({ approved: true })}>Approve</button>
<button onClick={() => void answer({ approved: false, reason: "user denied" })}>
Deny
</button>
{error && <p role="alert">{error}</p>}
</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>;
},
},
});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. The runtime records an error result (isError) and 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.
respondToApproval returns a promise that resolves once the runtime has accepted the response and rejects when it could not be recorded, for example an expired gate or a provider that refuses the answer as given. Await it before disabling the controls, so a refused response leaves the request retryable rather than spending it.
Approval gates require a runtime that implements them: the AI SDK v7 runtime emits them for toolApproval-gated tools, and LocalRuntime supports gates emitted by your ChatModelAdapter; see LocalRuntime approval gates. For tools where the user supplies the result itself, use unstable_humanToolNames with addResult instead; see human-in-the-loop tools.
For the wire-side setup (needsApproval, sendAutomaticallyWhen), see AI SDK v7 server-side tool approval.
Approval options
Beyond the plain allow / deny pair, the host can attach a list of decision options to an approval, for example "allow once", "allow for this session", and "always allow". Each option carries a machine-readable kind ("allow-once", "allow-always", "reject-once", "reject-always"); scope semantics like session versus global belong to the option's id and label, which only the host interprets:
const approval = {
id: "a1",
options: [
{ id: "once", kind: "allow-once" },
{ id: "session", kind: "allow-always", label: "Allow for this session" },
{
id: "always",
kind: "allow-always",
label: "Always allow",
grants: ["git *"],
confirm: true,
},
{ id: "deny", kind: "reject-once" },
],
};Renderers respond with the chosen option instead of a boolean; the option's kind resolves the decision:
respondToApproval({ optionId: "session" });The runtime receives { approvalId, approved, optionId, text?, reason? }, so a host that persists "always allow" decisions can key its store off optionId. Persistence is entirely host-owned: assistant-ui never stores a decision and never auto-answers future approvals. grants lists the patterns an option would persist (shown to the user before they commit), and confirm opts the option into a confirmation step. Options with custom _-prefixed kinds must be answered with an explicit approved value, optionally alongside the optionId so the chosen option is still recorded. The default ToolFallback bar renders them next to the known kinds and sends { optionId, approved: true } when one is chosen, since picking a declared option is an answer rather than a refusal; it also keeps a Deny control available whenever a decision request declares no reject-kind option.
Approval questions
An approval request can ask for something other than permission. approval.prompt carries the question, and approval.display says how to present it: "decision" (the default) for a yes / no gate, "select" when the answer is one of approval.options, and "text" when the user types it. approval.allowFreeform accepts a typed answer alongside the options, and approval.dismissible says the host records a dismissal: { approved: false } with no answer.
const approval = {
id: "a1",
prompt: "Which environment should I deploy to?",
display: "select",
allowFreeform: true,
options: [
{ id: "staging", kind: "_staging", label: "Staging" },
{ id: "production", kind: "_production", label: "Production" },
],
};A renderer answers a free-form request with the answer itself:
respondToApproval({ text: "staging" });The runtime records that as { approvalId, approved: true, text }, because answering a question is not refusing it. A text response to a request that declares neither display: "text" nor allowFreeform throws, so a host never receives an answer it has nowhere to record.
An answer on its own resolves only a question. On a decision the approval is the authorization, so a bare text throws there and the answer must accompany an explicit decision ({ approved, text }) or a chosen option; otherwise a typed note would authorize the call. allowFreeform on a decision therefore annotates the decision rather than replacing it, which is why the default ToolFallback shows a text field beside Allow and Deny with no separate submit. A question has no refusal of its own, so the default ToolFallback renders a text field for one and omits the Deny control it adds to a decision; a dismissible question gets a Dismiss control instead, which sends { approved: false } and drops whatever was typed. The text field submits the answer as typed, an empty one included, because the request declared no constraint on it; a host that cannot record an empty answer rejects the response, and the controls come back with its error.
Several questions or several answers
display: "questions" asks several questions at once, or lets one question take several answers. approval.questions lists them, each with an id, a prompt, an optional header, and either options (each an id, a label and an optional description) or none, in which case the question takes a typed answer. multiple lets a question take more than one option, and allowFreeform accepts a typed answer alongside its options. The request's own options, allowFreeform and text are unused here.
const approval = {
id: "a2",
prompt: "Before I refactor:",
display: "questions",
dismissible: true,
questions: [
{
id: "scope",
header: "Scope",
prompt: "Which folders should change?",
options: [
{ id: "src", label: "src" },
{ id: "tests", label: "tests" },
],
multiple: true,
},
{ id: "note", prompt: "Anything I should avoid?" },
],
};A renderer answers every question together, keyed by question id:
respondToApproval({
answers: {
scope: { optionIds: ["src", "tests"] },
note: { text: "keep the public API" },
},
});The runtime records that as { approvalId, approved: true, answers }, and the answers stay on approval.answers once it settles. Every question needs an answer: at least one of its options, or typed text where it accepts some. A response that misses a question, picks an option the question does not declare, picks several on a question without multiple, or types an answer a question does not accept throws, as does any approved, optionId or text sent alongside answers; { approved: false } dismisses the whole request. The default ToolFallback renders each question with its options as toggle buttons and a text field where it accepts one, keeps Send disabled until every question is answered, adds Dismiss only when the request is dismissible, and lists each question's answer once it settles.
Not every runtime emits these fields. The Eve runtime projects its input requests onto prompt, display, allowFreeform and options and never marks one dismissible, because it rejects a refusal to a question. The Pi runtime projects select, input and editor requests as dismissible questions because Pi resolves a cancelled one with undefined. The AI SDK v7 runtime's gates are boolean by construction, and the AG-UI runtime admits only gates whose response schema it can answer with a decision, so both keep sending plain decisions and carry only a prompt when the gate supplies one.
Approvals that end without a decision (a cancelled run or an expired request) are recorded by the host as approval.resolution: "cancelled" | "expired", which closes the gate without recording a deny. LocalRuntime records cancelled itself when the user sends another message instead of answering.
The default ToolFallback component renders supplied options automatically, including the confirmation step.
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
Info
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 runs once with the final data and needs no deferral.
You can let a tool call's arguments stream in and render the UI only 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; the renderer ignores them until the call is done:
const toolkit = defineToolkit({
renderChart: {
type: "backend",
render: ({ args, status }) => {
if (status.type !== "complete") return null;
return <Chart title={args.title} data={args.series} />;
},
},
});The chart mounts once, with the final args, after streaming finishes.
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 distinguish argument streaming from tool execution. 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, and argsStatus tracks completion of the full arguments object. Call it inside a tool-call message part context:
import { useToolArgsStatus } from "@assistant-ui/react";
const toolkit = defineToolkit({
submitForm: {
type: "backend",
render: ({ args }) => {
const { argsStatus, propStatus } = useToolArgsStatus<{
email: string;
phone: string;
}>();
return (
<form className="space-y-4" aria-busy={argsStatus === "streaming"}>
<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>
);
},
},
});argsStatus remains "streaming" while the object is partial, even if every field received so far is complete. Once the object is complete it becomes "complete", including while the hook's status remains "running" during tool execution. A field absent from propStatus is not in the current args: while argsStatus is "streaming" it may still arrive, and once argsStatus is "complete" it was never sent.
The hook uses parser metadata when available. The AI SDK marks settled tool arguments complete even while tool execution continues, so received fields in propStatus become "complete" at that point too.
Without parser metadata, a running part remains conservatively "streaming":
some runtimes build argsText from an incomplete argument snapshot. Any
non-running lifecycle settles argument streaming. "complete" does not
guarantee valid arguments or a successful tool result.
Partial Results & Streaming
Display results as they stream in:
const toolkit = defineToolkit({
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>
);
},
},
});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
A tool UI receives the tool call's state and callbacks as props:
type ToolCallMessagePartProps<TArgs, TResult> = {
// Tool arguments
args: TArgs;
argsText: string; // JSON stringified args
// Execution status
status: ToolCallMessagePartStatus;
isError?: boolean;
// Tool result (interim while isPreliminary is set)
result?: TResult;
isPreliminary?: boolean;
// Tool metadata
toolName: string;
toolCallId: string;
// Interactive callbacks
addResult: (result: TResult | ToolResponse<TResult>) => void;
resume: (payload: unknown) => void;
respondToApproval: (response: ToolApprovalResponse) => Promise<void>;
// Server-side approval gate
approval?: ToolCallMessagePart["approval"];
// Interrupt state
interrupt?: { type: "human"; payload: unknown }; // Payload from context.human()
// Optional artifact data
artifact?: unknown;
// What the user did in this UI, kept with the tool call
unstable_interactions?: {
entries: {
type: "action" | "human-response";
occurredAt: number; // epoch milliseconds
payload: unknown;
}[];
omitted?: number; // earlier entries dropped to stay within the limits
};
unstable_recordInteraction?: (input: {
type: "action" | "human-response";
payload: unknown;
}) => Promise<void>;
};Human Input Handling
When a tool calls human() during execution, the payload becomes available in the render function as interrupt.payload:
const toolkit = defineToolkit({
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>;
},
},
});Learn more about tool human input in the Tools Guide.
Recording Interactions
A tool UI can record what the user did in it, so a stored conversation keeps the answer next to the question. Call unstable_recordInteraction with the action the user took; the runtime stamps the time and appends it to the tool call's unstable_interactions, which cloud history stores with the message.
const toolkit = defineToolkit({
chooseSize: {
type: "human",
description: "Ask the user to pick a size.",
parameters: z.object({ sizes: z.array(z.string()) }),
render: ({ args, addResult, unstable_recordInteraction }) => (
<SizePicker
sizes={args.sizes}
onPick={(size) => {
void unstable_recordInteraction?.({
type: "human-response",
payload: { size },
}).catch(() => {});
addResult({ size });
}}
/>
),
},
});Some interactions are recorded for you: the answer passed to resume() is recorded as a human-response once the runtime accepts it, every action that present or prompt_user output of @assistant-ui/generative-ui dispatches to its action registry is recorded as an action, and the state a user changes in a render_spec call from generative-frame/spec/assistant-ui is recorded as a setState action so it comes back after a reload.
Recording is best effort. A payload must be plain JSON of at most 16,384 characters, and an action payload must be a JSON object. A tool call keeps its latest 32 entries within 65,536 characters (earlier ones are counted in omitted). The promise rejects when a runtime cannot record or a payload is invalid, so catch it as the example does; a failed recording never affects the user's action. The local runtime and the AI SDK, AG-UI and A2A runtimes record interactions. Runtimes built on useExternalStoreRuntime under a cloud thread list, such as LangGraph, LangChain and ADK, keep them on the cloud copy of the conversation, which is on unless the AssistantCloud client sets telemetry: { messages: false } or telemetry: false; their own messages do not carry them. A readonly thread ignores them. Interactions are never sent to the model or the agent on their own; a toolkit can still add what a UI shares on purpose to the model context, as generative-frame's does.
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
Reflect the status in the styling so state changes are visible:
<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
Keep controls reachable by keyboard and labelled for screen readers:
<button
onClick={() => addResult(value)}
aria-label="Confirm selection"
className="focus:outline-none focus:ring-2"
>
Confirm
</button>4. Optimize Performance
Define renderers at module scope so their identity is stable. When a renderer has to close over props or state, wrap it in useInlineRender so the component keeps its identity between renders; see Inline Pattern.
Info
A render function only draws the tool call; it does not run the tool. Keep
sensitive logic in a backend tool's execute, which runs on your server and
is not shipped to the browser.
Designing for the Conversation
A tool UI shares the transcript with the assistant's prose and the user's replies, so it is designed against the conversation rather than as a standalone screen.
Give Each Tool UI One Job
A tool UI either informs (a table, a chart, a link card), asks for a decision (an approval, an option list), adjusts something reversible (a filter, a parameter), or reports work in flight (a plan, a progress bar). When one surface does two of these, let one lead: a table with row actions is information first, and its actions stay secondary.
Fit the Chat Column
Messages are narrow and interleaved with text. State the purpose in the first 300px of height, design for 400 to 600px of width in a single column, give every control at least a 44px touch target, and show five to seven options at most, leaving the rest to a follow-up. The elements are sized to this column already (max-w-sm or max-w-md); keep a custom tool UI to the same widths.
Keep What the User Acts On Addressable
The model can only refer back to what it can name. Give the options, rows, and items a tool UI renders stable ids from your data, not array positions or ids minted at render time, and send those ids back through addResult. Then "the second option" and "row 4" in the next turn resolve to the same records the user saw.
Leave a Receipt
Once a user decides, the interactive surface should become a record of the decision, so scrolling back never shows a live control for a choice already made. Render the receipt from the tool call itself: result for a human tool, approval for an approval gate, and unstable_interactions for recorded actions. The kit follows this everywhere: ToolFallback replaces settled approval controls with a receipt, OptionList takes the committed answer as choice, and an answered generative prompt_user locks its controls.
Write the decision in the past tense and the request in the imperative, so the receipt reads as what happened to what was asked: "Denied · Delete all project files", "Answered · Staging". Skip receipts for reversible controls such as sorting or filtering; nothing was decided there.
Split the Work with the Message
The assistant's text introduces the tool UI and the tool UI carries the structure, so neither repeats the other: when the message asks "Which environment?", the option list shows only the environments. Avoid text inputs inside a tool UI when the answer could be typed into the composer, avoid tabs or navigation inside one surface (split it into separate tool calls instead), and never let an action change state silently; show its outcome.
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 runs 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. Use Tool UI's deferred pattern when the model 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