Generative UI

The model answers with UI by calling a tool. Its input names pieces from your catalog; your server checks it, your components draw it, and it reads as Markdown wherever it can't be drawn.

Chat
How’s our open pipeline this week?
query_deals
running
{ "ref": "deals_1", "count": 4,
  "total": 196000,
  "rows": [
    { "company": "Aalto Labs",
      "stage": "Proposal",
      "amount": 62400 },
    { "company": "Kide Oy",
      "stage": "Negotiation",
      "amount": 48000 },
    { "company": "Saari Systems",
      "stage": "Negotiation",
      "amount": 54100 },
    { "company": "Nordvik",
      "stage": "Proposal",
      "amount": 31500 }] }
Back to the model

Nothing yet: the call isn’t whole.

"use client";

import { type BoundData, defaultCatalog, genUiTool, isBinding } from "@intentface/chat/genui";
import { GenUi } from "@intentface/chat/genui-render";
import { Message } from "@intentface/chat/message";
import { parsePartialJson } from "ai";
import { useEffect, useMemo, useRef, useState } from "react";
import { components } from "./components";
import { formatJson } from "./format-json";
import {
  BarButton,
  Caret,
  CodeSection,
  CodeSide,
  CodeTabs,
  DrawnPane,
  Json,
  SplitWindow,
  ToolChip,
  UserBubble,
} from "./frame";

/*
 * One turn of a chat, played back. The person asks a question. The agent runs
 * its own data tool, `query_deals`. Then the model answers with UI: it calls
 * `show_ui`, and the chat draws the call as it streams in. On the right is
 * what the model actually wrote.
 *
 * - The table's rows aren't copied into the call. The query's result carries
 *   a ref, and the table names it: `"$data": "deals_1"`. The chat already
 *   holds that result, so it swaps the rows in (`resolve`, below).
 * - Once the call is whole, it is checked (`accept`). Accepted, it stays on
 *   screen and the model is told what was shown. Refused, the model is told
 *   what to fix and calls again: play it with a mistake.
 *
 * `accept` runs on your server, in the tool's `execute`. It runs here, in the
 * browser, only to stand in for one.
 */

const showUi = genUiTool(defaultCatalog, { name: "show_ui", kind: "spec" });

// What the agent's data tool returned earlier in the turn. The ref is yours to
// make up: anything short the model can copy back.
const QUERY = {
  ref: "deals_1",
  count: 4,
  total: 196000,
  rows: [
    { company: "Aalto Labs", stage: "Proposal", amount: 62400 },
    { company: "Kide Oy", stage: "Negotiation", amount: 48000 },
    { company: "Saari Systems", stage: "Negotiation", amount: 54100 },
    { company: "Nordvik", stage: "Proposal", amount: 31500 },
  ],
};

const RESULTS: Record<string, typeof QUERY> = { [QUERY.ref]: QUERY };

// The show_ui call: a card holding two numbers from the result and a table
// bound to its rows. The mistake is a child id that names no element.
const call = (deals: string) => ({
  root: "card",
  elements: [
    { id: "card", type: "Card", props: { title: "Pipeline" }, children: ["kpis", deals] },
    { id: "kpis", type: "Grid", props: { columns: 2 }, children: ["total", "count"] },
    {
      id: "total",
      type: "Metric",
      props: { label: "Total", value: QUERY.total, format: "currency", currency: "EUR" },
    },
    { id: "count", type: "Metric", props: { label: "Deals", value: QUERY.count } },
    {
      id: "deals",
      type: "DataTable",
      props: {
        columns: [
          { label: "Company" },
          { label: "Stage" },
          { label: "Amount", format: "currency", currency: "EUR" },
        ],
        rows: { $data: QUERY.ref, columns: ["company", "stage", "amount"] },
      },
    },
  ],
});

/**
 * Your chat's half of a binding: find the result its ref names, pick the
 * columns it lists, and file the rows by where the binding sits. Run on a
 * whole call; while it streams, a bound table is simply pending.
 */
const resolve = (input: unknown): BoundData => {
  const data: Record<string, unknown> = {};
  const { elements } = input as { elements: { id: string; props: { rows?: unknown } }[] };
  for (const element of elements) {
    const rows = element.props.rows;
    if (!isBinding(rows) || !Array.isArray(rows.columns)) continue;
    const result = RESULTS[rows.$data];
    if (!result) continue;
    const columns = rows.columns.map(String);
    data[`elements.${element.id}.props.rows`] = result.rows.map((row) =>
      columns.map((column) => row[column as keyof typeof row] ?? null),
    );
  }
  return data;
};

const RIGHT = formatJson(call("deals"), 36);
const WRONG = formatJson(call("deal"), 36);
const QUERY_TEXT = formatJson(QUERY, 36);

// The turn's two messages, as a chat holds them.
const ASK = { role: "user", text: "How’s our open pipeline this week?" };
const ANSWER = {
  role: "assistant",
  text: "Kide Oy and Saari Systems are both in negotiation, and together they make up just over half of the total.",
};
const REPLY = ANSWER.text.split(" ");

