YAML-driven Frontends
Habit frontends are authored as a single declarative file — frontend/index.yaml — and compiled to a self-contained HTML document on the fly. This guide explains the runtime, the schema, the workflow for converting an existing HTML page, and how to test the result locally.
If you already have a hand-written frontend/index.html, the Cortex server keeps serving it. You only opt into the YAML pipeline by adding frontend/index.yaml. When both files exist, the YAML wins.
Why YAML?
A habit's UI is almost always a thin shell over its workflows: a form, a fetch, a result panel, sometimes a tab strip or a history grid. Hand-written HTML/CSS/JS for every habit means duplicated boilerplate, drifting design tokens, and a long tail of one-off bugs.
The YAML engine inverts that:
- One schema (
schemas/ui-spec.schema.yaml) describes every pattern we use. - One renderer (
@ha-bits/cortex-core→compileUiSpec) produces consistent HTML, CSS, and a small client-side runtime. - One source of truth per habit. No more
<style>blobs to maintain.
File layout
showcase/<habit-id>/
├── stack.yaml # workflow + server config
├── habits/ # individual workflow YAMLs
└── frontend/
├── index.yaml # ← the UiSpec the server compiles
└── index.html # optional fallback, ignored when index.yaml existsThe stack.yaml doesn't change — it still points at the folder:
server:
port: 13000
frontend: ./frontendThe server resolves the directory and picks the first match from index.yaml, index.yml, ui.yaml, ui.yml, then index.html.
How compilation works
The pipeline lives in packages/cortex/core/src/ui/:
frontend/index.yaml
│
▼
parseUiSpec() // yaml → typed UiSpec
│
▼
compileUiSpec() // UiSpec → { html, css, js }
│ │ │
│ │ └── runtime.ts (state, dispatch, templating, streaming)
│ └──────── theme.ts (CSS variables from ThemeSpec)
└─────────────── layouts.ts + widgets.ts (server-rendered HTML)
│
▼
<!DOCTYPE html><html><head>…<style>…</style></head>
<body>…compiled markup…
<script>…RUNTIME_JS + boot data…</script>
</body></html>What the engine handles natively (no per-habit JS needed):
- Forms (text, email, number, date, textarea, select, chip-group, radio-cards, tag-input, file/image upload with base64).
- Layouts:
single,tabs,sidebar,wizard,mobile-shell,split,chat,showcase. - Templated text:
{{state.foo}},{{state.user.name | truncate:30}},{{now | iso}}. - Conditional rendering via
showWhen/hideWhen. - Action dispatch: HTTP (
GET/POST/etc.), OAuth gating, NDJSON streaming, polling. - Output widgets: result panels, metric grids, kv-grids, score rings, progress bars, badge lists, data tables, history grids, code/markdown blocks with copy buttons.
- Drag-and-drop file zones, downloads from base64 payloads, print-friendly views.
The canonical list of widget kinds lives in packages/cortex/core/src/ui/types.ts.
Server integration
The Cortex server (packages/cortex/server/src/server.ts) handles compilation transparently:
GET / (habit root URL)
└── if frontend/index.yaml exists:
compileUiYaml(file) → HTML response
else if frontend/index.html exists:
sendFile(file)For .habit zip bundles the loader applies the same rule against the bundled frontend/ directory.
Authoring a new YAML frontend
A minimal example:
# yaml-language-server: $schema=../../../schemas/ui-spec.schema.yaml
version: 1
meta:
id: hello-world
title: Hello World Demo
icon: "lucide:Hand"
theme:
preset: neural
mode: dark
state:
name: ""
result: null
actions:
greet:
method: POST
endpoint: /api/hello-world
body: { name: "{{state.name}}" }
responsePath: output
onSuccess: { set: { result: "$response" }, toast: "Greeted" }
widgets:
- kind: card
title: Say hello
children:
- kind: form
bindTo: state
fields:
- { name: name, type: text, label: Your name, required: true }
submit: { label: Say hello, action: greet, loadingLabel: "Greeting..." }
- kind: result-panel
source: state.result
title: Greeting
sections:
- { kind: json-dump, source: state.result, copy: true }That single file gives you a themed page, a validated form, a POST to /api/hello-world, a toast on success, a conditional result panel, and a JSON viewer with a copy button. No CSS, no JS.
Icons
Icon fields (meta.icon, layout.header.icon, layout.nav[].icon, widget icon props) accept:
| Format | Example | Notes |
|---|---|---|
| Lucide name (recommended) | lucide:Zap | 47 curated icons; inherits theme primary color |
| Image URL | /assets/logo.svg | Rendered as <img> |
| Inline SVG | <svg viewBox="0 0 24 24">…</svg> | Sanitized at compile time |
| Plain text | ◆ or H | Escaped text fallback |
| Omit | (no field) | Header/nav show labels only |
Lucide icons are generated from lucide-static via scripts/sync-lucide-icons.mjs into packages/cortex/core/assets/icons/lucide/ (not committed; run automatically before @ha-bits/cortex-core builds). The Cortex runtime serves them at /ha-assets/icons/lucide/ (Node server or Tauri www/ha-assets/). Reference any icon as lucide:IconName (PascalCase, e.g. lucide:Hand, lucide:TriangleAlert). See lucideIcons.ts for lookup helpers.
No emoji required. Prefer lucide:Name or omit the icon entirely.
Alternatives without icons:
- Labels only — drop
iconfrom nav items; tabs and sidebar use text alone. - Hero / image widgets — use
kind: herowithimageSourcefor a logo instead ofmeta.icon. - Hide header icon slot —
theme.customCss: ".ha-header__icon { display: none; }"
For richer examples, browse showcase/*/frontend/index.yaml — every habit in this repo now has one. Notable patterns:
- Tabs + history:
showcase/ai-quiz/frontend/index.yaml - Multi-platform output tabs:
showcase/social-media-manager/frontend/index.yaml - NDJSON streaming:
showcase/marketing-campaign/frontend/index.yaml - Chat layout:
showcase/openclaw-clone/frontend/index.yaml - OAuth gating:
showcase/cloud-file-upload/frontend/index.yaml - Showcase dashboard:
showcase/ecommerce-retail-order-management/frontend/index.yaml
Schema
The full schema is schemas/ui-spec.schema.yaml:
# UiSpec Schema — YAML-driven UI for Habits frontends
#
# Every habit ships a single `frontend/index.yaml` (a "UiSpec"). The Cortex
# server compiles it to a self-contained HTML document at request time via
# `compileUiSpec` / `compileUiYaml` in `@ha-bits/cortex-core`.
#
# This schema describes that YAML. Authoring tools (and humans) can lint
# against it. Keep it in sync with
# `packages/cortex/core/src/ui/types.ts`.
$schema: http://json-schema.org/draft-07/schema#
$id: https://habits.codenteam.com/schemas/ui-spec.schema.json
title: Habits UI Spec
description: |
A declarative spec for a habit's frontend. Compiled to HTML by
`@ha-bits/cortex-core`. Supports forms, tabs, sidebars, wizards, mobile
shells, chat threads, history grids, NDJSON streaming, OAuth gating,
polling, and static showcase dashboards.
type: object
additionalProperties: false
required: []
properties:
version:
description: Schema version. Currently always `1`.
type: integer
enum: [1]
meta:
$ref: "#/definitions/MetaSpec"
theme:
$ref: "#/definitions/ThemeSpec"
layout:
$ref: "#/definitions/LayoutSpec"
state:
description: |
Initial in-memory state. The runtime exposes this as `state.*` to
every template expression and binding (e.g. `{{state.foo}}`,
`bindTo: state`, `showWhen: state.result`).
type: object
additionalProperties: true
actions:
description: |
Map of named actions. Reference them from buttons/forms/onMount via
action id. Default endpoint is `/api/{meta.id}` if you omit
`endpoint`.
type: object
additionalProperties:
$ref: "#/definitions/ActionSpec"
onMount:
description: |
Action ids (or inline `{ set: {...} }` updates) dispatched when the
page first loads. May be a single id, an array of ids, or an array
mixing ids and inline ActionSpec fragments.
oneOf:
- type: string
- type: array
items:
oneOf:
- type: string
- $ref: "#/definitions/ActionSpec"
defaultView:
description: |
View id rendered first for `tabs`, `sidebar`, `mobile-shell`,
`wizard`, and `chat` layouts.
type: string
views:
description: |
Named views keyed by id (matches `layout.nav[].id`). For `single`
layout, omit `views` and use top-level `widgets` instead.
type: object
additionalProperties:
$ref: "#/definitions/ViewSpec"
widgets:
description: |
Convenience for `single` layout. Alternative to `views.main.widgets`.
type: array
items:
$ref: "#/definitions/WidgetSpec"
definitions:
# ---------------------------------------------------------------------
# Meta + theme
# ---------------------------------------------------------------------
MetaSpec:
type: object
additionalProperties: false
properties:
id:
type: string
description: |
Workflow id used to build the default `/api/{id}` endpoint for
actions. Should match the directory name (e.g. `ai-quiz`).
title:
type: string
description: Header title and document `<title>` fallback.
subtitle:
type: string
description:
type: string
icon:
type: string
description: |
Icon for the header. Prefer `lucide:Name` (e.g. `lucide:Zap`), or an
image URL, inline SVG string, or plain text. Omit to hide the icon.
documentTitle:
type: string
description: Overrides the `<title>`; defaults to `title`.
ThemeSpec:
type: object
additionalProperties: false
properties:
preset:
description: One of the bundled palette presets.
type: string
enum:
- neural
- ha-bits-blue
- ha-bits-cyan
- ha-bits-purple
- ha-bits-red
- ha-bits-emerald
- ha-bits-warn
- aurora
- cyberpunk
- mobile-blue
- tailwind-dark
- showcase-flat
mode:
type: string
enum: [dark, light]
primary: { type: string, description: Hex override }
secondary: { type: string, description: Hex override }
accent: { type: string, description: Hex override }
background: { type: string, description: Hex override }
font:
type: object
additionalProperties: false
properties:
body: { type: string }
mono: { type: string }
display: { type: string }
radius:
type: integer
minimum: 0
maximum: 32
density:
type: string
enum: [comfortable, compact, mobile]
customCss:
type: string
# ---------------------------------------------------------------------
# Layout
# ---------------------------------------------------------------------
LayoutSpec:
type: object
additionalProperties: false
properties:
type:
description: |
Top-level page shell. `showcase` renders a static dashboard with
metric grid + habit cards.
type: string
enum: [single, tabs, sidebar, wizard, mobile-shell, split, chat, showcase]
default: single
header:
$ref: "#/definitions/HeaderSpec"
footerStatus:
$ref: "#/definitions/FooterStatusSpec"
nav:
type: array
items:
$ref: "#/definitions/NavItemSpec"
navPosition:
type: string
enum: [top, bottom, left]
split:
type: object
required: [left, right]
properties:
left: { type: string }
right: { type: string }
ratio: { type: string }
HeaderSpec:
type: object
additionalProperties: false
properties:
title: { type: string }
subtitle: { type: string }
icon: { type: string }
sticky: { type: boolean }
badge:
type: object
properties:
text: { type: string }
tone: { $ref: "#/definitions/Tone" }
actions:
type: array
items:
$ref: "#/definitions/WidgetSpec"
FooterStatusSpec:
type: object
additionalProperties: false
properties:
dot:
type: string
enum: [live, idle, warn, error]
text: { type: string }
port: { oneOf: [{ type: integer }, { type: string }] }
version: { type: string }
NavItemSpec:
type: object
required: [id, label]
additionalProperties: false
properties:
id: { type: string }
label: { type: string }
icon: { type: string }
badge: { oneOf: [{ type: string }, { type: integer }] }
# ---------------------------------------------------------------------
# Actions
# ---------------------------------------------------------------------
ActionSpec:
type: object
additionalProperties: false
properties:
type:
description: |
`http` (default), `oauth`, `navigate`, `reset`, `logout`.
type: string
enum: [http, oauth, navigate, reset, logout]
open:
description: |
Shorthand: open a URL (e.g. an OAuth init endpoint or external
page). Equivalent to `type: navigate` with a URL.
type: string
reset:
description: |
State keys to reset to their initial values. Useful for "Start
over" buttons.
type: array
items: { type: string }
geolocate:
description: |
Request the browser's geolocation and write the result into the
provided state paths.
type: object
additionalProperties: false
properties:
latitudeTo: { type: string }
longitudeTo: { type: string }
set:
description: |
Shorthand: synchronously update state without making an HTTP
call. Equivalent to `type: reset` with `onSuccess.set`.
type: object
additionalProperties: true
method:
type: string
enum: [GET, POST, PUT, PATCH, DELETE]
endpoint:
description: Templated URL. Defaults to `/api/{meta.id}`.
type: string
body:
description: |
Templated body. Object whose leaf values are template strings.
oneOf:
- type: object
additionalProperties: true
- type: string
query:
type: object
additionalProperties: true
headers:
type: object
additionalProperties: { type: string }
responsePath:
description: |
Where in the JSON response the payload lives. Default `output`
with a fallback to root. Use `$` for the whole response.
type: string
stream:
description: |
Set to `ndjson` to parse a stream of newline-delimited JSON
events and dispatch them through `events[]`.
oneOf:
- { type: string, enum: [ndjson, tokens] }
- { type: boolean, enum: [false] }
events:
type: array
items:
$ref: "#/definitions/ActionEventHandler"
poll:
type: object
required: [intervalMs]
properties:
intervalMs: { type: integer, minimum: 100 }
auto: { type: boolean }
oauth:
type: object
required: [statusUrl, initUrl]
properties:
statusUrl: { type: string }
initUrl: { type: string }
confirm:
description: |
If set, prompts the user with this text before dispatching.
type: string
onSuccess:
$ref: "#/definitions/ActionSuccessSpec"
onError:
$ref: "#/definitions/ActionErrorSpec"
ActionEventHandler:
type: object
additionalProperties: false
properties:
match:
description: Partial-match object against the streamed event.
type: object
additionalProperties: true
append: { type: string, description: State path to push the event onto }
set:
type: object
additionalProperties: true
increment:
type: string
description: State path of a counter to increment.
ActionSuccessSpec:
type: object
additionalProperties: false
properties:
goto:
type: string
description: Navigate to this view id after success.
set:
description: |
State updates. Each value is a template string (e.g.
`"$response"`, `"{{state.foo}}"`), a literal, or null to clear.
type: object
additionalProperties: true
append:
description: |
Either a state path (string) to push the response into, or a
map of `{ statePath: itemTemplate }` for multi-list appends.
oneOf:
- type: string
- type: object
additionalProperties: true
toast:
oneOf:
- { type: string }
- type: object
additionalProperties: true
properties:
message: { type: string }
level: { type: string, enum: [info, success, warn, error] }
reload:
description: Re-dispatch one or more action ids after this action succeeds.
oneOf:
- { type: string }
- { type: array, items: { type: string } }
dispatch:
description: Alias for `reload`. Either spelling works at runtime.
oneOf:
- { type: string }
- { type: array, items: { type: string } }
resetForm:
type: string
download:
type: object
required: [dataPath]
properties:
dataPath: { type: string }
fileNamePath: { type: string }
mimeType: { type: string }
ActionErrorSpec:
type: object
additionalProperties: false
properties:
toast:
oneOf:
- { type: string }
- type: object
additionalProperties: true
properties:
message: { type: string }
level: { type: string, enum: [info, success, warn, error] }
set:
type: object
additionalProperties: true
# ---------------------------------------------------------------------
# Views + widgets
# ---------------------------------------------------------------------
ViewSpec:
type: object
additionalProperties: false
required: [widgets]
properties:
title: { type: string }
widgets:
type: array
items:
$ref: "#/definitions/WidgetSpec"
onEnter:
oneOf:
- type: string
- type: array
items: { type: string }
Tone:
type: string
enum: [primary, secondary, accent, success, warn, danger, info, muted]
# `WidgetSpec` is an open object — every widget shape has a `kind` plus
# widget-specific keys. The runtime ignores unknown keys, so the
# safest schema is "object with a string `kind` discriminator".
WidgetSpec:
type: object
required: [kind]
properties:
kind:
type: string
description: |
Widget discriminator. Common values include:
- layout: `section`, `card`, `row`, `column`, `tabs`,
`accordion`, `modal`, `bottom-sheet`, `split`
- input: `form`, `button`, `action-button`, `submit-button`,
`copy-button`, `download-button`, `print-button`,
`link-button`, `button-row`, `toggle`, `chip-group`,
`radio-cards`, `tag-input`
- output: `result-panel`, `pre`, `code-block`, `code`,
`json-dump`, `markdown`, `text`, `heading`, `html-preview`,
`image`, `hero`, `score-ring`, `bar-chart`, `progress-bar`,
`metric-grid`, `stat-row`, `badge`, `badge-list`,
`numbered-list`, `bullet-list`, `data-table`, `kv-grid`,
`list`, `checklist`, `trait-bars`, `step-list`,
`filter-bar`, `repeatable`
- feedback: `status-banner`, `alert`, `empty-state`, `spinner`,
`loading-steps`, `pipeline-indicator`
- realtime: `chat-panel`, `streaming-panel`, `streaming-text`
- app: `habit-grid`, `history-grid`, `history-list`,
`mode-selector`, `oauth-status-card`
id: { type: string }
className: { type: string }
showWhen:
type: string
description: |
Template expression. The widget is rendered only when this
evaluates truthy at runtime (e.g. `state.result`,
`state.queue.length > 0`).
hideWhen:
type: string
description: Inverse of `showWhen`. Either may be used.
additionalProperties: true
# The full per-widget shape is documented in
# `packages/cortex/core/src/ui/types.ts`. A short reference of the
# most common widgets follows so editors can offer reasonable
# completions even without dependent-schema support.
examples:
- kind: card
title: A grouped section
children:
- { kind: text, value: "Hello" }
- kind: form
bindTo: state
fields:
- { name: title, type: text, label: Title, required: true }
submit: { label: Save, action: save }
- kind: metric-grid
columns: 4
metrics:
- { value: "1,842", label: "Orders today" }
- kind: result-panel
source: state.result
title: Result
sections:
- { kind: json-dump, source: state.result, copy: true }
- kind: alert
level: error
showWhen: state.error
text: "{{state.error}}"Wire it into your editor via .vscode/settings.json:
{
"yaml.schemas": {
"./schemas/ui-spec.schema.yaml": [
"showcase/*/frontend/index.yaml",
"showcase/*/frontend/ui.yaml"
]
}
}Or pin a single file with a comment at the top:
# yaml-language-server: $schema=../../../schemas/ui-spec.schema.yamlTesting locally
1. Compile every YAML in one pass
The repo ships a smoke-test script that walks showcase/*/frontend/index.yaml, compiles each one, and writes the rendered HTML to .compiled-frontends/<habit-id>.html:
pnpm nx build @ha-bits/cortex-core # build the engine (once)
node scripts/smoke-test-ui-engine.mjs # compile everythingSample output:
✓ showcase/ai-quiz/frontend/index.yaml → .compiled-frontends/ai-quiz.html (72.8 KB)
✓ showcase/hello-world/frontend/index.yaml → .compiled-frontends/hello-world.html (68.6 KB)
...
78 ok, 0 failedTo compile a single file:
node scripts/smoke-test-ui-engine.mjs showcase/ai-quiz/frontend/index.yamlA non-zero exit code means at least one compile failed — useful for CI.
2. Preview the compiled HTML in a browser
cd .compiled-frontends
python3 -m http.server 7531
# open http://localhost:7531/ai-quiz.htmlThis shows the rendered page without booting Cortex — handy for checking layout, theme, and visibility logic.
3. Run end-to-end against Cortex
For a realistic test (real API calls, real state, real polling/streaming), boot the habit normally:
pnpm habits dev showcase/ai-quiz/stack.yaml
# open http://localhost:13000/The server logs which file it served:
[cortex] serving frontend YAML (index.yaml)Edit frontend/index.yaml, refresh the browser, and you'll see the change immediately.
4. Authoring loop tips
- Compile-then-diff. When porting an HTML page, keep the original
index.htmlin place. After compiling, open both side by side. Fields, copy strings, and field order should match. - Show conditionals. Use
showWhen: state.foo/hideWhen: state.errorinstead of duplicating views. The runtime evaluates them with full JS expressions (!state.loading,state.queue.length > 0). - Template strings. Anything inside
{{ ... }}is evaluated against the live state. Common filters:truncate:N,date,iso,currency,splitCsv,length,filterBy:foo=bar,sum:total,join:', '. - Default endpoint. If you omit
endpoint, the engine uses/api/{meta.id}. That covers most habits because the workflow id matches the directory. - Use the showcase converter.
scripts/convert-showcase-to-yaml.mjsparses any HTML page that follows the static showcase template (header + 4 stat cards + 3x2 habit grid) and emits anindex.yaml. Use it as a starting point for new industry showcases.
Adding new widgets or themes
Both live in @ha-bits/cortex-core:
| Concern | File |
|---|---|
| Type definitions | packages/cortex/core/src/ui/types.ts |
| Themes / CSS | packages/cortex/core/src/ui/theme.ts |
| Server-side render of widgets | packages/cortex/core/src/ui/widgets.ts |
| Layout shells | packages/cortex/core/src/ui/layouts.ts |
| Client runtime | packages/cortex/core/src/ui/runtime.ts |
| Icon rendering | packages/cortex/core/src/ui/icons.ts |
| Lucide icons | scripts/sync-lucide-icons.mjs → assets/icons/lucide/*.svg + lucideIcons.ts |
| Bundled assets | packages/cortex/core/assets/ (icons + fonts), served once at /ha-assets/ by the runtime |
| Entry point | packages/cortex/core/src/ui/index.ts |
After changing the engine, rebuild and re-run the smoke test:
pnpm nx build @ha-bits/cortex-core --skip-nx-cache
node scripts/smoke-test-ui-engine.mjsRemember to update schemas/ui-spec.schema.yaml and this doc whenever you add a new widget kind, layout, theme preset, or top-level UiSpec property.
