Generative UI
Your users ask; your app builds. The catalog says what a model may compose, the renderer renders what came back, and every decision in between stays the app's.
Import
JSX
import { catalog } from '@box-kite/react/catalog';
import SpecRenderer, { createSpecRegistry, specSchema } from '@box-kite/react/spec';Ask for a dashboard, watch it arrive
Pick a prompt and press Generate. What comes back is a JSON tree of component names, props and
$data paths, rendered here as real DashboardGrid, Widget, Sparkline, Gauge and DataGrid components — the same ones the rest of this site is built from.3020 characters, nothing refused
Panel theme
Revenue
Twelve weeks, in thousands
Conversion
62% of visits
Fulfilment
78% shipped on time
Recent orders
The six most recent, newest first
Customer
Channel
Placed
Total
Ana Muresan
Web
18 Sep
412.5
Bo Lindqvist
Partner
18 Sep
1290
Chidi Okafor
Web
17 Sep
87.25
Dilnoza Karimova
Retail
17 Sep
640
Elena Rusu
Web
16 Sep
215.8
Farid Haidari
Partner
16 Sep
998.4
Press Enter or Space to pick the widget up, the arrow keys to move or resize it, Enter to drop it and Escape to put it back.
Recorded here, live in the example app
This site is prerendered and served as static files, so there is no server to hold an API key: the three generations above are recordings, replayed a character at a time the way a stream delivers one. Everything else on this page is the real thing — this catalog, this registry, this renderer, and the same partial JSON an app gets from
streamObject. The live route is in examples/next-app, below.Three calls, and the app owns all three
The catalog describes what may be built, the registry pairs each name with the component that renders it, and
specSchema() turns the two into the constraint the model generates under — so the thing a model is held to and the thing that renders cannot disagree.JSX
import { catalog } from '@box-kite/react/catalog';
import SpecRenderer, { createSpecRegistry, specSchema } from '@box-kite/react/spec';
// 1. What may be built: every component and every value its props take, as JSON Schema.
const allowed = catalog({
include: ['DashboardGrid', 'Widget', 'Sparkline', 'Gauge', 'DataGrid'],
styleProps: ['d', 'gap', 'p', 'color', 'width', 'height'],
});
// 2. What each name renders. A name this does not hold renders nothing at all.
const registry = createSpecRegistry({ catalog: allowed, components: { DashboardGrid, Widget, Sparkline, Gauge, DataGrid } });
// 3. The constraint the model generates under, out of the same rules the renderer enforces.
const schema = specSchema(registry, { bindings: true });JSX
<SpecRenderer spec={spec} registry={registry} data={data} onIssues={setIssues} />It arrives in pieces, and it renders in pieces
A structured-output stream delivers the same object with a few more characters of it each time, so the renderer is built to be handed half of one: a node whose
type has not arrived yet renders nothing and reports nothing — it is a frame, not a fault — while a prop that is still half a string fails its own schema and is dropped until it is whole. Every node has an error boundary of its own, so a component that throws on props a model invented costs that node and nothing around it.A stream is not monotone, though — a column's
align passes through "e" on its way to "end", and a value that fails its own schema takes the object it sits in with it — so a prop a component cannot do without can go missing for a frame. What a node has been shown with it is not stripped of: the renderer keeps the last value each node was given for such a prop, and a grid on screen stays there. On top of that, a heavy component is the app's to gate — the grid below is a placeholder until the spec has arrived, which is one line in the registry rather than anything the spec knows about.JSX
const { partialObjectStream } = streamObject({
model: anthropic('claude-sonnet-5'),
schema: jsonSchema(specSchema(registry, { bindings: true })),
prompt,
});
for await (const partial of partialObjectStream) setSpec(partial);The model says where to look; the app says what is there
A generated tree carries styling, not values:
{ "$data": "orders" } is a path into whatever the app passed as data, $item and $index read from a repeat, and a reference is resolved before the prop is validated — so what the host supplied is what is judged, rather than the spelling of the path. It is a path, never an expression: there is no eval anywhere in the renderer.JSX
{
"type": "Widget",
"props": { "id": "orders", "name": "Orders" },
"slots": { "title": ["Recent orders"] },
"children": [
{
"type": "DataGrid",
"props": {
"data": { "$data": "orders" },
"def": { "rowKey": "id", "columns": [{ "key": "customer", "header": "Customer" }] }
}
}
]
}What it may not do
The demo below is the same dashboard asking for four things it may not have: a colour outside the palette, a component nobody registered, an action on a component that declares no events, and a path into data that is not there. Each one is refused on its own and reported to
onIssues with the path it happened at — and the rest of the tree renders around it, because a generated UI that goes blank over one bad prop is a generated UI nobody ships. The ring is refused twice over, which is the case worth watching: its path resolves to nothing, so the value it cannot do without never arrives — and a component is never handed a prop it needs, so the ring renders nothing rather than throwing.Revenue
Margin
Press Enter or Space to pick the widget up, the arrow keys to move or resize it, Enter to drop it and Escape to put it back.
A colour is a token, so the theme is not the model's
The only colours a spec can name are the ones the catalog lists, and a token is a variable rather than a value — so the dashboard above flips with the two buttons above it, and it would flip with your app's own theme in exactly the same way. A model that writes
indigo-500 has not chosen what indigo looks like in dark mode; it has chosen a name your theme already answers. #ff00ff matches no token, so it is dropped rather than painted.Against a real model
The model call is a server call, so the constraint has to be reachable from a server:
specSchema() is exported from @box-kite/react/catalog as well as from /spec, and that entry renders nothing and carries no use client banner. The whole route is this:app/api/generative/route.ts
JSX
import { anthropic } from '@ai-sdk/anthropic';
import { catalog, specSchema } from '@box-kite/react/catalog';
import { jsonSchema, streamObject } from 'ai';
const allowed = catalog({
include: ['DashboardGrid', 'Widget', 'Sparkline', 'ProgressRing', 'Gauge', 'DataGrid', 'Flex'],
styleProps: ['d', 'gap', 'p', 'ai', 'jc', 'color', 'width', 'height'],
});
export async function POST(request: Request) {
const { prompt } = await request.json();
const result = streamObject({
model: anthropic('claude-sonnet-5'),
schema: jsonSchema(specSchema(allowed, { bindings: true })),
system: 'Lay out a dashboard. Bind every value with { "$data": "<path>" } — never write one.',
prompt,
});
return result.toTextStreamResponse();
}The client half is
<SpecRenderer> over whatever the stream has delivered so far, and the data the app was always going to pass. It is in examples/next-app in the repository, built and smoke-tested on every commit; without an ANTHROPIC_API_KEY the route answers 503 and the page says so, which is also what CI checks.The trust boundary
The model composes the UI. It never executes code and never reaches data the app did not hand it, and that is a property of the renderer rather than of the prompt:
- A component name it did not register renders nothing at all — the registry is an allow-list, not a filter.
- A prop its component’s schema refuses is dropped, and the node renders with the rest of its props.
- The only prop that can become a function is one the catalog lists as an event, and what an action means is the app’s.
- A value is a path into the data prop: a path, never an expression, and there is no eval and no dangerouslySetInnerHTML anywhere in the renderer.
- HTML attributes are left out of the catalog, so a generated link has nowhere to put an href until the app opens one up — with the grammar it opens it up with.
- maxNodes and maxDepth end a runaway tree, and everything refused is reported rather than swallowed.