type Stage = "querying" | "streaming" | "checking" | "accepted" | "refused" | "replying" | "done";

const Turn = ({ mistake }: { mistake: boolean }) => {
  const [stage, setStage] = useState<Stage>("querying");
  const [attempt, setAttempt] = useState(1);
  const [length, setLength] = useState(0);
  const [words, setWords] = useState(0);
  const [partial, setPartial] = useState<unknown>();
  const [chosen, setChosen] = useState<"query" | "call">();
  const code = useRef<HTMLDivElement>(null);
  const chat = useRef<HTMLDivElement>(null);

  const source = mistake && attempt === 1 ? WRONG : RIGHT;
  const written = source.slice(0, length);
  const whole = useMemo(() => JSON.parse(source) as unknown, [source]);
  const streaming = stage === "streaming";
  // The call being written, unless you picked a tab yourself.
  const tab = chosen ?? (stage === "querying" ? "query" : "call");
  const data = useMemo(() => (streaming ? undefined : resolve(whole)), [streaming, whole]);
  const verdict = useMemo(() => showUi.accept(whole, { data: resolve(whole) }), [whole]);

  // The timeline: query, stream the call, check it, then reply.
  useEffect(() => {
    const after = (ms: number, next: () => void) => {
      const timer = setTimeout(next, ms);
      return () => clearTimeout(timer);
    };
    switch (stage) {
      case "querying":
        return after(900, () => setStage("streaming"));
      case "streaming":
        return length < source.length
          ? after(30, () => setLength((current) => current + 7))
          : after(60, () => setStage("checking"));
      case "checking":
        return after(700, () => setStage(verdict.ok ? "accepted" : "refused"));
      case "refused":
        return after(3200, () => {
          setAttempt(2);
          setLength(0);
          setPartial(undefined);
          setStage("streaming");
        });
      case "accepted":
        return after(400, () => setStage("replying"));
      case "replying":
        return words < REPLY.length
          ? after(45, () => setWords((current) => current + 1))
          : after(0, () => setStage("done"));
      default:
        return undefined;
    }
  }, [stage, length, source.length, words, verdict.ok]);

  // What the AI SDK hands a chat while a call streams: the text so far, parsed.
  useEffect(() => {
    if (!streaming) return;
    let current = true;
    parsePartialJson(written).then(({ value }) => {
      if (current) setPartial(value);
    });
    return () => {
      current = false;
    };
  }, [streaming, written]);

  // Keep the newest line of the call in view while it is written, and the
  // mistake in view once it is refused.
  useEffect(() => {
    const pane = code.current;
    if (!pane || tab !== "call") return;
    const mistake = pane.querySelector<HTMLElement>("[data-mark]");
    if (stage === "refused" && mistake) pane.scrollTop = mistake.offsetTop - pane.clientHeight / 3;
    else if (streaming && written) pane.scrollTop = pane.scrollHeight;
  }, [stage, streaming, written, tab]);

  // And the newest part of the reply, as a chat does: follow it as it grows.
  useEffect(() => {
    const pane = chat.current;
    const content = pane?.firstElementChild;
    if (!pane || !content) return;
    const observer = new ResizeObserver(() => {
      pane.scrollTop = pane.scrollHeight;
    });
    observer.observe(content);
    return () => observer.disconnect();
  }, []);

  const phase = streaming
    ? "streaming"
    : stage === "checking" || stage === "refused"
      ? "complete"
      : "accepted";
  const status =
    stage === "refused" ? (
      <span className="text-red-300">refused</span>
    ) : phase === "accepted" ? (
      <span className="text-emerald-300">accepted</span>
    ) : streaming ? (
      "streaming"
    ) : (
      "checking"
    );

  const back =
    stage === "accepted" || stage === "refused" || stage === "replying" || stage === "done"
      ? showUi.modelText(verdict)
      : stage === "checking"
        ? "Checking the call…"
        : "Nothing yet: the call isn’t whole.";

  return (
    <>
      <DrawnPane ref={chat} title="Chat">
        <div className="flex flex-col gap-4">
          <Message.Root className="flex flex-col" role={ASK.role}>
            <UserBubble>{ASK.text}</UserBubble>
          </Message.Root>
          <Message.Root className="flex flex-col gap-3" isLast role={ANSWER.role}>
            <ToolChip
              detail={stage === "querying" ? undefined : `${QUERY.count} rows`}
              done={stage !== "querying"}
              name="query_deals"
            />
            {stage === "querying" ? null : (
              <div className={`transition-opacity ${stage === "refused" ? "opacity-40" : ""}`}>
                <GenUi.Root components={components} key={attempt} live phase={phase}>
                  <GenUi.Spec data={data} input={streaming ? partial : whole} />
                </GenUi.Root>
              </div>
            )}
            {stage === "refused" ? (
              <p className="text-ink-tertiary text-sm">Refused. The model is calling again…</p>
            ) : null}
            {words > 0 ? (
              <p className="text-ink-body text-md leading-6">{REPLY.slice(0, words).join(" ")}</p>
            ) : null}
          </Message.Root>
        </div>
      </DrawnPane>

      <CodeSide
        footer={
          <>
            <div className="mb-1 font-medium font-sans text-white/50">Back to the model</div>
            <p className="whitespace-pre-wrap">{back}</p>
          </>
        }
      >
        <CodeSection
          header={
            <CodeTabs
              label="Tool calls"
              onChange={setChosen}
              options={[
                { value: "query", label: "query_deals" },
                { value: "call", label: "show_ui" },
              ]}
              value={tab}
            />
          }
          ref={code}
          status={
            tab === "query" ? (
              stage === "querying" ? (
                "running"
              ) : (
                "returned"
              )
            ) : stage === "querying" ? null : (
              <>
                {attempt === 2 ? "attempt 2 · " : null}
                {status}
              </>
            )
          }
        >
          {tab === "query" ? (
            <Json accent={`"${QUERY.ref}"`} text={QUERY_TEXT} />
          ) : stage === "querying" ? (
            <span className="text-white/40">Not called yet.</span>
          ) : (
            <>
              <Json
                accent={`"${QUERY.ref}"`}
                mark={stage === "refused" ? '"deal"' : undefined}
                text={written}
              />
              {streaming ? <Caret /> : null}
            </>
          )}
        </CodeSection>
      </CodeSide>
    </>
  );
};

export const Chat = () => {
  // A new key is a new turn, played from the start.
  const [run, setRun] = useState({ id: 0, mistake: false });
  const play = (mistake: boolean) => setRun((current) => ({ id: current.id + 1, mistake }));
  return (
    <SplitWindow
      bar={
        <>
          <BarButton onClick={() => play(false)}>Play again</BarButton>
          <BarButton onClick={() => play(true)}>Play with a mistake</BarButton>
        </>
      }
      columns="sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)]"
      height="sm:h-152"
    >
      <Turn key={run.id} mistake={run.mistake} />
    </SplitWindow>
  );
};

How it works

Generative UI is a tool call. The model never writes markup or code: it calls a tool whose input is JSON naming pieces from your catalog, and your chat draws that JSON with your own components. The demo above plays one turn:

  1. The agent fetches data with a tool it already has. Here query_deals returns four deals, with a short ref (deals_1) in its result.
  2. The model answers with UI by calling show_ui. The input names elements from the catalog: a Card holding a Grid of two Metrics and a DataTable. The table doesn't copy the four rows. It points at the query's result by its ref.
  3. Your chat draws the call while it streams. Each piece appears once its props parse, drawn by your component for it.
  4. Your server checks the whole call with accept. A mistake is refused with a message that names it, the message goes back to the model as the tool's result, and the model calls again. Play the demo with a mistake to see it.
  5. The model is told what was shown, in one line, and its reply adds only what the UI doesn't say.
  6. You keep the call. It is plain JSON, so you can store it, draw it again later, or read it as Markdown wherever it can't be drawn.

Two kinds of tool cover most answers. show_ui composes a layout from elements, as above. show_view picks one ready-made view and fills it in (see Views). Offer either or both; each is its own tool.

Usage guidelines

  • Experimental — the entry points may change in any minor release while the API settles. Pin an exact version, and check the changelog when upgrading.
  • Three entry points — @intentface/chat/genui is the headless core: the catalog, its zod schemas, the checks, the tool helper and Markdown. It imports no React, so your server uses it to check and store calls. @intentface/chat/genui-render draws them, and @intentface/chat/genui-testing checks your components.
  • Bring your own components — the package works out the logic of every piece and hands your components render props: values formatted, alignment and tone decided. Your components own markup, interaction and styling. Nothing the package ships is styled, so a stored call re-skins itself when your theme changes: it never held a colour. The demos on this page draw with one component map, components.tsx in their code, which you can copy.
  • Every piece reads as Markdown — toMarkdown is required on every catalog entry. It is what the UI becomes on a clipboard, in a model's history, in a search index, in a document an agent reads later, and on screen for a piece your map has no component for.
  • One Root per tool call — how far a call has got and what the reader did to it belong to that call, so render a GenUi.Root for each.
  • Not tied to a chat — the renderer reads no thread or message context. Mount it in a reply, a document, a slide or a read-only share.
  • No AI SDK dependency — the tool helper hands you a description, a JSON schema and validation; wrap them with whichever SDK you use. zod 4 is a peer dependency.
  • Get started — see Quick start to add the package.

Anatomy

tsx
import { GenUi } from "@intentface/chat/genui-render";

<GenUi.Root components={components} phase="accepted">
  <GenUi.Part>                  {/* optional: copying takes the whole UI */}
    <GenUi.Spec input={spec} /> {/* a layout */}
    {/* or <GenUi.View input={view} /> */}
  </GenUi.Part>
</GenUi.Root>

GenUi.Root renders no element; it holds the catalog, your component map, the phase and the reader's state. GenUi.Spec and GenUi.View render no wrapper around what they draw either, so a Grid's children are the grid's children.

The catalog

