The raw materials
Every generative interface is built from four primitives — structured output, tool calls, streams, and typed message parts. Understand these and every framework becomes a variation you can read.
- 01Explain how a model produces valid JSON, and what goes wrong when it doesn't
- 02Trace a tool call from model output to rendered component and back
- 03Describe why streaming forces you to render incomplete data, and how
- 04Model a chat message as typed parts with a lifecycle, and persist it safely
Four primitives, one loop
Strip away the frameworks and every generative interface reduces to the same loop:
- The model emits tokens.
- Some of those tokens are structured: JSON that matches a schema.
- Some structured output is a tool call: "run this function with these arguments".
- All of it arrives as a stream, which the client folds into typed message parts and renders.
CopilotKit, the AI SDK, assistant-ui, LangGraph's UI, and the AG-UI protocol are different answers to where each step runs and what the wire format looks like. The steps themselves don't change. Learn them once here.
1 · Structured output: getting data, not prose
A model is a text generator. To build interface, you need it to produce data your code can trust: a component name, a number, a list of options that matches a schema.
There are two ways to get there.
- Constrained decoding. The provider restricts which tokens the model may emit at each step so the output must match a JSON Schema. Most major APIs now offer a strict structured-output mode. The output parses every time, though it can still be wrong — a valid number in the wrong field is still valid.
- Validate and retry. You ask for JSON, parse it, validate it against a schema (Zod, Valibot, JSON Schema), and on failure send the error back and ask again. Slower and less certain, but it works everywhere.
In TypeScript the schema usually starts life as Zod and is converted to JSON Schema for the model:
const ExpenseApproval = z.object({
title: z.string(),
amount: z.number().positive(),
currency: z.enum(["USD", "EUR", "GBP"]),
submitter: z.string(),
});
Two lessons experts learn the hard way:
- The schema is a prompt. Field names and descriptions steer the model as much as your instructions do.
amountInCentsgets different behaviour fromamount. - Valid is not correct. Schema validation catches shape errors. It does not catch "approved the wrong expense". Behavioural checks come later (Module 06).
2 · Tool calls: the model asks, your code acts
A tool is a function the model may ask you to run. You describe it with a name, a description and an input schema. The model never runs anything itself; it emits a request, and your runtime decides what happens.
const tools = {
getExpense: {
description: "Look up an expense by id",
inputSchema: z.object({ id: z.string() }),
execute: async ({ id }) => db.expenses.find(id), // runs on the server
},
showExpenseApproval: {
description: "Show the person an approval card for an expense",
inputSchema: ExpenseApproval, // no execute: the UI handles it
},
};
The loop:
- The model emits a tool call:
showExpenseApproval({ title: "Team offsite", amount: 1240, ... }). - The runtime looks up the tool. If it has an
execute, it runs it and feeds the result back. If it's a frontend tool, the call is forwarded to the browser. - The result (or the person's answer) goes back to the model as a tool message.
- The model continues: more text, more calls, or done.
This is where generative UI begins. A tool call is the cleanest possible instruction to render something. The model chose which component and what data; your code owns how it looks and behaves. That is rung 3 of the spectrum, and it is where most production systems should start.
Three kinds of tool matter for interfaces:
| Kind | Runs where | Example | UI role |
|---|---|---|---|
| Backend tool | server | getExpense, sendInvoice |
show progress and result |
| Render tool | browser | showExpenseApproval |
the call is the component |
| Human-in-the-loop tool | browser, waits for a person | confirmPayment |
the run pauses until the person answers |
Lab 02 shows all three in a single run.
3 · Streams: rendering before you know the answer
Models produce output a token at a time. Waiting for the end before showing anything makes a two-second answer feel like ten. So everything streams — and that forces a hard rule on the client: you must render data that is incomplete.
For text this is easy: append each chunk. For structured data it is not. Halfway through a tool call, the arguments look like this:
{"title":"Team offsite","amount":12
That is not valid JSON. To render the card early, the client repairs the prefix — closes the open string, drops the dangling key, closes the open object — and renders what is known, with placeholders for what isn't. Frameworks call this partial objects, streaming tool arguments, or progressive rendering. Under the hood it's the same parser. Lab 03 lets you watch it character by character.
Things that go wrong with streams, and what experts do about them:
- Flicker. A number streams as
1,12,124,1240. Render numbers and money only once their field is complete, or animate them. - Layout shift. Reserve space with skeletons sized for the expected component.
- Premature action. Never enable a button whose meaning depends on a field that hasn't finished streaming. An "Approve" on a half-streamed amount is a bug waiting to happen.
- Cancellation. People interrupt. The stream must be abortable, and partial tool calls must be discarded, not executed.
On the wire, streams are usually server-sent events (SSE): one HTTP response, many data: lines. WebSockets appear when the client also needs to push events mid-run. The AG-UI protocol standardises what those events mean; Module 02 covers it.
4 · Message parts: a message is not a string
Early chat apps stored a message as a string. Generative UI broke that. A single assistant turn might contain some text, a reasoning summary, three tool calls in different states, a chart, a citation and a file. So modern SDKs model a message as an ordered list of typed parts:
type Part =
| { type: "text"; text: string }
| { type: "reasoning"; text: string }
| { type: "tool"; name: string; callId: string; state: ToolState; input?: unknown; output?: unknown }
| { type: "data"; name: string; data: unknown }
| { type: "source"; url: string; title?: string };
type ToolState = "input-streaming" | "input-available" | "output-available" | "output-error";
The Vercel AI SDK uses almost exactly these states for tool parts (later versions add states for approvals). The point isn't the names; it's that each tool part carries a lifecycle, and your component renders differently in each state:
| State | What the person should see |
|---|---|
| input-streaming | a skeleton filling in |
| input-available | the full card, actions possibly enabled |
| awaiting approval | the card with a decision required |
| output-available | the result, or the card in its "done" form |
| output-error | what failed and what they can do |
Persisting generated UI
Because parts are data, you can store them and re-render the conversation later. That creates a problem nobody warns you about: the components change, the stored data doesn't. A message saved in March references ExpenseCard v1; in June you ship v3 with a renamed prop. Old conversations break.
The fixes are old ideas applied to a new place: version your render tools (showExpenseApproval@2), keep migrations for stored parts, and keep a plain-text fallback for any part you can no longer render. Treat your component catalog like a public API, because once it's in someone's history, it is one.
Putting it together
Here is the whole idea in one sketch, framework-free:
// server: stream model output as typed events
for await (const chunk of model.stream({ messages, tools })) {
send(chunk); // text deltas, tool-call deltas, tool results
}
// client: fold events into parts, render parts
const parts = fold(events); // see Lab 02
return parts.map((p) =>
p.type === "text" ? <Markdown text={p.text} /> :
p.type === "tool" ? renderTool(p) : // registry: name → component
null
);
function renderTool(p) {
const Component = registry[p.name]; // only what you registered
if (!Component) return <Refused name={p.name} />;
return <Component state={p.state} input={parsePartial(p.input)} output={p.output} />;
}
Every framework you will meet is a more robust version of those twenty lines: a better stream format, a better registry, better state handling, approvals, persistence and multi-agent support. When a framework's docs confuse you, find the fold and the registry. They're always there.
Check yourself
- Constrained decoding guarantees valid JSON. Name a failure it does not prevent.
- What is the difference between a render tool and a human-in-the-loop tool, in terms of when the agent's run continues?
- Why should an "Approve" button stay disabled while the
amountfield is still streaming? - A stored conversation from three months ago no longer renders. What happened, and what two practices would have prevented it?
- In the twenty-line sketch, where is the security boundary for which components the model can render?