Generative UI done right is not "the model writes JSX" — it is the model expressing intent through typed tool calls, trusted app code executing them, and the stream mapping tool parts to real design-system components. The LLM picks what to show; your code decides how it renders and what it is allowed to do.
Tier: Advanced · Workflow area: AI features in the client
This playbook upgrades a text-only chat into one that answers "show me my orders" with your actual OrderCard — schema-validated, state-aware through the tool lifecycle, and incapable of rendering anything you did not ship.
Skip it when plain text genuinely serves the answer — generative UI for its own sake produces dashboards nobody asked for.
Define tools as typed contracts, not open-ended abilities. Server-side, each tool is a narrow, schema-bound operation:
const getOrders = tool({
description: "Fetch the signed-in user's recent orders",
inputSchema: z.object({
status: z.enum(["all", "open", "delivered"]).default("all"),
limit: z.number().int().max(10).default(5),
}),
execute: async ({ status, limit }) =>
orderService.list(session.userId, { status, limit }),
});
The model chooses arguments; execute runs your code with the session's identity — never an id the model supplied for "which user". Zod rejects malformed calls before they touch a service.
Render the tool lifecycle, not just the result. Streamed tool parts arrive as typed parts (tool-getOrders) with a state you switch on:
case "tool-getOrders":
switch (part.state) {
case "input-streaming":
case "input-available": return <OrderCardSkeleton />;
case "output-available": return <OrderList orders={part.output} />;
case "output-error": return <ToolErrorCard retry={...} />;
}
Because part types are inferred from your tool definitions (UIMessage typed with your tool set), part.output is fully typed into OrderList's props — the design-system component is data-bound, not model-rendered. Every tool gets all four states; a tool that only renders success will show users a void when the API hiccups.
Route interactivity back through code, not free text. Embedded components should act like app UI: an "email me this" button on the order card calls your normal mutation endpoint directly, or — when the assistant should continue the conversation — sends a structured follow-up (sendMessage with a data payload), never a synthesized English sentence pretending to be the user. Human-in-the-loop tools (cancellations, purchases) return a proposal the UI renders as an explicit confirm/deny pair, and only the user's click triggers execution.
Constrain what the model can compose. The registry of tools is the whole vocabulary — there is no "render arbitrary component" escape hatch. When product asks for a new visual answer, you add a tool + schema + component, review it once, and it becomes safely reusable. Write descriptions that tell the model when to prefer a tool over prose ("Use when the user asks about order status; do not paraphrase results in text as well"), and system-prompt the redundancy away — the classic bug is the model rendering a card and narrating its entire contents.
Test the seam like an API boundary. Three layers: unit-test that each schema rejects hostile/malformed inputs (extra fields, absurd limits); fixture-test each component against all four lifecycle states, wired into your Storybook/preview matrix; and eval-test the model layer with a small suite of prompts asserting tool choice and arguments ("show my open orders" → getOrders, status: "open"). The third layer is the one teams skip — it is the only one that catches the model deciding your tool descriptions are ambiguous.
Intent flows one way — model to tool call to typed props to component. The model never emits markup, never picks identities, never executes actions; it selects from a vocabulary of components you shipped, and Zod referees every call at runtime.
makeAssistantToolUI) mapping tool → component within its runtime; same architecture, more chat infrastructure included.streamUI: the server-components flavor streams rendered components directly; heavier coupling and less portable than tool-parts, but viable in RSC-first apps — the safety rules do not change.Tool calling is table stakes across providers, so in-tenancy models (Bedrock, Azure) run this architecture unchanged behind your route. For local models, tool-call reliability varies sharply — keep the tool set minimal, add few-shot examples of correct calls to the system prompt, and lean harder on the Zod layer plus a repair-reprompt loop for malformed calls. The client rendering code is identical in every case.
Tool descriptions and schemas are shipped to the model on every request — treat them as public strings and keep internal system names or secrets out of them.