The catalog is the list of pieces the model may use, and it is also the tool's documentation: the model is sent every piece's description and schema with the tool, so it never has to look anything up. defaultCatalog holds ten elements and six views:

ElementsViews
Card, Stack, Grid (containers)table, chart, metrics
Heading, Text, Metric, KeyValue, DataTable, Chart, Calloutrecord, email, steps

An entry is plain data, read four ways: the model is sent its description and schema; your server checks calls against the schema; the renderer draws only what parses; and its toMarkdown is how it reads everywhere else. Every entry has:

  • description — when to use it, written for the model.
  • props — a zod schema.
  • toMarkdown(props, context) — how it reads as Markdown.
  • example — realistic props. The tool shows them to the model, and tests draw them.
  • container (elements only) — whether it holds other elements.
  • label and summarise (views only) — what people call it, and one line for the model's history.

Optionally, toRender shapes its render props, an element's summarise describes it for the model's history, input lets its data be bound by reference, settle trims a long array while it streams, toRows exports its data as a table, and fixtures holds props in other states worth drawing. Extend or narrow a catalog by spreading it into defineCatalog:

ts
import { z } from "zod";
import {
  defaultCatalog,
  defineCatalog,
  defineElement,
  escapeMarkdownInline,
  formatValue,
} from "@intentface/chat/genui";

const Timeline = defineElement({
  description: "Dated events in order. For a history, not a plan.",
  container: false,
  props: z.object({ events: z.array(z.object({ date: z.string(), text: z.string() })) }),
  toMarkdown: (props) =>
    props.events
      .map((event) => `- ${escapeMarkdownInline(event.date)}: ${escapeMarkdownInline(event.text)}`)
      .join("\n"),
  // Pure: no React. Its component is drawn with what this returns.
  toRender: (props, { locale, timeZone }) => ({
    events: props.events.map((event) => ({
      date: formatValue(event.date, "date", undefined, { locale, timeZone }),
      text: event.text,
    })),
  }),
  example: { events: [{ date: "2026-10-01", text: "Signed" }] },
  fixtures: { empty: { events: [] } },
});

export const documents = defineCatalog({
  ...defaultCatalog,
  elements: { ...defaultCatalog.elements, Timeline },
  limits: { elements: 120, depth: 8 },
});

limits cap a layout at 40 elements, 6 levels deep and 24 children per container by default. checkSpec, canonicalSpec and the tool helper enforce them, and each takes its own limits to tighten them. The children cap is also part of the schema the model is sent, so only the catalog can raise it. The table row cap (500) is part of that schema too, so it is set where the schema is built: createDefaultCatalog({ maxRows }). That function also takes refDescription, the one description for the model only you can write: what your refs look like.

Render props

What the model writes is shaped for the model: raw numbers, rows as arrays, as few tokens as possible. Your components get render props instead, shaped for drawing. Pick an element to see one turned into the other:

Drawn by your component
Open pipeline€240,000+8%
Metric props
the model writes
{ "label": "Open pipeline",
  "value": 240000, "format": "currency",
  "currency": "EUR", "delta": 8 }
render props
your component gets
{ "label": "Open pipeline",
  "text": "€240,000",
  "delta": { "text": "+8%",
    "direction": "up",
    "tone": "positive" },
  "tone": "positive" }
"use client";

import { defaultCatalog, type RenderContext } from "@intentface/chat/genui";
import { GenUi } from "@intentface/chat/genui-render";
import { useState } from "react";
import { components } from "./components";
import { formatJson } from "./format-json";
import { CodeSection, CodeSide, CodeTitle, DrawnPane, Json, SplitWindow, Switch } from "./frame";

/*
 * What the model writes is shaped for the model: raw numbers, rows as arrays,
 * as few tokens as it takes. What your component gets is shaped for drawing:
 * every value formatted, alignment and tone decided. The package works one out
 * from the other (each entry's `toRender`), so your component only draws.
 */

const PIECES = ["Metric", "KeyValue", "DataTable", "Chart"] as const;
type Piece = (typeof PIECES)[number];

const context: RenderContext = {
  locale: "en-GB",
  timeZone: "UTC",
  phase: "accepted",
  state: {},
  update: () => {},
};

// A function in the render props is shown by what it does, not its source.
const shown = (value: unknown): unknown =>
  typeof value === "function"
    ? "(value) => text"
    : Array.isArray(value)
      ? value.map(shown)
      : value && typeof value === "object"
        ? Object.fromEntries(Object.entries(value).map(([key, inner]) => [key, shown(inner)]))
        : value;

const renderOf = (piece: Piece) => {
  const entry = defaultCatalog.elements[piece];
  const props = entry.props.parse(entry.example);
  return (entry.toRender as (props: unknown, context: RenderContext) => unknown)(props, context);
};

