# Generative UI

_Box Kite 2.1.0 · a markdown copy of https://www.box-kite.dev/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.

---

_Every page: https://www.box-kite.dev/llms.txt · every prop, measured: https://www.box-kite.dev/props.md_

