lumen lets an agent describe rich UI in a few lines of OpenUI Lang — and renders it live in your browser. Dashboards instead of walls of text, forms instead of twenty questions. An MCP server and a CLI, all on localhost, no API keys.
Some things an agent wants to say are not prose. lumen gives it a second channel.
Stats, tables, charts, diffs, timelines, forms — Zod-typed, Tailwind-styled, validated server-side so the agent can self-correct.
Every render hot-swaps the view. Named canvases become tabs; every canvas keeps its own history rail you can scroll back through.
Comment on any part of the view, answer forms, then one button turns the whole batch into a prompt you paste into your agent. Works with any agent.
When the agent renders again, toggle a unified diff of the program against its predecessor — see exactly what your feedback changed.
Origin-checked requests, pinned Host, no CORS surface. No network calls, no API keys — the agent is the model.
Canvases survive restarts. Export any view as a self-contained HTML file — or as the OpenUI source that produced it.
All four views below were rendered by an agent through the real pipeline — each one is a handful of lines of OpenUI Lang.
Ask for "this week in numbers" and get stat cards, charts and a timeline instead of a bulleted list. Sortable, paged tables handle the long stuff.
root = Page("Acme · Weekly Ops", [kpis, mid, notes])
kpis = Grid([k1, k2, k3, k4], 4)
k1 = Stat("MRR", "$82.4k", "+6.1%", "up")
traffic = BarChart([t1, t2, t3, t4, t5], "Sessions by day")
mix = Donut([m1, m2, m3], "Plan mix")
An agent explaining its own diff: the plan with per-step state, the files it touched, the patch itself with syntax-coloured +/− rows, and the test run that proves it.
plan = Steps([p1, p2, p3, p4], "Plan")
files = FileTree([f1, f2, f3], "Files touched")
patch = Diff("--- a/src/api/fetchUser.ts\n...", "src/api/fetchUser.ts", "typescript")
tests = Terminal("bun test ...", "bun test", 0)
Instead of asking in prose, the agent renders the question: a Choice for the decision, a Form for the overrides. Your answers reach it as exact structured JSON — {"decision":"deploy","region":"eu-west-1","replicas":6} — not parsed prose.
pick = Choice([yes, hold, abort], "Ready to ship?", "decision")
cfg = Form([region, replicas, canary, notes], "Save overrides")
region = Select("region", ["us-east-1", "eu-west-1"], "Region")
replicas = Slider("replicas", "Replicas", 2, 12, 1, 6)
Postmortems read better as gauges, sparklines, a timeline and an error-rate heatmap than as paragraphs. The follow-up checklist keeps its state across re-renders.
g1 = Gauge(94, "SLO budget left", 100, "#10b981") spark = Sparkline([320, 340, 2100, ...], "p99 latency (ms)") tl = Timeline([t1, t2, t3, t4, t5]) hm = Heatmap([h1, h2, h3], cols, "Error rate by service")
lumen never speaks into your agent's session. Your review travels through you — like a code review, for UI.
What lands on your clipboard:
Feedback on your lumen view (canvas "incident"). Parts are named by their statement id in the OpenUI Lang program you rendered; `data:` lines are Form/Choice answers. Address each point, then update the view with render_ui. Part `g1` (render #4): g1 = Gauge(94, "SLO budget left", 100, "#10b981") > Show the burn-rate trend next to this gauge Part `pick` (canvas "deploy", render #3): pick = Choice([yes, hold, abort], "Ready to ship?", "decision") data: {"decision":"deploy"}
And when the agent renders again:
50 components, one authoritative catalog — lumen prompt (or the get_ui_guide tool) prints it, generated from the same definitions the browser renders, so the two never drift.
| Layout | Page Stack Row Grid Section Card Divider |
|---|---|
| Text | Heading Text Markdown Code Badge |
| Data | Stat Table KeyValue List Timeline Progress |
| Feedback | Alert Callout |
| Interactive | Button Link Tabs Accordion |
| Charts | BarChart LineChart AreaChart Donut Sparkline Gauge Heatmap |
| Media | Image Avatar Audio |
| Code & change | Diff FileTree Terminal Steps Checklist |
| Status | Spinner Skeleton Empty |
| Input | Form TextInput Textarea Select Checkbox RadioGroup Slider Choice |
Requires Bun ≥ 1.1. Nothing else.
# get it bun add -g @shinyaigeek/lumen # wire it into Claude Code — it will spawn lumen for you lumen setup # --user for every project # or drive it by hand lumen serve echo 'root = Page("Hello", [s]) s = Stat("Revenue", "$1.2M", "+12%", "up")' | lumen render -
Or from a clone: git clone https://github.com/Shinyaigeek/lumen.git && cd lumen && bun install && bun run build,
then bun src/cli/lumen.ts wherever the commands above say lumen.
A localhost bind is not a trust boundary — any page you have open can talk to
http://localhost:4123.
So lumen treats the origin as the boundary: every request and WebSocket upgrade is
origin-checked, Host is pinned to loopback
(DNS-rebinding safe), and POSTs must be JSON so nothing rides the CORS simple-request lane.
Rendered output is data, not code — a fixed component set, no eval, no raw-HTML sink.
Exported HTML is inert by construction. The guard's four end-to-end attacks are pinned in
test/guard.test.ts;
report vulnerabilities via SECURITY.md.