export const RenderProps = () => {
  const [piece, setPiece] = useState<Piece>("Metric");
  const example = defaultCatalog.elements[piece].example;
  // Drawn in a card, as it would sit in a reply.
  const spec = {
    root: "card",
    elements: [
      { id: "card", type: "Card", props: {}, children: ["piece"] },
      { id: "piece", type: piece, props: example },
    ],
  };

  return (
    <SplitWindow
      bar={
        <Switch
          label="Element"
          onChange={setPiece}
          options={PIECES.map((value) => ({ value, label: value }))}
          value={piece}
        />
      }
      height="sm:h-120"
    >
      <DrawnPane title="Drawn by your component">
        <GenUi.Root components={components} locale="en-GB" timeZone="UTC">
          <GenUi.Spec input={spec} />
        </GenUi.Root>
      </DrawnPane>
      <CodeSide>
        <CodeSection header={<CodeTitle>{piece} props</CodeTitle>} status="the model writes">
          <Json text={formatJson(example, 40)} />
        </CodeSection>
        <CodeSection header={<CodeTitle>render props</CodeTitle>} status="your component gets">
          <Json text={formatJson(shown(renderOf(piece)), 40)} />
        </CodeSection>
      </CodeSide>
    </SplitWindow>
  );
};
EntryRender props
Card, Stack, Grid, Heading, Text, Callout, emailThe schema props
Metriclabel, text (formatted), delta?: { text, label?, direction, tone }, tone
KeyValueitems: { label, value, text }[]
DataTable, tablecolumns: { key, label, align, display, sortable, format? }[], rows: { key, cells: { value, text }[] }[], count: { total, matching }
Chart, chartkind, categories (labels as shown), series, stacked, format?, formatValue(v), formatTick(v)
metricsitems of Metric's render props
recordname, subtitle?, status?: { label, tone }, fields, note?
stepsitems: { key, title, detail?, status }[]

Every component also gets the schema props as props, a container gets its drawn children, and a view gets its title. A table column's display is always "text" and sortable always false for now; they are there so components written today keep working when those arrive. Values are formatted with Root's locale and timeZone.

Build the map with defineComponents, which checks it against the catalog: an element without a component, or a component whose props don't match its render props, is a type error.

tsx
const components = defineComponents(catalog, {
  elements: {
    Metric: ({ label, text, delta }) => (
      <div>
        {label} <strong>{text}</strong> {delta?.text}
      </div>
    ),
    // …one per element
  },
});

Root reads against the widest catalog you know, so it takes any part of a map: a piece without a component is drawn through Fallback from its Markdown.

Specs and views

A call to a layout tool like show_ui is a spec: a tree of elements the model composes. A call to a view tool like show_view is a view: one ready-made piece the model picks and fills.

Views

Drawn

Deals in play

AccountStageValue
NorthwindProposal€42,000
GlobexDiscovery€18,500
InitechNegotiation€67,000
show_view
the model’s call
{ "view": "table",
  "title": "Deals in play",
  "props": {
    "columns": [{ "label": "Account" },
      { "label": "Stage" },
      { "label": "Value",
        "format": "currency",
        "currency": "EUR" }],
    "rows": [
      ["Northwind", "Proposal", 42000],
      ["Globex", "Discovery", 18500],
      ["Initech", "Negotiation", 67000]] } }
"use client";

import { defaultCatalog, viewToMarkdown } from "@intentface/chat/genui";
import { GenUi } from "@intentface/chat/genui-render";
import { useState } from "react";
import { components } from "./components";
import { formatJson } from "./format-json";
import {
  CodeSection,
  CodeSide,
  CodeTitle,
  DrawnPane,
  Json,
  MarkdownText,
  SplitWindow,
  Switch,
} from "./frame";

/*
 * The six ready-made views. A view tool offers them; the model picks one and
 * fills its props, and that is the whole call. Right is the call, left is
 * what it draws with the components in `components.tsx`, or the Markdown it
 * reads as wherever it can't be drawn. The props are the catalog's own
 * examples: the ones the model is shown.
 */

type Name = keyof typeof defaultCatalog.views;

const TITLES: Partial<Record<Name, string>> = {
  table: "Deals in play",
  chart: "New and renewed, by month",
  metrics: "This week",
  steps: "Before the renewal",
};

const NAMES = Object.keys(defaultCatalog.views) as Name[];

const inputOf = (name: Name) => ({
  view: name,
  ...(TITLES[name] ? { title: TITLES[name] } : {}),
  props: defaultCatalog.views[name].example,
});

const format = { locale: "en-GB", timeZone: "UTC" } as const;

export const Views = () => {
  const [name, setName] = useState<Name>("table");
  const [shown, setShown] = useState<"screen" | "markdown">("screen");
  const input = inputOf(name);

  return (
    <SplitWindow
      bar={
        <Switch
          label="View"
          onChange={setName}
          options={NAMES.map((value) => ({ value, label: value }))}
          value={name}
        />
      }
      height="sm:h-120"
    >
      <DrawnPane
        actions={
          <Switch
            label="Shown as"
            onChange={setShown}
            options={[
              { value: "screen", label: "On screen" },
              { value: "markdown", label: "Markdown" },
            ]}
            value={shown}
          />
        }
        title="Drawn"
      >
        {shown === "screen" ? (
          <GenUi.Root components={components} {...format}>
            <GenUi.View input={input} />
          </GenUi.Root>
        ) : (
          <MarkdownText markdown={viewToMarkdown(input, format)} />
        )}
      </DrawnPane>
      <CodeSide>
        <CodeSection header={<CodeTitle>show_view</CodeTitle>} status="the model’s call">
          <Json text={formatJson(input, 40)} />
        </CodeSection>
      </CodeSide>
    </SplitWindow>
  );
};

A view is { view, title?, props }. Its layout is fixed, so the model only chooses which one and fills its props, which makes a view cheaper and harder to get wrong than a layout. Use a view when one fits the answer, and a layout when the answer needs several pieces together.

Specs

A spec is flat: { root, elements }, with containers listing their children by id. It comes in three forms:

FormShapeWho uses it
SpecInputSchemaElements as a list, each naming its idThe model. Tool-schema converters turn a z.record into "an object with no properties", so the model is never shown the map.
SpecSchemaElements by id, v, bindings in placeWhat you store.
ResolvedSpecSchemaThe same, every bound value in placeWhat is drawn and written as Markdown.

Get all three from specSchemas(catalog).

Accepting a call

canonicalSpec(input) turns a layout call into the spec to store, or into errors. It normalises the two slips models make (component written for type, props written beside type), refuses duplicate ids, validates, checks the tree, and stamps the catalog version. The errors are written for the model, so they can be returned as the tool's result. The tool helper's accept calls it for you.

ts
const result = canonicalSpec(toolInput, { catalog });
if (!result.ok) return { ok: false, errors: result.errors };
await save(result.spec); // { v: 1, root, elements: { … } }

A view takes the same path through canonicalView(input). It reads props written beside view, refuses a view the catalog lacks (listing the ones it has), validates the caption and the props, and stamps the catalog version:

ts
const result = canonicalView(toolInput, { catalog });
if (!result.ok) return { ok: false, errors: result.errors };
await save(result.view); // { v: 1, view: "table", title?, props }

when is reserved as an element key for conditions in a later catalog version. Until then it is refused by name rather than read as a prop, and an element that carries it is left out of what is drawn: drawn without its condition, it could show what was meant to be hidden.

Keeping specs

A spec can outlive the code that drew it by years. Never change an entry incompatibly in place: add ChartV2 beside Chart, or bump the catalog's version and add a migration keyed by the version it migrates from.

ts
const catalog = defineCatalog({
  ...base,
  version: 2,
  migrations: { 1: (spec) => renameType(spec, "Text", "Paragraph") },
});

const result = readStoredSpec(stored, { catalog }); // migrated, then validated

readStoredSpec reads a spec with no v as version 1. It refuses a spec from a newer catalog rather than guessing at it, and parses strictly: the leniency canonicalSpec gives model input never applies to something already stored. migrateSpec(spec, fromVersion, catalog) runs only the migration steps. There is no stored-view reader yet: a stored view is read back by passing it to canonicalView again.

The tool helper

genUiTool(catalog, options) gives your server everything a generative-UI tool needs, with no SDK in it: a description that tells the model when to use UI and the rules a call must follow, the JSON schema it is shown, an example, accept to check a call, and modelText for what to tell the model afterwards. The SDK doesn't enforce the schema; accept does, so a refusal names the field and reaches the model as something it can fix.

ts
import { genUiTool } from "@intentface/chat/genui";
import { jsonSchema, tool } from "ai";

const layout = genUiTool(catalog, { name: "show_ui", kind: "spec" });

export const showUi = tool({
  description: layout.description,
  inputSchema: jsonSchema(layout.inputSchema),
  execute: async (input) => {
    const result = layout.accept(input);
    if (result.ok && "spec" in result) await save(result.spec);
    // The chat reads `ok`; the model reads only the text.
    return { ok: result.ok, text: layout.modelText(result) };
  },
  toModelOutput: ({ output }) => ({ type: "text", value: output.text }),
});

A view tool takes kind: "view" and, optionally, the views it offers; its schema is { view, title?, props }. The helper is built once per catalog and options, so the tool definition stays the same from turn to turn and a provider's prompt cache holds. Before sending history back to the model, compactHistory(messages, { tools }) replaces earlier large UI inputs with a line saying what they showed.

Data by reference

A table's rows, a chart's categories, or a chart series' values can point at data instead of copying it. Copying is where the tokens and the typos are: a model that read 200 rows would retype every cell, and nothing would catch a mistyped one.

In a chat, the data is already there: it is the result of a tool the agent ran earlier in the turn. The demo at the top of this page does this in three steps:

  1. Your data tool puts a short ref in its result. The model sees the result, ref included.

    json
    { "ref": "deals_1", "rows": [{ "company": "Aalto Labs", "stage": "Proposal", "amount": 62400 }, …] }
  2. The model binds the table to that ref and names the columns to pick, in order:

    json
    "rows": { "$data": "deals_1", "columns": ["company", "stage", "amount"] }

    A chart binds one column at a time: "categories": { "$data": "deals_1", "column": "month" }.

  3. Your chat resolves the binding. It finds the result with that ref in the message history, picks the columns, and passes the rows to GenUi.Spec as data, filed by where the binding sits:

    ts
    // `results` maps each ref to its tool result, collected from the messages.
    const resolve = (spec: { elements: { id: string; props: { rows?: unknown } }[] }) => {
      const data: Record<string, unknown> = {};
      for (const element of spec.elements) {
        const rows = element.props.rows;
        if (!isBinding(rows) || !Array.isArray(rows.columns)) continue;
        const result = results[rows.$data];
        if (!result) continue;
        const columns = rows.columns.map(String);
        data[`elements.${element.id}.props.rows`] = result.rows.map((row) =>
          columns.map((column) => row[column] ?? null),
        );
      }
      return data;
    };
    
    <GenUi.Spec input={part.input} data={resolve(part.input)} />;

Resolve a call once it is whole; while it streams, a bound table is drawn as pending. Pass the same values to your server's accept(input, { data }) so the model's summary counts the rows. Tell the model what your refs look like with createDefaultCatalog({ refDescription }).

The package never reads a ref: what it names is up to you. A chat resolves it against a tool result, a document could resolve it against a saved query. A binding only picks, never computes, so every number on screen is one the source returned. If a ref can't be resolved, the piece is drawn through the Unbound slot, and its Markdown comes from onUnbound.

UI state

What the reader does to a drawn UI (an open tab, a ticked item, a sort) is kept beside the spec, never in it: an accepted spec never changes. Nothing in the default catalog keeps state yet. A custom entry does it in its toRender, whose context carries the piece's state and update(patch, reason); it hands your component a callback that calls update.

Root holds the state uncontrolled from defaultState, or controlled through state and onStateChange. A change carries the package's change details, and details.cancel() keeps it from landing.

tsx
const [state, setState] = useState(() => readState(spec, stored));

<GenUi.Root
  components={components}
  state={state}
  onStateChange={(next) => {
    setState(next);
    save(callId, persistedState(next));
  }}
>
  <GenUi.Spec input={spec} />
</GenUi.Root>;

persistedState keeps what outlives the session (open tabs and disclosures, ticked items) and drops the sort, page and search. readState(input, stored) reads stored state back against its spec, dropping pieces that have gone and fields of the wrong type. A read-only share passes nothing and persists nothing.

Markdown

Every spec and view also reads as Markdown: switch the views demo to Markdown to see one. toMarkdown(spec, options) and viewToMarkdown(view, options) write it, each entry deciding how it reads with its own toMarkdown(props, { children, title, state, locale, timeZone }). exportSpec and exportView return the same Markdown plus every table's data as rows, named by the heading before it or the card it sits in; toCsv and toTsv turn those rows into a file.

  • Literal text — whatever the model wrote or a source returned reads as written: nothing in it can format, link, or break the structure around it, and a bare address is not turned into a link. Links come only from you. A custom entry escapes its text the same way, with escapeMarkdown, escapeMarkdownInline and markdownHeading. The email view is the exception: it stays plain text for pasting into a mail client.
  • state — the reader's UI state, so the Markdown follows what they did.
  • locale, timeZone — locale defaults to the runtime's, so pass it on a server: stored Markdown must not change with the host that wrote it. timeZone defaults to UTC and applies to timestamps; a calendar date (2026-11-30) is always read in UTC, so it never becomes the evening before.
  • onUnbound — what a piece reads as when its data is bound and wasn't resolved. The default is null, which leaves the piece out. That suits copying from a chat, where the reader saw what was drawn. A document should return a line saying where the data lives: an agent reading a gap later would take it to mean there is no data.

Copying

A message's copy action reads each part with partToMarkdown(part, { tools, state }): text as written, each UI call as its Markdown at the reader's state. For a selection, wrap each UI in GenUi.Part and put copyWithGenUi on the container's onCopy. A selection that touches a UI copies that UI's whole Markdown in place of its on-screen text, since half a table isn't meaningful Markdown, and the clipboard then holds plain text only. Any other selection copies as the browser always does.

tsx
<div onCopy={copyWithGenUi}>
  <p>{text}</p>
  <GenUi.Root components={components}>
    <GenUi.Part>
      <GenUi.Spec input={spec} />
    </GenUi.Part>
  </GenUi.Root>
</div>

Streaming

While a call streams, the AI SDK hands your chat its input parsed as far as it has arrived (part.input). Pass that to GenUi.Spec or GenUi.View as it is, with a phase that says how far the call has got:

ts
const phase =
  part.state === "input-streaming" ? "streaming"
  : part.state === "output-available" && part.output.ok ? "accepted"
  : "complete";

