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.
"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:
- The agent fetches data with a tool it already has. Here
query_dealsreturns four deals, with a shortref(deals_1) in its result. - The model answers with UI by calling
show_ui. The input names elements from the catalog: aCardholding aGridof twoMetrics and aDataTable. The table doesn't copy the four rows. It points at the query's result by its ref. - Your chat draws the call while it streams. Each piece appears once its props parse, drawn by your component for it.
- 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. - The model is told what was shown, in one line, and its reply adds only what the UI doesn't say.
- 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/genuiis 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-renderdraws them, and@intentface/chat/genui-testingchecks 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.tsxin their code, which you can copy. - Every piece reads as Markdown —
toMarkdownis 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.Rootfor 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.
zod4 is a peer dependency. - Get started — see Quick start to add the package.
Anatomy
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:
| Elements | Views |
|---|---|
Card, Stack, Grid (containers) | table, chart, metrics |
Heading, Text, Metric, KeyValue, DataTable, Chart, Callout | record, 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.labelandsummarise(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:
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:
"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>
);
};| Entry | Render props |
|---|---|
Card, Stack, Grid, Heading, Text, Callout, email | The schema props |
Metric | label, text (formatted), delta?: { text, label?, direction, tone }, tone |
KeyValue | items: { label, value, text }[] |
DataTable, table | columns: { key, label, align, display, sortable, format? }[], rows: { key, cells: { value, text }[] }[], count: { total, matching } |
Chart, chart | kind, categories (labels as shown), series, stacked, format?, formatValue(v), formatTick(v) |
metrics | items of Metric's render props |
record | name, subtitle?, status?: { label, tone }, fields, note? |
steps | items: { 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.
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
"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:
| Form | Shape | Who uses it |
|---|---|---|
SpecInputSchema | Elements as a list, each naming its id | The model. Tool-schema converters turn a z.record into "an object with no properties", so the model is never shown the map. |
SpecSchema | Elements by id, v, bindings in place | What you store. |
ResolvedSpecSchema | The same, every bound value in place | What 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.
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:
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.
const catalog = defineCatalog({
...base,
version: 2,
migrations: { 1: (spec) => renameType(spec, "Text", "Paragraph") },
});
const result = readStoredSpec(stored, { catalog }); // migrated, then validatedreadStoredSpec 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.
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:
-
Your data tool puts a short
refin its result. The model sees the result, ref included.json{ "ref": "deals_1", "rows": [{ "company": "Aalto Labs", "stage": "Proposal", "amount": 62400 }, …] } -
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" }. -
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.Specasdata, 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.
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,escapeMarkdownInlineandmarkdownHeading. Theemailview 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—localedefaults to the runtime's, so pass it on a server: stored Markdown must not change with the host that wrote it.timeZonedefaults 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 isnull, 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.
<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:
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.
useSteadyis exported for your own pieces. - Settled. An entry with long arrays can define
settleto drop the row still being written, rather than draw it short. - Live. With
live, each piece goes throughArriveas 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:
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.
| Prop | Type | Default | Details |
|---|---|---|---|
components | GenUiComponentMap<C> | — | |
catalog | Catalog | defaultCatalog | |
phase | "streaming" | "complete" | "accepted" | "accepted" | |
live | boolean | false | |
locale | string | — | |
timeZone | string | "UTC" | |
state | UiState | — | |
defaultState | UiState | — | |
onStateChange | (next, details) => void | — | |
onError | (error, piece: { id?, type }) => void | console.error |
GenUi.Spec
A layout, drawn from its root down.
| Prop | Type | Default | Details |
|---|---|---|---|
input | unknown | — | |
data | BoundData | — |
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).
| Attribute | Description |
|---|---|
data-genui-part |
Components
| Prop | Type | Default | Details |
|---|---|---|---|
elements | { [name]: ComponentType<render & { props, children? }> } | — | |
views | { [name]: ComponentType<render & { props, title? }> } | — | |
Placeholder | ComponentType<{ title? }> | — | |
Pending | ComponentType<{ type, container, children? }> | — | |
Unbound | ComponentType<{ id?, type, props, refs }> | — | |
Fallback | ComponentType<{ markdown, id?, type }> | — | |
Arrive | ComponentType<{ children }> | — |
| Attribute | Description |
|---|---|
data-genui-placeholder | |
data-genui-pending | |
data-genui-fallback |
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
| Function | What it does |
|---|---|
defineCatalog, createDefaultCatalog, defineElement, defineView | Build catalogs and entries. |
specSchemas(catalog) | SpecInputSchema, SpecSchema, ResolvedSpecSchema, ElementSchema, built once per catalog. |
canonicalSpec, canonicalView | A call's input as the spec or view to store, or errors that name the field. |
readStoredSpec, migrateSpec | A 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, normaliseView | Read partial input without throwing. |
genUiTool, compactHistory, partToMarkdown | The tool helper, history compaction, a message part as Markdown. |
initialState, readState, persistedState | UI state: fresh, read back, the part worth storing. |
metricRender, tableRender, chartRender, keyValueItems | The built-in render props, for a custom entry that reuses them. |
toMarkdown, viewToMarkdown, exportSpec, exportView, toCsv, toTsv | Markdown, and tables as rows or files. |
escapeMarkdown, escapeMarkdownInline, markdownHeading | Literal text for a custom entry's toMarkdown. |
withData, isBinding, hasBinding, bindingRefs, bindingSchemas | Bindings. |
formatValue, formatTick, categoryLabel | Values as a reader sees them, with optional locale and timeZone. |
fixtures(catalog) | Every piece in every state worth drawing, as inputs. |