Skip to content
Chapter 107Lesson 2

Generative UI via tool parts

Generative UI with the Vercel AI SDK, rendering a model's tool outputs as typed React components through a chat message's tool parts.

In the last lesson the model called getInvoiceById, which returns { id, customerName, total, dueDate, status }. You never decided what the user sees when that result arrives. Your chat loop renders only text parts, so the tool result either shows nothing or dumps raw JSON into a bubble. What it should produce is an invoice card with a status badge and a “send reminder” button.

That is generative UI: by choosing which tool to call, the model chooses which React component the user sees, and your client renders that tool’s output as the component. This lesson shows you how to render any tool’s output as a typed, purpose-built component through every state of its lifecycle.

The AI SDK offers two ways to build generative UI, and you build on only one.

AI SDK UI is the useChat hook and the typed tool parts you already met: the model emits tool parts and your client renders them. The SDK recommends it for production, and it reuses the UIMessage, useChat, and route handler you already built, so there’s no new infrastructure. The other option, ai/rsc , is experimental and its API shifts between releases; this course names it only so you recognize it in the docs.

  • Streams typed tool-<name> parts on the UIMessage; the client switches on part.type to pick the component.
  • Rendering stays on the client, where React 19 and your component tree live.
  • Stable, production-recommended API.

Rendering a tool part: one more case in the switch

Section titled “Rendering a tool part: one more case in the switch”

You already built the rendering machinery. Every assistant message is { id, role, parts }, and the chat surface walks that array with message.parts.map((part) => ...) and switches on part.type. So far it has one case, text. Generative UI is the same switch with one more branch.

That branch comes from the tool call. When the model calls a tool, the assistant message grows a part typed tool- plus the tool’s key in your tools object, so the tool keyed getInvoiceById produces a tool-getInvoiceById part. The model’s call creates the part; your client decides what to render for it.

const MessagePart = ({ part }: { part: UIMessagePart }) => {
switch (part.type) {
case 'text':
return <span>{part.text}</span>;
default:
return null;
}
};

The switch you already shipped. One case for text, and a default that renders nothing for unrecognized part types.

The default: return null is load-bearing. As you add tools, older clients will meet part types they don’t recognize, and a switch with no fallback crashes mid-chat on an unknown part.type. The default makes an unknown part render nothing instead of throwing.

That snippet reads part.output, which exists only once the tool finishes; a tool still running has no output yet. The next section renders those in-between states.

A tool-getInvoiceById part moves through the four states from the last lesson, and you render each: input-streaming while argument tokens arrive, input-available once they parse and execute runs server-side, output-available when the result returns, and output-error on failure. Render only the last and a slow tool feels frozen, the user clicks and nothing changes for two seconds; render the whole journey instead.

While the tool runs, render a tool-specific skeleton, a greyed-out placeholder shaped like the content that’s loading, not a generic spinner. A chat with five tools shows five different skeletons: an invoice-shaped one while an invoice loads, a chart-shaped one while a chart loads. The user sees an invoice loading, not something loading.

Step through the switch over part.state, one state at a time.

case 'tool-getInvoiceById': {
switch (part.state) {
case 'input-streaming':
return null;
case 'input-available':
return <InvoiceCard.Skeleton />;
case 'output-available':
return <InvoiceCard {...part.output} />;
case 'output-error':
return <ToolError message={part.errorText} />;
}
}

Argument tokens are still arriving, so there’s nothing concrete to show. Most surfaces render nothing, or fall through to the next state’s skeleton.

case 'tool-getInvoiceById': {
switch (part.state) {
case 'input-streaming':
return null;
case 'input-available':
return <InvoiceCard.Skeleton />;
case 'output-available':
return <InvoiceCard {...part.output} />;
case 'output-error':
return <ToolError message={part.errorText} />;
}
}

The arguments are parsed and execute is running on the server. This is where the tool-specific skeleton goes: the user sees the shape of an invoice forming, not a generic spinner.

case 'tool-getInvoiceById': {
switch (part.state) {
case 'input-streaming':
return null;
case 'input-available':
return <InvoiceCard.Skeleton />;
case 'output-available':
return <InvoiceCard {...part.output} />;
case 'output-error':
return <ToolError message={part.errorText} />;
}
}

execute has returned and part.output is populated. Spreading it into <InvoiceCard /> works because the tool’s output shape is the card’s props, which the next sections make true at the type level.

case 'tool-getInvoiceById': {
switch (part.state) {
case 'input-streaming':
return null;
case 'input-available':
return <InvoiceCard.Skeleton />;
case 'output-available':
return <InvoiceCard {...part.output} />;
case 'output-error':
return <ToolError message={part.errorText} />;
}
}

The tool failed. The error lives on part.errorText, already sanitized by the SDK into a safe string, so render it through your own error component, never a raw error.message.

1 / 1

The same journey crosses the server boundary. Scrub through it and watch one part change state.

part.state input-streaming
Server
idle — execute() not called
network boundary
Client
part appears · nothing to render yet

The model emits a tool call; the part appears, arguments still streaming in.

part.state input-available
Server
execute() runs · org-scoped query
network boundary
Client
<InvoiceCard.Skeleton />

Arguments parse, so execute runs server-side, the only step on the server.

part.state output-available
Server
result returned
network boundary
Client
<InvoiceCard {...output} />

The result returns and output is populated, so the real card renders.

part.state output-error
Server
execute() threw
network boundary
Client
<ToolError message={errorText} />

execute threw, leaving a sanitized errorText for the error component.

For a genuinely long-running tool, execute can yield partial results so part.output fills in progressively, but the default is to return once with a small output schema.

Typing the chat end-to-end with InferUITools

Section titled “Typing the chat end-to-end with InferUITools”

The snippets above spread part.output into <InvoiceCard /> as if its shape were known, but to the client it’s still unknown, the outputSchema you named last lesson was never wired up. Wire it now and part.output autocompletes instead of forcing a cast.

You type the whole chat outward from the tool definitions, in three steps.

import type { InferUITools, UIDataTypes, UIMessage } from 'ai';
import { tools } from '@/lib/llm/tools';
type MyUITools = InferUITools<typeof tools>;
type MyUIMessage = UIMessage<never, UIDataTypes, MyUITools>;

InferUITools reads each tool’s Zod schemas and infers its { input, output } shapes, turning the tools object into a typed map of what every tool takes and returns.

import type { InferUITools, UIDataTypes, UIMessage } from 'ai';
import { tools } from '@/lib/llm/tools';
type MyUITools = InferUITools<typeof tools>;
type MyUIMessage = UIMessage<never, UIDataTypes, MyUITools>;

Stamp those types onto the message. UIMessage’s generics are <metadata, dataParts, tools>: never for metadata (this chat carries none), the UIDataTypes default for data parts, and your inferred tools last. This type now knows every tool part’s shape.

import type { InferUITools, UIDataTypes, UIMessage } from 'ai';
import { tools } from '@/lib/llm/tools';
type MyUITools = InferUITools<typeof tools>;
type MyUIMessage = UIMessage<never, UIDataTypes, MyUITools>;

Both helpers come from ai; tools is imported from your registry module, and the next section covers why that import has to exist.

1 / 1

Pass MyUIMessage to useChat<MyUIMessage>({ ... }) on the client and to the message-stream helper on the server, and both ends speak the same typed message. The payoff lands in the switch you wrote earlier: inside the output-available branch of case 'tool-getInvoiceById', part.output autocompletes as the exact shape the tool returns.

const { messages } = useChat<MyUIMessage>({ /* ... */ });
const overdue = part.output.status === 'overdue';
return <InvoiceCard {...part.output} highlight={overdue} />;

Write outputSchema once on the tool, and InferUITools carries it all the way to the component’s prop type. The third generic, UIDataTypes, types non-tool message parts; you’re using its default, which is a topic for another lesson.

Why tools lives in its own module (lib/llm/tools.ts)

Section titled “Why tools lives in its own module (lib/llm/tools.ts)”

InferUITools<typeof tools> reads a single tools object, so that object must live in a module both ends import. Define a tool inline in the route handler and there’s nothing for the client to import a type from: the message type never learns the tool, and part.output collapses to unknown. No error, just lost autocomplete and an invitation to cast. The last lesson kept the tool inline to keep the snippet legible; this is the extraction it was heading toward.

So tools get their own file, lib/llm/tools.ts, alongside the conventions you already follow: lib/llm/models.ts for model handles and lib/llm/prompts.ts for system prompts. The route handler imports tools to run; the client imports MyUIMessage to type the chat.

  • Directorysrc/
    • Directorylib/
      • Directoryllm/
        • models.ts model handles (smartModel, fastModel)
        • prompts.ts system prompts
        • tools.ts the tool registry — and MyUIMessage

The two tabs show the same tool defined the way that breaks typing and the way that preserves it.

app/api/chat/route.ts
export const POST = authedRoute('member', chatRequestSchema, async ({ messages }) => {
const result = streamText({
model: smartModel,
messages: convertToModelMessages(messages),
// Defined here, the client can't import this — part.output stays `unknown`.
tools: { getInvoiceById: tool({ /* ... */ }) },
});
return result.toUIMessageStreamResponse();
});

No exported tools object, so InferUITools has nothing to read.

Design the tool output as the component’s props

Section titled “Design the tool output as the component’s props”

A tool’s outputSchema and its React component’s props are one shape, designed together. A getInvoiceById tool that returns { id, customerName, total, dueDate, status } feeds <InvoiceCard {...part.output} /> directly, no adapter in between. Write the schema once and it serves as the component’s prop type.

The mistake is returning whatever the data source hands you, like a raw Drizzle row, and reshaping it inside the component. The moment your card computes row.amountCents / 100 or row.customer.displayName ?? row.customer.email, it knows the column is in cents and the customer is a nested join. Change the query and you break a render, and that coupling stays invisible until it does.

So shape the output in the tool: the schema you write there is the component’s prop type, so emit the shape the component wants. This is the same “project, don’t dump” discipline from the last lesson, now aimed at the component’s contract instead of the token budget.

// outputSchema: the raw DB row, handed straight to the component
const InvoiceCard = ({ row }: { row: InvoiceRow }) => {
const total = formatCurrency(row.amountCents / 100);
const name = row.customer.displayName ?? row.customer.email;
return <Card>{name}{total}{row.status}</Card>;
};

Coupled to the data layer. A query change breaks the render.

Data selection, shaping, and authorization live in the tool; presentation lives in the component. The drill below sorts a handful of real transformations into the two bins.

Each of these transformations has a right home. Sort each one into the layer where it belongs. Drag each item into the bucket it belongs to, then press Check.

In the tool's outputSchema Data selection, shaping, authorization
In the React component Presentation only
Filter rows to the current organization
Pick the top 5 rows to return
Join the customer name onto the invoice
Convert amountCents into a formatted currency string
Choose the color of the status badge
Format dueDate as a relative time like “in 3 days”

A single assistant message can hold text parts and multiple tool parts, in the order the model emitted them. The model narrates between its renders: “Here’s the invoice you asked about”, then the invoice card, then “want me to send a reminder?” Since parts.map walks the array in order, the rule is to render each part where it sits, never collecting the tool renders into a stack at the bottom of the bubble.

One assistant message in parts order: text, an invoice card, more text, then a second tool render.

Guarding destructive actions: propose, then confirm

Section titled “Guarding destructive actions: propose, then confirm”

Every tool so far only reads. The hard case is a tool that acts: sending an invoice, deleting a row, charging a card.

The model must never trigger a destructive action directly. “Occasionally wrong” is fine for a sentence and catastrophic for a DELETE: on a hallucinated argument, an unguarded tool sends the wrong invoice or deletes the wrong row, and at scale that’s a when, not an if.

The fix is to split one destructive action into two tools with a human click between them:

  • proposeInvoiceSend is read-only. It returns a preview, the recipient, amount, and due date, which the client renders as a confirmation card with “Send” and “Cancel” buttons.
  • confirmInvoiceSend does the work, and the client emits it only after the user clicks Send, continuing the conversation with a new message.

This deliberately breaks the agentic loop: control returns to the person before the next step can fire. The model can plan the send, but it cannot pull the trigger.