complete means the input is whole but not yet accepted. It draws like accepted, and an entry's toRender gets the phase, so a piece can hold off anything a person would set until the call is accepted: a refused call is replaced by its retry. Keep drawing a refused call until the retry starts streaming, then drop it, so the UI doesn't vanish and come back.

  • Only what parses is drawn. An element whose type isn't known yet is skipped, and one whose props don't parse yet is drawn by Pending. A container still pending keeps its children on screen.
  • Steady. Streamed JSON goes valid, invalid, valid again many times a second: an enum caught mid-word fails for one chunk. While streaming, each piece keeps the last props that parsed until newer ones do, so it never blinks back. useSteady is exported for your own pieces.
  • Settled. An entry with long arrays can define settle to drop the row still being written, rather than draw it short.
  • Live. With live, each piece goes through Arrive as it lands. Decide it once, at mount: a call reopened from history should draw still.

Once whole, a piece that still doesn't parse is dropped, or drawn by Unbound when the reason is unresolved data. A piece that throws while rendering is dropped by its own error boundary and reported to onError; the rest of the spec still draws.

Testing

@intentface/chat/genui-testing checks a component map against what its render props promise. checkComponents draws every fixture of the catalog (each piece's example, its own fixtures, and mid-stream) through your map in a real DOM, and returns what fails as strings, so it works under any test runner:

ts
import { checkComponents } from "@intentface/chat/genui-testing";

test("our components draw every piece", async () => {
  expect(await checkComponents(components, { catalog })).toEqual([]);
});

It checks what a reader gets, not markup: no piece throws or draws nothing, a container draws its children, and every piece has a component. For the built-in entries it also checks that every text the render props carry is on screen, that a table has its header and row roles, and that a heading has the heading role. Pass the catalog you actually offer: a piece you leave to Fallback is reported as missing. fixtures(catalog) hands the same inputs to your own stories. It needs a DOM (happy-dom, jsdom) and @testing-library/dom.

API reference

GenUi.Root

Holds the catalog, components, phase and state of one UI. Renders no element.

PropTypeDefaultDetails
componentsGenUiComponentMap<C>—
catalogCatalogdefaultCatalog
phase"streaming" | "complete" | "accepted""accepted"
livebooleanfalse
localestring—
timeZonestring"UTC"
stateUiState—
defaultStateUiState—
onStateChange(next, details) => void—
onError(error, piece: { id?, type }) => voidconsole.error

GenUi.Spec

A layout, drawn from its root down.

PropTypeDefaultDetails
inputunknown—
dataBoundData—

GenUi.View

A view, filled with its props. Takes { view, title?, props }; props written beside view are read as its props. Same props as GenUi.Spec.

GenUi.Part

Marks where one UI sits, so copying a selection that touches it takes the UI's whole Markdown. Renders a <div> element, and accepts className, style and render (see Styling).

AttributeDescription
data-genui-part
Always present; its value identifies the part.

Components

PropTypeDefaultDetails
elements{ [name]: ComponentType<render & { props, children? }> }—
views{ [name]: ComponentType<render & { props, title? }> }—
PlaceholderComponentType<{ title? }>—
PendingComponentType<{ type, container, children? }>—
UnboundComponentType<{ id?, type, props, refs }>—
FallbackComponentType<{ markdown, id?, type }>—
ArriveComponentType<{ children }>—
AttributeDescription
data-genui-placeholder
The default Placeholder (aria-busy).
data-genui-pending
The default Pending; data-genui-type names the element.
data-genui-fallback
The default Fallback; data-genui-type names the piece.

Hooks and helpers

  • defineComponents(catalog, map) — the map, checked against the catalog.
  • copyWithGenUi(event) — a copy handler that takes each touched UI's whole Markdown.
  • useGenUiElement() — the piece being drawn: { id, type } for an element, { type } for a view. Use it to look a piece up in state you keep beside the spec, without adding that state to its props.
  • useGenUiLive() — whether the input is live.
  • useSteady(value) — the newest defined value, held across renders. Compared by identity, so pass a memoised value.

Core functions

FunctionWhat it does
defineCatalog, createDefaultCatalog, defineElement, defineViewBuild catalogs and entries.
specSchemas(catalog)SpecInputSchema, SpecSchema, ResolvedSpecSchema, ElementSchema, built once per catalog.
canonicalSpec, canonicalViewA call's input as the spec or view to store, or errors that name the field.
readStoredSpec, migrateSpecA stored spec back, brought forward to the catalog's version.
checkSpec(spec, { catalog?, limits? })Tree rules a schema can't state: dangling, shared and cyclic children, size and depth.
readSpec, normaliseSpec, normaliseViewRead partial input without throwing.
genUiTool, compactHistory, partToMarkdownThe tool helper, history compaction, a message part as Markdown.
initialState, readState, persistedStateUI state: fresh, read back, the part worth storing.
metricRender, tableRender, chartRender, keyValueItemsThe built-in render props, for a custom entry that reuses them.
toMarkdown, viewToMarkdown, exportSpec, exportView, toCsv, toTsvMarkdown, and tables as rows or files.
escapeMarkdown, escapeMarkdownInline, markdownHeadingLiteral text for a custom entry's toMarkdown.
withData, isBinding, hasBinding, bindingRefs, bindingSchemasBindings.
formatValue, formatTick, categoryLabelValues as a reader sees them, with optional locale and timeZone.
fixtures(catalog)Every piece in every state worth drawing, as inputs.