Generative UI
A catalog of the governed design system that a model composes a view from, and the renderer that checks every part and says what it left out.
What
packages/genui lets a model answer with a view instead of text. The model sends one JSON object,
a spec; the renderer draws it with the design system.
{ "type": "Grid", "props": { "columns": 2 }, "children": [
{ "type": "Stat", "props": { "label": "Balance", "value": 48210.55, "format": "currency", "currency": "EUR" } },
{ "type": "Link", "props": { "text": "All runs", "href": "/runs" } }
] }| Piece | File | What it does |
|---|---|---|
| Catalog | catalog.ts | 13 read-only components, 6 form components offered on request, and the props a spec may set, as zod schemas |
| Drawing | draw.tsx | Each entry as design-system components |
| Renderer | render.tsx | renderSpec(spec) → { element, issues }; GeneratedView; formatIssues |
| Prompt | prompt.ts | catalogPrompt(options) for the system prompt; specJsonSchema(options) for a tool's input |
| Check | catalog-types.ts | The compiler fails if a catalog enum differs from the component's own |
The catalog: Stack, Grid, Card, Heading, Text, Stat, DataList, Table, Badge,
Alert, Progress, EmptyState, Link. With { forms: true }, also Form, TextField
(text, email, tel, date, number or several lines), SelectField (options as texts, or { label, value } when what is shown is not what is sent),
ChoiceField (radio buttons),
Checkbox and Switch.
Forms are opt-in. validateSpec, renderSpec, GeneratedView (forms), catalogPrompt,
specJsonSchema and viewInputSchema all take { forms }, off by default: without it a form
component is "not in the catalog", even while a view streams. The benchmark's spec mode asks for
forms; the chat does not. Fields go only inside a Form, and a Form holds no other Form. A
preset value of a select or a choice must be one of its options.
A product's own components. Five are the warehouse's (domain: "warehouse" in their entry;
drawn in draw-warehouse.tsx), offered only with { domain: "warehouse" }: StockTable (on
hand, reserved, available worked out, "Reorder" at or below the reorder point), PickTable
(lines sorted by location, "Picked" or "To pick", the progress), ShipmentStatus (a status code
as a badge), and two form parts, ReceiptLines (a "Received" quantity per order line, filled in,
never above the ordered) and QuantityField (a whole quantity with its unit and what is on hand).
Each answers a whole part of a page in one node, from the data; the shared components remain.
Values bound to data. With data from the host, a value can point at it instead of copying
it: { "$data": "/account/balance" }, a JSON pointer, anywhere a value goes. A table's rows can be
{ "$data": "/transactions" }; a list of objects becomes a list of one field with
{ "$data": "/accounts", "pick": "name" }; a list of pointers joins their values into one text,
{ "$data": ["/customer/firstName", "/customer/lastName"] } for a full name (join sets the
separator, a space by default). A DataList value may be a number, formatted for you, since a
bound value keeps the data's type. validateSpec(spec, { data }), renderSpec(spec, { data }) and GeneratedView's data resolve the pointers first (data.ts); one that leads
nowhere is one issue at its path (""/balance" is not in the task's data."), and a view without
data that uses one is told to write the value. catalogPrompt({ bind: true }) explains the
syntax. The spec keeps the pointers, so a stored view is a template: draw it again with new data
and it shows the new numbers. A Table column whose key is in no row is an issue too, since a
mistyped field name is the easy mistake once rows are bound.
A page profile. validateSpec(spec, { page: true }) also asks for what a whole page needs:
exactly one level-1 Heading, its title, before any other heading, and headings that go down
one level at a time (a Card's title is drawn as an h2, so it counts as level 2). The page
check counts the same things afterwards (axe's page-has-heading-one and heading-order); asked
here, the model fixes them on its next try. catalogPrompt({ page: true }) states the rule. The
chat leaves it off: its answers are parts of a page.
Actions a host offers. { actions } names what forms may run (ActionSpec: a description and
its fields). With actions, every Form must set action to one of them and every field's name
to one of its fields, and the required fields must be there (actionIssues in validate.ts).
Without actions a form may not name one. A form with an action is drawn as ActionForm
(action-form.tsx, a client component): on submit it hands the named values to the host's
handler, given to GeneratedView as onAction; without a handler it stays inert. The chat uses
it to open pages (guide 17).
A generated form sends nothing. It is drawn as <form method="dialog">: outside a dialog the
browser checks the required fields and minLength, then stops, with or without JavaScript. Where
the data would go, and what happens next, is the host's decision, not the model's.
Why
- A spec cannot break the design system. Only catalog components, only their props, only
their values. Unknown props are dropped by the schemas, so
classNameandstylenever reach a component. Amounts and dates are raw values; the design system formats them. - Nothing is lost in silence. A node that is not in the catalog, or whose props fail, is left
out and reported with its path (
$.children[2].props.tone); the rest renders.GeneratedViewadds a plain note: "2 parts of this view could not be shown". A valid-looking spec that shows nothing is the failure the spike found in json-render (anecdote 16). - It renders on the server. The renderer has no hooks: a server component can render a stored view, and the browser downloads nothing for it beyond the design system's own client parts (the form fields are Ark UI components, so a form brings theirs).
- Our own, not json-render. Measured in the spike (journal ch. 42): smaller prompt, every generation rendered on a free model, server rendering, props checked at render time.
- The enums are the components' own. A tone added to
Badgefailstscincatalog-types.tsuntil the catalog offers it too. - Safe by construction.
Linktakes only paths of the app (/runs?…), neverhttps:,//hostorjavascript:. A spec nests at most 12 levels and has at most 400 parts.
Try it
bun run storybook # Generative UI: Playground, Account overview, Transfer form, Broken, Streaming, Prompt
bun test packages/genui # rendering, mistakes, links, streaming, limits, prompt, schemaIn the Playground story, edit the JSON on the left: the view on the right follows, with the list of what could not be shown. From code:
import { GeneratedView, renderSpec, formatIssues } from "@vektos/genui";
<GeneratedView spec={spec} />; // the view, and a note if parts are missing
const { issues } = renderSpec(spec); // what was left out, with paths
formatIssues(issues); // a message asking the model to fix themWhile a spec streams, pass streaming to GeneratedView: a node appears once its props are
complete enough to pass, and nothing is reported until the end.
Extend it
To add a component to the catalog:
- An entry in
CATALOG(catalog.ts): a description the model reads, a zod schema of the props a spec may set (strings with a maximum length), andchildrenif it holds other nodes. Mirror the component's own enums. - Its drawing in
DRAW(draw.tsx). Write layout values as literals in each branch: Panda only generates CSS for values it can read (anecdote 14). - A line in
DRAWERS(render.tsx) and, for each enum, a pair incatalog-types.ts. - Use it in
ACCOUNT_OVERVIEW(examples.ts), or inTRANSFER_FORMfor a form component (form: truein its entry): tests check the two examples use every component.
packages/genui/src is in Panda's include and is graded by the dogfood test like the design
system.
In the chat
show_view (packages/chat/src/view.ts, guide 17) takes the view's root node
as its input, with a plain schema (type from the catalog's names, props, children), and
checks it in full with validateSpec. The richer specJsonSchema() is not what the model gets:
with { view } and that recursive schema, Claude Haiku sent the view as a JSON string one time in
three, often with a brace missing (anecdote 17).
The chat imports @vektos/genui/check: the catalog, the checks and the prompt, without React or
the design system.
In the benchmark: the spec mode
--context genui-spec (packages/bench/src/runner/spec.ts) answers a task with a view instead of
code. The model gets the catalog's prompt, the brief and the task's data, and hands in the view
with submit_view: the same root-node input as the chat's show_view (viewInputSchema,
normalizeViewInput in @vektos/genui/check), checked with validateSpec, three tries. The
accepted view becomes spec.json and a one-line page.tsx that draws it with GeneratedView;
from there gradeRun grades it exactly as a code run. The catalog it offers includes the forms,
and its checks include the page profile. --context genui-spec-data is the same with binding:
the views may point at the task's data, which the page imports from fixture.json and passes as
data. It is a level of its own because it helps one model and costs another (ch. 65): Beam
did 12 of 12 with it, Nemotron 7 to 8 of 12 against 11 without. --context genui-spec-domain
offers the task's product's own components on top of the shared ones (ch. 67); for banking,
which has none, it is genui-spec. What submit_view answered at each try is kept with
the step and shown on the run's page, so a run that ran out of tries says why.
Without --task it runs the six tasks the catalog can express (SPEC_TASKS): loan-contract and
statement, which show data, and card-dispute, kyc-form, notification-settings and
transfer-form, which are forms.
What a form does once sent (an error after a submit, a confirmation in its place, a message that goes away by itself) is not in a view. The page check grades what the page shows when it loads, for code runs as well, so the two modes are graded alike; neither is tested for behaviour.
infisical run -- bun run bench --model reflection/beam-free --context genui-spec --label try --no-backup
infisical run -- bun run bench --model reflection/beam-free --context genui-spec-data --label try --no-backupKnown gaps
-
The spec mode covers six tasks of ten. Dialogs, tabs, pagination and a copy button cannot be a view yet; neither can anything a form does after it is sent.
-
The page profile knows headings only. Landmarks, link names and colour contrast are the page check's, after the run.
-
The context's version is the whole package. The spec mode's
contextHashcoversrunner/spec.tsand every source ofpackages/genui/src, so a change to the drawing alone also counts as a change of context (guide 23). -
A spec run reads its data in the prompt, where a code run reads
fixture.jsonwith a tool. Fine for small fixtures; a large one would cost tokens a code run does not pay. -
Actions are the host's, and few. A form runs only what the host registered; the chat registers two that open pages. No buttons or dialogs outside a form.
-
One example in the prompt. Enough for a free model in the spike; GenUI-2's evals will tell.
-
Dates are formatted in the locale of the nearest
LocaleProvider(defaulten-US). -
specJsonSchema()is recursive ($ref); a provider that refuses recursive schemas needs the looser shape and the prompt instead.