Skip to Content
Vektos
Guides

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" } }
] }
PieceFileWhat it does
Catalogcatalog.ts13 read-only components, 6 form components offered on request, and the props a spec may set, as zod schemas
Drawingdraw.tsxEach entry as design-system components
Rendererrender.tsxrenderSpec(spec) → { element, issues }; GeneratedView; formatIssues
Promptprompt.tscatalogPrompt(options) for the system prompt; specJsonSchema(options) for a tool's input
Checkcatalog-types.tsThe 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 className and style never 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. GeneratedView adds 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 Badge fails tsc in catalog-types.ts until the catalog offers it too.
  • Safe by construction. Link takes only paths of the app (/runs?…), never https:, //host or javascript:. 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, schema

In 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 them

While 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:

  1. 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), and children if it holds other nodes. Mirror the component's own enums.
  2. 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).
  3. A line in DRAWERS (render.tsx) and, for each enum, a pair in catalog-types.ts.
  4. Use it in ACCOUNT_OVERVIEW (examples.ts), or in TRANSFER_FORM for a form component (form: true in 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-backup

Known 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 contextHash covers runner/spec.ts and every source of packages/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.json with 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 (default en-US).

  • specJsonSchema() is recursive ($ref); a provider that refuses recursive schemas needs the looser shape and the prompt instead.

On this page