A live canvas that lets a coding agent describe rich UI — and see it rendered in your browser.
📖 Documentation site — the use cases, the feedback loop, and diff mode, in pictures.
Your agent doesn't return a wall of text. It calls a tool, describes an interface
in a compact declarative language (OpenUI Lang by
thesys), and lumen renders it live on localhost with a polished,
Tailwind-styled component set: stat cards, tables, charts, timelines, tabs,
markdown, callouts, and more.
agent ──(render_ui / lumen render)──▶ lumen server ──WebSocket──▶ your browser
OpenUI Lang program (Bun) live rich UI
- Two ways in — an MCP server (for Claude Code & other agents) and a CLI.
- Rich components — 63 Tailwind components (layout, data, charts, diagrams, code, media, feedback, inputs).
- Live — every render hot-swaps the browser view over a WebSocket; a history rail keeps past renders.
- Feedback by clipboard, difit-style — comment on any part of the view, or select text to comment on just that passage, then Copy feedback turns the batch into one prompt you paste into your agent. Works with any agent; no extra tools, flags, or push channel.
- Self-correcting —
render_uivalidates the program server-side and reports unknown components / missing props back to the agent. - No API keys, offline — the agent is the model, so lumen just parses and renders. Nothing leaves your machine.
Requires Bun ≥ 1.1 — lumen runs on Bun, not Node.
bun add -g @shinyaigeek/lumen
lumen setup # this project; --user for every projectThat's it: setup registers lumen with Claude Code, which spawns the server for
you the next time you start it. Ask your agent for anything with structure and it
renders at http://localhost:4123.
Prefer not to install? bunx @shinyaigeek/lumen serve runs it once. For setup,
install globally instead — it records the path of the CLI it ran from, and a
bunx path is a cache entry, not a home.
lumen serve # starts http://localhost:4123 and opens your browser
echo 'root = Page("Hello", [s])
s = Stat("Revenue", "$1.2M", "+12%", "up")' | lumen render -The browser updates instantly. See lumen prompt for the full language + component guide.
git clone https://github.com/Shinyaigeek/lumen.git && cd lumen
bun install
bun run build # bundles the web UI into ./dist
bun src/cli/lumen.ts setup # substitute for `lumen` in any command belowsetup registers lumen as an MCP server, so Claude Code spawns it for you —
you never run lumen serve yourself. It merges into an existing config (backing
it up first) and pre-approves the server so there's no first-run prompt. Pass
--dry-run to see what it would write.
It also installs a lumen skill at .claude/skills/lumen/SKILL.md (~/.claude
with --user), holding the OpenUI Lang guide and worked recipes for the cases
where a view beats prose. That pairs with the instructions the MCP server sends
on connect, which are what make the agent reach for the canvas unprompted —
rendering a Diff when it explains a change, Steps for a plan, a Choice
when it needs you to decide — instead of waiting to be asked. The split is
deliberate: the instructions are short because they sit in the system prompt
every turn; the skill is long because it is read only when the agent is about to
render.
MCP is a protocol, and the clients differ only in where the config lives and what
the wrapper key is called. Pass --client:
lumen setup --client cursor # .cursor/mcp.json (or ~/.cursor with --user)
lumen setup --client codex --user # ~/.codex/config.toml
lumen setup --client vscode # .vscode/mcp.jsonclaude (the default), cursor, windsurf, vscode, gemini, zed, codex.
Some have only one scope — codex and windsurf are user-level, vscode is
project-level — and asking for the other one is an error rather than a config
written where nothing will read it. The skill is a Claude Code feature, so
only that client gets one; everywhere else the agent learns when to render from
the instructions the server sends on connect.
To register it by hand instead, add to ~/.claude.json (or a project
.mcp.json):
{
"mcpServers": {
"lumen": {
"command": "bun",
"args": ["/absolute/path/to/lumen/src/cli/lumen.ts", "mcp"],
"env": { "LUMEN_PORT": "4123" }
}
}
}Point args[0] at the CLI file you actually have — the one in your clone, or the
one a global install put behind the lumen command
(readlink -f "$(bun pm bin -g)/lumen"). Registered by hand you get the tools
and the instructions, but not the skill — copy it in with lumen setup when you
want one.
Then just work as usual, or ask for a view explicitly ("show me X with lumen"). Either way the agent will:
- call
get_ui_guideto learn the component vocabulary, - call
render_uiwith an OpenUI Lang program, - — and the UI appears at
http://localhost:4123(opened automatically on the first render).
Tools exposed: render_ui, patch_ui, await_input, get_ui_guide, clear_ui,
fork_canvas, list_canvases, close_canvas, export_canvas.
A render replaces the whole program, which is wasteful when one number changed.
patch_ui takes a fragment holding only the statements that changed — same
syntax, same ids — and merges it into the current view. Statements it names are
replaced, ones it introduces are appended, everything else is untouched.
bar = Progress(70, "7/10 tests")
note = Text("2 failing in auth")
A patch updates the current render in place rather than adding a history
entry, so ticking a progress bar twenty times does not bury the twenty renders
before it. lumen patch <file|-> does the same from the shell.
lumen normally never speaks into your agent's session: you carry the feedback
over on the clipboard. await_input is the one, opt-in exception. The agent
calls it after rendering a Choice/Form/Review, and blocks until you submit;
the browser says your agent is waiting and posts your answer straight back, so
there is nothing to copy.
It only applies while a wait is live — with no wait, submissions batch for the clipboard exactly as before — it never carries your comments, and it always times out (300s by default, 1800s at most), because an agent parked forever on a question you never saw is worse than one that gives up and says so.
Renders live on named canvases, shown as tabs in the browser. render_ui
targets the "main" canvas unless you pass a canvas name (an unknown name
creates a new tab). Each canvas keeps its own current view, history and comment
batch — independent from the others.
To explore an alternative without losing the original, fork_canvas branches
a canvas into a new one seeded with its current view; render to the new canvas to
change it. The human can do the same from the browser with the ⑂ Branch button.
list_canvases shows what's open; close_canvas (or the tab's ✕) removes one.
Unless the agent explicitly waits (await_input above), lumen never speaks into
its session. Instead, like difit, it
records your review and hands it back through you:
- Comment on a part — toggle Comment mode, then click any part of the view to open an inline comment popover anchored to that block, Notion-style. The part stays highlighted with a 💬 count, and the comment records the part's statement id and source line.
- Comment on a passage — in the same mode, select any text and a 💬 pill offers to comment on just that sentence. The passage stays tinted on the canvas and is quoted verbatim to the agent, so a remark about one line of prose does not arrive as a remark about the whole block.
- Leave an overall note — the bar at the bottom collects remarks about the whole view.
- Answer a
FormorChoice— submitted values are recorded into the same batch, as exact structured data. - 📋 Copy feedback — one button turns the whole batch into a prompt and puts it on the clipboard. Paste it into your agent — any agent — and it knows exactly which statement every remark targets:
Feedback on your lumen view (canvas "main"). Parts are named by their statement
id in the OpenUI Lang program you rendered; a `selected:` line means the comment
is about that passage of the rendered part, not the whole part; `data:` lines are
Form/Choice answers. Address each point, then update the view with render_ui.
Part `kpis` (render #12):
kpis = Grid([r, u, c], 3)
> make this blue
Part `notes` (render #12):
notes = Markdown("Shipping **billing v2** unblocked three enterprise deals.")
selected: “unblocked three enterprise deals”
> two, not three
Overall (render #12):
> add a legend
Comments live in the browser (localStorage) — they survive a reload, persist across re-renders Notion-style, and never touch the server. Delete one from its popover, or 🗑 discards the batch. Once the agent has dealt with a point, hit Resolve on it: the comment stays in the popover as a record but drops out of the next Copy feedback, so round two does not re-send what round one fixed. A part whose remarks are all resolved shows a ✓ instead of the 💬 count.
When the agent renders again, changes quote their diff. The canvas itself stays clean — reading a view and reviewing what changed are different jobs — so the diff has its own UI: the ± Diff toggle in the header swaps the canvas for a GitHub-style unified diff of the render against its predecessor (line numbers, +/− rows). A part's comment popover also shows its exact before → after source — so you see precisely what your comment changed, no manual diffing.
The agent can also ask a question by rendering it. Choice is a row of buttons
that post their value the moment they're clicked; Form wraps TextInput,
Textarea, Select, Checkbox, RadioGroup and Slider fields and posts them
all on submit.
root = Page("Deploy review", [pick, cfg])
pick = Choice([yes, no], "Ship it?", "approved")
yes = { label: "Deploy", value: "true", tone: "primary" }
no = { label: "Hold", value: "false", tone: "danger" }
cfg = Form([region, replicas], "Create", "Environment")
region = Select("region", ["us-east-1", "eu-west-1"], "Region")
replicas = Slider("replicas", "Replicas", 1, 9, 1, 3)
The human's answers land in the copied feedback as structured data, keyed by
each field's name — no parsing of prose:
{ "approved": "true" }
{ "region": "eu-west-1", "replicas": 3 }Fields only submit from inside a Form, and only the live render accepts input —
a form scrolled back to in the history rail is a record, not an input.
One statement per line, positional args, root is the entry point:
root = Page("Quarterly Review", [kpis, note], "Q4 FY26")
kpis = Grid([r, u, c], 3)
r = Stat("Revenue", "$1.24M", "+18% QoQ", "up")
u = Stat("Active users", "451k", "+9%", "up")
c = Stat("Churn", "2.1%", "-0.3pp", "down")
note = Callout([body], "📈", "Takeaway")
body = Markdown("Revenue growth is **accelerating**.")
BarChart, LineChart and AreaChart also take a series array to plot
several series at once (bars group, or stack with stacked). Table cells may
be strings, numbers, or { text, tone } pills, with per-column align, an
optional sortable header, a filterable search box, and a pageSize that
pages long tables. Sorting and filtering span the whole table, not the visible
page.
Data the agent should not have to transcribe. Table(src: "./bench.csv")
points at a .csv/.tsv/.json/.jsonl file inside the working directory;
the server reads it and the browser fills the table (headers from the file when
you omit columns). Rows never travel through the agent's context, so they cost
nothing to re-render and cannot be mistyped. Reads are confined to the working
directory — symlinks resolved first — restricted to those extensions, capped at
8MB and 5000 rows, and a refusal says why on the canvas.
Paths worth clicking. FileRef("src/app.ts", 88) renders a path that opens
in your editor at that line, and Diff, FileTree and StackTrace link theirs
the same way. lumen builds the URI itself from a fixed table of schemes — an
agent-written vscode:// href is still refused, as it must be. Set
LUMEN_EDITOR to vscode, vscode-insiders, cursor, windsurf, zed,
idea or none; with nothing set lumen believes the terminal it was launched
from, and falls back to none, which renders paths as plain text.
Run lumen prompt (or the get_ui_guide tool) to print the authoritative,
always-in-sync catalog. Component families:
| Family | Components |
|---|---|
| Layout | Page Stack Row Grid Section Card Divider |
| Text | Heading Text Markdown Code Badge |
| Data | Stat Table Compare KeyValue JsonTree List Timeline Board Progress |
| Feedback | Alert Callout |
| Interactive | Button Link Tabs Accordion |
| Charts | BarChart LineChart AreaChart Donut Sparkline Gauge Heatmap |
| Media | Image Avatar Audio Video |
| Diagram | Graph |
| Code & change | Diff DiffStat FileTree Terminal Steps Checklist AnnotatedCode StackTrace FileRef |
| Input | Form TextInput Textarea Select Checkbox RadioGroup Slider MultiSelect FilePicker Rank Choice Review |
lumen setup [--user] [--client <c>] [--host <h>] [--dry-run] Register lumen with your agent's client
lumen patch <file|-> [--canvas <id>] Update only the statements the fragment names
lumen serve [--port <n>] [--host <h>] [--no-open] Start the web UI server
lumen mcp [--port <n>] [--host <h>] Run as an MCP server over stdio
lumen render <file|-> [--title <t>] Send an OpenUI Lang doc to a running server
lumen clear Clear the current view
lumen export [canvas] [--format html|oui] [--out <file>] Save a canvas to a file
lumen prompt Print the OpenUI Lang guide + catalog
Env: LUMEN_PORT (default 4123), LUMEN_HOST (bind address, default localhost —
see below), LUMEN_EDITOR (which editor FileRef paths open in; none to
disable), LUMEN_OPEN (0 = never open a browser, 1 = force),
LUMEN_STATE (snapshot path, default ~/.lumen/state.json; 0 disables persistence),
LUMEN_ALLOWED_HOSTS (extra Host values to accept — see Security).
lumen binds to localhost, so the canvas is only openable on the machine the
agent runs on. That is wrong for a container, a VM, a remote dev box, or when you
want the view on a second screen: bind every interface instead.
lumen serve --host 0.0.0.0 # or LUMEN_HOST=0.0.0.0
[lumen] serving at http://localhost:4123
[lumen] also reachable at http://192.168.1.24:41230.0.0.0 is a bind address, not a destination, so lumen never hands it out — it
publishes localhost (which a wildcard bind also answers) and prints the LAN
addresses other devices can use. When the agent's client spawns lumen for you,
there is no command line to pass, so record it in the MCP entry:
lumen setup --host 0.0.0.0 writes LUMEN_HOST into it.
This drops the interface out of the trust boundary. The origin check still
stops another page in your browser, but a request with no Origin is a
non-browser client and is allowed by design — on loopback that means a local
program, on 0.0.0.0 it means anyone who can reach the port. They can read every
canvas and render to it. Bind wide only on a network you trust, or front lumen
with a proxy that authenticates (and list its name in LUMEN_ALLOWED_HOSTS).
Hit ⇩ Export in the header, run lumen export, or have the agent call
export_canvas. html writes a self-contained page — the script and
styles are inlined, so it opens straight from disk with no server and no
network. It renders read-only: there is no websocket, and with no submit or
comment providers the forms and comment layer are inert by construction.
oui writes the OpenUI Lang source instead, which lumen render will replay.
lumen export main --out report.html # a snapshot you can email
lumen export main --format oui # the program that produced itCanvases and their history are snapshotted to ~/.lumen/state.json and restored
on startup — restarting your editor or MCP client no longer throws the session
away. Writes are debounced and atomic. Set LUMEN_STATE=0 to run purely in
memory. (Your comments persist separately, in the browser's localStorage.)
lumen binds to localhost, but a localhost bind is not a trust boundary —
it keeps lumen off the network, not away from your browser. Any page you happen
to have open can send requests to http://localhost:4123; WebSockets aren't
covered by the same-origin policy at all, and a text/plain POST is a CORS
simple request that needs no preflight. Left open, a page on evil.example
could read every canvas over /ws, and POST /api/render to put views of its
choosing in front of you. (Your comments never reach the server at all — they
leave the browser only via your clipboard.)
So lumen treats the origin as the boundary (src/server/guard.ts):
- Every request and WebSocket upgrade is origin-checked. A browser always
sends
Originon cross-origin requests, on POSTs and on WS handshakes; lumen requires it to match theHostthe request arrived on. That is the same-origin rule, and it needs no configuration —localhost,127.0.0.1and a LAN address all work. The opaquenullorigin is never trusted. Hostis pinned to the bind address, so a page whose DNS points at127.0.0.1(DNS rebinding) is refused too. SetLUMEN_ALLOWED_HOSTS=a.example,b.exampleif you front lumen with a reverse proxy. A wildcard bind (--host 0.0.0.0) answers to names lumen cannot enumerate, so this check stands down there — see Watching from another device.- POST bodies must be
application/json. That takes the request out of the CORS simple-request set, so the browser has to preflight — and lumen answers no preflight, because it publishes no CORS headers. - A request with no
Originis allowed: that's a non-browser client (the lumen CLI, curl), which this cannot defend against anyway — a program making raw requests sets whatever headers it likes.
test/guard.test.ts pins all of the above, including the four end-to-end
attacks it prevents.
One endpoint reads from disk: /api/data, behind Table(src:). It is confined
to the working directory (with symlinks resolved before the check, so a link
inside it cannot point out), restricted to .csv/.tsv/.json/.jsonl, and
capped in size — test/data.test.ts pins each refusal. On a loopback bind that
is no more reach than any program on the machine already has; on --host 0.0.0.0 it is one more reason to only do that on a network you trust.
Beyond that: lumen makes no network calls and needs no API keys — the agent
is the model. Rendered output is data, not code: render_ui parses OpenUI Lang
and renders a fixed component set; there is no eval and no raw-HTML sink.
Exported HTML is inert by construction (no websocket, no submit or comment
providers).
To report a vulnerability, see SECURITY.md.
src/lib/— the component library: Zod-typeddefineComponentdefinitions with React + Tailwind renderers, shared by the browser (to render) and the server (to generate the guide, so the two never drift).src/server/app.ts— aBun.serveserver: static SPA +/api/render+ a WebSocket that broadcasts each new document.src/mcp/mcp.ts— the MCP stdio server; hosts the web server in-process and validates programs with@openuidev/lang-core's parser.web/— a Vite + React SPA that subscribes over WebSocket and renders the current program with<Renderer>from@openuidev/react-lang.
Built on the MIT-licensed OpenUI by thesys.
Issues and pull requests are welcome — see CONTRIBUTING.md for how to get set up, what CI checks, and how to add a component. By participating you agree to the Code of Conduct. Security reports go through SECURITY.md, not the public issue tracker.
MIT — see LICENSE. Built on the MIT-licensed OpenUI by thesys.
