.md file to compare - side-by-side diff against openui-lang
openui-lang
description: "Triggers on prompt mention of 'openui-lang', 'lang', 'compose UI', 'generative UI', 'shadcn', 'render shape', 'Query()', '@Filter', 'defineComponent', 'createLibrary', '[[TOOL:Lang]]'."
What it does for you
The format your assistant uses to build live screens for you.
What it produces
A recent result, so you can see the kind of work it returns.
loading…
How to get it
These run inside the Snappy workspace. Want this working in your business? I set skills like this up with you, in one focused week.
For developers how this skill is built, graded, and how it runs
at a glance- the short version
what's inside - the parts that make up a skill 2/4 present
A skill is just a few plain-text files. Only the main one is required. The rest are optional, added as the work needs them. This is what the skill is made of; how it runs is just below.
state/skills/openui-lang/SKILL.md
present
state/lib/openui-lang.ts
not present
state/bin/openui-lang/
not present
state/skills/openui-lang/AGENTS.md
present
how it runs - the shared frame every skill uses 3/5 present
Every skill runs the same way. One part does the work, a separate part checks it, and a short loader hands the AI exactly what it needs for the job. Anything this skill doesn't use shows a one-line note saying why, on purpose, not by accident.
state/log/evals.ndjson what it has learned - fixes written back in over time sample
When a run hits something this skill didn't handle, the fix gets written back into the skill so it doesn't happen again. FIXED means it was corrected on the spot. LOGGED means it's queued for a bigger rewrite. Either way, the skill gets a little better and never makes the same mistake twice.
- Loading feedback rows…
how the work flows- step by step
SKILL.md- the skill, written out in plain English
openui-lang - First-Class Composition Language
Purpose
OpenUI Lang is the assignment-based, line-oriented DSL that the snappy-chat LLM emits to compose generative UI from primitives. It is the single source-of-truth for "how the model expresses a card / chart / list / form" on this surface. This skill exists because every fresh agent forgets that Lang is a thing - the recurring failure mode is a subagent who gets asked to "add a new shape," reaches for a per-shape .tsx file plus a server.ts regex, and re-introduces 200 LOC of dead matcher code instead of just teaching the LLM the right primitive composition.
The cockpit pivoted to OpenUI primitives + LLM composition on 2026-04-30 (see ~/projects/snappy-chat/CLAUDE.md § "Generative-UI shapes (primitives pivot)"). This skill is the structural fix: future agents who mention openui-lang in a brief inherit the full mental model from the loader.
When to Use This Skill
Activates when working with:
- Any prompt mentioning
openui-lang,OpenUI Lang,Lang,[[TOOL:Lang]] - The system-prompt section in
state/bin/head-screen/server.tsthat
teaches the LLM how to emit Lang
state/config/openui-lang-prompt.txt(the auto-generated component
signature catalog injected at run time)
- The
Langentry inweb/src/dispatch-card.tsxDISPATCH_REGISTRY web/src/genui-library.tsx(re-exportsopenuiChatLibraryas
genuiLibrary)
- Any per-skill
state/skills/<slug>/resources/ui.openuidashboard - Adding a new generative-UI affordance - primitives-first, not
per-shape-file
What is OpenUI Lang
Plain-English summary: a compact text DSL that says "this UI is built from these named pieces, which compose like this." Each line is one assignment of the form identifier = Expression. The root assignment is special - it's the entry point. Everything else is forward-resolvable: references work both up and down the file.
Why the LLM emits it instead of JSX or JSON:
- Streaming-friendly. The parser validates line-by-line, so partial
output renders incrementally as tokens arrive. JSON's strict bracket pairing and JSX's nesting both stall progressive rendering.
- Token-efficient. Documented as ~67% fewer tokens than the
equivalent JSON tree. On a Card-with-chart-and-table dispatch this is the difference between a 1.2s and a 4s first-paint.
- Graceful invalid-output handling. A malformed sub-expression is
dropped; the rest of the tree still renders. JSON would fail entirely.
- Positional-only.
Card([children], "subtle")not
Card(items: [...], variant: "subtle"). Less syntax for the model to hallucinate; the prop order in defineComponent({props: z.object({...})}) IS the call order.
Conceptual model (four pieces work together):
- Library - Zod schemas + React renderers (in our codebase:
openuiChatLibrary from @openuidev/react-ui, re-exported as genuiLibrary in web/src/genui-library.tsx).
- Prompt Generator - converts the library into the system-prompt
text the LLM reads. Emitted to state/config/openui-lang-prompt.txt and loaded by state/bin/head-screen/server.ts at startup.
- Parser - validates streaming Lang text into typed elements.
- Renderer -
<Renderer library={genuiLibrary}>from
@openuidev/react-lang; maps parsed elements to React components.
The separation lets the LLM focus on what to render while the framework handles how.
Statement Types
Three statement shapes total. Memorize this:
# 1. Component (the common case)
welcomeCard = Card([CardHeader("Welcome"), TextContent("Hello")])
# 2. State (reactive variable)
$days = 7
# 3. Data (Query / Mutation binding)
agents = Query("get_agents", {}, {agents: []}, 30)
Root rule: every program must define root = Card(...) (or any top-level component). That assignment is the entry point.
Forward references work. You can use a name before it's defined; the parser resolves all references after the full input is parsed. This is the "hoisting" rule from state/config/openui-lang-prompt.txt:148.
Reference-or-die rule. Every variable except root MUST be referenced by at least one other variable. Unreferenced variables are silently dropped and will NOT render. Always include defined variables in their parent's children/items array.
Language Primitives
The compositional building blocks. One-line each. Find the full list in state/config/openui-lang-prompt.txt (auto-generated from the library).
Layout
Card([children], variant?)- outer container. Variants: default, sunk, subtle, raised.CardHeader(title?, subtitle?)- title row inside a Card.Stack([children], direction?, gap?)- flex layout. Direction: row|column. Gap: s|m|l.Col([children], gap?)/Row([children], gap?)- explicit vertical/horizontal stacks.Separator(orientation?, decorative?)- divider.
Lists
ListBlock([ListItem...])- bulleted/numbered runs.ListItem(text, secondary?)Steps([StepsItem...], current?)- ordered phase markers.StepsItem(label, description?, status?)- status: complete|active|pending.Accordion([AccordionItem...])/AccordionItem(title, body)Tabs([TabItem...])/TabItem(label, [body])
Content
TextContent(text, size?)- sizes: small | default | large | small-heavy | large-heavy. Supports markdown.MarkDownRenderer(textMarkdown, variant?)- full GFM markdown.Callout(variant, title, description, visible?)- info|warning|error|success|neutral.TextCallout(variant?, title?, description?)- lighter inline callout.CodeBlock(language, codeString)- syntax-highlighted code.Image(alt, src?)/ImageBlock(src, alt?)/ImageGallery([{src,alt,details}]).
Tables
Table([Col...])- column-oriented (each Col holds its own data array).Col(label, data, type?)- type: string|number|action.
Charts (positional series shape)
BarChart(labels[], series[], variant?, xLabel?, yLabel?)- grouped|stacked.LineChart(labels[], series[], variant?, xLabel?, yLabel?)- linear|natural|step.AreaChart(labels[], series[], variant?, xLabel?, yLabel?).PieChart(labels[], series[])/RadarChart(labels[], series[]).RadialChart(labels[], series[])/ScatterChart(labels[], series[]).HorizontalBarChart(labels[], series[], variant?, xLabel?, yLabel?).Series(category, values[])- one data series.
Tags / Buttons / Inputs
Tag(label, variant?)/TagBlock([Tag...]).Button(label, action?)/Buttons([Button...])-actionis an Action expression.Input(label, $binding<string>)/TextArea(label, $binding<string>).Select(label, options[], $binding)/RadioGroup/CheckBoxGroup/Slider/DatePicker.Form([FormControl...])/FormControl(label, [child]).
The LLM has the full ~80 primitive vocabulary at run time. Find every registered name with:
grep -oE '"[A-Z][A-Za-z]+"' \
~/projects/snappy-chat/web/node_modules/@openuidev/react-ui/dist/genui-lib/openuiChatLibrary.js \
| sort -u
Or look up the d.ts at web/node_modules/@openuidev/react-ui/dist/genui-lib/openuiChatLibrary.d.ts.
Operators
Arithmetic: +, -, *, /, % Comparison: ==, !=, >, <, >=, <= Logical: &&, || Unary: !, - Ternary: cond ? a : b
@Builtins (closed allowlist)
Functions prefixed with @. Enforced by state/lint/builtin-whitelist.ts - invented operators silently fail to render. Do not extend this list in prose - it must round-trip with the lint allowlist.
Aggregation
@Count(array)- number of elements.@Sum(numbers[]),@Avg(numbers[]),@Min(numbers[]),@Max(numbers[]).
Array
@First(array),@Last(array)- pluck endpoints.@Filter(array, field, op, value)-opis a comparison operator string.@Sort(array, field, direction?)- direction: asc|desc.@Each(array, varName, template)- iterate; bind each row tovarName.
Math
@Round(number, decimals?),@Abs(number),@Floor(number),@Ceil(number).
Actions (mutation triggers; only inside Action expressions)
@Run(ref)- fire a Mutation by name.@Set($var, value)/@Reset($var, $var2, ...)- write reactive state.@ToAssistant(msg)- send a follow-up user message.@OpenUrl(url)- navigate.
Reactive State and Bindings
Declare state with $name = default:
$days = 7
agents = Query("get_agents", {days: $days}, {agents: []}, 30)
Passing $days to a Slider or Input creates two-way binding - user input updates $days, Query re-fetches automatically. Args marked $binding<type> in primitive signatures accept a $variable reference.
Data Fetching
Query - runs on load, refetches when $variable args change:
data = Query("tool_name", {arg: value}, {default_shape: []}, 30)
Four positional args: tool name, arg object, default shape (rendered while loading), cache TTL in seconds.
Mutation - declared but only fires via @Run():
saveResult = Mutation("save_thread", {title: $title})
Trigger: Button("Save", Action([@Run(saveResult)])).
Member access (dot-pluck):
agents = Query("get_agents", {}, {agents: []}, 30)
names = agents.agents.name # plucks .name from every row → array
count = @Count(agents.agents)
System-Prompt Patterns (canonical)
The OpenUI docs ship a getSystemPrompt(library, options) helper that produces a complete system prompt from a registered library. Our implementation does the equivalent at startup - state/config/openui-lang-prompt.txt is the auto-generated artifact.
Feature flags (set on the prompt generator):
| Flag | Enables | Default |
|---|---|---|
toolCalls | Query(), Mutation(), @Run | true if tools provided |
bindings | $variables, @Set, @Reset | true if toolCalls enabled |
editMode | Incremental edit (patches only) | false |
inlineMode | Text + fenced code responses | false |
Customizable additions: preamble (project context), additionalRules (domain-specific), toolExamples (worked Query/Mutation examples).
In snappy-chat we additionally inject:
- The 22-shape phrasing index (server.ts §
SYSTEM_PROMPT shape-guide) - The
DATA-DRIVEN COMPOSITIONrule (server.ts ~line 7919) - when the
user asks for counts/runs/scores, the LLM MUST use Query() not literal numbers.
- Per-skill
resources/ui.openuidashboards routed via
GET /skills/<slug>/openui (see head-screen/AGENTS.md).
Variants - Where the Library Choice Matters
Different ecosystems publish different openui*Library exports. Pick by target surface:
| Variant | When to lean on it | Library export |
|---|---|---|
| shadcn-chat | Chat surface integrating with shadcn/ui design tokens - server emits abstract Lang, client maps to shadcn primitives | shadcnComponentGroups, custom createLibrary({...}) |
| vercel-ai-chat | Streaming chat with multi-step tool calling via @ai-sdk/openai and useChat - transport-agnostic, <Renderer /> stays unchanged | openuiChatLibrary |
| dashboard | KPI cards, tables, charts driven by MCP-style tool registry; LLM composes UIs that themselves call backend tools | openuiChatLibrary + tool registry |
| react-email | Static, non-interactive content with inline-style requirement (email clients strip <style>) - 44 components in emailLibrary | emailLibrary (from @openuidev/react-email) |
| react-native | Native mobile via Expo - twin libraries (real renderers on device, null renderers for backend prompt generation, since CLI can't import RN in Node) | openuiNativeLibrary |
snappy-chat uses the vercel-ai-chat variant - openuiChatLibrary from @openuidev/react-ui, re-exported as genuiLibrary. The LLM emits Lang inside [[TOOL:Lang]]…[[/TOOL]]; the Lang entry in DISPATCH_REGISTRY runs <Renderer library={genuiLibrary}>.
For per-skill dashboards we lean toward the dashboard variant - KPI tiles, Query-driven tables, charts. See state/skills/<slug>/resources/ui.openui files (e.g. email-send-tick, slack, snappy-recover).
Worked Example - Query + $variable + @Count + Card
This is the canonical pattern the LLM should produce when asked "how many agents have run?":
$days = 7
agents = Query("get_agents", {days: $days}, {agents: []}, 30)
filtered = @Filter(agents.agents, "lastRun", "!=", null)
$total = "" + @Count(filtered)
root = Card([
CardHeader("Agents", "Last " + $days + " days"),
Stack([
Card([TextContent("Active", "small"), TextContent($total, "large-heavy")], "subtle"),
Slider("Window (days)", $days, 1, 30, 1)
], "row", "m")
])
What this demonstrates:
Queryfor live data (NEVER literal counts)$variablefor derived display values@Filter+@Countto aggregate without server round-trip- String coercion for KPI text:
"" + @Count(...) - Reactive: dragging the Slider updates
$days→ Query refetches → Count
updates
Anti-Patterns
These hard-block. Each has a lint or has cost a session of debugging.
- No raw HTML / JSX strings. Lang is the only path. The previous
KNOWN_COMPONENTS regex catalog in server.ts is being deprecated precisely because it bypassed Lang.
- No
name=valueorname: valuearg syntax. Args are POSITIONAL
ONLY. Colon syntax silently breaks the parser. Burned 2026-04-29T07:42:54Z.
- No invented
@Operators. Whitelist enforced by
state/lint/builtin-whitelist.ts. Adding @Mean to prose without also adding it to the lint allowlist = silent render failure.
- No string concatenation hacks for literal numbers. When the user
asks for a count, USE Query() + @Count(). Hardcoded "47 dispatches" is an LLM fabrication and the DATA-DRIVEN COMPOSITION rule explicitly forbids it.
- No defining variables you don't reference. Unreferenced ≠
rendered. Always thread defined variables back through the parent's children array.
- No per-shape
.tsxfile as the default path for new UI. The
legacy recipe (per-shape file + DISPATCH_REGISTRY entry + server.ts matcher + welcome chip) is dead. Default = describe what you want in a prompt, let the LLM compose primitives.
- No
getSystemPrompt()direct calls in a per-request hot path.
Generated once at startup and cached in state/config/openui-lang-prompt.txt.
Where it Lives in snappy-chat
Three files own the Lang surface end-to-end:
- Server prompt -
state/bin/head-screen/server.ts
LANG_PROMPT_PATHconstant points at
state/config/openui-lang-prompt.txt
- Loaded once at boot, injected into the system prompt for every
/dispatch/chat request
DATA-DRIVEN COMPOSITIONrule + 22-shape phrasing index live in
the same prompt block (~line 7900)
- Client renderer -
web/src/dispatch-card.tsx
langDispatchEntry()wraps<Renderer library={genuiLibrary}>Langentry inDISPATCH_REGISTRYis the only path for primitive
compositions
- Five "promoted canned shapes" (DispatchCard, AgentDetail,
ProgressList, PhaseDisclosure, HTMLPreview) also route through Lang
- Threaded shape memory + patch merging in
lang-history.ts
- Library -
web/src/genui-library.tsx
genuiLibrary = openuiChatLibrary(from@openuidev/react-ui)- 5 promoted shapes registered alongside via
defineComponent - Three-line re-export pattern for promoted-shape files (TS
isolatedModules constraint): export { View, Component }, export type { Props }, import { Component } (local binding for the components array)
Per-skill dashboards: state/skills/<slug>/resources/ui.openui - plain Lang text, served via GET /skills/<slug>/openui.
Resource Files
This skill is prose-only; companion files live in this folder:
AGENTS.md- the per-turn loader. Critical Rules + commands.prompt-fragment.md- the compact system-prompt injection (≤500 tokens)
fired when openui-lang intent is detected.
Related Skills
- crayon-sdk - the broader Crayon/C1/OpenUI ecosystem
reference. OpenUI Lang is the language layer; crayon-sdk covers the outer chat shell, SSE wire format, thread persistence.
- openui-mcp - Context7 MCP for live OpenUI docs lookup
during work.
- head-screen - the server that injects the Lang
prompt and runs the DISPATCH_REGISTRY Lang entry.
- ui-components - the (now-legacy) per-shape
authoring guide. Read for historical context only; do NOT extend.
Skill Status: ACTIVE Eval: auto-shape (frontmatter + body presence; failure mode is silent: agent doesn't load Lang model and writes per-shape .tsx files) Reference Implementation: snappy-chat (web/src/dispatch-card.tsx Lang entry + state/config/openui-lang-prompt.txt)
AGENTS.md- what the AI loads when this skill comes up
openui-lang - loader
Per-turn rules for openui-lang. Full reference: state/skills/openui-lang/SKILL.md. Prose-only skill - no lib, no sidecar; eval=auto-shape. Critical rules are load-bearing; do not skip.
Critical Rules
- OpenUI Lang IS the composition language. Never use raw HTML strings, JSX strings, or template-string concatenation when generative UI is the target. The LLM emits Lang text inside
[[TOOL:Lang]]…[[/TOOL]]markers; theLangentry inweb/src/dispatch-card.tsxDISPATCH_REGISTRYis the ONLY path for primitive compositions. Verified bystate/lint/dispatch-contract.ts.
- Args are POSITIONAL ONLY.
Card([children], "subtle")works;Card(items: [...], variant: "subtle")andCard(items=[...])silently break the parser. Order matters; names do not. The prop order indefineComponent({props: z.object({...})})IS the call order. Burned 2026-04-29T07:42:54Z.
- Closed @builtin allowlist.
@Count,@Sum,@Avg,@Min,@Max,@Round,@Abs,@Floor,@Ceil,@Filter,@Sort,@First,@Last,@Eachfor data;@Run,@Set,@Reset,@ToAssistant,@OpenUrlfor actions. Verified bystate/lint/builtin-whitelist.ts. Inventing@Mean,@GroupBy, etc. = silent render failure. Don't extend the list in prose without round-tripping the lint allowlist.
Query()for live data, NEVER raw literal numbers. When the user asks for counts / runs / scores / aggregates, the model MUST emitQuery("tool_name", {args}, {default_shape}, cache_seconds)plus@Count/@Filter/@Sum- not "47 dispatches" hardcoded. This is theDATA-DRIVEN COMPOSITIONrule inserver.ts~line 7919. Hallucinated numbers are the canonical failure mode.
- Three statement types. (a) Component:
name = Component(args); (b) State:$name = default; (c) Data:name = Query(...) / Mutation(...). Root rule: every program definesroot = ...(entry point). Forward references work - define order doesn't matter, but every variable exceptrootMUST be referenced somewhere or it's silently dropped.
$variablefor derived/reactive values. Two-way binding works for inputs marked$binding<type>in signatures. Pattern:$days = 7; agents = Query("get_agents", {days: $days}, ...); Slider("Window", $days, 1, 30, 1)- slider drag updates$days, Query refetches automatically. Use$total = "" + @Count(...)to coerce numbers to strings for KPI text.
- Member access (dot-pluck) replaces map().
agents.agents.nameplucks.namefrom every row in theagents.agentsarray. Combine with@Filter:@Filter(agents.agents, "lastRun", "!=", null). There is no inlinemap()- dot-pluck is the only path for column projection.
- Default UI path = primitives via Lang, NOT per-shape
.tsxfiles. The legacy recipe (per-shape file +DISPATCH_REGISTRYentry + server.ts regex + welcome chip) is being deprecated. New affordance = describe in a prompt, let the LLM compose. Pre-canned shape only justified for high-frequency UX with a fixed schema (the 5 promoted: DispatchCard, AgentDetail, ProgressList, PhaseDisclosure, HTMLPreview). All five route THROUGH Lang.
- Variant pointers. Chat surface integration → vercel-ai-chat variant (snappy-chat uses this -
openuiChatLibrary). KPI tiles + tables + charts → dashboard variant. Email rendering with inline-style constraint → react-email (44-componentemailLibrary). Mobile native → react-native twin-library pattern. shadcn design system → customcreateLibrary({componentGroups: shadcnComponentGroups}).
- Snappy-chat surface = three files. Server prompt:
state/bin/head-screen/server.ts(loadsstate/config/openui-lang-prompt.txtat boot, ~line 8250). Client renderer:web/src/dispatch-card.tsx(langDispatchEntry()+Langregistry entry). Library:web/src/genui-library.tsx(genuiLibrary = openuiChatLibrary). Don't add new entries elsewhere - these three are the boundary.
- System prompt is auto-generated, cached at boot.
state/config/openui-lang-prompt.txtis regenerated from the registered library; do NOT hand-edit. To add a new primitive, register it viadefineComponentingenui-library.tsx, then regen the prompt file. Hot-edit attempts will be overwritten next boot.
- Skill-owned OpenUI resources live at
state/skills/<slug>/resources/*.openui. Plain Lang text (NOT JSON, NOT JSX).ui.openuiis the default skill dashboard. Named files such asschedule.openuiare saved right-canvas surfaces and must start with// surface,// intents, and// responsemetadata so the saved-surface registry can route natural requests without asking the model to recreate the UI. Examples:email-send-tick/resources/ui.openui,slack/resources/ui.openui,linkedin-post/resources/schedule.openui.
getSystemPrompt(library, options)flags.toolCallsenablesQuery()/Mutation()/@Run.bindingsenables$variables/@Set/@Reset(auto-true iftoolCalls).editModefor incremental patches.inlineModefor text + fenced code responses. Customize viapreamble,additionalRules,toolExamples. Don't pass these per-request - generate once at startup.
- Lang is positional everywhere - including server-side translators. When server.ts builds Lang text from a payload (e.g. the legacy
emitTriplepath), positional args MUST be preserved. A translator that emitsComp(name="x", value=1)at the server breaks identically to a model that emits it.
- Threaded shape memory + patch merging.
web/src/lang-history.tskeeps per-thread Lang shape memory;mergeStatements()in dispatch-card.tsx blends incremental patches before render. The LLM may emit a partial patch (refining one statement) instead of re-emitting the full tree. Don't assume each[[TOOL:Lang]]body is self-contained - merge against history.
Commands
| operation | command | |
|---|---|---|
| main reference | state/skills/openui-lang/SKILL.md | |
| canonical prompt fragment | state/skills/openui-lang/prompt-fragment.md | |
| auto-generated prompt (cached) | state/config/openui-lang-prompt.txt | |
| server-side load point | state/bin/head-screen/server.ts (LANG_PROMPT_PATH) | |
| client renderer entry | web/src/dispatch-card.tsx (langDispatchEntry, DISPATCH_REGISTRY.Lang) | |
| library export | web/src/genui-library.tsx (genuiLibrary = openuiChatLibrary) | |
| primitive vocabulary | web/node_modules/@openuidev/react-ui/dist/genui-lib/openuiChatLibrary.d.ts | |
| list all registered names | `grep -oE '"[A-Z][A-Za-z]+"' web/node_modules/@openuidev/react-ui/dist/genui-lib/openuiChatLibrary.js \ | sort -u` |
| default skill resource | state/skills/<slug>/resources/ui.openui | |
| named saved surface | state/skills/<slug>/resources/<name>.openui with // surface, // intents, // response comments | |
| dashboard endpoint | GET /skills/<slug>/openui | |
| dispatch endpoint | POST /dispatch/chat (system prompt includes Lang fragment) | |
| verify shape emit | `curl -s -X POST 127.0.0.1:3147/dispatch/chat -d '{"intent":"show me a kpi card"}' \ | jq .toolCalls` |
| companion docs | ~/projects/snappy-chat/CLAUDE.md § "Generative-UI shapes (primitives pivot)" | |
| eval log | state/log/evals.ndjson (skill: openui-lang, eval_mode: auto-shape) | |
| writeback | state/log/loader-feedback.log |
Statement Cheat-Sheet
| Need | Lang |
|---|---|
| Container with title | Card([CardHeader("Title"), child]) |
| Vertical stack | Stack([a, b, c], "column", "m") |
| Horizontal row | Stack([a, b, c], "row", "m") |
| KPI tile | Card([TextContent("Label", "small"), TextContent($val, "large-heavy")], "subtle") |
| Live count | data = Query("tool", {}, {rows: []}, 30); $n = "" + @Count(data.rows) |
| Filtered count | $active = "" + @Count(@Filter(data.rows, "status", "==", "active")) |
| Two-way slider | $days = 7; Slider("Window", $days, 1, 30, 1) |
| Iterated cards | @Each(data.rows, "row", Card([TextContent(row.name)])) |
| Action button | Button("Save", Action([@Run(saveResult)])) |
| Tab panel | Tabs([TabItem("One", [body1]), TabItem("Two", [body2])]) |
| Steps tracker | Steps([StepsItem("Plan", null, "complete"), StepsItem("Build", null, "active")], 1) |
| Bar chart | BarChart(["A", "B", "C"], [Series("count", [3, 5, 2])]) |
Variant Pointer
| Use case | Variant | Library |
|---|---|---|
| snappy-chat (this repo) | vercel-ai-chat | openuiChatLibrary |
| Skill-owned resources | dashboard / saved surface | openuiChatLibrary + Query/Mutation-driven data |
| Static HTML email | react-email | emailLibrary (44 components, inline styles) |
| Native mobile | react-native | twin libraries (real + null renderers) |
| shadcn design system | shadcn-chat | custom createLibrary({componentGroups: shadcnComponentGroups}) |
Known Pitfalls
- Args POSITIONAL. Colon / equals syntax silently breaks (rule 2).
- Unreferenced variables silently dropped. Always thread back through the parent's children array (rule 5).
- Invented
@Operatorssilently fail. Stick to the closed allowlist (rule 3). - Hardcoded numbers = LLM fabrication.
Query()+@Count()for live values (rule 4). - System prompt is cached at boot. Hand-edits to
openui-lang-prompt.txtget clobbered at next regen (rule 11). - Patch vs. full re-emit. LLM may emit a patch; merge against thread history rather than assuming self-contained (rule 15).
- Per-shape
.tsxfiles are the rare path. Don't reach for them when Lang primitives plus a savedresources/*.openuisurface suffice (rules 8 and 12).
Self-Test
An agent reading this should correctly:
- [ ] Know prose-only, eval=auto-shape; "RUN THIS" briefs →
[SKIPPED] - [ ] Use POSITIONAL args only; never
name=value/name: value - [ ] Stick to the closed
@builtinallowlist; no invented operators - [ ] Use
Query()for live data, never literal counts - [ ] Define
root = ...; reference every variable; forward-ref OK - [ ] Use
$variablesfor reactive state;"" + @Count(...)for string KPIs - [ ] Pluck columns via
data.rows.field, not inline map - [ ] Default new UI = describe in prompt, NOT a per-shape
.tsxfile - [ ] Pick variant by surface (vercel-ai for chat, dashboard for KPIs, etc.)
- [ ] Know the three-file boundary in snappy-chat (server prompt + dispatch-card + genui-library)
- [ ] Don't hand-edit
openui-lang-prompt.txt(auto-regen) - [ ] Skill-owned resources live at
state/skills/<slug>/resources/*.openui; named surfaces carry// surface,// intents, and// response - [ ] Use
getSystemPrompt(library, options)flags at startup, not per-request - [ ] Server-side translators preserve positional args
- [ ] Merge patch emissions against thread history via
mergeStatements()
<!-- kernel-ok: historical attribution for the PID-loader pattern. -->
Self-correcting loader (PID feedback)
This is the rule that makes loaders learn. Ported from snappy-kernel, where it demonstrably worked: the snappy-dom-cartographer skill mapped Skool admin UI once, every later ship-agent inherited the full map for free.
Before you finish, do two things:
1. Fix gaps (P - proportional). MANDATORY. If this loader didn't cover your case - if you had to read another file, run an undocumented command, work around a wrong selector, or discover a quirk - you MUST attempt an Edit to this AGENTS.md before you log.
- Default action: edit this file. Surgical. Add the missing line, table
row, or code example where it belongs. No restructuring.
- Only
LOGGEDis allowed when: the fix needs >10 lines, spans multiple
files, or requires a structural rewrite. In that case the state/regen/drain.sh queue picks it up asynchronously.
- "I didn't have time" / "it's minor" / "the next agent will figure it out"
are NOT valid reasons. Minor gaps compound - that's the whole point of this loop.
- The goal: the next agent never has to leave the loader.
2. Log the result.
echo "[$(date -u +%FT%TZ)] openui-lang: <what was missing or fixed> [FIXED|LOGGED] action_kind=<kind>" >> state/log/loader-feedback.log
- The class token between
[ts]and:MUST be the literal folder slug
(openui-lang) - producer slug, writeback class, and grade class must be equal so state/lib/controller-tune.ts can pair the brief.
FIXED= you patched this loader inline (P-fix).LOGGED= too large for inline; the PostToolUse enqueue + Stop-hook drain
will rewrite the loader from scratch on next session-end.
action_kind:shape-ok|skill-ran|loader-rewritten|pattern-elevated
Do not skip this. Every agent run must leave the system better than it found it. The loader is the setpoint; you are the sensor; the gap is the error signal; closing the gap is the correction.
api.ts- the code it can call
⚠ no api.ts - this skill has no typed action surface
scripts- helper scripts it can run
prose-only skill - 7 inline code blocks live in SKILL.md above (no state/bin/ sidecar yet).
how we check it- the checks, plus the last 10 runs
no recent runs logged - the eval contract is declared but nothing has been graded yet