gen ui demo
Type a question. A chain of Claude tool-use calls fetches live data, picks components from a framework-agnostic web-component library, gives the content structure, and arranges it into a layout. Plain code runs between the model calls and enforces the design rules. The renderer is plain Svelte. The LLM never writes code.
The demo runs as a standalone app at
gen-ui.jeffmills.dev. Reviewers can clone
the repo and npm run dev in under a minute, with a mock
mode that needs no API key.
how it works
+ emit components
normalize (code)
never sizes
one summary line per component
repair + typeset (code)
- Stage 1, data and components
- The only agentic stage. The model gets one flat list of tools of two kinds. Data tools are executed: keyless public APIs for weather, exchange rates, and encyclopedia summaries. Component tools are never executed. The call is the output: the app records the tool input as a component instance and replies "rendered" so the model carries on. Around thirty component tools are generated from the Shoelace manifest, plus hand-written chart and table tools. The first round must call a tool, so the model cannot answer in prose.
- Stage 2, structure and roles
- A second model reorders and groups the components and marks a role on every piece of text: card title, headline figure, body, caption. It never chooses a size. If its answer is missing or malformed, stage 1's list passes through unchanged, and a rewrite that introduces a number stage 1 did not produce is rejected for that component.
- Stage 3, layout
- A cheaper model never sees the markup. It gets one summary line per component (id, tag, group, a density summary, and a minimum width the code computed) and must call a single layout tool whose pattern enum is generated from the layout spec. A small, structural task is why the cheaper model is enough.
- Code passes, between and after
- Normalization strips inline styles, invented classes, scripts, and images that did not come from a tool result. Layout repair guarantees every component appears exactly once, rows use one of the closed patterns, nothing sits in a slot narrower than its minimum, and a group stays together. Typesetting turns each role into a type style and steps down a size ladder until the text fits its slot, with one size shared across a group so cards in a row match.
- Follow-ups and memory
- A small final call suggests three next prompts and can never fail the request. The server is stateless: the browser sends back each earlier prompt with a short note of what was shown, and every turn fetches its data again instead of trusting numbers from an earlier one.
the layout spec: one file, four consumers
A design system's layout rules usually live in a Figma file and in designers' heads. To get a model to follow them you write them into a prompt, and to guarantee them you write them again as code. Two copies drift.
Here the rules are data. One spec file holds the grid, a closed set of six row patterns, the narrowest slot each kind of component may occupy, the grouping rules, and a table mapping text roles to type styles with a size ladder for each. Four things are generated from it: the prompt text, the layout tool's schema, the stylesheet, and the validators the repair pass runs.
rowPatterns: [
{ id: "1-up", tracks: [1] },
{ id: "2-up", tracks: [1, 1] },
{ id: "2-up-wide-left", tracks: [2, 1] },
{ id: "2-up-wide-right", tracks: [1, 2] },
{ id: "3-up", tracks: [1, 1, 1] },
{ id: "4-up", tracks: [1, 1, 1, 1] },
] Change a number in the spec and the prompt and the enforcement change together. The model is asked for the pattern, and the code guarantees it. That split is the main thing I would carry into any production version: a prompt is a request, and anything that must hold needs to be checked by something that cannot be talked out of it.
roles, not sizes
Asking a model to pick font sizes produces pages where every card makes its own decision. The model is good at knowing that a string is a headline figure and bad at knowing whether it fits in a 240px slot. So the model labels meaning and code does the fitting.
<span data-role="stat-value">1 USD = 0.92 EUR</span>
<span data-role="caption">as of today</span> The typesetting pass computes each component's slot width from the repaired layout, estimates the text's width at each step of that role's ladder, and takes the largest size that fits. Three figures in one row step down together, so the row reads as a set.
data tools shaped for a model
- Small, flat, labelled, pre-rounded. A tool returns what the page needs, not the raw API response. Arithmetic happens in code, so the model does none.
- Honest failure. An error goes back to the model as data, with an instruction to say the data could not be retrieved and not to fill the gap from memory. A tool that throws never fails the run. Ask for the weather in a place that does not exist and you get a warning on the page and everything else intact.
- Caveats travel with the data. A result can carry a note the model is told to pass on when it changes how a figure should be read.
- Bounded latency. Every call has an abort timeout and a short cache, because one prompt often asks about the same thing twice.
tested where it can be
Model output is not unit-testable. Everything around it is. The normalize, repair, and typeset passes are pure functions, and the orchestrator takes the model and the network as arguments, so the whole pipeline runs under test with a scripted model. The suite has a little over a hundred tests, including a property-style test that feeds the repair pass hundreds of random layout trees and checks that what comes out always validates against the spec. The page also has a "what the pipeline did" panel that shows each repair the code made on a live run.
what this proved
- The composition / rendering split holds. The LLM reasons about which components and what content; the application code draws. Each side is good at its job.
- Tool schemas are the contract. Constraining all model output to typed JSON eliminates the code-correctness and injection risks that block production use of free-form LLM output.
- The manifest is the API surface. Because the
component tools are generated from
custom-elements.jsonat build time, the design system stays the source of truth. The quality of that manifest decides whether this works at all. - Design rules belong in data, enforced in code. A prompt gets you most of the way. The repair and typesetting passes are what make the layout dependable.
- Each stage needs only what its job requires. The layout model sees summaries, not markup. The structure model sees components, not history. Smaller inputs let cheaper models do the work and make each stage easier to reason about.
- A well-designed design system is the substrate. Framework-agnostic web components with constrained props were the right shape for this years before generative UI was a real category.
known gaps
- No streaming. A prompt waits for several sequential model calls, and the progress labels are timers.
- The sanitizer is an allowlist scanner, not a parser. It is enough for a demo that renders its own model's output. It is not what I would ship in front of untrusted input.
- Sizes are fitted to one canvas width and do not re-fit on resize.
stack
- Frontend
- Astro, Svelte 5 (runes), Shoelace 2.x web components, Chart.js, Tailwind v4 for tokens
- Pipeline
- Anthropic Messages API, multi-stage tool use with a tool-calling loop, prompt caching on tools, system prompt, and loop rounds
- Layout rules
- One spec file based on IBM Carbon's grid, spacing, and type tokens, generating prompt text, tool schema, CSS, and validators
- Data
- Keyless public APIs for weather, exchange rates, and encyclopedia summaries
- Tool generation
-
Build-time parser over Shoelace’s
custom-elements.jsonmanifest, emitting one Anthropic tool spec per component - Tests
- Vitest over the deterministic passes and the full pipeline with a scripted model
- Hosting
- Cloudflare Pages + Pages Functions, key in Pages env vars
Write-ups: Generating UI Without Generating Code · live demo · source