Add two tools to the chat surface — weather and order status — whose streamed results render as typed design-system components with loading, partial, and error states per tool part. The pass bar: the part-mapping code compiles fully typed, and every tool state is demonstrable in Storybook or a recording.
Jonas's chat assistant answers "where's my order?" with a paragraph describing the order status. His PM wants what users actually expect: the order card — the same component the orders page uses, data-bound, tappable. That's generative UI, and the architecture matters more than the effect: the model never emits markup or picks components. It emits typed intent — a tool call with arguments — and trusted app code maps that intent to real design-system components. The starter you inherit gets the effect right and the architecture wrong; your job is to type it end to end and give every tool state a designed rendering.
~120 minutes hands-on · Ship stage (maps to module M7, playbook PB-49) · $0 — extends the Lab 10/11 build; mock server emits the tool parts; Storybook for state demos.
From your chat-lab repo: npm install zod and npx storybook@latest init if not present. Teach the mock server tools — add to server/index.mjs:
const TOOL_SCRIPTS = {
"weather in berlin": [
{ type: "tool-start", id: "t1", tool: "getWeather", args: { city: "Berlin" } },
{ type: "tool-delta", id: "t1", patch: { tempC: 19, condition: "partly-cloudy" } },
{ type: "tool-delta", id: "t1", patch: { high: 22, low: 13, wind: 14 } },
{ type: "tool-result", id: "t1", status: "ok" },
{ type: "text", delta: "Mild day in Berlin — light jacket weather." },
],
"order 4821": [
{ type: "tool-start", id: "t2", tool: "getOrderStatus", args: { orderId: "4821" } },
{ type: "tool-delta", id: "t2", patch: { status: "shipped", carrier: "DHL" } },
{ type: "tool-delta", id: "t2", patch: { eta: "2026-08-08", items: 3 } },
{ type: "tool-result", id: "t2", status: "ok" },
{ type: "text", delta: "It left the warehouse yesterday — arriving Friday." },
],
"order 9999": [
{ type: "tool-start", id: "t3", tool: "getOrderStatus", args: { orderId: "9999" } },
{ type: "tool-result", id: "t3", status: "error", error: "ORDER_NOT_FOUND" },
{ type: "text", delta: "I couldn't find that order — check the number?" },
],
};
app.post("/api/chat2", async (req, res) => {
const key = (req.body?.prompt ?? "").toLowerCase().trim();
const script = TOOL_SCRIPTS[key] ?? [{ type: "text", delta: "Try: weather in berlin / order 4821 / order 9999" }];
res.setHeader("Content-Type", "text/event-stream");
res.flushHeaders();
for (const part of script) {
await new Promise((r) => setTimeout(r, 700)); // slow enough to SEE each state
res.write(`data: ${JSON.stringify(part)}\n\n`);
}
res.write("data: [DONE]\n\n");
res.end();
});
The starter part-renderer "a teammate generated," src/chat/ToolPart.tsx — commit verbatim, then improve:
export function ToolPart({ part }: { part: any }) {
const data = part.data as any;
if (part.tool === "getWeather") {
return (
<div className="card">
<b>{part.args.city}</b>
<span>{data.tempC}°C, {data.condition}</span>
<span>H {data.high}° / L {data.low}° · wind {data.wind} km/h</span>
</div>
);
}
if (part.tool === "getOrderStatus") {
return (
<div className="card">
<b>Order #{part.args.orderId}</b>
<span>{data.status.toUpperCase()} via {data.carrier}</span>
<span>ETA {data.eta} · {data.items} items</span>
</div>
);
}
return null;
}
Enable strictness so the compiler is your reviewer — in tsconfig.json compiler options, ensure "strict": true and add "noImplicitAny": true (Vite's default template has strict on; verify).
/api/chat2 events into your message state (each tool-start appends a tool part; tool-delta shallow-merges patch into that part's data; tool-result sets its status) and render tool parts through the starter ToolPart. Send weather in berlin and watch closely during the 700ms gaps. Expected: a crash or "undefined" renders between tool-start and the first delta (data.tempC on empty data — and data.status.toUpperCase() will throw for the order tool). Then send order 9999. Expected: the error result renders as a broken half-card or nothing — silently. Log both observations in genui-log.md; these are the seeded defects.src/chat/toolSchema.ts: a WeatherData and OrderStatusData schema (all data fields optional — partial by construction, because deltas arrive incrementally), plus a discriminated union ToolPart over { tool: "getWeather", args: {...}, status: "running" | "ok" | "error", data: Partial<...>, error?: string }. Export inferred types (z.infer). Parse every incoming SSE tool event through the schema at the boundary — unknown tools or malformed patches must fail there, loudly, not deep in a component.ToolPart.tsx: a discriminated-union switch on part.tool where each branch receives its precise type — no any, no casts. Add an exhaustiveness check (default: return assertNever(part)) so adding a third tool without a renderer becomes a compile error. Route each branch to a real component: WeatherCard, OrderStatusCard.{ args, status, data, error } and renders: loading (status running, no data yet — skeleton with the args visible: "Checking weather for Berlin…"), partial (running with some fields — show what exists, skeleton the rest; every field access must tolerate absence by type, since data is Partial), and error (designed failure: for ORDER_NOT_FOUND, a card saying so with a "check order number" hint — not an exception, not an empty div). The success state is just partial with everything present plus status: ok.WeatherCard.stories.tsx and OrderStatusCard.stories.tsx with stories per state: Loading, Partial, Complete, Error. The stories pin the states independently of stream timing — this is your visual contract and regression surface. Expected: 8 stories, all rendering distinctly.npx tsc --noEmit must be clean. Run all three prompts and watch each card move through loading → partial → complete (the 700ms cadence makes each state visible to the eye), and order 9999 land in the designed error card while the model's follow-up text still streams after it. Record a short screen capture (or screenshot each phase) into proof/.tool-delta with patch: { tempC: "warm" }, or an unknown tool: "getStock"). Expected: the Zod boundary rejects it, the UI renders a generic "unsupported tool response" fallback part — and nothing crashes. This is the difference between typing your happy path and typing your boundary. Revert after.genui-log.md: model emits intent → schema validates at the boundary → typed parts → exhaustive component map → design-system cards own their states. Note explicitly what the model is never allowed to do (emit HTML/JSX, name arbitrary components, bypass validation) and why the Partial<> typing is load-bearing for streaming.The 700ms inter-part delay is pedagogical slow motion. Real tool latency varies from instant to many seconds, which is exactly why every state you just designed will be seen by real users — the loading and partial states aren't edge cases, they're the primary experience of any tool slower than perception.
npx tsc --noEmit clean, with grep -rn "any\|as any" src/chat/ToolPart.tsx src/chat/toolSchema.ts returning no type escapes.order 9999 shows the designed error card; follow-up text still streams.genui-log.md contains the step-1 defect observations and the architecture note.any = 3.Generative UI is a typing discipline wearing a UX costume — the model streams typed intent, a schema boundary makes it trustworthy, an exhaustive map makes it total, and Partial<> everywhere makes streaming honest; the components were never the hard part, the contract was.