Generative UI
ulams renders pages from declarative documents: trees of catalogue components with props, never model-written HTML or code. Today these documents are written by hand or built in code from API data. The same format is what the AI Course Builder generates, streamed from the server as AG-UI events over SSE (see LLM layer and Course Builder).
| Status | |
|---|---|
Component catalogue with JSON Schemas and text fallbacks (@ulams/ui) |
Built |
| Renderer with data bindings, validation and fallbacks | Built |
Landing pages as documents (front/web/src/docs/*.json) |
Built |
| Course page and lesson player built as documents in code | Built |
| AG-UI event log and SSE stream from Laravel carrying A2UI surfaces | Coming |
| Builder components (interview controls, outline editor, diff view) | Coming |
| Layout topic that stores and renders a hand-written learner layout (ADR 0052) | Built |
AI-composed learner layouts, simulation component, critics |
Coming |
Documents
Section titled “Documents”A document is a tree of nodes:
{ "component": "Page", "props": { "theme": "coffee", "title": "The Coffee Atlas — Learn coffee the slow way" }, "children": [ { "component": "SiteHeader", "props": { "brand": "The Coffee Atlas", "links": [{ "label": "Chapters", "href": "#syllabus" }], "cta": { "label": "Start the free chapter", "href": { "$data": "/course/previewHref", "$default": "/" } } } } ]}(abridged from front/web/src/docs/coffee.json)
componentnames an entry of the registry infront/ui/src/registry.ts. The UI catalogue lists all of them with their props.propsare flat and explicit. Text is plain text, never HTML; onlyProse.markdowntakes Markdown, with raw HTML escaped.childrenare allowed only on container components (Page,Main,Stackand similar).idis optional and becomes the element id (an A2UI component id).
This is the tree form of an A2UI v0.9 surface. A2UI streams a flat component list with ids and a separate data model; converting between the two is mechanical and belongs to the AG-UI transport (ADR 0008).
Where documents come from today
Section titled “Where documents come from today”| Document | Source |
|---|---|
| Tenant landing pages | front/web/src/docs/{coffee,oncall,nightsky,gravity,poland,ulam}.json, chosen by the tenant theme |
| Platform landing | front/web/src/docs/platform.json |
| Site header and footer on every tenant page | Taken from the tenant’s landing document (chromeFor() in src/lib/docs.ts) |
| Course page, lesson player | Built in code from API data in front/web/src/lib/page-docs.ts (courseHeaderDoc, syllabusDoc, purchaseDoc, …) with the same catalogue |
Data bindings
Section titled “Data bindings”Any prop value may be a binding to the page’s data model:
{ "$data": "/course/lessons", "$default": [] }$data is an RFC 6901 JSON Pointer into the data model passed to <Render data={…} /> (in
front/web, the site model built from the tenant’s settings, courses, tutors, events and products).
Missing, null, empty-string and empty-array values resolve to $default, or to nothing, so the
schema default applies. Documents therefore never contain numbers or claims that are not in the data:
the registry’s rules for agents say so explicitly.
Validation and fallbacks
Section titled “Validation and fallbacks”<Render> calls prepare() from @ulams/ui/render-core once per document:
- Resolve bindings in the node’s props.
- Unknown component: render a plain-text fallback built from its string props.
- Deeper than 12 levels (configurable): fallback, guarding against runaway generated documents.
- Validate props against the component’s JSON Schema (
@ulams/ui/schema), applying defaults. Objects reject unknown properties by default; links and image sources must passisSafeHref()(relative,#,?,./,http(s),mailto:,tel:), which rejectsjavascript:anddata:. - Invalid props: render the component’s own
fallback(props)text. A container with invalid props still renders its valid children.
Fallbacks render as plain paragraphs with data-fallback="<component>"; in development each issue is
logged as [ulams/ui] <component>: <path> <message>. A broken node never breaks the page.
validateDocument(doc, data) runs the same checks without rendering and returns the problems. The
reference frontend’s unit tests (front/web/tests/unit/docs.test.ts) use it to check that every
landing document is valid against the catalogue with real API data and with an empty API.
import { validateDocument } from "@ulams/ui/render-core";
const problems = validateDocument(doc, data);// [] or [{ component: "Hero", issues: [{ path: "/primaryCta/href", message: "…" }] }]Adding a component
Section titled “Adding a component”- Write the Astro component in
front/ui/src/components/, accessible and themed with the--ulams-*variables. - Add a registry entry:
description(for the model),category,interactive,children,propsschema andfallback. - Map it in
front/ui/src/Node.astro. - Interactive parts are web components in
front/ui/src/elements/, loaded only by pages that use them; browser calls go through the BFF client (elements/bff.ts). - Add schema and fallback tests in
front/ui/tests/.
The catalogue JSON for prompts and tools: yarn workspace @ulams/ui catalogue.
The course builder validates a generated landing document before publishing it against the whole page
catalogue as a manifest with closed props schemas (front/ui/catalogue/page-manifest.json, copied to the
API by yarn workspace @ulams/ui page-manifest; the UI tests fail when it is out of date).
Learner layout components
Section titled “Learner layout components”A generated lesson layout may use only the approved set, exported as LEARNER_LAYOUT_COMPONENTS
from @ulams/ui/registry and as a manifest for the API (front/ui/catalogue/learner-layout-manifest.json,
yarn workspace @ulams/ui learner-manifest):
| Component | JavaScript | What it is for |
|---|---|---|
Timeline |
none | ordered events or stages with optional done / current / upcoming state |
FlipCards |
small web component | term or question on the front, answer revealed by a button |
CodeBlock |
small web component | code listing with a copy button (no syntax colouring, no “Run” until Phase 7.2) |
PracticeActivity |
small web component | scaffolding container: intro, toolbox, challenges[] of level 1-3 with tiered hints (nudge, pointer, near_solution), feedback per answer and a worked solution |
Callout, Steps, ComparisonTable, H5PFrame, LiaScriptLesson |
as before | complete the set |
PracticeActivity shows the worked solution only after an attempt (an answer checked, or “I have
tried it” for open tasks), signalled by the bubbling event ulams:practice-attempt; until then the
solution sits in an inert <template>. The same attempt fires ulams:complete on the document, which
completes a Layout topic in the lesson player. Its text fallback lists the tasks without hints or solutions.
Tests render each component with the Astro container API and run axe on the result, alone and all nine
in one lesson body (front/ui/tests/learner-layout.test.ts). The API stores a layout as a
Layout topic and validates it against this manifest.
Streaming from the server
Section titled “Streaming from the server”- Every Course Builder interaction is an AG-UI run. Queued jobs append events (
RUN_*,STEP_*,TEXT_MESSAGE_*,STATE_SNAPSHOT/STATE_DELTA,ACTIVITY_*) to acourse_builder_eventstable; the row id is the SSEid. - A2UI messages (
createSurface,updateComponents,updateDataModel,deleteSurface) travel inside AG-UI events, behind one adapter on each side. The A2UI v0.9 message schemas are vendored unmodified infront/ui/vendor/a2ui/v0.9(Apache-2.0, pinned commit in itsNOTICE); the studio validates every incominga2ui-surfaceenvelope against them in development and tests validate one surface of every kind the API emits. Production builds skip the check. GET …/sessions/{id}/eventsstreams withLast-Event-IDresume and a snapshot on connect. Connections close after 25 s and the client reconnects, so php-fpm workers are not held; the route gets its own small FPM pool and wakes on a Valkey pub/sub ping.- User actions start a new run (
POST …/runs), validated against the surface the server issued. - The model picks catalogue components with props, validated against the manifest exported by
@ulams/ui; invalid specs fall back to text. No CopilotKit,@ag-ui/clientor@a2ui/lit: a small SSE reader and the existing renderer.
Model-written code is only ever allowed inside a sandboxed simulation component (isolated origin,
strict CSP, no network), which is also on the roadmap. The LLM side is described in
LLM layer.