Hero image for Generative UI: When AI Renders the Interface

Generative UI: When AI Renders the Interface

AI GenerativeUI Vercel AI SDK React Frontend Streaming AgenticUX LLM Next.js

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)

  1. The Limits of Conversational UI — When the chat box gets in the way
  2. 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 DivestreamableUI 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 TypeCUI (Text)Generative UIWinnerReason
Simple Q&A✓ ConciseOverkillCUIOne-line answers don’t need a chart
Comparison (2+ items)✗ Verbose table✓ Data tableGen UITables scan faster than prose lists
Trend over time✗ Unreadable✓ Line/bar chartGen UIHumans see trends visually
Numerical data (5+ numbers)✗ Hard to parse✓ Chart or tableGen UIEyes vs. brain for parsing
Multi-step workflow✗ Linear prose✓ Stepper/wizardGen UIProgress visibility reduces anxiety
Form input needed✗ Awkward in chat✓ Native formGen UIStructured input > freeform text
Explanation / Reasoning✓ NaturalUnnecessaryCUIProse is the right format for reasoning
Alert / Warning✓ ImmediateOverkillCUIDon’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: streamUI runs on the server, returns a stream of React components, and useUIState holds 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_form tool (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 calling done(), the client hangs indefinitely waiting for more stream data. Wrap every streaming action in try-catch, and always call uiStream.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_human decision 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 workflowId in 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 textFallback in 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-core or 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

PrincipleApplication in Gen UI
Separate concernsLLM decides what to render; your components define how
Least privilegeRole-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 gracefullyEvery tool call has an error fallback component
StreamingShow 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

ConceptOne-Line Principle
CUI limitsText is universal but not always optimal; match format to information type
ArchitectureLLM selects from component registry — never generates markup or executes code
Vercel AI SDKstreamUI + RSC makes server-streamed components the standard pattern
Component registryCentralize, categorize, and role-restrict available components
StreamingAlways show skeleton first; progressive rendering beats waiting for completeness
Agentic UXStepper + HITL forms + explanation cards turn agents into legible workflows
Design patternsProgressive disclosure, contextual defaults, reversible-first, explain-alongside
Anti-patternsComponent overload, surprise transitions, locked forms, silent failures
AccessibilityARIA live regions, text fallbacks, focus management — all non-negotiable
Core insightThe 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.

AI 11: Fine-Tuning & SLMs →

When prompt engineering isn’t enough and you need the model itself to change.