Lesson 8 of 8
Intelligent UI
Let a model answer with real, accessible interfaces: charts, forms, and buttons that stream in safely.
What Intelligent UI is
Chat assistants are moving past walls of text. OpenAI's Intelligent UI in ChatGPT mixes prose with charts, forms, buttons, and small tools that appear while the answer streams, and a button press becomes the next turn of the conversation. Other efforts, from structured-output component catalogs to generative UI toolkits, share the idea: the model may only compose components your app registered, and your app renders them. The interface becomes part of the answer, and the model never ships code.
How comp0 fits
A model is a poor judge of accessibility, so comp0 makes it part of the contract. @comp0/genui is a model-facing layer on top of comp0 with no third-party runtime and no provider lock-in. Its catalog describes every prop with a Standard Schema, the single source of the validator, the JSON Schema for structured outputs, and the system prompt. Every control, chart, table, and image requires its accessible name, and a component without everything it requires is not drawn. Components render only the props their schema declares, so no model-provided style, event handler, or unsafe URL reaches the DOM.
import { catalog, genuiPrompt, responseJsonSchema } from "@comp0/genui";
catalog.map((entry) => entry.name); // Stack, Card, Select, BarChart, Table, ...
genuiPrompt(); // the system prompt that teaches a model the catalog
responseJsonSchema(); // the JSON Schema for structured outputsInstall the packages
Add the integration next to comp0 and the peers it asks you to own. Nothing here needs an API key: you bring your own model provider, and comp0 never calls one.
pnpm add @comp0/genui @comp0/react react react-domThe format the model writes
A response is one JSON object, normally a Stack. Every component is an object with a type and its props inline, containers take children, and data such as options, chart points, or table rows is plain JSON. A control that other parts depend on writes a binding, { "$bind": "seats", "initial": 5 }, and a computed value writes an expression, { "$expr": "seats * 12" }, over the bound names. Expressions run in a small whitelisted evaluator with arithmetic, comparisons, and a few functions such as round and sum: there is no eval, no property access, and no way to reach globals.
{
"type": "Stack",
"children": [
{
"type": "Slider",
"label": "Seats",
"name": "seats",
"min": 1,
"max": 25,
"value": { "$bind": "seats", "initial": 5 }
},
{
"type": "Output",
"label": "Cost for a year",
"value": { "$expr": "seats * 12 * 12" },
"unit": " USD"
}
]
}Teach the model the catalog
genuiPrompt returns the format rules, the binding and expression rules, every component with its props, comp0's accessibility and safety rules, and worked JSON examples. Send it as the system message to any provider. Add your own rules with the rules option. The same text ships pre-generated as @comp0/genui/genui.prompt.md, and it is linked from llms.txt so coding assistants can read it too.
import { genuiPrompt } from "@comp0/genui";
const system = genuiPrompt({
preamble: "You are a travel assistant.",
rules: ["Prices are in euros."],
});
// Pass `system` as the system message to your provider's chat API.Or constrain the model with a schema
Providers that support structured outputs or tool calls can be held to the format. responseJsonSchema returns the recursive JSON Schema of one root component, where every component has its own definition so containers nest each other. Pass strict: true for strict structured outputs: every property becomes required, optional ones become nullable, and the keywords strict mode rejects are removed. The renderer treats null as left out, so a strict response renders as is. Rules a schema cannot express, such as unsafe URLs or unknown binding names, are still checked by the validator.
const response = await client.chat.completions.create({
model,
messages,
response_format: {
type: "json_schema",
json_schema: { name: "ui", strict: true, schema: responseJsonSchema({ strict: true }) },
},
});Render the stream
Append each chunk your provider streams to a string and hand it to GenUI with streaming. The renderer parses the growing text on every update, so the interface appears top-down: the shell first, then the details. Any provider that can stream text works, so the loop below is yours to fill in. A model may wrap the JSON in a Markdown code fence; the parser skips it.
import { GenUI } from "@comp0/genui";
import { useEffect, useState } from "react";
export function Answer({ stream }: { stream: AsyncIterable<string> }) {
const [response, setResponse] = useState("");
const [streaming, setStreaming] = useState(true);
useEffect(() => {
(async () => {
for await (const chunk of stream) setResponse((text) => text + chunk);
setStreaming(false);
})();
}, [stream]);
return <GenUI response={response} streaming={streaming} />;
}Starter or Pro: which plan fits a team of five?
The assistant's answer appears here.
JSON the model wrote
{
"type": "Stack",
"children": [
{
"type": "Text",
"text": "Pro fits a team of five: Starter allows one seat, and Pro adds review workflows and an audit log."
},
{
"type": "Table",
"caption": "Starter and Pro compared",
"columns": [
"Feature",
"Starter",
"Pro"
],
"rows": [
[
"Projects",
"3",
"Unlimited"
],
[
"Seats",
"1",
"Up to 25"
],
[
"Audit log",
"No",
"Yes"
],
[
"Price per seat",
"$0",
"$12 per month"
]
]
},
{
"type": "Alert",
"title": "Billing",
"tone": "info",
"message": "Pro bills per seat each month. For five seats that is $60, and you can switch back at any time."
},
{
"type": "Stack",
"direction": "row",
"children": [
{
"type": "Button",
"label": "Stay on Starter",
"variant": "secondary",
"message": "I will stay on Starter"
},
{
"type": "Button",
"label": "Upgrade to Pro",
"variant": "primary",
"message": "Upgrade us to Pro"
}
]
}
]
}Turn actions into the next turn
A pressed button, chosen suggestion, or submitted form calls onAction with a message built from the visible labels, such as Plan: Pro; Seats: 5, and a form also reports its values by control name. Send the message as the next user message and the conversation continues. onStateChange and initialState let you save what the person typed and restore it later. When the finished response has problems, onError receives structured errors, and formatErrors turns them into a message you can send back to the model for a repair.
<GenUI
response={response}
streaming={streaming}
onAction={(action) => ask([...history, { role: "user", text: action.message }])}
onStateChange={(state) => saveWithMessage(state)}
initialState={savedState}
onError={(errors) => ask([...history, { role: "user", text: formatErrors(errors) }])}
/>;What it guarantees
Streaming: every prefix of a response renders without errors, warnings, or exceptions, components keep their identity as the response grows, and typed text and focus survive. While streaming, output sits in a BusyRegion marked aria-busy, partial data does not warn, focus never moves, and buttons and submits wait. When streaming ends the full response is validated once. Safety: only catalog components and declared props render, links accept http(s), mailto, tel, relative, and fragment URLs, images accept http(s) and relative URLs, and text is always text, never Markdown or HTML. Accessibility: names are required by schema, charts include a data table, and warnings and errors are announced at once.
Style the headless output
comp0 ships no CSS. The layout components carry plain token attributes such as data-direction, data-gap, data-columns, and data-tone, and every part keeps its data-slot, so one stylesheet themes everything a model can compose. This page's demo does exactly that in Tailwind, in light and dark.
[data-slot="stack"] {
display: flex;
flex-direction: column;
gap: 1rem;
}
[data-slot="grid"][data-columns="3"] {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
[data-slot="card"][data-tone="accent"] {
border-color: teal;
}- GenUI reference: the format, every catalog component, the renderer, and the utilities in full.