Skip to content

Repository files navigation

lumen ✦

A live canvas that lets a coding agent describe rich UI — and see it rendered in your browser.

npm CI license: MIT

📖 Documentation site — the use cases, the feedback loop, and diff mode, in pictures.

The lumen canvas rendering a weekly ops dashboard an agent described in OpenUI Lang

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-correctingrender_ui validates 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.

Quick start

Requires Bun ≥ 1.1 — lumen runs on Bun, not Node.

bun add -g @shinyaigeek/lumen
lumen setup          # this project;  --user for every project

That'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.

Use it from the CLI

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.

From a clone instead

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 below

What setup does

setup 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.

Another client than Claude Code

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.json

claude (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:

  1. call get_ui_guide to learn the component vocabulary,
  2. call render_ui with an OpenUI Lang program,
  3. — 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.

Keeping a view alive — patch_ui

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.

Waiting for an answer — await_input

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.

Multiple canvases (tabs) — branch a conversation

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.

Talking back to the agent — comments you carry over on the clipboard

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:

  1. 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.
  2. 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.
  3. Leave an overall note — the bar at the bottom collects remarks about the whole view.
  4. Answer a Form or Choice — submitted values are recorded into the same batch, as exact structured data.
  5. 📋 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.

Asking with UI, not prose — inputs that come back as data

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.


The language (OpenUI Lang)

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

CLI reference

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).

Watching from another device — --host 0.0.0.0

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:4123

0.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).

Export — keep a view after the server is gone

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 it

Persistence

Canvases 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.)


Security

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 Origin on cross-origin requests, on POSTs and on WS handshakes; lumen requires it to match the Host the request arrived on. That is the same-origin rule, and it needs no configuration — localhost, 127.0.0.1 and a LAN address all work. The opaque null origin is never trusted.
  • Host is pinned to the bind address, so a page whose DNS points at 127.0.0.1 (DNS rebinding) is refused too. Set LUMEN_ALLOWED_HOSTS=a.example,b.example if 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 Origin is 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.

How it works

  • src/lib/ — the component library: Zod-typed defineComponent definitions 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 — a Bun.serve server: 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.

Contributing

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.

License

MIT — see LICENSE. Built on the MIT-licensed OpenUI by thesys.

About

A live canvas for coding agents: your agent describes rich UI in OpenUI Lang and it renders in your browser, on localhost. MCP server + CLI, on Bun.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages