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 the tool parts on a chat message.

In the last lesson the model learned to call getInvoiceById, and you were careful to keep its return small: { id, customerName, total, dueDate, status }. But you never decided what the user actually sees when that call comes back. Right now there are only two outcomes, and neither is good: nothing appears, because the chat loop you built before only knows how to render text parts, or a blob of raw JSON lands in a chat bubble. The model just decided, on its own, to fetch a structured invoice, so the right response is to turn that into an invoice card with a status badge and a “send reminder” button. This lesson shows you how. By the end you’ll be able to render any tool’s output as a typed, purpose-built React component, in every state of its short life, with the model choosing which component appears. The lesson is short because it builds almost entirely on things you already have. You learn the pattern here; the worked invoice-chat surface comes together in the next chapter.

One decision comes before any code, because if you search the AI SDK docs for “generative UI” you’ll find two options, and picking the wrong one can cost you weeks.

The first is ai/rsc , the streamUI, useUIState, and useAIState functions. It streams server-rendered RSC components directly from the model call. The second is AI SDK UI: the useChat hook and the tool parts you already met, where the model emits typed tool parts and your client renders the components. These aren’t two flavors of the same thing. They’re two separate architectures, and you build on only one.

This course takes the second one. The choice isn’t arbitrary: ai/rsc is marked experimental in the AI SDK docs, and the team’s own documented recommendation is to use AI SDK UI for production. The reason to follow that recommendation is continuity. The tool-parts path reuses the exact UIMessage, useChat, and route handler you already built, so there’s no new infrastructure. It keeps rendering on the client, where React 19’s primitives and your whole component tree already live. And it avoids ai/rsc’s ongoing API churn: experimental APIs change shape between releases, and you don’t want your invoice card rewritten because an SDK minor version moved.

The two tabs below lay the choice out side by side. We name ai/rsc once so you recognize it when you trip over it in the docs, and then we leave it alone.

The path this course takes.

  • Streams typed tool-<name> parts on the UIMessage; the client switches on part.type and renders the component.
  • Reuses the same useChat, UIMessage, and route handler from the chat surface you already built, so there’s no new infrastructure.
  • Rendering stays on the client where React 19 and your component tree live, on a stable, production-recommended API.

From a text part to a tool part: one new case

Section titled “From a text part to a tool part: one new case”

The reason this lesson is short: you already wrote the rendering machinery. Every assistant message is { id, role, parts }, and back in the chat surface you walked that array with message.parts.map((part) => ...) and switched on part.type. At the time there was exactly one case to handle, text. Generative UI is that same switch with one more branch.

The new branch comes from the tool call itself. When the model calls a tool, the assistant message grows a part whose type is the string tool- followed by the tool’s key in your tools object. The tool keyed getInvoiceById produces a part typed tool-getInvoiceById. So the model calling the tool is what creates the part, and your client deciding what to render for that type is the other half. That decision is the whole of generative UI.

The two tabs below show the same switch before and after. The first is the baseline you already shipped. The second adds one case.

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 any part type this surface doesn’t recognize.

That default: return null line does more work than it looks. You add tools over time, so parts will arrive that an older client doesn’t recognize, and a switch with no fallback that hits an unknown part.type crashes in the middle of a chat. Always keep a default that fails gracefully: an unknown part type should render nothing, never throw. Treat it as part of the pattern, not an afterthought.

One catch you can already see coming: that snippet reads part.output, but a tool that’s still running hasn’t produced any output yet. part.output only exists once the tool has finished. The next section covers how to render the in-between states.

A tool-getInvoiceById part isn’t a single fixed thing. It moves through the four states you met in the last lesson, and now you get to render each one. As a reminder, the states are: input-streaming while the model’s argument tokens are still arriving, input-available once they’re parsed and execute is running server-side, output-available when the result comes back, and output-error if something failed. Rendering only the last one is the common mistake that makes a chat feel frozen on any slow tool: the user clicks, nothing changes for two seconds, then a card pops in. You want to render the whole journey.

The key habit for this section: while the tool runs, render a tool-specific skeleton, not a generic spinner. A skeleton is a greyed-out placeholder shaped like the thing that’s loading. A chat with five tools should show five different skeletons, an invoice-shaped one while an invoice loads and a chart-shaped one while a chart loads, because that specificity is the whole affordance the tool-parts model gives you. The user sees an invoice loading, not something loading. A single shared spinner throws that information away.

The switch over part.state is the canonical shape of this whole lesson. Step through it state by state.

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 model’s argument tokens are still arriving, so there’s nothing concrete to show yet. Most surfaces render nothing here, or fall through to the same skeleton as the next state. This is the lightest of the four states.

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 validated, and execute is running on the server right now. This is where the tool-specific skeleton goes: <InvoiceCard.Skeleton />, not a generic spinner. The user sees the shape of an invoice forming, which is the affordance you’re paying for.

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, so this is the real render. Spreading part.output straight into <InvoiceCard /> works because the tool’s output shape is the card’s props. The next sections are about making that true on purpose and 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 message lives on part.errorText, where the SDK has already sanitized it into a safe string, so render it through your own error component. Never reach for a raw error.message here. This is the same error-sanitization discipline from the chat surface, applied to tool parts.

1 / 1

The whole journey across the server boundary is easier to hold as a picture than as four cases. Scrub through the sequence below and watch the same part change state. Notice that execute is the only step that happens on the server, and that each state is its own distinct client render, not one render deferred until the end.

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

The model emits a tool call. A tool-getInvoiceById part appears in state input-streaming; its arguments are still arriving token by token, so there’s nothing concrete to render yet.

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

The arguments are parsed, so the state becomes input-available and execute runs server-side, the only step that lives on the server. Meanwhile the client renders <InvoiceCard.Skeleton />.

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

The result returns, so the state becomes output-available and output is populated on the part. The client switches on part.type and renders the real InvoiceCard from output.

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

The error branch: if execute threw, the state becomes output-error with a sanitized errorText on the part. The client renders its own error component, never a raw error.message.

One capability is worth knowing about, though you won’t build it here. For a genuinely long-running tool, execute can yield partial results instead of returning once, and part.output populates progressively, with the same streaming feel as a streaming object. The default, and what you should reach for, is to return once with a small output schema. Reach for progressive output only when a tool is slow enough that watching it fill in actually helps the user.

Typing the chat end-to-end with InferUITools

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

You may have noticed that the last two snippets spread part.output into <InvoiceCard /> as if its shape were known. But how does the client know it’s { id, customerName, total, dueDate, status } and not unknown? It doesn’t, yet. That’s the loose end the last lesson left when it named outputSchema but never wired it up. You tie it off here, and in return part.output autocompletes instead of forcing a cast.

The way to do this is to type the entire chat from the tool definitions outward. v5 gives you a small chain of three steps, and they read top to bottom.

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 walks your tool registry and infers, for every tool, its { input, output } shapes from the Zod schemas. One line turns the whole tools object into a typed map of what each 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>;

Now stamp those tool types onto the message type. UIMessage’s three generics are <metadata, dataParts, tools>, so here you pass never for metadata (this chat carries none), the UIDataTypes default for data parts, and your inferred tools last. This is the type that 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 itself, and tools is imported from your registry module, the same module the route handler imports. That shared import is what makes the typing work end to end. The next section explains why it has to live in its own file.

1 / 1

With MyUIMessage defined, you pass it to useChat<MyUIMessage>({ ... }) on the client, and you pass the same type on the server to the message-stream helper. That second step is what makes the typing end-to-end: both ends now speak the same typed message. The payoff lands right back in the switch you wrote earlier. Inside the output-available branch of case 'tool-getInvoiceById', part.output is no longer unknown. It autocompletes as the exact shape the tool returns.

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

No cast, no unknown, no guessing what fields exist. This is where the outputSchema you defined on the tool finally earns its keep: it’s the schema that flows through InferUITools to become the component’s prop type. Write the schema once, and the autocomplete follows it everywhere.

One generic went by without much comment. UIDataTypes is the slot for custom data parts, typed message parts that aren’t tools. You’re using the default here, which is a feature for another day.

The tool registry lives in lib/llm/tools.ts

Section titled “The tool registry lives in lib/llm/tools.ts”

That last section quietly assumed something: that there’s a single tools object you can import from one module, once into the route handler and once into the client’s type. That assumption matters, and it’s worth making explicit, because the obvious shortcut breaks it.

Tools are first-class application code. They’re version-controlled, lint-covered, and reviewed, exactly like routes, so they get their own file: lib/llm/tools.ts. That mirrors the conventions you already follow, lib/llm/models.ts for model handles and lib/llm/prompts.ts for system prompts. The route handler imports import { tools } from '@/lib/llm/tools' and passes it to streamText, and the client component imports the inferred MyUIMessage type from the same module.

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

Here’s why this is a rule and not a preference. InferUITools<typeof tools> needs a single, exported tools object to infer from. Define a tool inline in the route handler instead, and the client has nothing to import, because the route handler isn’t a module the client can pull a type out of. So the message type can’t see the tool, and part.output quietly collapses back to unknown. You don’t get an error; you get worse autocomplete and a silent invitation to cast. The file convention isn’t tidiness. It’s what makes end-to-end typing possible at all. The last lesson kept the running tool inline on purpose, to keep the snippet legible while you learned the shape, and this is the extraction it was always heading toward.

The two tabs below 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();
});

The tool lives in a route handler, which the client can’t import a type from. InferUITools has no exported tools object to read, so the typed message never learns the tool’s shape.

Co-designing the tool output and the component props

Section titled “Co-designing the tool output and the component props”

This is the most senior habit in the lesson, and it follows straight from the typing you just wired: the tool’s outputSchema and the React component’s props are one shape, designed together.

Consider what that means. A getInvoiceById tool that returns { id, customerName, total, dueDate, status } feeds <InvoiceCard /> directly, as <InvoiceCard {...part.output} />, with no adapter in between. A getMonthlyRevenue tool that returns { months, totals } is shaped for <RevenueChart />. In each case the tool’s output schema is the component’s prop type. You’re not writing two contracts that happen to line up; you’re writing one contract, once.

The mistake to avoid is returning whatever the data source hands you, such as a raw Drizzle row, and reshaping it inside the component. It feels harmless, but it isn’t. The moment your card does const total = row.amountCents / 100 or const name = row.customer.displayName ?? row.customer.email, the component now knows where its data came from. It knows the column is in cents, knows the customer is a nested join, and knows which field to fall back to. Change the query and you break a render. The component has been coupled to the data layer’s quirks, and that coupling stays invisible until it breaks something.

The rule is simple to state: when you write the tool’s outputSchema, you are writing 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, with the projection target named precisely. There, you projected to keep the result small and cheap; here, you project to the component’s contract.

The two tabs below put the coupled and decoupled versions side by side. The cost of reshaping is only obvious when you can see both at once.

// 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>;
};

The component knows the column is in cents and the customer is a nested join. It’s coupled to the data layer, so a query change breaks the render, and the coupling stays invisible until it does.

So where does the line actually fall? Some transformations belong in the tool, some in the component, and getting the boundary right is the skill. The drill below sorts a handful of real transformations into the two bins. The rule you’re building toward is this: data selection, shaping, and authorization live in the tool, and presentation lives in the component.

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”

One more property of the parts array is small but easy to get wrong. A single assistant message can hold text parts and multiple tool parts, in render order. The model narrates between its tool renders: “Here’s the invoice you asked about”, then the invoice card, then “want me to send a reminder?” The text and the cards are interleaved, and the order is meaningful because the model controls it.

Your client already does the right thing here, because parts.map walks the array in order. So the only rule is to render each part where it sits. The mistake is collecting all the tool renders and stacking them at the bottom of the bubble, with the model’s text floating above, disconnected from the cards it was describing. The narration belongs next to the thing it narrates, because that’s where the model put it.

The mock below shows what interleaving looks like in a real bubble, with text, then a card, then more text, then a second render, so you can see the shape rather than just read about it.

One assistant message, rendered in parts order: text, an invoice card, more text, then a second tool render. parts.map walks the array top to bottom, so each part lands where the model placed it.

Destructive tools: the propose/confirm pattern

Section titled “Destructive tools: the propose/confirm pattern”

Everything so far has rendered tools that read. Now for the highest-stakes pattern in the lesson, the one the last lesson flagged and deferred: what happens when a tool does something, like sending an invoice, deleting a row, or charging a card.

The rule is firm. The model must never trigger a destructive action directly. Not “usually shouldn’t”, but never. The stakes are concrete: an unguarded destructive tool means that one day the model will send the wrong invoice or delete the wrong row on a hallucinated argument. With a real user base, that’s a when, not an if. Models are confident and occasionally wrong, and “occasionally wrong” is fine for a sentence and catastrophic for a DELETE.

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

  • proposeInvoiceSend is read-only. It returns a preview, namely who the invoice goes to, the amount, and the due date, which the client renders as a confirmation card with explicit “Send” and “Cancel” buttons.
  • confirmInvoiceSend does the actual work. The client only emits this tool call after the user clicks Send, continuing the conversation with a new message.

The model proposes, the human confirms, and the loop continues only on the human’s click. Connect this to the agentic loop from the last lesson, which runs the model step after step until it’s done. The confirmation is a deliberate break in that loop, a point where control leaves the model and returns to the person before the next step can run. The model can plan the send all it likes; it cannot pull the trigger.

The flow below makes the human-click gate a visible node, not an afterthought. Trace it: the model proposes, the client renders the card, and the path forks at the buttons, where one branch reaches the actual write and the other ends.

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 that renders the two buttons. The preview tool carries an outputSchema describing what the user is about to approve, and 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 two 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 end-to-end version, with real preview data and the full continuation wiring, is built in the next chapter. Here you have the shape, and the shape is the part that matters: two tools, with a human in the middle.

What stays the same: auth, audit, persistence

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

Step back and notice what you didn’t have to touch. Adding generative UI changed how tool results render. It changed nothing about the seams underneath, because tool parts ride the exact same protocol everything else does. That composability, a new capability on untouched foundations, is the real win here, and it’s reassuring to see how little moved.

  • Authorization still happens inside each tool’s execute, through the org-scope filter against session.orgId from the last lesson. Generative UI adds nothing to the trust boundary, and the model still can’t reach a row the tool doesn’t hand it.
  • Audit events still write in onStepFinish per step and onFinish in aggregate. Unchanged.
  • The Server Component shell still loads the conversation’s history and hands it to the client. Unchanged.
  • Persistence still saves the full UIMessage[] server-side. The shape is unchanged, but now that array includes the tool parts and their typed outputs. That’s where one thing to watch out for lives.

That persistence point deserves a closer look. The handler return you already wrote saves messages for free:

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

The risk is persisting a lossy shape, saving only the text or dropping the tool parts to “save space.” Do that and the rendered surface vanishes on the next mount: the user reloads the page and their invoice card is gone, replaced by nothing, because the part that drove it was never saved. Persist the full UIMessage[], tool parts and all. The card you rendered is only as durable as the message you stored.

Hold the whole thing in one sentence: 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 and switches on type and state, and destructive work waits behind a human click rather than the model’s. Every section of this lesson applied one piece of that idea.

The questions below check the four decisions the lesson exists to teach. If any of them feels shaky, that’s the section to reread.

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.