8.2 KiB
Create: describing an app, and the line language joule-ui takes in
A design note. The normative text is SPEC.md §11 (the line language) and
the conformance pack in conformance/lang/. The WAI side is
wai/wai-ui/.
Create is a page where a person describes a WAI app ("a show for my channel, with chat and a live poll") and watches it build itself from WAI components while a model streams its answer. The room it builds is a real room on a room service. The person then exports it as a starter project.
1. The line language onto joule-ui's tree
A model writes OpenUI Lang (the open-source project's line-per-component language, MIT
licensed; see NOTICE): one statement per line, name = Component(arg, ...),
names usable before they are defined. joule-ui takes it in the way it takes in A2UI
(joule-ui-a2ui): the language is a way to write a joule-ui widget tree against a
host's registry, and what comes out is an ordinary Widget, checked by joule-ui's
own validate.
| The language | joule-ui |
|---|---|
| the library | the Registry, plus the name of its root widget |
Component(a, b, c) |
a Widget; the arguments map to props in the order the widget's schema lists (WidgetSchema::positional, new in core) |
the prop named children in that order |
Widget::children; a list of components |
strings, numbers, booleans, null, lists, objects |
Text, Number, Bool, (absent), List, Object |
| a reference to another statement | that statement's value (hoisting) |
@Name(args) in a prop |
PropValue::Action { id: "Name", payload } |
$state, Query, Mutation, operators, ? :, member access, built-in functions, a sequence of actions |
not carried: joule-ui puts no expression and no behaviour on the wire. Each is parsed (the grammar is parsed whole) and refused not-declarative, the smallest piece dropped |
The crate is joule-ui-lang: a streaming parser, an ingest into widget trees validated
against a registry, and a canonical writer back to the language. Every error names the
line and the reason (unknown-component, missing-required, type-mismatch,
disallowed-child, not-declarative, ...; the table is SPEC §11.8).
2. Streaming
The text arrives in chunks. After each chunk the parser gives a snapshot: the tree as far as it can be built, the errors so far, and the names still unresolved.
- Complete and pending. A statement is complete once its newline has arrived and the
next non-blank character has too and is not
?(a line starting?would continue a ternary). Complete statements never change. The last statement is pending: it is closed for the moment (an open string closed, open brackets closed in reverse order) and built, so a title shows letter by letter as it streams. - What renders before the tree is whole. The tree starts at
root. A reference to a statement not yet written is unresolved: in a list it is left out, in a prop it is absent. A widget whose required prop is absent only because something has not arrived is held back, silently, until it can render. So the first line,root = App([room], "Friday show"), renders an empty app at once, and each later line fills it in. - How validation works on a stream. Every widget is validated against the registry
as it is built, by joule-ui's
validate; a widget that fails is dropped, and its error is reported once its statement is complete (a pending statement's problems may be gone by its next chunk). The snapshot's whole tree passesvalidatebefore it is handed to a renderer: no snapshot ever holds a widget the registry refuses. At the end of the stream the held-back cases become errors (missing-required,no-root), and the result equals parsing the whole text at once.
3. The WAI registry and the renderer
wai-ui publishes a joule-ui registry of WAI widgets, each backed by the room client
of joule-moq-web (the page that joins rooms, verifies every object against its receipt,
watches together and keeps co-performance):
| Widget | What it is |
|---|---|
App(children, title) |
the root |
Room(children, title) |
a live room: join with camera and microphone, or watch |
WatchTogether(source, children, title) |
a watch room: everyone watches one file in sync |
Show(children, title, audience) |
a room with co-performance: hosts on camera, an audience that votes |
Player(title) |
the room's video, laid out from its signed record |
Chat(title) |
the room's chat events, each verified; a participant sends |
Poll(question, options, seconds) |
a vote: the room's clock opens the window and signs the selection |
Participants() |
who is present, from the room's lifecycle |
Receipts() |
this page's sealed interaction log, verified, and the room's verified objects |
EnergyMeter() |
what each interaction cost, with how the figure was obtained |
Text(text) |
a paragraph |
Each room kind accepts only the widgets its service keeps (a watch room carries co-watch
statements alone, so it accepts no Chat and no Poll); joule-ui's AllowedChildren
says so, and a line that puts a Poll in a WatchTogether is refused with its line and
disallowed-child. An app holds one room.
The renderer (wai/wai-ui/web/) reconciles each snapshot into the page by key (a
statement's name), so a room already joined is never torn down by the next chunk. Every
action a person fires (join, leave, send a chat, vote, play) goes through
joule-ui-interaction in the page's WebAssembly build: authorized, metered (a page has
no meter, so the figure is the estimator's, from the handler's wall time, and labelled
so), and sealed into the page's hash-chained log under a key the page makes. Receipts
shows the log and the page's own verify_chain of it. A browser handler is asynchronous,
so the interaction crate gains a two-step form of its lifecycle (begin_interaction, then
Pending::finish) with the same three phases; a denied action yields no Pending, so
its handler cannot run.
4. Export
Export writes a starter project from the tree: the program (app.oui, in the canonical
form of the writer), the tree (app.json), the room service's configuration
(room.toml, derived from the room widget: a watch room's source, a show's
audience inputs) and the relay's (relay.toml), the page that renders the program with
the same renderer and components and their runtime under vendor/ (each file named with
its SHA-256 in vendor.json), a same-origin front for local development (serve.mjs;
--up starts the relay and the room service, their keys made in .wai-dev/, which is
never committed), a check (check.mjs, the project's build) that parses app.oui
against the registry with the runtime's own parser, refuses a project without its room
configuration, with one that does not match the app, or with a runtime file changed, and
writes dist/; and a README and an AGENTS.md for the next person or coding agent. No key
is in it. The file set is made in Rust by one writer (wai_ui::export), whoever asks
(the page, which zips it; wai-ui export; the MCP server), so the project format changes
in one place.
5. How a page talks to a model
Create spends nobody's money and holds no one's key on a server. Two ways in, and the page says which is in use:
- Your own model. The visitor names an endpoint that streams the common
chat/completionsserver-sent-events format (a model server they run on their own machine, or a provider they hold a key for), a model name and a key. The page calls it from the browser. The key stays in the page's memory; it is never stored and never sent anywhere else. - Your coding agent.
wai-ui mcpis an MCP server the visitor's own agent runs on their machine. Its tools give the agent the registry and the language (create_guide), check a program (create_check), stream a program into the visitor's open Create page through a bridge on127.0.0.1the page connects to with a pairing code (create_stream), and write the starter project into the agent's workspace (create_export).
A third mode replays a recorded stream, which is how the end-to-end test drives the page. A model hosted by the site is out of scope until the owner decides it.