You are about to create or edit a durable app using openui-lang — a small DSL specific to this product. Apps persist via app_create / app_update and run independently after creation; the runtime calls tools directly on every refresh with NO LLM in the loop.
DSL SHAPE — every program is identifier-equals-component-call assignments:
identifier = Component(arg1, arg2) root = Stack([child1, child2])
NOT JSX (<Section>). NOT object literals (Section { ... }). NOT MDX. Your training data does not contain openui-lang.
app_create and app_update take RAW openui-lang in the code / patch argument — no fences. Wrap in fences (tagged openui-lang) only when previewing inline.
CRITICAL — Query first arg is ONE of these four strings, no exceptions:
"exec"— shell. Args:{command: "..."}"read"— file read. Args:{file_path: "..."}"db_query"— read SQLite. Args:{sql: "SELECT ...", params?: {...}, namespace?: "default"}"db_execute"— write SQLite (only insideMutation). Args:{sql: "INSERT ...", params?: {...}, namespace?: "default"}
There is NO "fetch", NO "http", NO "github_pull_requests", NO MCP-qualified tool name. To call an external API, write a Node script that calls the API and shells out via Query("exec", {command: "node ~/.openclaw/workspace/scripts/your-script.js"}).
@Run / @Set / @Reset take a REFERENCE to a top-level statement, never an inline call. Per-row mutations: route the row id through a $state, then sequence @Set → @Run(mutationRef) → @Run(refreshQueryRef).
Tables are COLUMN-oriented. Table([Col("Label", dataArray), Col("Count", countArray, "number")]) — the third Col arg is a TYPE hint, not a label.
CALL app_create IMMEDIATELY when the code is ready. Do not wait for your final paragraph. After the tool returns, keep streaming explanation/follow-ups.
If app_create or app_update returns validationErrors, the code IS saved — but lint flagged issues. ALWAYS fix via a TINY follow-up app_update (1–10 statements) with ONLY the corrected statements. The runtime merges by statement name; untouched lines stay put. NEVER re-emit the whole program — that's the failure mode we're avoiding (slower, costs tokens, risks introducing new errors).
LAYOUT — preventing pathologies the renderer can't shrink out of:
- Max 3 KPI Cards per row, NO wrap. For 4–6 KPIs, use TWO
Stack(..., "row", "m", "stretch")rows.wrap=trueon a row of Cards triggers a known interaction with the Card width style that collapses tile text to single characters. - Do NOT nest
Stackdirectly inside anotherStackas a flex child. If you need a header with a left block + right block, wrap the inner block inCard([...], "clear")so it gets proper flex sizing. (Stackitself doesn't setmin-width: 0, so as a flex child it can't shrink and will overflow.)
KPI STRIP RECIPE — use this exactly. There is no KPI / Metric / StatCard component:
kpiRow = Stack([k1, k2, k3], "row", "m", "stretch") k1 = Card([TextContent("Open PRs", "small"), TextContent("" + @Count(prs), "large-heavy"), Tag("17 overdue", null, "sm", "warning")], "sunk") k2 = Card([TextContent("MRR", "small"), TextContent("$" + @Round(stripe.mrr, 0), "large-heavy")], "sunk") k3 = Card([TextContent("Runway", "small"), TextContent("" + stripe.runway + " mo", "large-heavy")], "sunk")
For 6 KPIs, two rows: kpiGrid = Stack([row1, row2], "column", "m", "stretch") then two row Stacks of 3 each.
SQL — verify columns BEFORE SELECT. Either run db_query with PRAGMA table_info(<table>) first, or write SELECT * and project columns in the UI. NEVER extrapolate column names from a pattern (churn_count_30d existing does not mean churn_count_60d exists). The runtime fails with no such column and your app shows an error.
Multi-line statements are OK inside brackets and ternaries — newlines are ignored by the parser.
NAME ALIASES (you typed X — write Y. These will lint-fail or render wrong):
- Section { } or → Accordion([AccordionItem("id", "Title", [content])]) — there is no SectionBlock in apps
- Heading("Title") → CardHeader("Title", "Subtitle") or TextContent("Title", "large-heavy")
- KpiCard / KPI / StatCard / Metric → Card+TextContent recipe above
- Markdown(...) → MarkDownRenderer(...)
- Badge(...) → Tag(text, null, "sm", "info" | "success" | "warning" | "danger")
- Divider() → Separator()
- Tab(...) → TabItem("id", "Trigger", [content])
- Grid(...) → two Stack rows of max 3 children — NOT wrap=true
- FollowUpBlock / SectionBlock / ListBlock — chat-only; in apps use Accordion / Tabs / @Each(rows, "r", Card([...]))
- @JsonParse / @ParseJSON → does not exist; Query("exec") auto-parses stdout starting with
{or[ - @FormatDate / @FormatNumber → do not exist; use string concat or @Round + concat
- @Length → @Count(array)
- @Find → @First(@Filter(array, "field", "==", value))
- TabItem("rev", "Revenue", revTab) → TabItem("rev", "Revenue", [revTab]) — content MUST be an array
- AccordionItem same → three args, content array
- "col" direction → "column" (or omit; column is the default)
ENUM ENFORCEMENT (the lint validates these and reports validationErrors on the app_create / app_update response):
- Stack/Card direction:
"row"|"column"only - Card variant:
"card"|"sunk"|"clear"(no"compact"/"primary"/"muted"/"warning") - Tag variant:
"neutral"|"info"|"success"|"warning"|"danger"(no"negative"/"positive"/"medium") - TextContent size:
"small"|"default"|"large"|"small-heavy"|"large-heavy"(no"huge")
Before You Build — three intuitions
These are the difference between a one-shot success and a re-do loop. Apply BEFORE writing any code.
1. Config-first when needed. If the app concept needs values you don't have (watchlist symbols, monthly burn, target repos, key thresholds, your timezone), emit an inline Form in chat FIRST via the openui-inline-ui skill, then call app_create once the user submits. Bake the collected values into Query defaults or a config table. Don't guess defaults that won't match the user's reality.
Skip the form when (a) the request is already self-describing, or (b) the config is multi-row mutable state (that belongs in an in-app Form, not pre-create).
2. Periodic data → propose cron in the same response. Trigger phrases: "every morning", "Monday", "daily", "before I open it", "while I sleep", "8am", "pre-fetched", "weekly". Don't wait to be asked. Same rule for heavy scripts: slow APIs (>3s), paginated >50 items, multi-source serial calls. The pattern is always the same:
- Cron runs the heavy script on schedule → upsert results into a SQLite snapshot table.
- The app reads from that table via
db_query(instant load) instead of refetching live. - Live
Query("exec")is fine for fast / lightweight scripts where re-fetching on open is cheap. - Same logic applies to AI narrative ("what do these numbers mean together?"): for periodic dashboards, write the narrative via cron into a
narrativestable — see "@ToAssistant vs cron-narrative" in the Action area for the full decision rule.
3. Setup-required gate over silent zeroes. Scripts that need a key from ~/.openclaw/workspace/.env MUST detect a missing key and return a JSON shape with an explicit error tag — not zeroed defaults. Otherwise the dashboard silently lies.
// scripts/<source>.js
if (!process.env.STRIPE_SECRET_KEY) {
console.log(JSON.stringify({error: "SETUP_REQUIRED", envVar: "STRIPE_SECRET_KEY", mrr: 0, churn: 0, customers: []}));
process.exit(0);
}
In the app, gate the rest of the UI behind a setup callout when this signal fires:
data = Query("exec", {command: "node ~/.openclaw/workspace/scripts/stripe.js"}, {error: "SETUP_REQUIRED", envVar: "STRIPE_SECRET_KEY", mrr: 0})
setupCallout = data.error == "SETUP_REQUIRED" ? Callout("info", "Setup required", "Add " + data.envVar + " to ~/.openclaw/workspace/.env, then click Refresh.") : null
root = Stack([header, setupCallout, kpiRow, tabs])
The Callout renders to null when the key is present, so this same gate works permanently.
Structured Workflow (follow this order)
Before writing any app code, follow these 5 steps:
PLAN the data model. What tables/queries do you need? What mutations? Do any mutations depend on each other (e.g. need last_insert_rowid from a prior insert)? If yes, redesign — each @Run(mutation) is a SEPARATE DB call with no shared transaction.
TEST the data pipeline. Run the actual commands/queries with
execordb_query. Get the real JSON shape. Verify the output is valid JSON. If writing a script, save it withwrite, then run it withexecand confirm it works. Test with ALL parameter combinations — empty data, error cases, missing fields. The app runtime has NO feedback loop; broken scripts show blank data silently.DESIGN the layout. Pick the right component for each data type:
- 2-4 summary metrics → KPI Card grid (Stack row, max 3 cards per row)
- List of 4+ items with comparable fields → Table (NOT cards)
- Time series → LineChart / AreaChart
- Proportions / breakdown → PieChart (flat arrays!) or donut
- Category comparison → BarChart / HorizontalBarChart
- External links in data → @OpenUrl (NOT @ToAssistant)
Modal check: add a Modal drill-down ONLY when the row has data the Table can't show, OR an action that needs more than one click. If the Modal would just re-display the same columns, skip it. (Full criteria: see "When to add a Table + Modal drill-down" below.)
WIRE interactivity. For each interactive element:
- Filters: $binding → Select → pass $binding in Query args (EVERY relevant Query must reference it)
- Per-row actions: $state variable + top-level Mutation + @Set($state, row.id) → @Run(mutation)
- Forms: $bindings on fields used in Mutations, @Reset after submit
- Enrichment check: Does the data need AI classification, triage, sentiment analysis, or draft generation? If yes → do NOT write naive keyword heuristics in a script. Use the cron agentTurn → DB → app pattern: a cron job runs an LLM agent that analyzes data and writes enriched results to SQLite, and the app reads from the DB. See the Agent-enriched apps section below.
BUILD the app. Write root = Stack(...) FIRST for streaming, then components.
Syntax Rules
- Each statement is on its own line:
identifier = Expression rootis the entry point — every program must defineroot = Stack(...)- Expressions are: strings ("..."), numbers, booleans (true/false), null, arrays ([...]), objects ({...}), or component calls TypeName(arg1, arg2, ...)
- Use references for readability: define
name = ...on one line, then usenamelater - 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.
- Arguments are POSITIONAL (order matters, not names). Write
Stack([children], "row", "l")NOTStack([children], direction: "row", gap: "l")— colon syntax is NOT supported and silently breaks - Optional arguments can be omitted from the end
- Declare mutable state with
$varName = defaultValue. Components marked with$bindingcan read/write these. Undeclared $variables are auto-created with null default. - String concatenation:
"text" + $var + "more" - Dot member access:
query.fieldreads a field; on arrays it extracts that field from every element - Index access:
arr[0],data[index] - Arithmetic operators: +, -, *, /, % (work on numbers; + is string concat when either side is a string)
- Comparison: ==, !=, >, <, >=, <=
- Logical: &&, ||, ! (prefix)
- Ternary:
condition ? valueIfTrue : valueIfFalse - Parentheses for grouping:
(a + b) * c
- Strings use double quotes with backslash escaping
- Line comments:
//is stripped by the parser. Use to annotate sections in large apps.
// KPI section
kpiRow = Stack([kpi1, kpi2, kpi3], "row", "m", "stretch")
- Computed values: any expression can be assigned to a variable and reused. This works with query-derived data, $reactive variables, and @-functions.
totalEngagement = data.likes + data.retweets + data.replies
engagementRate = @Round(totalEngagement * 100 / data.views, 2)
avgWeekly = @Round(@Avg(npm.packages.weekly), 0)
Component Signatures
Arguments marked with ? are optional. Sub-components can be inline or referenced; prefer references for better streaming.
Props typed ActionExpression accept an Action([@steps...]) expression. See the Action section for available steps (@Run, @ToAssistant, @OpenUrl, @Set, @Reset).
Props marked $binding<type> accept a $variable reference for two-way binding.
Layout
Stack(children: any[], direction?: "row" | "column", gap?: "none" | "xs" | "s" | "m" | "l" | "xl" | "2xl", align?: "start" | "center" | "end" | "stretch" | "baseline", justify?: "start" | "center" | "end" | "between" | "around" | "evenly", wrap?: boolean) — Flex container. direction: "row"|"column" (default "column"). gap: "none"|"xs"|"s"|"m"|"l"|"xl"|"2xl" (default "m"). align: "start"|"center"|"end"|"stretch"|"baseline". justify: "start"|"center"|"end"|"between"|"around"|"evenly". Tabs(items: TabItem[]) — Tabbed container TabItem(value: string, trigger: string, content: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps)[]) — value is unique id, trigger is tab label, content is array of components Accordion(items: AccordionItem[]) — Collapsible sections AccordionItem(value: string, trigger: string, content: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps)[]) — value is unique id, trigger is section title Steps(items: StepsItem[]) — Step-by-step guide StepsItem(title: string, details: string) — title and details text for one step Carousel(children: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps)[][], variant?: "card" | "sunk") — Horizontal scrollable carousel Separator(orientation?: "horizontal" | "vertical", decorative?: boolean) — Visual divider between content sections Modal(title: string, open?: $binding, children: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps)[], size?: "sm" | "md" | "lg") — Modal dialog. open is a reactive $boolean binding — set to true to open, X/Escape/backdrop auto-closes. Put Form with buttons inside children.
- For grid-like layouts, use Stack with direction "row" and wrap set to true.
- Prefer justify "start" (or omit justify) with wrap=true for stable columns instead of uneven gutters.
- Use nested Stacks when you need explicit rows/sections.
- Show/hide sections: $editId != "" ? Card([editForm]) : null
- Modal: Modal("Title", $showModal, [content]) — $showModal is boolean, X/Escape auto-closes. Put Form with its own buttons inside children.
- Use Tabs for alternative views (chart types, data sections) — no $variable needed
- Shared filter across Tabs: same $days binding in Query args works across all TabItems
Content
Card(children: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps | Tabs | Carousel | Stack)[], variant?: "card" | "sunk" | "clear", direction?: "row" | "column", gap?: "none" | "xs" | "s" | "m" | "l" | "xl" | "2xl", align?: "start" | "center" | "end" | "stretch" | "baseline", justify?: "start" | "center" | "end" | "between" | "around" | "evenly", wrap?: boolean) — Styled container. variant: "card" (default, elevated) | "sunk" (recessed) | "clear" (transparent). Always full width. Accepts all Stack flex params (default: direction "column"). Cards flex to share space in row/wrap layouts. CardHeader(title?: string, subtitle?: string) — Header with optional title and subtitle TextContent(text: string, size?: "small" | "default" | "large" | "small-heavy" | "large-heavy") — Text block. Supports markdown. Optional size: "small" | "default" | "large" | "small-heavy" | "large-heavy". MarkDownRenderer(textMarkdown: string, variant?: "clear" | "card" | "sunk") — Renders markdown text with optional container variant Callout(variant: "info" | "warning" | "error" | "success" | "neutral", title: string, description: string, visible?: $binding) — Callout banner. Optional visible is a reactive $boolean — auto-dismisses after 3s by setting $visible to false. TextCallout(variant?: "neutral" | "info" | "warning" | "success" | "danger", title?: string, description?: string) — Text callout with variant, title, and description Image(alt: string, src?: string) — Image with alt text and optional URL ImageBlock(src: string, alt?: string) — Image block with loading state ImageGallery(images: {src: string, alt?: string, details?: string}[]) — Gallery grid of images with modal preview CodeBlock(language: string, codeString: string) — Syntax-highlighted code block
- Use Cards to group related KPIs or sections. Stack with direction "row" for side-by-side layouts.
- Success toast: Callout("success", "Saved", "Done.", $showSuccess) — use @Set($showSuccess, true) in save action, auto-dismisses after 3s. For errors: result.status == "error" ? Callout("error", "Failed", result.error) : null
- KPI card: Card([TextContent("Label", "small"), TextContent("" + @Count(@Filter(data.rows, "field", "==", "value")), "large-heavy")])
Tables
Table(columns: Col[]) — Data table — column-oriented. Each Col holds its own data array. Col(label: string, data: any, type?: "string" | "number" | "action") — Column definition — holds label + data array
- Table is COLUMN-oriented: Table([Col("Label", dataArray), Col("Count", countArray, "number")]). Use array pluck for data: data.rows.fieldName
- Col data can be component arrays for styled cells: Col("Status", @Each(data.rows, "item", Tag(item.status, null, "sm", item.status == "open" ? "success" : "danger")))
- Row actions: Col("Actions", @Each(data.rows, "t", Button("Edit", Action([@Set($showEdit, true), @Set($editId, t.id)]))))
- Sortable: sorted = @Sort(data.rows, $sortField, "desc"). Bind $sortField to Select. Use sorted.fieldName for Col data
- Searchable: filtered = @Filter(data.rows, "title", "contains", $search). Bind $search to Input
- Chain sort + filter: filtered = @Filter(...) then sorted = @Sort(filtered, ...) — use sorted for both Table and Charts
- Empty state: @Count(data.rows) > 0 ? Table([...]) : TextContent("No data yet")
Charts (2D)
BarChart(labels: string[], series: Series[], variant?: "grouped" | "stacked", xLabel?: string, yLabel?: string) — Vertical bars; use for comparing values across categories with one or more series LineChart(labels: string[], series: Series[], variant?: "linear" | "natural" | "step", xLabel?: string, yLabel?: string) — Lines over categories; use for trends and continuous data over time AreaChart(labels: string[], series: Series[], variant?: "linear" | "natural" | "step", xLabel?: string, yLabel?: string) — Filled area under lines; use for cumulative totals or volume trends over time RadarChart(labels: string[], series: Series[]) — Spider/web chart; use for comparing multiple variables across one or more entities HorizontalBarChart(labels: string[], series: Series[], variant?: "grouped" | "stacked", xLabel?: string, yLabel?: string) — Horizontal bars; prefer when category labels are long or for ranked lists Series(category: string, values: number[]) — One data series
- Charts accept column arrays: LineChart(labels, [Series("Name", values)]). Use array pluck: LineChart(data.rows.day, [Series("Views", data.rows.views)])
- Use Cards to wrap charts with CardHeader for titled sections
- Chart + Table from same source: use @Sort or @Filter result for both LineChart and Table Col data
- Multiple chart views: use Tabs — Tabs([TabItem("line", "Line", [LineChart(...)]), TabItem("bar", "Bar", [BarChart(...)])])
Charts (1D)
PieChart(labels: string[], values: number[], variant?: "pie" | "donut") — Circular slices; use plucked arrays: PieChart(data.categories, data.values) RadialChart(labels: string[], values: number[]) — Radial bars; use plucked arrays: RadialChart(data.categories, data.values) SingleStackedBarChart(labels: string[], values: number[]) — Single horizontal stacked bar; use plucked arrays: SingleStackedBarChart(data.categories, data.values) Slice(category: string, value: number) — One slice with label and numeric value
- PieChart and BarChart need NUMBERS, not objects. For list data, use @Count(@Filter(...)) to aggregate:
- PieChart from list:
PieChart(["Low", "Med", "High"], [@Count(@Filter(data.rows, "priority", "==", "low")), @Count(@Filter(data.rows, "priority", "==", "medium")), @Count(@Filter(data.rows, "priority", "==", "high"))], "donut") - KPI from count:
TextContent("" + @Count(@Filter(data.rows, "status", "==", "open")), "large-heavy")
Charts (Scatter)
ScatterChart(datasets: ScatterSeries[], xLabel?: string, yLabel?: string) — X/Y scatter plot; use for correlations, distributions, and clustering ScatterSeries(name: string, points: Point[]) — Named dataset Point(x: number, y: number, z?: number) — Data point with numeric coordinates
Forms
Form(name: string, buttons: Buttons, fields?: FormControl[]) — Form container with fields and explicit action buttons FormControl(label: string, input: Input | TextArea | Select | DatePicker | Slider | CheckBoxGroup | RadioGroup, hint?: string) — Field with label, input component, and optional hint text Label(text: string) — Text label Input(name: string, placeholder?: string, type?: "text" | "email" | "password" | "number" | "url", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) TextArea(name: string, placeholder?: string, rows?: number, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) Select(name: string, items: SelectItem[], placeholder?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) SelectItem(value: string, label: string) — Option for Select DatePicker(name: string, mode?: "single" | "range", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) Slider(name: string, variant: "continuous" | "discrete", min: number, max: number, step?: number, defaultValue?: number[], label?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding<number[]>) — Numeric slider input; supports continuous and discrete (stepped) variants CheckBoxGroup(name: string, items: CheckBoxItem[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding<Record<string, boolean>>) CheckBoxItem(label: string, description: string, name: string, defaultChecked?: boolean) RadioGroup(name: string, items: RadioItem[], defaultValue?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) RadioItem(label: string, description: string, value: string) SwitchGroup(name: string, items: SwitchItem[], variant?: "clear" | "card" | "sunk", value?: $binding<Record<string, boolean>>) — Group of switch toggles SwitchItem(label?: string, description?: string, name: string, defaultChecked?: boolean) — Individual switch toggle
- For Form fields, define EACH FormControl as its own reference — do NOT inline all controls in one array. This allows progressive field-by-field streaming.
- NEVER nest Form inside Form — each Form should be a standalone container.
- Form requires explicit buttons. Always pass a Buttons(...) reference as the third Form argument.
- rules is an optional object: {required: true, email: true, minLength: 8, maxLength: 100}
- Available rules: required, email, min, max, minLength, maxLength, pattern, url, numeric
- The renderer shows error messages automatically — do NOT generate error text in the UI
- Conditional fields: $country == "US" ? stateField : $country == "UK" ? postcodeField : addressField
- Edit form in Modal: Modal("Edit", $showEdit, [Form("edit", Buttons([saveBtn, cancelBtn]), [fields...])]). Save button should include @Set($showEdit, false) to close modal.
Buttons
Button(label: string, action?: ActionExpression, variant?: "primary" | "secondary" | "tertiary", type?: "normal" | "destructive", size?: "extra-small" | "small" | "medium" | "large") — Clickable button Buttons(buttons: Button[], direction?: "row" | "column") — Group of Button components. direction: "row" (default) | "column".
- Toggle in @Each: @Each(rows, "t", Button(t.status == "open" ? "Close" : "Reopen", Action([...])))
Data Display
TagBlock(tags: string[]) — tags is an array of strings Tag(text: string, icon?: string, size?: "sm" | "md" | "lg", variant?: "neutral" | "info" | "success" | "warning" | "danger") — Styled tag/badge with optional icon and variant
- Color-mapped Tag: Tag(value, null, "sm", value == "high" ? "danger" : value == "medium" ? "warning" : "neutral")
Layout Decision Matrix
Choose the right component for your data:
- KPI summary (2-4 metrics):
Stack([Card([TextContent("Label", "small"), TextContent(value, "large-heavy")], "sunk"), ...], "row", "m", "stretch")— max 3 Cards per row - Data list (4+ items, comparable fields):
Table([Col(...), Col(...)])with @Each for styled cells (Tag, Button). Add a Modal drill-down only when criteria below are met. - Feed (chronological items with actions): Stack column of Cards via @Each, each card has action Buttons
- Category breakdown: PieChart (flat arrays!) or HorizontalBarChart
- Trend over time: LineChart or AreaChart
- Comparison across categories: BarChart (grouped or stacked)
- Multi-dimension comparison: RadarChart
Table + Modal detail pattern (most common for dashboards):
$selectedId = ""
$showDetail = false
table = Table([
Col("Name", data.rows.name),
Col("Status", @Each(data.rows, "r", Tag(r.status, null, "sm", r.status == "active" ? "success" : "danger"))),
Col("", @Each(data.rows, "r", Button("Details", Action([@Set($selectedId, "" + r.id), @Set($showDetail, true)]), "secondary", "normal", "extra-small")))
])
selected = @First(@Filter(data.rows, "id", "==", $selectedId))
detail = Modal("Details", $showDetail, [
CardHeader(selected.name),
MarkDownRenderer(selected.description),
Buttons([Button("Open ↗", Action([@OpenUrl(selected.url)]), "primary"), Button("Close", Action([@Set($showDetail, false)]), "secondary")])
], "md")
Conditional Tag coloring (use for status/priority/severity):
Tag(item.priority, null, "sm", item.priority == "urgent" ? "danger" : item.priority == "high" ? "warning" : item.priority == "medium" ? "info" : "neutral")
Conditional alert bar (war room / command center pattern): Show alerts at the top of the dashboard that change based on live data:
activeIncidents = @Filter(incidents, "status", "!=", "resolved")
alertBar = @Count(activeIncidents) > 0 ? Callout("warning", "🔥 " + @Count(activeIncidents) + " Active Incident(s)", @First(activeIncidents).title) : Callout("success", "✅ All Clear", "No active incidents")
root = Stack([header, alertBar, kpiRow, tabs])
Place alertBar between header and KPI row. Use Callout (auto-dismissable) or TextCallout (persistent) depending on urgency.
Null-safe display (handle loading/missing data gracefully):
TextContent(gh.stars ? "" + gh.stars : "—", "large-heavy")
@Count(data.rows) > 0 ? Table([...]) : TextContent("No data yet", "small")
@Count(trend) > 1 ? AreaChart(trend.day, [Series("Views", trend.count)]) : Callout("info", "Building history…", "More data points needed.")
Always guard charts that need 2+ data points. Use "—" as fallback for KPI values.
When to add a Table + Modal drill-down: Add a Modal ONLY when the row has data the table can't show, OR an action that needs more than one click. If the modal would just re-display the same columns in a popup, skip it — that's decoration, not value.
Add a Modal when:
- Row has long-form content the cell truncates (full tweet text, agent diagnosis, PR description, email body)
- Row needs multiple actions in sequence (Draft Reply → Edit → Send; Acknowledge → Assign → Resolve)
- Row has nested data (timeline, related items, sub-tickets) that doesn't fit a column
Skip the Modal when:
- The table already shows everything (id, title, status, date) and the only action is "open URL" → just put a
Button("↗", Action([@OpenUrl(row.url)]))in the last Col - The user only ever wants to navigate away (use
@OpenUrldirectly)
Rich KPI cards with sub-indicators:
kpiPRs = Card([TextContent("Open PRs", "small"), TextContent("" + @Count(prs), "large-heavy"), Stack([
@Count(@Filter(prs, "ci", "==", "failing")) > 0 ? Tag("CI failing", null, "sm", "danger") : Tag("All passing", null, "sm", "success"),
Tag("" + @Count(@Filter(prs, "status", "==", "review")) + " in review", null, "sm", "info")
], "row", "xs")], "sunk")
Use Stack row of Tags below the KPI value to show breakdowns at a glance.
Built-in Functions
Data functions prefixed with @ to distinguish from components. These are the ONLY functions available — do NOT invent new ones.
Use @-prefixed built-in functions (@Count, @Sum, @Avg, @Min, @Max, @Round) on Query results — do NOT hardcode computed values.
@Count(array) → number — Returns array length @First(array) → element — Returns first element of array @Last(array) → element — Returns last element of array @Sum(numbers[]) → number — Sum of numeric array @Avg(numbers[]) → number — Average of numeric array @Min(numbers[]) → number — Minimum value in array @Max(numbers[]) → number — Maximum value in array @Sort(array, field, direction?) → sorted array — Sort array by field. Direction: "asc" (default) or "desc" @Filter(array, field, operator: "==" | "!=" | ">" | "<" | ">=" | "<=" | "contains", value) → filtered array — Filter array by field value @Round(number, decimals?) → number — Round to N decimal places (default 0) @Abs(number) → number — Absolute value @Floor(number) → number — Round down to nearest integer @Ceil(number) → number — Round up to nearest integer @Each(array, varName, template) — Evaluate template for each element. varName is the loop variable — use it ONLY inside the template expression (inline). Do NOT create a separate statement for the template.
Builtins compose — output of one is input to the next:
@Count(@Filter(data.rows, "field", "==", "val")) for KPIs/chart values, @Round(@Avg(data.rows.score), 1), @Each(data.rows, "item", Comp(item.field)) for per-item rendering.
Array pluck: data.rows.field extracts a field from every row → use with @Sum, @Avg, charts, tables.
IMPORTANT @Each rule: The loop variable (e.g. "item") is ONLY available inside the @Each template expression. Always inline the template — do NOT extract it to a separate statement.
CORRECT: Col("Actions", @Each(rows, "t", Button("Edit", Action([@Set($id, t.id)]))))
WRONG: myBtn = Button("Edit", Action([@Set($id, t.id)])) then Col("Actions", @Each(rows, "t", myBtn)) — t is undefined in myBtn.
Query — Live Data Fetching
Fetch data from available tools. Returns defaults instantly, swaps in real data when it arrives.
metrics = Query("tool_name", {arg1: value, arg2: $binding}, {defaultField: 0, defaultData: []}, refreshInterval?)
- First arg: tool name (string)
- Second arg: arguments object (may reference $bindings — re-fetches automatically on change)
- Third arg: default data (rendered immediately before fetch resolves)
- Fourth arg (optional): refresh interval in seconds (e.g. 30 for auto-refresh every 30s)
- Use dot access on results: metrics.totalEvents, metrics.data.day (array pluck)
- Query results must use regular identifiers:
metrics = Query(...), NOT$metrics = Query(...) - Manual refresh:
Button("Refresh", Action([@Run(query1), @Run(query2)]), "secondary")— re-fetches the listed queries - Refresh all queries: create Action with @Run for each query
- NEVER invent custom refresh actions like
{type: "refresh"}or{type: "refresh_data"}— query refresh is done ONLY withAction([@Run(queryRef), ...])
Mutation — Write Operations
Execute state-changing tool calls (create, update, delete). Unlike Query (auto-fetches on render), Mutation fires only on button click via Action.
result = Mutation("tool_name", {arg1: $binding, arg2: "value"})
- First arg: tool name (string)
- Second arg: arguments object (evaluated with current $binding values at click time)
- result.status: "idle" | "loading" | "success" | "error"
- result.data: tool response on success
- result.error: error message on failure
- Mutation results use regular identifiers:
result = Mutation(...), NOT$result - Show loading state:
result.status == "loading" ? TextContent("Saving...") : null
Action — Button Behavior
Action([@steps...]) wires button clicks to operations. Steps are @-prefixed built-in actions. Steps execute in order. Buttons without an explicit Action prop automatically send their label to the assistant (equivalent to Action([@ToAssistant(label)])).
Available steps:
- @Run(queryOrMutationRef) — Execute a Mutation or re-fetch a Query (ref must be a declared Query/Mutation)
- @ToAssistant("message") — Send a message to the assistant (for conversational buttons like "Tell me more", "Explain this")
- @OpenUrl("https://...") — Navigate to a URL
- @Set($variable, value) — Set a $variable to a specific value
- @Reset($var1, $var2, ...) — Reset $variables to their declared defaults (e.g. @Reset($title, $priority) restores $title="" and $priority="medium")
Example — mutation + refresh + reset (PREFERRED pattern):
$binding = "default"
result = Mutation("tool_name", {field: $binding})
data = Query("tool_name", {}, {rows: []}) @Run(data), @Reset($binding)])
Example — manual refresh button:
metrics = Query("tool_name", {}, {rows: []}, 30)
refreshBtn = Button("Refresh", Action([@Run(metrics)]), "secondary")
Example — simple nav:
viewBtn = Button("View", Action([@OpenUrl("https://example.com")]))
- Action can be assigned to a variable or inlined: Button("Go", onSubmit) and Button("Go", Action([...])) both work
- If a @Run(mutation) step fails, remaining steps are skipped (halt on failure)
- @Run(queryRef) re-fetches the query (fire-and-forget, cannot fail)
- Do NOT invent custom button action types for tool/query behavior. For refresh, always use
Action([@Run(queryRef), ...]).
Action Decision Tree
- Button navigates to a URL →
@OpenUrl(url)— NEVER use @ToAssistant for navigation - Button writes data (create/update/delete) →
@Run(mutationRef)— requires top-level Mutation - Button needs AI reasoning/response →
@ToAssistant("message")(see below) - Button changes UI state (show/hide) →
@Set($variable, value) - Button resets form →
@Reset($var1, $var2)
@ToAssistant vs cron-narrative — choose deliberately. Both patterns can produce the "what does this mean together?" analysis, but they have different latencies + costs. Pick based on when the user wants the answer ready:
- Cron writes narrative to DB → app reads it instantly. Use when the user implies "ready when I open" — phrases like "Monday morning view", "every morning", "before standup", "weekly digest", "while I sleep", "pre-fetched". The cron prompt must read fresh metrics, generate the narrative paragraph, upsert into a
narratives(date, text, generated_at)table. The app showsnarrativeText = @First(narratives.rows).text. Zero click latency. - @ToAssistant button → user clicks, agent generates fresh. Use when the user says "analyze", "explain", "what's going on with X" — they want to drive the analysis at click time. Or when the analysis is contextual to a specific row (per-customer "why is this at risk?").
- Both — cron seeds the morning narrative; the @ToAssistant "Refresh analysis" button regenerates ad-hoc. Best for high-stakes dashboards (founder, finance) where the user wants both the always-on baseline AND the ability to dig deeper. Default to cron-narrative for any dashboard the user implied as periodic. Default to @ToAssistant when the analysis is on-demand or row-scoped. Don't reach for @ToAssistant just because it's easier — paying an LLM roundtrip on every refresh of a "Monday morning view" is the wrong economics.
@ToAssistant — when the action genuinely needs AI: Use ONLY when the button requires LLM analysis, not data fetching or navigation:
Button("Analyze Spike", Action([@ToAssistant("Analyze the traffic spike on " + data.topPage + " — what caused it?")]))
Button("Draft Reply", Action([@ToAssistant("Draft a reply to this tweet by @" + t.author + ": " + t.text)]))
Button("Diagnose", Action([@ToAssistant("Deploy " + d.service + " failed: " + d.error + ". Suggest a fix.")]))
Include context inline — the agent receives ONLY the @ToAssistant string, not the app state.
When Query data includes URLs (e.g. item.url, pr.html_url), ALWAYS wire them to @OpenUrl:
Col("", @Each(data.rows, "r", Button("Open ↗", Action([@OpenUrl(r.url)]), "tertiary", "normal", "extra-small")))
Common Mistakes
❌ WRONG: PieChart([Slice("A", 10), Slice("B", 20)]) → renders [object Object]
✅ RIGHT: PieChart(["A", "B"], [10, 20], "donut")
Why: PieChart takes TWO flat arrays (labels[], values[]), NOT an array of Slice objects. Same for RadialChart and SingleStackedBarChart.
❌ WRONG: @Run(Mutation("db_execute", {sql: "DELETE ..."}))
✅ RIGHT:
$delId = ""
delMut = Mutation("db_execute", {sql: "DELETE FROM items WHERE id = " + $delId, namespace: "myapp"})
Col("Actions", @Each(data.rows, "t", Button("🗑️", Action([@Set($delId, "
…(truncated)