TL;DR: GUML is a tiny, indentation-based UI language designed to be written by a language model, and a Rust compiler that turns it into React, Svelte 5, static HTML, Web Components, a JSON UI tree, A2UI or MCP-UI. The model writes 24 lines. The compiler writes the fetch, the abort, the retry with backoff, the stale-while-revalidate cache, the optimistic update, the rollback and the accessible names: 253 lines of React for the task app below. Every error comes back in one pass, with a code and a suggestion, and the obvious ones are fixed without asking the model again.
cargo install guml-cli # the `guml` command pnpm add @guml/core # the same compiler as WebAssembly pip install guml # and from Python🔗 GitHub · 📦 @guml/core on npm · 📄 Research report
Table of Contents
- Why I built this
- The 24-line argument
- A tour of the language
- Inside the compiler
- A lexer that cannot decide alone
- Every error in one pass
- Did you mean card?
- Repair without a model
- What 24 lines become
- Seven backends, one vocabulary
- Themes and capabilities
- What is measured, and what is not
- A real model tried it
- The playground and the chat
- Proof: what I ran for this post
- What I found while writing this
- The question still open
- What I learned
- What's next
- Try it
Why I built this
Ask any AI app builder for a task list and watch what comes back: two hundred lines of React. useState, useEffect, an AbortController, a loading flag, an error flag, an optimistic update, a rollback, a className string per element, and aria-labels if you're lucky.
Almost none of that is a decision. It's convention: the same fetch-with-cancellation shape, the same rollback, the same focus ring. The model is being paid, in tokens, in latency and in error probability, to re-derive a compiler's output by hand, every single time.
Tailwind makes the point sharply. className="mt-6 flex items-center justify-center gap-3" is about fourteen tokens that express three tokens of intent: row, centered, normal gap.
So the question behind GUML is simple: what if the model only wrote the decisions, and a compiler wrote the conventions? There's no widely used representation that sits between "natural-language prompt" and "framework source code". Every AI builder jumps the whole distance in one hop. GUML is the stop in the middle.
Diagram from the original.
The dashed box is the whole idea. Nothing in it appears in the source the model writes, so the model can't spend tokens on it, and it can't get it wrong.
The 24-line argument
This is the task-list fixture, fixtures/b.guml, complete:
page Tasks type Task {id, title, done:bool, createdAt:date} data tasks:Task[] GET /api/tasks add POST /api/tasks {title} optimistic:prepend save PATCH /api/tasks/{id} {done} optimistic drop DELETE /api/tasks/{id} optimistic state draft="" state filter=all|open|done head Tasks — {tasks.open.count} open form >tasks.add{title:draft}; draft="" input draft aria="New task" placeholder="Add a task…" btn Add primary disabled={!draft.trim()} busy="Adding…" tabs filter list tasks where={filter} check {done} >tasks.save text {title} strike={done} btn Delete quiet aria="Delete {title}" >tasks.drop empty Nothing here yet. Read it once and you know the whole app: a typed resource with three mutations (two of them optimistic), a text draft, a three-way filter, a live count in the heading, a form, tabs, and a list with an empty state.
Here it is compiled in the browser. This is the docs playground running the Rust compiler as WebAssembly:
Notice the small line under the input: tabs — not lowered by this compiler version. The in-browser preview renderer doesn't draw tabs yet, and it says so instead of drawing something approximate. That's one of the project's four invariants, and it'll come up again.
A tour of the language
The whole syntax fits in a paragraph, which is the point: the full spec plus the vocabulary costs a model about 2,935 estimated tokens of prompt, and the spec file states a budget of 3,000.
Lines are structure. Indentation is nesting, 2 spaces per level. No closing tags, no braces for blocks, no imports. A tab is an error (GUML0001).
Directives declare the data model:
page Counter title="Clicks" // name -> component type Task {id, title, done:bool} // fields default to string state count=0 // type inferred from the initial value state filter=all|open|done // an enumerated domain; first value is initial data tasks:Task[] GET /api/tasks // a resource add POST /api/tasks {title} optimistic:prepend on {filter} >tasks.list // an effect: re-runs when filter changes Elements are tag [positionals] [name=value] [>action]. Positionals can be a label, a modifier, a {binding}, a /route or an #anchor. The action comes last because > swallows the rest of the line.
Bindings are read-only expressions: paths, comparison, boolean logic, arithmetic, and a fixed set of aggregates (.count .sum .open .done .trim .lower .upper). {tasks.open.count} is "how many rows have the bool field unset". It works on invoices too, where the bool is paid, because the compiler reads the flag from the row type rather than from a name it hoped for.
Actions are statements separated by ;: >count++, >count=0, >tasks.add{title:draft}; draft="".
The vocabulary is closed. There are 49 primitives in a registry (card row col section nav hero form tabs list table btn input select metric modal toast…), 24 modifiers (primary ghost quiet danger sm lg center between…), and 12 global attributes. There is no class attribute: presentation belongs to the theme.
def declares a user component. It's a compile-time macro with parameters and one slot, and by the time codegen runs there's no trace of it.
Two conformance levels. core is markup only: no I/O, no state, meant to be safe to render from an untrusted agent. app adds state, data, actions and the repeaters. An app construct at the core level is GUML0091.
Escape hatches. js and raw blocks pass through verbatim and unchecked. Each one emits a GUML0090 note, so the escape-hatch rate stays countable. That rate is one of the things the research measures.
Inside the compiler
The compiler is a Rust workspace of 14 crates, about 33,000 lines. The heaviest are guml-compiler (about 10.7k lines, the analysis passes) and guml-codegen (about 9.1k lines, the backends).
The driver is short enough to quote. From crates/guml-compiler/src/lib.rs:
let parsed = guml_parser::parse(src, reg); // ... expand::expand(&mut program, &mut diagnostics); // def macros sema::analyse(&program, reg, &mut diagnostics); // scope + accessibility validate::validate(&program, reg, &mut diagnostics); // structure, methods, unused types::check(&program, &mut diagnostics); // expression types, aggregates Diagram from the original.
All the analysis passes always run, even after an error, so diagnostics accumulate. Codegen runs only on an error-free program. There's no separate IR: the AST, guml_ast::Program, is what every backend consumes, and each backend lowers expressions through one shared function.
The type checker is the newest stage and, according to the docs, the one that paid for itself most directly. It found a live bug in a published example where {invoices.open.count} had been compiling to a filter on a field the type didn't declare: always zero, and no test noticed.
A lexer that cannot decide alone
GUML has an ambiguity no lexer can resolve on its own. The comment at the top of crates/guml-syntax/src/lib.rs puts it in two lines:
btn Decrement ghost >count-- // Decrement is a label, ghost is a modifier p Press the buttons to change. // the whole remainder is prose content Same shape, opposite meanings. Whether the rest of a line is structure or prose depends on the tag, and the tag's kind lives in the registry. So the lexer produces both readings of every line: a token list and the raw text. Then the parser, which knows the registry, picks one.
The rule is subtle: a text tag stays prose unless the line contains an = whose name the registry accepts for that tag. So p Set x=1 to enable is prose, and text {title} strike={done} is structure. Step through it:
A frame from the interactive sketch in the original — watch it run.
Two more lexer decisions I like:
- Actions terminate the line by construction. Everything after
>is one token, so actions are lexable in a single pass with no lookahead. - URLs are an allow-list. A route is
/pathat token start, so$24/mostays one word, and absolute URLs are limited tohttp://andhttps://. As the comment says, a general "scheme followed by://" rule would makejavascript:anddata:lexable as request targets. The allow-list is the security boundary.
Every error in one pass
This is the invariant I'd defend hardest, and the project's own CLAUDE.md says it plainly: "Each repair-loop round is a full LLM generation; single-error reporting is a product defect, not a style preference."
A compiler that stops at the first error is fine for a human, who fixes one thing and presses save. For a model it's a disaster: each round trip is a full generation, billed at output rates. So the parser recovers from everything:
- An unknown tag is reported, with a suggestion, and then parsed as a container anyway, so errors in its children still surface.
- A missing attribute value is reported and parsing continues.
- Bad indentation is reported once per run, not once per line. Before that fix, one mis-indented
sectionproduced 15 strayGUML0011s, and a repair loop handed sixteen errors will edit sixteen lines, fifteen of which were correct.
Here's the playground's deliberately broken sample, three mistakes from three different passes:
And the same file from the CLI, exactly as guml check printed it on my machine:
error[GUML0030]: unknown tag `crad` --> broken.guml:4:1 | 4 | crad sm center | ^^^^ = help: did you mean `card`? = suggestion: card error[GUML0033]: binding refers to `kount`, which is not declared --> broken.guml:6:3 | 6 | metric {kount} | ^^^^^^^^^^^^^^ = help: did you mean `count`? = suggestion: count error[GUML0050]: `btn` has no accessible name --> broken.guml:7:3 | 7 | btn quiet >count++ | ^^^^^^^^^^^^^^^^^^ = help: give the control a text label, or add `aria="…"` describing what it does = suggestion: btn aria="…" (Look closely at the carets under metric {kount}. They'll matter later.)
Notice the third error. A button with no accessible name is a compile error, not a lint warning. The explain text says why: "an inaccessible interface is a broken interface, and the compiler is the last place able to notice."
Diagnostic codes are an API
There are 49 codes, GUML0001 to GUML0104, grouped in decades: lexical, layout, syntax, resolution, semantics, accessibility, types, structure, domains, escape hatches. They're append-only: a test enforces it, and the Rust enum is #[non_exhaustive]. The reason is that a repair loop keys on them, so GUML0033 has to mean the same thing forever. 0060 is deliberately unallocated, because "shipping a code with no emit site is how a diagnostic surface starts lying about what it can detect."
Every diagnostic is machine-actionable JSON:
{"code":"unknown_tag","id":"GUML0030","severity":"error","message":"unknown tag `crad`", "span":{"start":27,"end":31,"line":4,"col":1},"help":"did you mean `card`?","suggestion":"card"} suggestion is present only when the fix is unambiguous, which is what lets a machine apply it without asking anyone.
Did you mean card?
How does the compiler know crad means card? The obvious answer is edit distance, and the obvious answer is wrong. Plain Levenshtein scores crad → card as 2 (two substitutions), which is too far for a four-letter word. Typos are very often swapped neighbours, so GUML uses Optimal String Alignment, which adds one more case to the recurrence:
$$
d(i,j)=\min\begin{cases}
d(i-1,j)+1 & \text{delete}\
d(i,j-1)+1 & \text{insert}\
d(i-1,j-1)+[a_i \neq b_j] & \text{substitute}\
d(i-2,j-2)+1 & \text{if } a_i=b_{j-1} \text{ and } a_{i-1}=b_j
\end{cases}
$$
Watch the two tables fill, and see where the transposition (highlighted) saves an edit:
A frame from the interactive sketch in the original — it does not render here.
The threshold is at most 1 edit for names of four characters or fewer, and at most 2 above that. But edit distance can't help with the most common mistake a model makes, which is writing HTML out of habit: button → btn is three edits. So before any distance is computed, the registry checks a table of 39 HTML habits (div→col, span→text, button→btn, hr→divider, ul→menu, dialog→modal…), and each entry carries a note explaining why GUML doesn't have that element.
Modifiers are stricter still: exactly one edit, on a word of at least four characters. That rule exists because of a real data-loss bug, where btn Click me was "fixed" to btn Click md.
Repair without a model
Models don't return clean files. They return Here you go:, a code fence, the code, another fence, and Let me know if you want changes!. guml repair strips that packaging and applies every unambiguous fix, with no model call, ever:
| Layer | Fixes | Cost |
|---|---|---|
sanitize | code fences, markdown rules, trailing commentary | free |
format | indentation, tabs, spacing | free |
fix | every unambiguous diagnostic suggestion, up to 3 rounds | free |
Diagram from the original.
Two safety rules make this trustworthy. First, a layer that would increase the error count is discarded rather than kept: "deterministic" is not the same as "always an improvement". Second, a suggestion containing … is a template for a human (btn aria="…"), not a replacement, so it's shown and never applied. Splicing it in would put a literal ellipsis in the accessible name, which is worse than the original error.
I fed it the broken sample wrapped in a fence and some chat filler. The JSON report:
{"applied":["GUML0030"],"changed":true,"errorsBefore":7,"errorsAfter":2,"rounds":2, "sanitize":{"fence":true,"rules":0,"trailing":0}} Seven errors down to two with zero tokens spent. The fence and the commentary are gone and crad became card. What's left are the two that genuinely need a decision: is kount a typo or a missing state, and what should this button say?
What 24 lines become
Here's the part the model never writes. These excerpts are real output from guml build fixtures/b.guml, 253 lines of React and TypeScript in total.
A retry helper that knows what's safe to retry. It uses exponential backoff (300 ms, then 600 ms), only for idempotent methods, only on a network failure or a 5xx, and never after an abort:
async function retrying(url: string, init: RequestInit = {}, tries = 3): Promise<Response> { const idempotent = !init.method || ["GET", "HEAD", "PUT", "DELETE"].includes(init.method); let wait = 300; for (let attempt = 1; ; attempt++) { const last = attempt >= tries || !idempotent; try { const res = await fetch(url, init); if (res.ok || res.status < 500 || last) return res; } catch (err) { if (last || (err instanceof Error && err.name === "AbortError")) throw err; } await new Promise((resolve) => setTimeout(resolve, wait)); wait *= 2; } } A cache with stale-while-revalidate and in-flight deduplication. It caches GETs only, treats entries as stale after 30 seconds (a constant, deliberately "not a knob"), serves the last good data if a refresh fails, and lets mutations invalidate by URL prefix, so PATCH /api/tasks/{id} invalidates /api/tasks:
async function cached<T>(url: string, init: RequestInit = {}): Promise<T> { const method = init.method ?? "GET"; const key = `${method} ${url}`; if (method !== "GET") return read<T>(url, init); const hit = GUML_CACHE.get(key); const fresh = hit && Date.now() - hit.at < GUML_STALE_MS; if (hit && !fresh) { void refresh<T>(key, url, init).catch(() => {}); } if (hit) return hit.data as T; const pending = GUML_INFLIGHT.get(key); if (pending) return pending as Promise<T>; return refresh<T>(key, url, init); } A list fetch that can be cancelled, with an alive guard so a late response never writes into an unmounted component:
const tasksList = useCallback(() => { const controller = new AbortController(); let alive = true; setTasksLoading(true); setTasksError(null); cached<Task[]>("/api/tasks", { signal: controller.signal }) .then((rows) => { if (alive) setTasks(rows); }) .catch((err: unknown) => { if (!alive || (err instanceof Error && err.name === "AbortError")) return; setTasksError(err instanceof Error ? err.message : "Unknown error"); }) .finally(() => { if (alive) setTasksLoading(false); }); return () => { alive = false; controller.abort(); }; }, []); And the optimistic update with rollback, all from the word optimistic:prepend:
const tasksAdd = useCallback( async (body: Partial<Task>) => { const snapshot = tasks; setTasksSaving(true); const optimistic = { id: `tmp-${Date.now()}`, ...body } as Task; setTasks((prev) => [optimistic, ...prev]); try { const res = await retrying("/api/tasks", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`Request failed: ${res.status}`); invalidate("/api/tasks"); const created = (await res.json()) as Task; setTasks((prev) => prev.map((it) => (it === optimistic ? created : it))); } catch (err: unknown) { setTasks(snapshot); setTasksError(err instanceof Error ? err.message : "Could not save"); } finally { setTasksSaving(false); } }, [tasks], ); Diagram from the original.
Here's that state machine running. Rows appear instantly with a tmp- id. When the server answers, they either become real or vanish. Click to change the failure rate:
A frame from the interactive sketch in the original — it does not render here.
Leave it at 70% for a while and you'll spot a subtle thing. When two adds overlap and the first fails, the rollback restores the first one's snapshot, so the second row disappears too, even if it later succeeds. The generated React has the same property, because snapshot is captured per call. It's on my list.
Two smaller niceties: the tasksSaving flag only exists if some busy= reads it, and it clears in finally, "or one failed mutation leaves the button saying 'Adding…' forever". And an error boundary is only emitted when the document uses an escape hatch, since generated code can't throw in ways the compiler didn't write.
Seven backends, one vocabulary
The same 24 lines through every backend:
| Backend | --backend | Lines for b.guml | What it's for |
|---|---|---|---|
| React + TS | react | 253 | the default; ~85 lines are the inlined runtime |
| React, shared runtime | react --runtime shared | 171 + 95 | one runtime module across pages, so invalidation holds across them |
| Svelte 5 | svelte | 202 | runes |
| Web Components | wc | 267 | no framework, no build step |
| Static HTML | html | 297 | mostly the inlined stylesheet |
| JSON UI tree | json | 274 | for your own renderer; the playground preview uses it |
| A2UI | a2ui | 240 | Google's agent-UI wire format, "a2ui-shaped" |
| MCP-UI | mcp-ui | 13 | one JSON resource embedding the wc script |
The design goal is that backends share decisions rather than copy them: one element table (element_for(tag)), one design-system table (theme::active().classes(tag, mods)), one expression lowering (expr::lower_in) and one liveness answer (referenced_names, which deliberately over-approximates so codegen never drops a declaration the output still mentions).
The element table exists because of a bug. The HTML backend once had a fallback of _ => "div", so the no-JavaScript build had no landmarks. Now a test checks that every tag lowers to the same element in every backend.
The A2UI emitter is labelled honestly: it was "written from a description of the protocol rather than against its published JSON schema". Actions become declared intents, so no JavaScript travels in the payload.
Themes and capabilities
A theme is data, with a contract
There are no class strings in GUML source, so all presentation comes from a theme. A theme is JSON: rules mapping (tag, modifiers) to classes, plus a contract the compiler enforces. From crates/guml-codegen/src/theme.rs:
pub fn validate(&self) -> ...







