Generative UI: When AI Renders the Interface
In AI 09, we built the quality infrastructure to ensure our AI systems are measurably correct. Now we face a different question: even when the AI gives a correct answer, is it giving it in the right form?
A user asks: “Show me revenue by region for Q1 through Q4.” The AI could respond with 16 numbers embedded in a paragraph of prose — technically correct, completely unusable. Or it could render an interactive bar chart with color-coded regions, hover tooltips showing exact figures, and a trend line highlighting the biggest shifts. Same data. Radically different interface.
This is the Generative UI problem: LLMs don’t just output what — they can decide how. The chat box is not always the right container.
TL;DR
Text is not always the best interface for AI output. Generative UI lets the LLM choose the presentation.
The UI Paradigm Evolution:
┌──────────────────────────────────────────────────────────────┐
│ UI Paradigm Timeline │
│ │
│ GUI (1984) Static, human-designed │
│ └─ Forms, buttons, menus — same layout for every user │
│ │
│ CUI (2022) Text-in → Text-out │
│ └─ Chat interfaces — universal but one-dimensional │
│ │
│ Generative UI Text-in → Rich Components out │
│ (2023+) └─ LLM selects: chart? table? form? │
│ │
│ Agentic UX Goal-in → Agent orchestrates UI flow │
│ (2025+) └─ USES Generative UI as its rendering │
│ layer across multi-step workflows │
│ (stepper → form → table → report) │
└──────────────────────────────────────────────────────────────┘
Clarification on the hierarchy:
─────────────────────────────────
CUI and Generative UI are both interface paradigms.
Agentic UX is an interaction paradigm — it uses Generative UI
as its rendering mechanism. Agentic UX does not replace
Generative UI; it orchestrates it across multiple turns.
Think of it like:
Generative UI = paint and brushes (the rendering primitive)
Agentic UX = the artist directing a painting session
(choosing what to render, in what order, when)
Article Map
I — Problem Layer (when text fails)
- The Limits of Conversational UI — When the chat box gets in the way
- Visual Information Density — Why a chart beats a paragraph
II — Architecture Layer (how to build it)
3. Generative UI Architecture — Two approaches, one winner
4. Vercel AI SDK Deep Dive — streamableUI and RSC streaming
5. Tool-Based UI Generation — Component registry pattern
6. Streaming UI: Progressive Rendering — Skeleton states and real-time updates
III — Design Layer (how to use it well) 7. Agentic UX: AI Decides the Presentation — Multi-step UI flows 8. Design Patterns & Anti-Patterns — What works, what backfires 9. Accessibility & Fallback Strategies — No one gets left behind 10. Key Takeaways — The principles that transfer
1. The Limits of Conversational UI
1.1 When Text Gets in the Way
The chat interface (CUI) was a revelation: natural language in, intelligent response out. But the universality of text is also its constraint — not every answer wants to be a sentence.
The Information Format Mismatch:
User: "Show me Q1–Q4 revenue by region"
─── CUI Response ──────────────────────────────────────────────
"Here's the revenue breakdown by region:
North America: Q1 $12.1M, Q2 $14.3M, Q3 $15.8M, Q4 $18.2M
Europe: Q1 $8.2M, Q2 $9.1M, Q3 $9.7M, Q4 $11.4M
Asia-Pacific: Q1 $5.4M, Q2 $7.2M, Q3 $9.8M, Q4 $13.1M
Latin America: Q1 $2.1M, Q2 $2.3M, Q3 $2.5M, Q4 $2.9M
Total: Q1 $27.8M, Q2 $32.9M, Q3 $37.8M, Q4 $45.6M"
Time to understand the trend: ~30 seconds of scanning
───────────────────────────────────────────────────────────────
─── Generative UI Response ────────────────────────────────────
→ Renders: Interactive grouped bar chart
- X-axis: Q1, Q2, Q3, Q4
- Y-axis: Revenue ($M)
- 4 color-coded series (NA, EU, APAC, LATAM)
- Hover tooltips showing exact values
- Summary note: "APAC growth +142% YoY, outpacing all regions"
Time to understand the trend: ~3 seconds of glancing
───────────────────────────────────────────────────────────────
Same data. 10× faster comprehension. Same AI system.
1.2 When to Use CUI vs. Generative UI
The answer isn’t “always use Generative UI.” Text genuinely is the right format for many responses:
| Task Type | CUI (Text) | Generative UI | Winner | Reason |
|---|---|---|---|---|
| Simple Q&A | ✓ Concise | Overkill | CUI | One-line answers don’t need a chart |
| Comparison (2+ items) | ✗ Verbose table | ✓ Data table | Gen UI | Tables scan faster than prose lists |
| Trend over time | ✗ Unreadable | ✓ Line/bar chart | Gen UI | Humans see trends visually |
| Numerical data (5+ numbers) | ✗ Hard to parse | ✓ Chart or table | Gen UI | Eyes vs. brain for parsing |
| Multi-step workflow | ✗ Linear prose | ✓ Stepper/wizard | Gen UI | Progress visibility reduces anxiety |
| Form input needed | ✗ Awkward in chat | ✓ Native form | Gen UI | Structured input > freeform text |
| Explanation / Reasoning | ✓ Natural | Unnecessary | CUI | Prose is the right format for reasoning |
| Alert / Warning | ✓ Immediate | Overkill | CUI | Don’t need a dashboard for a warning |
🔧 Engineer’s Note: The anti-pattern is defaulting to Generative UI for everything. A user asking “What’s the capital of France?” should get “Paris.” — not a map component. The rule: if a human analyst presenting this information would reach for a chart, table, or form — the AI should too. If they’d just speak a sentence — text is right.
2. Visual Information Density
2.1 Data-Ink Ratio Applied to AI Output
Edward Tufte’s concept of data-ink ratio — maximize the information conveyed per unit of ink — applies directly to AI output format selection:
Data-Ink Ratio for AI Responses:
Text response to "Compare Q3 performance across 5 products":
┌────────────────────────────────────────────────────────────┐
│ "Product A had revenue of $2.1M in Q3, representing a 15% │
│ increase. Product B had $3.4M, up 8%. Product C showed │
│ strong growth at $1.8M, +34%. Product D was $4.2M, -3%. │
│ Product E had $0.9M, +22%." │
│ │
│ Words: 47 | Numbers: 10 | Patterns visible: 0 │
│ Time to rank products: ~20 seconds │
└────────────────────────────────────────────────────────────┘
Table response to same question:
┌─────────────┬──────────┬──────────┐
│ Product │ Q3 Rev │ QoQ Δ │
├─────────────┼──────────┼──────────┤
│ Product D │ $4.2M │ -3% ↓ │
│ Product B │ $3.4M │ +8% ↑ │
│ Product A │ $2.1M │ +15% ↑ │
│ Product C │ $1.8M │ +34% ↑↑ │
│ Product E │ $0.9M │ +22% ↑ │
├─────────────┼──────────┼──────────┤
│ TOTAL │ $12.4M │ +14% │
└─────────────┴──────────┴──────────┘
Time to identify largest, fastest growing: ~3 seconds
2.2 The Financial AI Connection
For the AI 08 financial use cases — reconciliation reports, anomaly dashboards, CFO summaries — Generative UI is not a nice-to-have. It’s the difference between a tool an accountant checks and one they trust:
Financial AI Response Quality Spectrum:
Level 1 (Worst): Wall of text with embedded numbers
Level 2: Structured prose with bullet points
Level 3: Markdown table (renders in some UIs)
Level 4: Interactive data table with sort/filter
Level 5 (Best): Dashboard: chart + table + key insights
+ one-click approve/reject for exceptions
AI 08's HITL Review Dashboard (§7.3) = Level 5
Chat-only financial AI = Level 1-2 at best
The CFO sees Level 5. The CFO trusts Level 5.
🔧 Engineer’s Note: The financial AI built in AI 08 without Generative UI is a command-line tool wearing a suit. The logic is right but the presentation makes it inaccessible to the CFO, controller, and board. Generative UI is what transforms “technically functional” into “actually adopted.” Adoption is the metric that matters — a 95%-accurate AI that nobody uses has 0% effective accuracy.
3. Generative UI Architecture
3.1 Two Approaches: The Right One and the Wrong One
There are two ways to have an LLM generate UI. One is safe, composable, and production-ready. The other is a security vulnerability waiting to happen:
Approach A: LLM Generates Markup (❌ Wrong Way)
──────────────────────────────────────────────────
User: "Show me revenue chart"
LLM: "<div class='chart-container'>
<canvas id='revenueChart'></canvas>
<script>
// LLM-generated JavaScript
new Chart(document.getElementById('revenueChart'), {...})
</script>
</div>"
Client: eval(llm_output) ← 💀 XSS attack surface
Problems:
✗ Security: LLM can inject arbitrary JS (prompt injection → XSS)
✗ Consistency: LLM-generated CSS is unpredictable and ugly
✗ Accessibility: No ARIA attributes, screen reader hostile
✗ Maintainability: Can't version-control AI-generated markup
✗ Performance: Parser overhead + runtime eval
Approach B: LLM Selects from Component Registry (✅ Right Way)
──────────────────────────────────────────────────────────────
User: "Show me revenue chart"
LLM: tool_call: render_chart({
type: "bar",
data: [...],
xAxis: "quarter",
yAxis: "revenue_usd",
colorScheme: "financial"
})
Client: <RevenueBarChart {...validated_data} />
Benefits:
✓ Security: LLM can only call pre-approved tools — no arbitrary code
✓ Consistency: Your component library, your design system
✓ Accessibility: Components built with ARIA from the start
✓ Maintainability: Components are React code you own and version
✓ Performance: Pre-built, tree-shaken, optimized bundles
The LLM is the ORCHESTRATOR. Your components are the RENDERER.
LLM decides WHAT and with WHAT DATA. You decide HOW it looks.
3.2 The Core Architecture
Generative UI System Architecture:
┌──────────────────────────────────────────────────────────────┐
│ Server Side │
│ │
│ User Input → LLM │
│ │ │
│ ├── Tool call: render_table({data, cols}) │
│ ├── Tool call: render_chart({type, data}) │
│ ├── Tool call: render_form({schema}) │
│ └── Plain text (if no tool needed) │
│ │
│ For each tool call: │
│ → Validate & sanitize data │
│ → Create streamable UI wrapper │
│ → Stream component to client │
└──────────────────────────┬───────────────────────────────────┘
│ (Server Component streaming)
┌──────────────────────────▼───────────────────────────────────┐
│ Client Side │
│ │
│ Receive stream → Resolve component → Render in UI │
│ │
│ Component Registry (pre-built React components): │
│ <DataTable /> <BarChart /> <LineChart /> │
│ <KPIDashboard /> <AnomalyCard /> <ApprovalForm /> │
│ │
│ LLM never touches this code. It only provides data. │
└──────────────────────────────────────────────────────────────┘
🔧 Engineer’s Note: The component registry is your design system’s AI integration layer. Every component in it should be one your design team has already approved — same colors, same spacing, same typography as the rest of your product. The LLM is a guest in your design system; it picks from the menu, it doesn’t cook new dishes. This constraint is a feature, not a bug: it guarantees visual consistency regardless of what the LLM decides to render.
3.3 Token Cost & Payload Optimization
A hidden performance trap in Generative UI: if the LLM is responsible for producing the data that goes into charts and tables, it will sometimes emit thousands of raw data rows as JSON — expensive in tokens and slow to stream.
The Wrong Pattern (LLM emits raw data):
─────────────────────────────────────────
LLM tool call:
render_time_series({
series: [
{ date: "2025-01-01", value: 12100000 },
{ date: "2025-01-02", value: 12240000 },
... × 365 more rows ...
]
})
Problems:
✗ 10,000+ tokens just for data payload (= $$$ in API cost)
✗ LLM hallucination risk on individual data points
✗ Streaming latency increases with payload size
✗ LLM should not be generating raw financial figures
The Right Pattern (LLM emits parameters, backend fetches):
───────────────────────────────────────────────────────────
LLM tool call:
render_time_series({
query: {
metric: "revenue",
startDate: "2025-01-01",
endDate: "2025-12-31",
groupBy: "month",
region: "APAC",
}
})
Your tool handler:
→ Receives query parameters from LLM
→ Fetches + aggregates data from your ERP/database
→ Returns 12 monthly data points (not 365 daily)
→ Passes to <TimeSeriesChart />
LLM output: ~50 tokens (parameters only)
Data payload: fetched server-side, never in LLM context
// Implement parameter-driven tools: LLM provides the query, not the data
render_time_series: {
description: "Display revenue over time. Provide query params; data will be fetched server-side.",
parameters: z.object({
metric: z.enum(["revenue", "expenses", "profit", "cashflow"]),
startDate: z.string().describe("ISO 8601 date, e.g. 2025-01-01"),
endDate: z.string().describe("ISO 8601 date, e.g. 2025-12-31"),
groupBy: z.enum(["day", "week", "month", "quarter"]).default("month"),
region: z.string().optional(),
title: z.string(),
}),
generate: async ({ metric, startDate, endDate, groupBy, region, title }) => {
// LLM never sees or generates the actual numbers
const data = await db.queryTimeSeries({ metric, startDate, endDate, groupBy, region });
const { TimeSeriesChart } = await import("@/components/charts/TimeSeriesChart");
return <TimeSeriesChart data={data} title={title} />;
},
},
🔧 Engineer’s Note: LLMs are not databases — don’t treat them like one. The LLM’s job is to understand the user’s intent and translate it into structured query parameters. Your backend’s job is to execute those parameters and return clean, aggregated data. This division keeps token costs low (~50 tokens per chart call), eliminates hallucinated data values, and lets the AI SDK stream the skeleton + query immediately while the data fetch happens in parallel.
4. Vercel AI SDK Deep Dive
The Vercel AI SDK (ai package) is the most production-ready framework for Generative UI in React/Next.js applications. It handles the tricky parts: streaming components from server to client, managing React Server Component (RSC) state, and integrating with multiple LLM providers.
4.1 The streamUI Function
// app/actions.tsx — Server Action
"use server";
import { streamUI } from "ai/rsc";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
// Pre-built components in your design system
import { LoadingSkeleton } from "@/components/LoadingSkeleton";
import { RevenueBarChart } from "@/components/charts/RevenueBarChart";
import { DataTable } from "@/components/DataTable";
import { AnomalyCard } from "@/components/AnomalyCard";
import { ReconciliationForm } from "@/components/ReconciliationForm";
export async function submitFinancialQuery(userMessage: string) {
const result = await streamUI({
model: anthropic("claude-3-7-sonnet-20250219"),
system: `You are a financial AI assistant for a CFO dashboard.
When users ask for data, use the appropriate visualization tool.
When users ask questions, respond with text.
Never show raw JSON to users.`,
messages: [{ role: "user", content: userMessage }],
// Show skeleton while LLM is thinking
initial: <LoadingSkeleton lines={4} />,
// Tool definitions with Zod schemas for type safety
tools: {
render_revenue_chart: {
description: "Display revenue data as an interactive bar or line chart",
parameters: z.object({
chartType: z.enum(["bar", "line", "area"]),
data: z.array(z.object({
period: z.string(),
region: z.string(),
revenue: z.number(),
currency: z.string().default("USD"),
})),
title: z.string(),
yAxisLabel: z.string().default("Revenue ($M)"),
}),
generate: async ({ chartType, data, title, yAxisLabel }) => {
// Validate data range (prevent XSS via data)
const cleanData = data.map(d => ({
...d,
revenue: Math.min(Math.max(d.revenue, 0), 1e12), // sanity cap
period: d.period.replace(/[<>"']/g, ""), // strip HTML
}));
return <RevenueBarChart type={chartType} data={cleanData}
title={title} yAxisLabel={yAxisLabel} />;
},
},
render_data_table: {
description: "Display tabular data with sort and filter capabilities",
parameters: z.object({
columns: z.array(z.object({
key: z.string(),
header: z.string(),
type: z.enum(["text", "number", "currency", "date", "badge"]),
sortable: z.boolean().default(true),
})),
rows: z.array(z.record(z.unknown())),
caption: z.string().optional(),
pageSize: z.number().default(10),
}),
generate: async ({ columns, rows, caption, pageSize }) => {
return <DataTable columns={columns} data={rows}
caption={caption} pageSize={pageSize} />;
},
},
render_anomaly_card: {
description: "Display a financial anomaly with context and approval buttons",
parameters: z.object({
transactionId: z.string(),
bankData: z.object({ date: z.string(), amount: z.number(), description: z.string() }),
erpData: z.object({ date: z.string(), amount: z.number(), account: z.string() }).nullable(),
aiClassification: z.enum(["MISSING_ENTRY", "TIMING_DIFFERENCE", "SUSPICIOUS", "ROUNDING"]),
aiConfidence: z.number().min(0).max(1),
aiReasoning: z.string(),
ragCitations: z.array(z.string()),
riskLevel: z.enum(["LOW", "MEDIUM", "HIGH"]),
}),
generate: async (props) => {
// This renders the HITL dashboard card from AI 08 §7.3
return <AnomalyCard {...props} onApprove={handleApprove}
onReject={handleReject} onEdit={handleEdit} />;
},
},
},
});
return result.value;
}
4.2 Client-Side Integration
// app/financial-dashboard/page.tsx
"use client";
import { useState } from "react";
import { useActions, useUIState } from "ai/rsc";
import type { AI } from "@/app/ai";
export default function FinancialDashboard() {
const [input, setInput] = useState("");
const [messages, setMessages] = useUIState<typeof AI>();
const { submitFinancialQuery } = useActions<typeof AI>();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!input.trim()) return;
// Immediately show user message
setMessages(prev => [
...prev,
{ id: Date.now(), role: "user", display: <p>{input}</p> },
]);
// Server action returns streamed UI
const responseUI = await submitFinancialQuery(input);
// Add AI response — may be text, chart, table, or anomaly card
setMessages(prev => [
...prev,
{ id: Date.now() + 1, role: "assistant", display: responseUI },
]);
setInput("");
}
return (
<div className="dashboard">
{/* Render messages — each may be a different component type */}
<div className="message-list">
{messages.map(msg => (
<div key={msg.id} className={`message message--${msg.role}`}>
{msg.display} {/* ← Could be text, chart, table, or form */}
</div>
))}
</div>
<form onSubmit={handleSubmit} className="query-input">
<input
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Ask about your financial data..."
/>
<button type="submit">Send</button>
</form>
</div>
);
}
4.3 Setting Up the AI Context
// app/ai.ts — AI state provider (required for useActions/useUIState)
import { createAI } from "ai/rsc";
import { submitFinancialQuery } from "./actions";
export const AI = createAI({
actions: {
submitFinancialQuery,
},
initialUIState: [] as Array<{
id: number;
role: "user" | "assistant";
display: React.ReactNode;
}>,
initialAIState: [] as Array<{
role: "user" | "assistant";
content: string;
}>,
});
// app/layout.tsx — Wrap the app
import { AI } from "./ai";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<AI>{children}</AI>
</body>
</html>
);
}
🔧 Engineer’s Note: The Vercel AI SDK’s RSC pattern is powerful but has a learning curve. The key mental model:
streamUIruns on the server, returns a stream of React components, anduseUIStateholds them client-side like any other React state. The component definitions live on the client (bundled); the LLM decides which ones to render on the server. This separation is what keeps the architecture secure — LLM decisions never touch client-side JavaScript execution.
5. Tool-Based UI Generation
5.1 The Component Registry Pattern
The tool definitions in §4 work one-at-a-time, but a production system needs a systematic way to manage dozens of components. The Component Registry pattern centralizes this:
// lib/ui-tools/registry.ts
import { z } from "zod";
import type { CoreTool } from "ai";
type UITool = CoreTool & {
generate: (params: unknown) => Promise<React.ReactNode>;
category: "data" | "form" | "alert" | "layout";
};
// Central registry: all renderable components available to the LLM
const COMPONENT_REGISTRY: Record<string, UITool> = {
// ── Data Visualization ────────────────────────────────────────
render_bar_chart: {
description: "Render a bar chart for categorical comparisons",
category: "data",
parameters: z.object({
data: z.array(z.object({ label: z.string(), value: z.number() })),
title: z.string(),
color: z.enum(["blue", "green", "red", "financial"]).default("financial"),
}),
generate: async ({ data, title, color }) => {
const { BarChart } = await import("@/components/charts/BarChart");
return <BarChart data={data} title={title} colorScheme={color} />;
},
},
render_time_series: {
description: "Render a line chart for trend analysis over time",
category: "data",
parameters: z.object({
series: z.array(z.object({
name: z.string(),
color: z.string().optional(),
points: z.array(z.object({ date: z.string(), value: z.number() })),
})),
title: z.string(),
yAxisFormat: z.enum(["number", "currency", "percentage"]).default("number"),
}),
generate: async (props) => {
const { TimeSeriesChart } = await import("@/components/charts/TimeSeriesChart");
return <TimeSeriesChart {...props} />;
},
},
render_kpi_grid: {
description: "Render a grid of KPI metric cards for financial dashboards",
category: "data",
parameters: z.object({
metrics: z.array(z.object({
label: z.string(),
value: z.string(),
change: z.number().optional(), // +/- percentage
trend: z.enum(["up", "down", "neutral"]).optional(),
icon: z.string().optional(),
})),
}),
generate: async ({ metrics }) => {
const { KPIGrid } = await import("@/components/KPIGrid");
return <KPIGrid metrics={metrics} />;
},
},
// ── Forms & Actions ───────────────────────────────────────────
render_approval_form: {
description: "Render a review form for human-in-the-loop decisions",
category: "form",
parameters: z.object({
title: z.string(),
description: z.string(),
fields: z.array(z.object({
key: z.string(),
label: z.string(),
value: z.string(),
editable: z.boolean().default(false),
})),
riskLevel: z.enum(["LOW", "MEDIUM", "HIGH"]),
}),
generate: async (props) => {
const { ApprovalForm } = await import("@/components/ApprovalForm");
return <ApprovalForm {...props}
onApprove={handleApprove} onReject={handleReject} />;
},
},
// ── Alerts ────────────────────────────────────────────────────
render_alert: {
description: "Render an alert message for warnings, errors, or info",
category: "alert",
parameters: z.object({
severity: z.enum(["info", "warning", "error", "success"]),
title: z.string(),
message: z.string(),
action: z.object({ label: z.string(), href: z.string() }).optional(),
}),
generate: async (props) => {
const { Alert } = await import("@/components/Alert");
return <Alert {...props} />;
},
},
};
// Build tool definitions for the LLM (strips out the generate function)
export function buildLLMTools() {
return Object.fromEntries(
Object.entries(COMPONENT_REGISTRY).map(([name, tool]) => [
name,
{ description: tool.description, parameters: tool.parameters },
])
);
}
// Execute a tool call by name
export async function executeUITool(
toolName: string,
toolParams: unknown,
): Promise<React.ReactNode> {
const tool = COMPONENT_REGISTRY[toolName];
if (!tool) {
return <p className="error">Unknown component: {toolName}</p>;
}
// Validate params against schema before rendering
const validatedParams = tool.parameters.parse(toolParams);
return tool.generate(validatedParams);
}
5.2 Dynamic Tool Selection
Not every LLM call needs all tools available. Expose only relevant tools per context:
// lib/ui-tools/context-selector.ts
type UserRole = "cfo" | "accountant" | "analyst" | "auditor";
// Role-based tool access — least privilege for UI (mirrors AI 07 §8.3)
const ROLE_TOOLS: Record<UserRole, string[]> = {
cfo: ["render_kpi_grid", "render_bar_chart", "render_time_series"],
accountant: ["render_approval_form", "render_data_table", "render_alert",
"render_bar_chart"],
analyst: Object.keys(COMPONENT_REGISTRY), // Full access
auditor: ["render_data_table", "render_alert"], // Read-only views
};
export function getToolsForRole(role: UserRole) {
const allowedTools = ROLE_TOOLS[role];
return Object.fromEntries(
Object.entries(COMPONENT_REGISTRY)
.filter(([name]) => allowedTools.includes(name))
.map(([name, tool]) => [name, tool])
);
}
// Usage in server action:
// const tools = getToolsForRole(user.role);
// const result = await streamUI({ tools, ... });
🔧 Engineer’s Note: Applying least-privilege to UI tools is the same principle as AI 07 §8.3 — just at the rendering layer. A CFO doesn’t need the
render_approval_formtool (that’s the accountant’s job). An auditor shouldn’t see edit tools. This isn’t just about security — it reduces the LLM’s decision space and makes its choices more reliable. Fewer tools = fewer wrong tool selections = better UX.
6. Streaming UI: Progressive Rendering
6.1 Why Streaming Matters for LLM Interfaces
LLM generation takes time — typically 1-5 seconds for a complete response. Without streaming, the user stares at a blank space. With streaming, they see progress immediately.
The Streaming UX Hierarchy:
Level 0: No streaming (worst)
─────────────────────────────
User asks → [2-5 second blank screen] → Full response appears
Perceived: "Is it broken? Did it freeze?"
Level 1: Text streaming
───────────────────────
User asks → [tokens appear one by one]
Perceived: "It's thinking and typing"
Level 2: Skeleton streaming (for Generative UI)
────────────────────────────────────────────────
User asks → [skeleton component appears instantly]
→ [LLM finalizes parameters]
→ [real component replaces skeleton]
Perceived: "Something is loading; here is the structure"
Level 3: Progressive streaming (best)
──────────────────────────────────────
User asks → [think text streams: "Loading Q3 revenue data..."]
→ [skeleton bar chart with animated placeholder bars]
→ [real data populates bars progressively, region by region]
Perceived: "I can see it working in real time"
6.2 Implementing Streaming with Skeleton States
// components/charts/StreamingBarChart.tsx
"use client";
import { motion, AnimatePresence } from "framer-motion";
interface BarChartProps {
data: { label: string; value: number }[];
title: string;
isLoading?: boolean;
}
// Skeleton version: same layout, animated placeholder bars
function BarChartSkeleton() {
return (
<div className="chart-container chart-skeleton" aria-busy="true" aria-label="Loading chart">
<div className="chart-title skeleton-line" style={{ width: "60%" }} />
<div className="chart-bars">
{[0.7, 0.9, 0.5, 0.8, 0.6].map((height, i) => (
<div
key={i}
className="skeleton-bar"
style={{ height: `${height * 100}%` }}
/>
))}
</div>
</div>
);
}
// Real chart with smooth animation from skeleton
export function StreamingBarChart({ data, title, isLoading = false }: BarChartProps) {
if (isLoading) return <BarChartSkeleton />;
const maxValue = Math.max(...data.map(d => d.value));
return (
<AnimatePresence mode="wait">
<motion.div
className="chart-container"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
<h3 className="chart-title">{title}</h3>
<div className="chart-bars" role="img" aria-label={`Bar chart: ${title}`}>
{data.map((item, i) => (
<motion.div
key={item.label}
className="bar-wrapper"
initial={{ opacity: 0, scaleY: 0 }}
animate={{ opacity: 1, scaleY: 1 }}
transition={{ delay: i * 0.05, duration: 0.4, ease: "easeOut" }}
style={{ transformOrigin: "bottom" }}
>
<div
className="bar"
style={{ height: `${(item.value / maxValue) * 100}%` }}
aria-label={`${item.label}: ${item.value}`}
/>
<span className="bar-label">{item.label}</span>
<span className="bar-value">{item.value.toLocaleString()}</span>
</motion.div>
))}
</div>
</motion.div>
</AnimatePresence>
);
}
6.3 The Streaming Server Action Pattern
// app/actions.tsx — Streaming with progressive updates
"use server";
import { createStreamableValue, createStreamableUI } from "ai/rsc";
export async function streamFinancialAnalysis(query: string) {
// Create streamable placeholder — client sees this immediately
const uiStream = createStreamableUI(<LoadingSkeleton message="Analyzing..." />);
// Create streamable for thought process
const thoughtStream = createStreamableValue("");
// Run in background — don't await
(async () => {
try {
// Phase 1: Show thinking text while fetching data
uiStream.update(<LoadingSkeleton message="Fetching data from ERP..." />);
thoughtStream.update("Fetching Q3 revenue data...");
const erpData = await fetchERPData(query);
// Phase 2: Update skeleton with progress
uiStream.update(
<StreamingBarChart data={[]} title="Loading..." isLoading={true} />
);
thoughtStream.update("Processing and categorizing by region...");
const processedData = await processData(erpData);
// Phase 3: Render final component
uiStream.done(
<StreamingBarChart
data = {processedData.chartData}
title = {processedData.title}
isLoading = {false}
/>
);
thoughtStream.done("Analysis complete.");
} catch (error) {
// Always close streams — even on error
uiStream.done(<ErrorCard message="Failed to load data. Please retry." />);
thoughtStream.done("Error occurred during analysis.");
}
})();
// Return immediately — streams update independently
return {
ui: uiStream.value,
thought: thoughtStream.value,
};
}
🔧 Engineer’s Note: Critical: always call
.done()on streamable values — even in error paths. If the server action throws mid-stream without callingdone(), the client hangs indefinitely waiting for more stream data. Wrap every streaming action in try-catch, and always calluiStream.done()in the catch block with an error component. The UX of a clean error message beats an infinite spinner by miles.
7. Agentic UX: AI Decides the Presentation
7.1 Beyond Single-Turn Generation
Generative UI so far has been one question → one component. Agentic UX extends this to multi-step workflows where the AI orchestrates a sequence of UI transitions — filling forms, validating data, showing results, requesting approval — as a complete interaction arc.
Single-Turn Generative UI vs. Agentic UX:
Single-Turn:
User: "Show me Q3 revenue"
AI: [renders bar chart] ← done
Agentic UX (multi-step):
User: "Process the December bank reconciliation"
│
▼
[AI shows progress stepper]
Step 1/4: Fetching bank data...
│
▼
Step 2/4: [renders data preview table — "12 unmatched entries found"]
"Before I continue, do you want to proceed?"
│
▼ (user clicks Continue)
Step 3/4: [renders anomaly cards for each exception]
"4 items require human review. 8 auto-resolved."
│
▼ (user approves/rejects each)
Step 4/4: [renders final reconciliation report]
"Reconciliation complete. Posting journal entries..."
│
▼
[renders audit confirmation card with download link]
The AI is both conductor and interface designer.
Each step's UI is chosen by the agent based on what's needed next.
7.2 Implementing an Agent Workflow with UI Steps
// app/actions/reconciliation-workflow.tsx
"use server";
import { createStreamableUI } from "ai/rsc";
export async function runReconciliationWorkflow(month: string, year: number) {
const uiStream = createStreamableUI(
<WorkflowStepper currentStep={0} steps={[
"Fetch Bank Data",
"Match Transactions",
"Review Exceptions",
"Generate Report",
]} />
);
(async () => {
try {
// ── Step 1: Fetch ────────────────────────────────────────────
uiStream.update(
<WorkflowStepper currentStep={1} status="loading"
message="Fetching bank statement from API..." />
);
const bankData = await fetchBankStatement(month, year);
const erpData = await fetchERPTransactions(month, year);
// Show preview before proceeding — user confirmation
uiStream.update(
<DataPreviewCard
bankCount = {bankData.transactions.length}
erpCount = {erpData.transactions.length}
month = {month}
year = {year}
onConfirm = {() => continueWorkflow("match")}
onCancel = {() => cancelWorkflow()}
/>
);
// Wait for user confirmation (via server event)
await waitForUserAction("match");
// ── Step 2: Match ────────────────────────────────────────────
uiStream.update(
<WorkflowStepper currentStep={2} status="loading"
message="Matching transactions..." />
);
const { matched, unmatched } = await reconciliationAgent.match(
bankData, erpData
);
// ── Step 3: Review exceptions ────────────────────────────────
if (unmatched.length > 0) {
// Render all anomaly cards for human review
uiStream.update(
<ExceptionReviewPanel
exceptions = {unmatched}
autoResolved = {matched.autoResolved}
onAllReviewed = {(decisions) => continueWorkflow("report", decisions)}
/>
);
const decisions = await waitForUserAction("report");
// ── Step 4: Generate report ──────────────────────────────────
uiStream.update(
<WorkflowStepper currentStep={4} status="loading"
message="Generating audit report..." />
);
const report = await generateReport(matched, decisions, month, year);
// Final render: download + audit confirmation
uiStream.done(
<ReconciliationComplete
report = {report}
downloadUrl = {report.pdfUrl}
auditTrailId = {report.auditId}
/>
);
} else {
// No exceptions — skip to report
uiStream.done(<AutoCompleteCard matched={matched} month={month} year={year} />);
}
} catch (error) {
uiStream.done(
<WorkflowError
message = "Reconciliation failed. Please check logs and retry."
error = {error instanceof Error ? error.message : "Unknown error"}
/>
);
}
})();
return uiStream.value;
}
7.3 Progress Transparency: The Stepper Pattern
A core Agentic UX principle: always show the user where they are in the process. Long-running agent workflows create anxiety if the user can’t see progress:
Stepper Component for Multi-Step Agent Tasks:
┌──────────────────────────────────────────────────────────────┐
│ December 2025 Bank Reconciliation │
│ │
│ ●──────────●──────────●──────────○ │
│ Fetch Match Review Report │
│ ✓ Done ✓ Done ▶ Active ○ Pending │
│ │
│ Step 3 of 4: Review Exceptions │
│ 4 transactions require your decision │
│ 8 auto-resolved by AI (confidence > 95%) │
│ │
│ Estimated time remaining: ~2 minutes │
└──────────────────────────────────────────────────────────────┘
Principles:
├── Always show total steps upfront (reduces "how long?" anxiety)
├── Show completed steps with ✓ (past tense, provides satisfaction)
├── Show current step with ▶ and live message (present tense)
├── Show future steps with ○ (acknowledges there's more)
└── Estimate remaining time when possible (even rough estimates help)
🔧 Engineer’s Note: Agentic UX is where HITL (AI 05, AI 08) becomes a visual design challenge, not just an architectural one. The
escalate_to_humandecision from the agent’s logic maps directly to “show the approval form” in the UI layer. The agent’s state machine (AI 06 §7) is the backend; Generative UI is the frontend that makes it legible to non-technical users. These aren’t separate concerns — they’re the same workflow, viewed from different levels of the stack.
7.4 State Persistence: Surviving the Page Refresh
Long-running agent workflows introduce a critical web engineering problem that single-turn Generative UI does not have: what happens when the user refreshes the page or closes the tab mid-workflow?
Without state persistence, a user halfway through a 4-step reconciliation process loses all progress. The fix is a backend state machine:
// Backend: persist workflow state so it survives page refresh
// db/workflow-state.ts
export type WorkflowState = {
workflowId: string; // UUID, becomes URL param
workflowType: "reconciliation" | "approval" | "report";
currentStep: number; // 0-4
status: "running" | "awaiting_user" | "complete" | "failed";
stepData: Record<string, unknown>; // bank data, decisions, etc.
createdAt: Date;
updatedAt: Date;
};
// Server action: create workflow and persist state
export async function startReconciliationWorkflow(month: string, year: number) {
const workflowId = crypto.randomUUID();
// Persist initial state to DB before any async work
await db.workflowState.create({
data: {
workflowId,
workflowType: "reconciliation",
currentStep: 0,
status: "running",
stepData: { month, year },
}
});
// Return workflowId — client appends to URL
// URL becomes: /dashboard/reconcile?workflowId=abc-123
// On page refresh: client reads URL param, fetches state, restores UI
return { workflowId };
}
// Client: restore from URL on mount
// app/dashboard/reconcile/page.tsx
export default function ReconcilePage() {
const searchParams = useSearchParams();
const workflowId = searchParams.get("workflowId");
useEffect(() => {
if (workflowId) {
// Fetch persisted state and restore the correct UI step
fetch(`/api/workflow/${workflowId}`)
.then(r => r.json())
.then(state => restoreWorkflowUI(state));
}
}, [workflowId]);
// ...
}
User experience with state persistence:
Step 1: User starts reconciliation → workflowId created
URL: /dashboard?workflowId=abc-123
Step 3: User reviews exceptions — closes tab (accident)
On return to same URL:
→ Client reads workflowId from URL
→ Fetches persisted state from backend
→ UI restores directly to Step 3 (Review Exceptions)
→ User sees their previous review decisions intact
→ Workflow continues naturally
Without persistence:
→ Blank page on reload
→ User must restart from Step 1
→ 10-minute reconciliation lost
→ User stops trusting the tool
🔧 Engineer’s Note: A
workflowIdin the URL is the simplest persistence layer that works. The URL becomes a resumable bookmark. For enterprise users who run multi-hour reconciliation sessions and switch between meetings, this is not a nice-to-have — it’s the difference between a tool they adopt and one they abandon after the first crash. Workflow state belongs in your database from the very first step, not as an afterthought on completion.
8. Design Patterns & Anti-Patterns
8.1 Patterns That Work
Pattern 1: Progressive Disclosure
──────────────────────────────────
Show summary first; let user drill down.
AI returns: KPI dashboard (4 headline figures)
User clicks: "Show details" on the outlier metric
AI returns: Breakdown table with filters
User asks: "Why is APAC down?"
AI returns: Trend chart + text explanation
Why it works: Matches how executives actually consume information.
Don't force data they didn't ask for.
Pattern 2: 카Contextual Defaults
────────────────────────────────
Pre-fill forms and charts with the most likely answer.
User: "Book the reconciliation entry for this timing difference"
AI: [renders journal entry form, pre-filled with:]
Account: 2350-AP-ACCRUAL (from AI classification)
Amount: $45,230.00 (from transaction data)
Period: Dec 2025 (from context)
Ref: TXN-20251230-4172 (from source data)
User just verifies and clicks Confirm.
Why it works: 80% of keystrokes eliminated. Error rate drops.
Pattern 3: Reversible First
─────────────────────────────────────
Default to actions the user can undo. Require explicit
confirmation for irreversible ones.
Reversible (show immediately): Filter changes, view changes
Reversible (soft confirm): Mark as reviewed, save draft
Irreversible (hard confirm dialog): Post journal entry,
send email to auditor, delete record
Why it works: Reduces anxiety ("what if I click the wrong thing?")
and increases adoption of AI-driven automation.
Pattern 4: Explanation Alongside Action
────────────────────────────────────────
Always render the AI's reasoning next to the generated UI.
┌──────────────────────────────────────────────────────┐
│ AI Classification: TIMING_DIFFERENCE │
│ Confidence: 94% │
│ │
│ Reasoning: The bank records the debit on Dec 30, │
│ while the ERP records the credit on Jan 2. This │
│ is consistent with IFRS 16.22 (commencement date │
│ recognition). Source: [IFRS_16_standard.pdf §22] │
│ │
│ [✓ Approve] [✗ Reject] [✎ Edit] │
└──────────────────────────────────────────────────────┘
Why it works: Users trust what they understand.
Black-box AI buttons get ignored. Explained AI gets used.
8.2 Anti-Patterns to Avoid
Anti-Pattern 1: Component Overload
────────────────────────────────────
❌ Rendering a full dashboard for every question
User: "What's our accounts payable balance?"
AI: [renders 6-panel dashboard with 12 charts]
✅ Match complexity to question
User: "What's our accounts payable balance?"
AI: "Your AP balance is $2.4M as of Dec 31, 2025."
Anti-Pattern 2: Surprise Transitions
──────────────────────────────────────
❌ Abrupt component replacement with no animation
User: clicks "Show details" → [entire screen flashes,
new table appears with no transition]
✅ Smooth transitions signal continuity
Always use enter/exit animations (Framer Motion, CSS transitions)
Duration: 150-250ms (fast enough to not feel slow)
Anti-Pattern 3: Non-Editable Forms
────────────────────────────────────
❌ AI pre-fills form, user can't change values
"The AI has determined these values are correct."
[locked input fields]
✅ Always allow override
Users must be able to correct AI mistakes.
This is not just UX — it's the HITL requirement from AI 05.
Trust = transparency + editability.
Anti-Pattern 4: Silent Failures
─────────────────────────────────
❌ Tool call fails → blank space appears
LLM tried to render chart → tool threw error → {nothing}
✅ Always surface failures with context
Show: what failed, why (if known), what the user can do.
<ErrorCard
message = "Chart data unavailable: ERP connection timeout"
action = "Retry"
onAction = {() => refetch()}
/>
Anti-Pattern 5: Lock-In to the AI's Choice
────────────────────────────────────────────
❌ Only display the chart the AI chose
✅ Let users switch visualization
AI renders bar chart → user can switch to table via button
AI renders table → user can export to CSV
Generative UI is a starting point, not a prison.
Anti-Pattern 6: Ignoring Mobile Context
────────────────────────────────────────
❌ LLM renders <DataTable rows={847} columns={10} /> on mobile
→ Unusable horizontal scroll on a 375px screen
→ User sees 2 columns and abandons the tool
✅ Two-pronged defense:
1. Make tools device-aware (pass context to system prompt):
const deviceType = getDeviceType(userAgent); // "mobile" | "tablet" | "desktop"
system: `User device: ${deviceType}.
On mobile: prefer KPI cards or summary lists.
On desktop: full tables and charts are appropriate.`
2. Make components responsible for their own responsive behavior:
<DataTable
columns = {columns}
data = {rows}
mobileView = "card-list" // ← shows card per row on mobile
desktopView = "full-table"
/>
The LLM decides WHAT to show;
the component decides HOW to show it on each device.
Anti-Pattern 7: No React Error Boundary Around Dynamic Components
────────────────────────────────────────────────────────────────
❌ Dynamically injected component throws during render
→ Entire chat UI crashes
→ All previous messages disappear
→ User sees a white screen
(Zod validates props at the tool boundary, but components can still
throw during render when unexpected data shapes pass through.)
✅ Wrap every AI-rendered component in <ErrorBoundary>
Each message slot gets its own boundary so one crash
doesn't destroy the entire conversation.
// components/AIMessageBoundary.tsx
"use client";
import { Component, ReactNode } from "react";
interface State { hasError: boolean; error?: Error; }
export class AIMessageBoundary extends Component<
{ children: ReactNode; fallbackMessage?: string },
State
> {
state: State = { hasError: false };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
// Log to your observability stack (AI 09 §10)
console.error("AI component render error:", error, info.componentStack);
}
render() {
if (this.state.hasError) {
return (
<div className="ai-error-card" role="alert">
<p>⚠️ {this.props.fallbackMessage ?? "This response couldn't be rendered."}</p>
<pre>{this.state.error?.message}</pre>
<button onClick={() => this.setState({ hasError: false })}>Retry</button>
</div>
);
}
return this.props.children;
}
}
// Usage: wrap each message individually
// components/MessageList.tsx
export function MessageList({ messages }: { messages: Message[] }) {
return (
<div className="message-list">
{messages.map(msg => (
<AIMessageBoundary
key = {msg.id}
fallbackMessage = "AI response unavailable. The data format was unexpected."
>
{msg.display} {/* Could be text, chart, table, or form */}
</AIMessageBoundary>
))}
</div>
);
}
🔧 Engineer’s Note: Anti-Pattern 3, 4, and 7 are the most critical for enterprise AI adoption. Enterprise users — accountants, controllers, compliance officers — have been burned by software that was “always right.” They will not trust an interface that doesn’t let them correct the AI, surfaces blank screens instead of errors, or crashes the entire conversation when one chart renders badly. Every AI-generated form must be editable. Every AI failure must produce a clear message. Every dynamic component must live inside an
<ErrorBoundary>. Trust is the product.
9. Accessibility & Fallback Strategies
9.1 Generative UI Accessibility Requirements
Dynamically rendered components create unique accessibility challenges — the page structure changes unpredictably from the user’s perspective. Standard ARIA patterns address this:
// Accessible AI response container
function AIChatMessage({ role, content }: { role: string; content: React.ReactNode }) {
return (
<article
aria-label = {`${role === "assistant" ? "AI" : "You"} said:`}
aria-live = {role === "assistant" ? "polite" : undefined}
aria-atomic = "true"
className = {`message message--${role}`}
>
{/* Screen reader announcement when new AI response arrives */}
{role === "assistant" && (
<span className="sr-only">AI response:</span>
)}
{content}
</article>
);
}
// Accessible data table (generated by AI)
function AccessibleDataTable({ columns, data, caption }: DataTableProps) {
return (
<div role="region" aria-label={caption || "Data table"}>
<table aria-describedby="table-caption">
{caption && (
<caption id="table-caption">{caption}</caption>
)}
<thead>
<tr>
{columns.map(col => (
<th
key = {col.key}
scope = "col"
aria-sort = {col.sortable ? "none" : undefined}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, i) => (
<tr key={i}>
{columns.map(col => (
<td key={col.key} data-label={col.header}>
{renderCell(row[col.key], col.type)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
// Accessible bar chart (SVG-based with ARIA)
function AccessibleBarChart({ data, title }: BarChartProps) {
return (
<figure aria-labelledby="chart-title">
<figcaption id="chart-title">{title}</figcaption>
<svg
role = "img"
aria-label = {`Bar chart: ${title}. ${
data.map(d => `${d.label}: ${d.value}`).join(", ")
}`}
>
{/* Chart SVG content */}
</svg>
{/* Accessible tabular fallback for screen readers */}
<details className="chart-data-table">
<summary>View chart data as table</summary>
<table>
<thead><tr><th>Category</th><th>Value</th></tr></thead>
<tbody>
{data.map(d => (
<tr key={d.label}>
<td>{d.label}</td>
<td>{d.value.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</details>
</figure>
);
}
9.2 The Text Fallback Strategy
Not every client can render rich components: API clients, mobile apps without your component library, email notifications, print views. Build text fallback into your tool response layer:
// Every UI tool generates both a component AND a text fallback
type UIToolResult = {
component: React.ReactNode; // For web UI
textFallback: string; // For API/email/print/export
structuredData: unknown; // For programmatic use
};
async function generateRevenueChart(params: ChartParams): Promise<UIToolResult> {
const chartData = await processChartData(params);
const text = formatChartAsText(chartData, params.title);
// → Store textFallback in DB alongside the message record
// so exported chat logs or non-React viewers can retrieve it
await db.message.create({
data: {
conversationId: params.conversationId,
role: "assistant",
componentType: "revenue_chart",
textFallback: text, // ← persisted
structuredData: JSON.stringify(chartData),
}
});
return {
component: <RevenueBarChart data={chartData} title={params.title} />,
textFallback: text,
structuredData: chartData,
};
}
function formatChartAsText(data: ChartData[], title: string): string {
const maxLabelWidth = Math.max(...data.map(d => d.label.length));
const maxValue = Math.max(...data.map(d => d.value));
const rows = data
.sort((a, b) => b.value - a.value)
.map(d => {
const bar = "█".repeat(Math.round((d.value / maxValue) * 20));
return `${d.label.padEnd(maxLabelWidth)} ${bar} ${d.value.toLocaleString()}`;
});
return `${title}\n${"─".repeat(50)}\n${rows.join("\n")}`;
}
// Output for email/API:
// Q3 Revenue by Region
// ──────────────────────────────────────────────────
// North America ████████████████████ 18,200,000
// Europe ███████████ 11,400,000
// Asia-Pacific ████████████████ 13,100,000
// Latin America ███ 2,900,000
🔧 Engineer’s Note: Store
textFallbackin your database alongside the component state — not just in memory. When a user exports chat history, views a session in a mobile email client, or your system generates a PDF audit trail, the React component is not available. What is available is the persisted text fallback: a readable, printable representation of every AI response. For regulated industries (finance, healthcare, legal), this also serves as the human-readable audit record of what the AI presented to the user at each decision point.
9.3 Keyboard Navigation & Focus Management
Dynamic UI generation breaks default browser focus flow. When a component replaces a skeleton, focus must be managed explicitly:
"use client";
import { useEffect, useRef } from "react";
// Manage focus when AI response arrives
export function AIResponseContainer({ children, isLoading }: {
children: React.ReactNode;
isLoading: boolean;
}) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// When loading completes, move focus to the new content
if (!isLoading && containerRef.current) {
// Find first focusable element in the new component
const focusable = containerRef.current.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable) {
focusable.focus();
} else {
// If no interactive element, announce to screen reader
containerRef.current.focus();
}
}
}, [isLoading]);
return (
<div
ref = {containerRef}
tabIndex = {-1} // Makes div focusable programmatically
aria-live = "polite"
>
{children}
</div>
);
}
🔧 Engineer’s Note: Accessibility for Generative UI is harder than for static UI, but it’s not optional. Enterprise software buyers often have accessibility requirements baked into procurement. The patterns above — ARIA, text fallbacks, focus management — are the baseline. Run
axe-coreor Lighthouse accessibility audits on your generative components before shipping. An AI-rendered chart that screen readers can’t interpret is a component that fails its users.
10. Key Takeaways
10.1 The Rendering Decision Framework
When building any AI-powered feature, apply this decision tree:
Should this AI response use Generative UI?
Is the output primarily reasoning/explanation?
└─ YES → Use text (CUI). Prose is the right format.
Is the output a single short fact?
└─ YES → Use text. A chart for "Paris" is absurd.
Does the output contain 5+ numbers?
└─ YES → Use table or chart.
Does the output show change over time?
└─ YES → Use line/bar chart.
Does the output require user input?
└─ YES → Use form component.
Does the output require a decision?
└─ YES → Use approval card with HITL (AI 05 pattern).
Is this part of a multi-step workflow?
└─ YES → Use stepper + Agentic UX pattern.
None of the above → Default to text + check back here
next time your users complain.
10.2 Architecture Principles That Transfer
| Principle | Application in Gen UI |
|---|---|
| Separate concerns | LLM decides what to render; your components define how |
| Least privilege | Role-based component registry (CFO gets KPI, auditor gets table) |
| Defense in depth (AI 07) | Zod validation on tool params + data sanitization before render |
| HITL (AI 05, AI 08) | Approval forms as first-class UI components in the registry |
| Eval the output (AI 09) | Test that the LLM selects the right component for each query type |
| Fail gracefully | Every tool call has an error fallback component |
| Streaming | Show skeleton first; real data fills in progressively |
10.3 The Full Picture: AI Stack Updated
AI Engineering Stack (AI 00–10):
══════════════════════════════════════════════════════════════
AI 00 Foundation ← Understand the engine
AI 01 Prompting ← Control the engine
AI 02 Dev Toolchain ← Build with the engine
AI 03 RAG ← Give the engine knowledge
AI 04 MCP ← Connect the engine
AI 05 Agents ← Make the engine act
AI 06 Multi-Agent ← Make engines collaborate
AI 07 Security ← Protect the engine
AI 08 Cross-Domain ← Apply the engine (your moat)
AI 09 Evals & CI/CD ← Verify the engine
AI 10 Generative UI ← Present the engine ← YOU ARE HERE
══════════════════════════════════════════════════════════════
Coming Up:
AI 11 Fine-Tuning ← Customize the engine
10.4 Key Takeaways Summary
| Concept | One-Line Principle |
|---|---|
| CUI limits | Text is universal but not always optimal; match format to information type |
| Architecture | LLM selects from component registry — never generates markup or executes code |
| Vercel AI SDK | streamUI + RSC makes server-streamed components the standard pattern |
| Component registry | Centralize, categorize, and role-restrict available components |
| Streaming | Always show skeleton first; progressive rendering beats waiting for completeness |
| Agentic UX | Stepper + HITL forms + explanation cards turn agents into legible workflows |
| Design patterns | Progressive disclosure, contextual defaults, reversible-first, explain-alongside |
| Anti-patterns | Component overload, surprise transitions, locked forms, silent failures |
| Accessibility | ARIA live regions, text fallbacks, focus management — all non-negotiable |
| Core insight | The same AI that makes correct decisions needs a UI layer that makes those decisions visible |
🔧 Engineer’s Note: Generative UI is the answer to the question “why isn’t anyone using our AI system?” Technically correct AI that presents its results as walls of text never achieves adoption. The CFO didn’t stop caring about the reconciliation — they stopped opening the tool because it showed them a JSON blob. Generative UI closes the loop between AI capability and human adoption. An AI system is not done when it produces correct answers. It’s done when the right people can act on those answers — in the right form, at the right time.
What’s Next: The Series Finale
AI 10 completes the user-facing layer of the AI engineering stack. The series now has one remaining article — the one that closes the customization loop.
When prompt engineering isn’t enough and you need the model itself to change.