Model decides to send
It picks the action — but can't pull the trigger.
proposeInvoiceSend
Read-only · returns a preview shape
Confirmation card
ToNorthwind Trading Co.
Total$4,820.00
DueJun 30, 2026
Send
Cancel
confirmInvoiceSend
The write · sends the invoice → result
Nothing happens
The loop ends — no write fires.
The propose/confirm gate. The model can only reach the write through a human click — `confirmInvoiceSend` never fires on the model’s decision alone.

In code, the shape is two tool definitions plus a confirmation component. The preview tool carries an outputSchema for what the user is about to approve; the commit tool carries the real execute.

lib/llm/tools.ts
export const proposeInvoiceSend = tool({
description: 'Preview sending an invoice. Read-only — does not send.',
inputSchema: z.object({ invoiceId: z.uuid() }),
outputSchema: z.object({ recipient: z.string(), total: z.string(), dueDate: z.string() }),
execute: async ({ invoiceId }) => getInvoiceSendPreview(invoiceId, session.orgId),
});
export const confirmInvoiceSend = tool({
description: 'Send the invoice. Only call after the user confirms.',
inputSchema: z.object({ invoiceId: z.uuid() }),
execute: async ({ invoiceId }) => sendInvoice(invoiceId, session.orgId),
});

The confirmation card renders the buttons, and the client emits the confirm call only on the Send click:

case 'tool-proposeInvoiceSend':
return (
<ConfirmCard preview={part.output}>
<button onClick={() => sendMessage({ text: 'Yes, send it.' })}>Send</button>
<button onClick={dismiss}>Cancel</button>
</ConfirmCard>
);

The next chapter wires the end-to-end version with real preview data; here the shape is the point: two tools, with a human in the middle.

What stays the same: auth, audit, persistence

Section titled “What stays the same: auth, audit, persistence”

Generative UI changed how tool results render, but not the seams underneath, because tool parts ride the same protocol as everything else.

  • Authorization still happens inside each tool’s execute, through the org-scope filter against session.orgId from the last lesson.
  • Audit events still write per step in onStepFinish and in aggregate in onFinish.
  • The Server Component shell still loads the conversation’s history and hands it to the client.
  • Persistence still saves the full UIMessage[] server-side, but that array now includes the tool parts and their typed outputs.

The handler return you already wrote saves messages for you:

return result.toUIMessageStreamResponse({
originalMessages: messages,
onFinish: ({ messages }) => saveChat(chatId, messages),
});

The one thing to watch is persisting a lossy shape. Save only the text, or drop the tool parts to “save space”, and the invoice card vanishes on the next mount, because the part that drove it was never saved. Persist the full UIMessage[], tool parts and all.

The model is a router that picks which component renders, the tool’s output schema is that component’s prop contract, the client walks parts in order switching on type and state, and destructive work waits behind a human click.

You’re building a generative-UI chat surface for production in 2026. Which path does the AI SDK’s own documentation recommend?

Emit typed tool-<name> parts and render them from useChat on the client.
Stream server-rendered components straight from the model call with streamUI.
Either one — they’re interchangeable flavors of the same feature, so pick by taste.

You define getInvoiceById inline inside the route handler instead of exporting it from lib/llm/tools.ts. The code compiles and the chat works. What did you quietly give up?

On the client, part.output widens to unknown — there’s no exported tools object InferUITools can read a shape from.
The route handler refuses to start, because the SDK requires tools to live under lib/llm/.
The model stops seeing the tool, so it never decides to call getInvoiceById.

Why split a “send invoice” action into a read-only proposeInvoiceSend tool and a separate confirmInvoiceSend tool?

To put a human click between the model’s decision and the irreversible work — the model can plan the send, but only a person can pull the trigger.
Because a single AI SDK tool isn’t allowed to both read data and write data in one execute.
To speed the send up by running the preview and the actual send in parallel.

In which tool-part state does a tool-specific skeleton like <InvoiceCard.Skeleton /> belong?

The state where the arguments are parsed and execute is running on the server, with no output yet.
The state where the result has come back and the part carries its output.
The state where execute threw and the part carries a sanitized error message.