lux
A visual output surface for AI agents.
Lux gives agents and apps a shared visual surface. The intended architecture is a hub/display split: clients send UI descriptions to luxd, the Hub owns authoritative element state and behavior, and the Display renders a replica of the current scene while forwarding user interactions back to the Hub.
The design draws on X11's client/server split and Smalltalk-style live introspection. MCP is one gateway into Lux, not the whole architecture. If you want the short version of the rewrite target, start with docs/architecture/target/target.md. If you need help navigating the docs, use docs/README.md. For the product direction, positioning, and risk assessment — the Working Backwards PR/FAQ — see prfaq.pdf.
Platforms: macOS, Linux
Stage: alpha --- protocol is stable, published on PyPI as punt-lux
A Claude Code plugin displaying a project issue board --- the agent fetches live data from DoltDB via bd list --json, builds a filterable table with detail panel, and renders it in a single tool call. Filters and row selection run at 60fps with zero MCP round-trips.
The same list/detail pattern generalizes to any tabular data. Search, combo filters, pagination, and a detail panel --- all driven by a single show_table() call.
Dashboards compose metric cards, charts, and tables. show_dashboard() builds the layout from structured data --- no manual element positioning needed.
Quick Start
curl -fsSL https://raw.githubusercontent.com/punt-labs/lux/1117250/install.sh | sh
Restart Claude Code twice. The Lux display window opens automatically when agents send visual output.
Manual install (if you already have uv)
uv tool install 'punt-lux[display]'
Then install the plugin via the marketplace:
claude plugin marketplace add punt-labs/claude-plugins
claude plugin install lux@punt-labs
Lightweight install (CLI and protocol types, no renderer)
If you only need the lux CLI and the JSON protocol element types --- enough to
drive a running luxd over its REST API and to build element trees in Python
--- install the base package:
uv add punt-lux
This pulls ~2 MB of lightweight deps. The 66 MB display stack (imgui-bundle, numpy, Pillow, PyOpenGL) is only needed to run the renderer (lux display) and is available via punt-lux[display].
Verify before running
curl -fsSL https://raw.githubusercontent.com/punt-labs/lux/1117250/install.sh -o install.sh
shasum -a 256 install.sh
cat install.sh
sh install.sh
Run a demo
lux display &
uv run python demos/dashboard.py
Demos are in demos/ --- each connects as a client and drives the display:
| Demo | What it shows |
|---|---|
interactive.py |
Sliders, checkboxes, combos, text inputs, color pickers |
containers.py |
Windows, tab bars, collapsing headers, groups |
dashboard.py |
Multi-window layout with draw canvases and live controls |
data_viz.py |
Tables, plots, progress bars, spinners, markdown |
menu_bar.py |
Custom menus, event handling, periodic refresh |
Features
- 25 element kinds --- text, buttons (arrow, small), images, sliders, checkboxes, combos, inputs (text, number), radios, color pickers (alpha, full picker), selectables, trees, tables, plots, progress bars, spinners, markdown, draw canvases, modals, dialogs, groups, tab bars, collapsing headers, windows, separators
- Frames --- scenes target named frames (inner windows) via
frame_id. Frames persist after disconnect, can be adopted by new clients, and support initial sizing (frame_size) and ImGui window flags (frame_flags) - Layout nesting --- windows contain tab bars contain groups contain any element, arbitrarily deep
- Incremental updates ---
updatepatches individual elements by ID without replacing the scene - Session menus --- the menu bar shows one submenu per live session. A session registers a menu entry via
register_callback; a click on it is held for the owning session, which services it from its own shell. The built-in "Beads" entry each lux-enabled session registers is how the beads board reopens from the menu - Interaction handling --- button clicks, slider changes, and menu clicks fire their handlers on the Hub (D21 remote dispatch); the raw event log is readable via
list_recent_events. Hub handlers canpublishapp events that the agent reads viarecv - Frame auto-focus --- frames automatically focus (brought to front) when they receive a scene update
- Persistent tabs --- each
show()call opens a dismissable tab; samescene_idreplaces content in-place. Users can close individual tabs - Themes --- 11 themes via
set_theme:imgui_colors_dark,imgui_colors_light,imgui_colors_classic,darcula,darcula_darker,material_flat,photoshop_style,grey_flat,cherry,light_rounded,microsoft_style - Auto-spawn --- the Hub (luxd) starts the display renderer on first use if it isn't already running
- Unix socket IPC --- length-prefixed JSON frames, no HTTP overhead, no threads
MCP Tools
Agents interact with Lux through 30 MCP tools that luxd serves over its streamable-HTTP /mcp endpoint:
| Tool | What it does |
|---|---|
| Scene management | |
show(scene_id, elements) |
Replace the display with a new element tree. Supports frame_id, frame_size, frame_flags for windowed frames |
show_table(scene_id, columns, rows) |
Display a filterable data table with optional detail panel |
show_dashboard(scene_id, ...) |
Display a dashboard with metric cards, charts, and a table |
update(scene_id, patches) |
Patch elements by ID (set fields or remove) |
clear() |
Remove all content from the display |
| Communication | |
ping() |
Round-trip latency check |
recv() |
Take the next queued app event for this session (pub/sub) without blocking; returns event:<topic>:<payload> or none immediately. Poll on your own schedule. UI interactions are handled Hub-side, not delivered here |
set_menu(menus) |
Add custom menus to the menu bar |
register_callback(callback_id, label) |
Register a menu entry this session owns; clicks are held for it (identity-guarded) |
pending_callbacks() |
Take the menu clicks held for this session, draining them (poll on your own schedule) |
set_theme(theme) |
Switch display theme |
| Configuration | |
display_mode(repo) |
Read current display mode (y/n) for the caller's project --- pass the absolute project path |
set_display_mode(mode, repo) |
Set display mode for the caller's project --- pass the absolute project path |
set_window_settings(...) |
Configure opacity, font scale, decoration, idle FPS |
set_frame_state(frame_id, ...) |
Minimize or restore a frame |
| Introspection | |
inspect_scene(scene_id) |
Return element tree for a scene |
list_scenes() |
List all active scenes with metadata |
screenshot() |
Capture display as base64 PNG |
get_display_info() |
Display dimensions, frame count, client count |
get_window_settings() |
Current window configuration |
get_theme() |
Current theme name |
list_clients() |
Connected clients with names and scene counts |
list_menus() |
The menu bar, including the per-session callback submenus |
list_recent_events(count) |
Recent interaction events |
list_errors(count) |
Recent error log entries |
| Pub/Sub (Agent Subscribe) | |
subscribe(topic) |
Subscribe to a Hub-scoped app topic; delivered via recv |
unsubscribe(topic) |
Stop receiving a topic |
publish(topic, payload) |
Publish an app event to a Hub topic (separate from the UI observer mechanism) |
What It Looks Like
Show text and a button
{"tool": "show", "input": {
"scene_id": "hello",
"elements": [
{"kind": "text", "id": "t1", "content": "Hello from the agent"},
{"kind": "button", "id": "b1", "label": "Click me"}
]
}}
Returns "shown:hello" immediately — the Hub has accepted the scene and its
background replicator paints it; no tool call ever waits on the display. A
button click fires its handler on the Hub (the agent does not poll for it). To observe interactions, read the introspection log:
{"tool": "list_recent_events", "input": {"count": 5}}
A Hub-side handler can publish an app event that the agent then reads with
recv (see the Pub/Sub tools above).
Multi-window dashboard
{"tool": "show", "input": {
"scene_id": "dash",
"elements": [
{"kind": "window", "id": "w1", "title": "Controls", "x": 10, "y": 10,
"children": [
{"kind": "slider", "id": "vol", "label": "Volume", "value": 50}
]},
{"kind": "window", "id": "w2", "title": "Chart", "x": 320, "y": 10,
"children": [
{"kind": "plot", "id": "p1", "title": "Trend",
"series": [{"label": "y", "type": "line",
"x": [1,2,3,4], "y": [10,20,15,25]}]}
]}
]
}}
Update a single element
{"tool": "update", "input": {
"scene_id": "dash",
"patches": [
{"id": "vol", "set": {"value": 75}}
]
}}
Element Kinds
| Category | Kinds |
|---|---|
| Display | text, button (arrow, small variants), image, separator |
| Interactive | slider, checkbox, combo, input_text, input_number, radio, color_picker (alpha, picker modes) |
| Lists | selectable, tree |
| Data | table, plot, progress, spinner, markdown |
| Canvas | draw (line, rect, circle, triangle, polyline, text, bezier) |
| Layout | group, tab_bar, collapsing_header, window, modal, dialog (modal confirm dialog with Hub-side handler dispatch) |
All elements with an id support an optional tooltip field (string shown on hover).
CLI Commands
| Command | What it does |
|---|---|
lux display |
Start the display server (ImGui window) |
lux enable |
Enable visual output for this project |
lux disable |
Disable visual output for this project |
lux status |
Check if the display server is running |
lux doctor |
Check installation health (Python, fonts, plugin) |
lux install |
Install the Claude Code plugin via the marketplace |
lux uninstall |
Uninstall the Claude Code plugin |
lux show beads |
Display the beads issue board via luxd's REST API (no LLM needed) |
lux ping |
Ping the display through luxd; print round-trip time |
lux hub-install |
Register the luxd session hub as a launchd/systemd service |
lux hub-uninstall |
Remove the luxd service |
lux ensure-hub |
Ensure luxd is running (--restart to restart) |
lux hub-status |
Report luxd service status |
lux version |
Print version |
Library (Python)
LuxRestClient is the public Python client of luxd. A downstream application
reaches the Hub through this typed client — not by hand-rolling REST calls — so it
gets the same validation, typing, and identity handling the CLI does. It imports
without the [display] extra (no ImGui/OpenGL pulled in).
from punt_lux import LuxRestClient, RenderRequest, SceneShown
# connect() locates a running luxd and derives this invocation's identity from
# the git repository it runs in — so the scene below is owned by that repo.
client = LuxRestClient.connect()
result = client.render(
RenderRequest(
scene_id="hello",
elements=[{"kind": "text", "id": "t1", "content": "Hello from Python"}],
)
)
if isinstance(result, SceneShown):
print("shown:", result.scene_id)
Every request carries the caller's X-Lux-Client-* identity headers, so each
installed scene is attributed to its repository rather than an anonymous caller.
An unreachable luxd raises HubUnavailableError; a reachable Hub's refusal of a
request comes back as a typed OpError in the result.
Listening: a persistent hub client
A daemon that wants to receive — pub-sub events it subscribed to, and the menu
callbacks the user clicked — holds one WebSocket to luxd with LuxHubClient.
It shares the identity of a LuxRestClient, so a callback the daemon registers
over REST is delivered on this stream. The receive loop renews the lease on every
contact and reconnects on a dropped connection, re-subscribing automatically; the
Hub buffers any clicks missed during a gap and drains them on reconnect.
Re-subscribing restores topics, but a menu callback lives on the session's lease,
which lapses during a long outage — so register it (and re-push scenes) in
on_connect, which runs after every handshake, first connect and each
reconnect. Registering in an outer register-then-listen sequence would run once;
the internal reconnect would never re-run it, and the menu entry would stay gone.
import asyncio
from punt_lux import ClientIdentity, LuxRestClient
# One identity for both legs: scene pushes over REST, the listen stream over the
# WebSocket. A long-lived daemon declares an "app" identity — who it is, not where
# it ran — and a short lease TTL so its menu entries leave when it dies.
rest = LuxRestClient.for_identity(ClientIdentity(kind="app", name="voxd", lease_ttl=30))
def on_callback(callback_id: str) -> None:
print("menu click:", callback_id) # e.g. run the action for this item
def on_event(topic: str, payload: dict[str, object]) -> None:
print("event:", topic, payload) # e.g. {"album_id": "jazz-1"}
def on_connect() -> None:
# Runs after every handshake — re-establish the per-connection state the
# reconnect does not: register menu callbacks, re-push any scenes.
rest.register_callback("music", "Music")
listener = rest.listener(
on_callback=on_callback, on_event=on_event, on_connect=on_connect
)
listener.subscribe("music.play", "music.stop")
asyncio.run(listener.listen()) # blocks, reconnecting as needed
The handlers may be sync or async; the loop awaits a coroutine. A raising
on_connect is logged and the connection continues — a failed re-registration
never tears down a healthy socket. Call stop() to end the loop after its current
connection closes. Events and callbacks are generic — the topics and callback ids
are the app's own vocabulary, not Lux's.
Architecture
Agent or app
│ MCP or direct Hub API
▼
luxd (Hub)
│ authoritative state + introspection
│ scene replicas + remote invocations
▼
lux display (ImGui + OpenGL)
│ renders at 60fps
▼
Window on screen
The Hub is the single source of truth for element state, ownership, and handler dispatch. The Display is a rendering replica: it paints the current scene and forwards interactions back to the Hub, which runs the real handler and re-pushes updated state. MCP is one entry point, not the only one.
Connecting Claude Code directly over HTTP
luxd serves MCP over streamable HTTP at http://127.0.0.1:8430/mcp, on the same loopback port as its REST API. Claude Code can connect to that endpoint natively through its HTTP MCP configuration, with no mcp-proxy bridge in the path. Point Claude Code's MCP config (a project .mcp.json or the plugin's mcpServers block) at the endpoint:
{
"mcpServers": {
"lux": { "type": "http", "url": "http://127.0.0.1:8430/mcp" }
}
}
The bundled plugin ships exactly this HTTP config in its mcpServers block — no mcp-proxy bridge and no lux serve stdio fallback. The installer registers luxd as a launchd service pinned to --port 8430, so the static URL is correct on installed systems; if you run luxd on a non-default port, read the real one from the port file (~/.punt-labs/lux/hub.port, i.e. HubPaths().read_port()) and set the URL to match.
A copy-paste example is in .claude-plugin/mcp-http.example.json. Start luxd first (lux hub-install and start the service, or run luxd in a terminal), then verify the direct connection end to end:
uv run python scripts/direct_connection_probe.py
The probe initializes a session, lists the tool surface, and calls a read-only tool. luxd binds loopback only and refuses a non-loopback --host at startup; remote access awaits authentication.
Documentation
Docs Guide | Target Architecture | Target Topology | Target UI Model | Target Introspection | Current Architecture | Design Log | Changelog
Development
uv sync --extra display # Install dependencies (dev group installs by default)
uv run ruff check . # Lint
uv run ruff format --check . # Check formatting
uv run mypy src/ tests/ # Type check (mypy)
uv run pyright # Type check (pyright)
uv run pytest # Test
Acknowledgements
Lux is a thin orchestration layer. The rendering is done by Dear ImGui, Omar Cornut's immediate-mode GUI library. ImGui handles all the hard problems --- text layout, widget state, input handling, GPU rendering --- and does so in a single-pass retained-mode-free architecture that maps naturally to Lux's "send JSON, render this frame" model. The 60fps render loop, the composable widget tree, and the ability to drive a full UI from a socket with no threading are all consequences of ImGui's design.
Python bindings come from imgui-bundle by Pascal Thomet, which packages ImGui, ImPlot, and several other ImGui extensions into a single pip-installable wheel with complete type stubs. imgui-bundle is what makes "install one Python package, get a GPU-accelerated UI" possible.
FastMCP provides the MCP server layer.
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file punt_lux-0.22.1.tar.gz.
File metadata
- Download URL: punt_lux-0.22.1.tar.gz
- Upload date:
- Size: 848.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1151dd7049fc8438eb763688cb080f9f674d4d763e0d4f71f8ff76bded8c8863
|
|
| MD5 |
a60a8cc930e172896d1e6638cd5cd874
|
|
| BLAKE2b-256 |
5b55773191c46af87b90258ad821d3b81c71bd0b57b1e7d2616dc4264cacf7aa
|
Provenance
The following attestation bundles were made for punt_lux-0.22.1.tar.gz:
Publisher:
release.yml on punt-labs/lux
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
punt_lux-0.22.1.tar.gz -
Subject digest:
1151dd7049fc8438eb763688cb080f9f674d4d763e0d4f71f8ff76bded8c8863 - Sigstore transparency entry: 2281141895
- Sigstore integration time:
-
Permalink:
punt-labs/lux@6a95361a9bc71c28743d89dff43af370812f562c -
Branch / Tag:
refs/tags/v0.22.1 - Owner: https://github.com/punt-labs
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@6a95361a9bc71c28743d89dff43af370812f562c -
Trigger Event:
push
-
Statement type:
File details
Details for the file punt_lux-0.22.1-py3-none-any.whl.
File metadata
- Download URL: punt_lux-0.22.1-py3-none-any.whl
- Upload date:
- Size: 1.1 MB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2f94907e41f01decdbfd6c7fb4906619ac99a84684b5e83c7aaaff7432e4c366
|
|
| MD5 |
cbe26964a0d5a236b1d5a722a7ed795f
|
|
| BLAKE2b-256 |
b104a7a378ab964b44f68f2d11a772db4e4afd598b7d7e6643d88cd2a43523d5
|
Provenance
The following attestation bundles were made for punt_lux-0.22.1-py3-none-any.whl:
Publisher:
release.yml on punt-labs/lux
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
punt_lux-0.22.1-py3-none-any.whl -
Subject digest:
2f94907e41f01decdbfd6c7fb4906619ac99a84684b5e83c7aaaff7432e4c366 - Sigstore transparency entry: 2281141904
- Sigstore integration time:
-
Permalink:
punt-labs/lux@6a95361a9bc71c28743d89dff43af370812f562c -
Branch / Tag:
refs/tags/v0.22.1 - Owner: https://github.com/punt-labs
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@6a95361a9bc71c28743d89dff43af370812f562c -
Trigger Event:
push
-
Statement type: