A work-oriented HTTP UI bolted onto the daemon. Its primary view reads the canonical muxa hierarchy: Workspace → Work → Run → Agent session, with tmux session/window/pane retained only as the current execution binding. An optional Linear/GitHub/Jira issue is a reference attached to Work; it is not the Work identity itself.
The board never promotes every visible tmux window into a Work card. Managed or persisted Work appears on the four-stage board, while generic windows are listed separately as Unlinked executions. Runs and panes remain expandable when execution detail or controls are needed.
The timeline and raw agent/pane/terminal inventories remain available as collapsed secondary panels. Data is updated live over Server-Sent Events, and optional control actions are protected by a bearer token that can be pasted into the browser like a PAT.
The dashboard is off by default and loopback-only when on by default. Token authentication is the default auth mode, and an enabled dashboard must have an explicit token. There is no path that exposes data beyond your local machine without explicit public-bind acknowledgement.
| Path | What it returns |
|---|---|
GET / |
The dashboard HTML (loads the JS bundle). |
GET /static/* |
Embedded JS/CSS assets. |
GET /api/health |
{ ok, version, protocol } |
GET /api/access |
Current read/control access mode and whether the supplied PAT can edit. |
GET /api/agents |
Current Store snapshot. |
GET /api/fleet?selector=... |
Cached hierarchy, including the always-present local node. |
GET /api/panes |
Global tmux pane list (every readable socket), with per-socket scan errors. |
GET /api/works |
Canonical v2 Workspace/Work/Run snapshot plus unlinked executions. |
GET /api/work-metadata |
Durable titles, goals, next actions, and manual workflow stages. |
GET /api/collaboration |
Indexed collaboration requests for the node/edge graph and sequence drill-down. |
GET /api/terminal-sessions |
Muxa-owned PTY sessions. |
GET /api/timeline |
Timeline document from activity.ndjson plus currently-open agent/tmux spans. |
GET /api/events |
SSE stream: snapshot (initial), transition (live), lagged (backpressure) |
POST /api/panes/{pane}/prompt |
Send and optionally submit text to a pane. |
POST /api/panes/{pane}/abort |
Send Ctrl-C to a pane. |
POST /api/fleet/{host}/command |
Execute a serialized Fleet operation; host mode is rechecked. |
PUT /api/work-metadata |
Save a definition by logical {workspace_id, work_id} identity. |
POST /api/work-control/prompt |
Prompt every live agent run linked to one Work. Accepts text, or shared-composer skill/body/context. |
POST /api/work-control/up |
Start or converge a Work pipeline and optionally link an external issue. Requires allow_work_start. |
POST /api/work-control/abort |
Send Ctrl-C to every live agent run linked to one Work. |
POST /api/terminal-sessions/{id}/input |
Send input to a Muxa-owned PTY. |
POST /api/terminal-sessions/{id}/terminate |
Terminate a Muxa-owned PTY. |
auth = "token" protects all API endpoints. auth = "public_read" leaves
GET/SSE endpoints public but always requires the bearer token for write/control
actions. auth = "none" leaves reads public and disables writes entirely.
Collaboration bodies and attached details are stricter: they are returned only
in auth = "token" mode with a valid bearer. public_read and none expose a
redacted topology/status summary even if a PAT is supplied.
Static routes are public in every mode — see "Why the HTML is public" below.
Fleet routes always expose the in-process local node. [fleet] enabled = true adds outbound SSH hosts; reads reuse the daemon's per-host cache and
never create request-scoped SSH connections. See
FLEET.md.
muxa init --component dashboardThe initializer enables the loopback dashboard, generates a token, persists it
in ~/.config/muxa/config.toml, and prints a one-time bootstrap URL such as
http://127.0.0.1:7878/#token=....
For a manual setup, provide the token explicitly:
TOK=$(openssl rand -hex 32)
muxad --dashboard --dashboard-token "$TOK"
# then in the browser: http://127.0.0.1:7878/#token=$TOKThe page captures #token=... into localStorage and rewrites the URL.
Subsequent requests carry Authorization: Bearer <tok>. The token persists
across tab close and browser restart, so you only need to paste it once per
browser profile. To revoke, clear the muxa.token key (DevTools → Application
→ Local Storage) or restart muxad with a different token.
Legacy ?token=... URLs are still accepted for compatibility and immediately
scrubbed, but fragments are the supported bootstrap format because browsers do
not send them in HTTP requests or Referer headers.
Disabling auth requires an explicit opt-out:
# ~/.config/muxa/config.toml
[dashboard]
enabled = true
auth = "none"
# bind defaults to 127.0.0.1:7878Or via flags:
muxad --dashboard --dashboard-auth noneOpen http://127.0.0.1:7878/.
This is the mode for a dashboard that anyone on the reachable network may view, while only a browser with the PAT can display and use edit controls:
[dashboard]
enabled = true
bind = "0.0.0.0:7878"
allow_public = true
auth = "public_read"
token = "replace-with-a-long-random-token"Open http://<host>:7878/ for anonymous read-only access. Select unlock
edit and paste the token to enable prompt, abort, input, and terminate
controls. The browser stores it in localStorage; selecting lock edit
removes it again.
The equivalent CLI invocation is:
TOK=$(openssl rand -hex 32)
muxad --dashboard \
--dashboard-bind 0.0.0.0:7878 \
--dashboard-auth public_read \
--dashboard-token "$TOK" \
--allow-publicYou must opt in to both a non-loopback bind and a token. Either alone fails at startup:
TOK=$(openssl rand -hex 32)
muxad --dashboard \
--dashboard-bind 0.0.0.0:7878 \
--dashboard-token "$TOK" \
--allow-publicOpen http://<host>:7878/#token=$TOK in the browser.
If you skip --allow-public or --dashboard-token, muxad refuses to start
with a clear message — same applies to TOML configs.
For a trusted private network, you can intentionally expose the read-only API without a bearer token:
[dashboard]
enabled = true
bind = "0.0.0.0:7878"
allow_public = true
auth = "none"Or via flags/env:
muxad --dashboard \
--dashboard-bind 0.0.0.0:7878 \
--allow-public \
--dashboard-auth noneThis exposes all GET/SSE data to anyone who can reach the port. POST control
routes return 403 Forbidden, so this mode cannot become anonymously writable.
Use it only on a network you already trust.
⚠️ TLS is out of scope. Use a reverse proxy (nginx, Caddy, Traefik) to terminate TLS in front of the dashboard. Setproxy_buffering off;for the SSE endpoint or live updates will batch.
All knobs live under [dashboard] in the TOML config. The CLI / env layer
overrides per-field, with env > CLI flag > TOML > built-in default (clap
already enforces env-beats-flag for the fields it covers).
| Key | Type | Default | CLI / env |
|---|---|---|---|
enabled |
bool | false |
--dashboard / --no-dashboard / MUXA_DASHBOARD_ENABLED |
bind |
string | "127.0.0.1:7878" |
--dashboard-bind / MUXA_DASHBOARD_BIND |
auth |
string | "token" |
--dashboard-auth / MUXA_DASHBOARD_AUTH (token, public_read, or none) |
token |
string | unset (required with token/public_read) |
--dashboard-token / MUXA_DASHBOARD_TOKEN |
allow_public |
bool | false |
--allow-public / MUXA_DASHBOARD_ALLOW_PUBLIC |
pane_cache_ttl_ms |
u64 | 2000 |
(TOML only) |
The daemon's agent registry is already global per user — every tmux server
forwards AgentEvents to the same muxad. The dashboard's new trick is
the pane scanner: it enumerates every tmux socket under $TMUX_TMPDIR,
/tmp/tmux-$UID/, and (on macOS) /private/tmp/tmux-$UID/, runs tmux -S <sock> list-panes -a against each, and folds the results.
Per-socket failures are captured into errors[] rather than blanking the
whole list — a wedged tmux server cannot kill the dashboard's view of healthy
ones. Every per-socket invocation has a 1 s timeout.
Results are cached for pane_cache_ttl_ms (default 2 s, lazy pull) so a
hammering refresh loop doesn't fork tmux 60 times a minute.
muxa::work::build_snapshot is the single projection used by the HTTP and CLI
dashboards. The browser consumes /api/works; it does not infer Work from a
session name, a ticket-shaped window name, or repository cwd.
| Concept | Stable identity / source | Dashboard responsibility |
|---|---|---|
| Workspace | workspace_id |
Scope and aggregate Work progress. |
| Work | {workspace_id, work_id} plus durable metadata |
Outcome, local stage, goal, next action. |
| External issue | source, provider stable ID, display key, URL, external status | Context/reference only; never the Work primary key or board stage. |
| Run | host/socket/session/window execution identity | One current or previous execution attempt for Work. |
| Agent session | daemon agent session attached to a Run pane | Runtime state, model, prompt/response, control target. |
| Signal | attention, blocked, error | Overlay on Work; never a board lane. |
The workspace rail remains flat. Selecting a workspace filters the four local Work stages: Queued, In progress, Review, and Done. Attention, blocked, and error render as signal badges on the card so a waiting agent does not silently rewrite the operator's workflow stage. Selecting a Work opens a drawer that shows local stage, external issue status, Run state, and Agent state as separate facts. Expanding execution reveals all linked Runs and panes.
Durable records live in $XDG_DATA_HOME/muxa/dashboard-work.json (normally
~/.local/share/muxa/dashboard-work.json). Schema v2 keys records by logical
Work identity. Schema v1 host/socket/session/window annotations are migrated
with their old binding retained for compatibility. Writes remain atomic and
serialized by the daemon.
Managed tmux options (@muxa_workspace_id, @muxa_work_id, agent role/task)
link a Run to Work. muxa work up also stores optional external-source metadata
on the window, which /api/works discovers and persists. Unmanaged windows stay
in unlinked_executions; they are never guessed into Work from their names.
The collaboration panel projects retained requests into two linked views. The graph uses stable host/socket/agent-session nodes (plus one operator-console node) and aggregates directed request edges by peer pair, count, kind, and status; replies appear as dashed reverse edges. Selecting an edge or room drills the adjacent sequence into chronological request/reply arrows. Selecting an event opens its thread/parent, Work/Run, artifact, link, AIR, message, and reply details when the response is authorized. The sequence caps the rendered tail at 200 requests, while load more extends the retained result used by the graph and drill-down.
The toolbar filters by time range, exact room, Work, thread, kind, and status. The API form is:
GET /api/collaboration?since=7d&workspace=callabo&work=CAL-7345&thread=thread-id&parent=req-id&kind=review&status=completed&limit=100&cursor=...
All supplied filters are conjunctive. work_id, workspace_id, thread_id,
and parent_request_id are accepted as aliases for their short names. room
is a URL-encoded JSON RoomId and matches host, socket, and window exactly.
limit defaults to 100 and must be 1–500. Pagination is newest-first keyset
pagination: pass opaque pagination.next_cursor back as cursor; do not parse
it or replace it with an offset. The response contains generated_at,
details_included, requests, and
pagination { total, limit, has_more, next_cursor }. Invalid filters return
400; a disabled collaboration subsystem returns 503.
When details_included is false, request/reply bodies, provenance, paths,
generic artifacts, links, and AIR references are omitted. Participant, room,
thread/parent, Work/Run, kind, status, timestamps, and counts remain available
for a useful summary graph. Use auth = "token" for private full-detail
history; a PAT cannot unlock details while the server is configured as
public_read.
The board could steer Work it had not started — prompt its Runs, abort them,
annotate it — but not create the team. POST /api/work-control/up, and the
board header's start work control, close that: give it a stable Work id and,
optionally, a separate external issue key. Muxa routes it to a workspace and
directory and creates whichever agent panes the pipeline declares but the Run
does not have yet.
Pressing it twice fills gaps rather than duplicating the team; with a body it
also prompts the agents already running. See PIPELINE.md for the
[[route]] and [pipeline.*] configuration it needs.
It is off until you set [dashboard] allow_work_start = true, on top of the
control token. Every other write route steers a process you already started;
this one starts new ones with permissions bypassed. That is a different kind of
authority, so it gets its own grant — the way [ask] and [collaboration] do.
Disabled, the route answers 501 rather than 403, because the token is fine;
the capability simply is not offered here.
The daemon runs the muxa binary for this rather than reimplementing the
pipeline. The launcher and the managed-tmux registry live in the CLI crate,
which depends on the library the daemon links, so calling them in-process would
mean inverting that dependency or keeping a second implementation alive.
An external issue title and provider status stay in external_items; they do
not overwrite the local Work title or stage. The response reports
linked_external_item, while the compatibility field seeded_metadata
remains false. This keeps provider sync and operator-authored workflow state
independent.
The timeline panel calls /api/timeline?since=7d by default and can switch
to 24h, today, last week, month, last month, 30d, 12w, or a clicked YYYY-MM-DD calendar day in the
browser. The endpoint also accepts session=<name> and agent=<kind>
(codex, claude_code, gemini_cli, antigravity, opencode, unknown).
API callers can also pass comma-separated exclude-pane / exclude-session
case-sensitive globs, for example
/api/timeline?since=month&exclude-session=monitor*.
The browser renders a daily contribution-map style heatmap above the lane
graph with ISO-style Monday-first week rows; clicking a day drills the graph
into that local calendar day. Timeline
lanes are grouped by session by default, with agent, human, and tmux
foreground lanes shown under the same session header.
The overview's act metric follows the same meaning as muxa stats ACT:
engaged human time estimated from submitted prompts, tmux input ticks, and
time spent present while agents are waiting for a human answer. Prompt/input
padding is clipped to active presence (tmux foreground, prompt input, or tmux
attach; not a plain open muxa watch interval). The human metric follows
muxa stats HUMAN: the union of tmux foreground time and muxa-recorded human
interaction intervals. Timeline lanes still show raw human-interaction spans
separately as interaction.
The workspace sidebar sorts by workflow priority, latest agent activity, or name. Timeline session filtering remains independent from logical workspace selection because one repository workspace may contain several physical tmux sessions.
Closed intervals come from activity.ndjson. Currently-open agent states
come from the live Store snapshot, and currently-open tmux foreground spans
come from session-activity.json when that tracker is enabled.
The frontend opens a single fetch-streamed SSE connection at /api/events.
Three event types appear on the wire:
snapshot— sent once on connect. Payload:{ "agents": [...] }. Lets a freshly-loaded page paint without a/api/agentsround-trip.transition— emitted on every state change. Payload: a serializedTransition. The client mutates one row in place.lagged— when the broadcast receiver falls behind the server's ring buffer. The client refetches/api/agentsfor a clean baseline.
(EventSource can't carry Authorization headers, so the frontend uses
fetch() and parses SSE frames manually. ~30 lines of JS.)
The static HTML/JS/CSS routes intentionally sit outside the auth middleware. Three reasons:
- Bootstrap. Browsers can't inject custom headers on top-level
navigation. The first GET has to succeed unauthenticated for the JS to
start, read
#token=..., and persist it inlocalStorage. - No embedded data. The bundle holds no agent state. It's a thin client
that asks
/api/*for everything. API access follows the selected auth mode. - The cost is bounded. An unauthenticated GET to
/returns the same bytes for everyone. There's nothing to leak.
If this tradeoff doesn't work for you, front the daemon with a reverse proxy that strips the carve-out (or use mTLS).
- External issue references are captured at
muxa work uptime. Continuous two-way Linear/GitHub/Jira synchronization, dependencies, and estimates are not implemented. - Control is intentionally narrow: Work/pane prompt and abort plus Muxa-owned PTY input/terminate. Configuration, files, and arbitrary shell commands are not writable through the dashboard.
- No mobile UI. The CSS scales OK to ~600 px but isn't designed for phones.
- No multi-user auth. One token = one bearer.
These are deliberate v1 cuts; the dashboard::router
function is pub so a future PR can .merge() extra routes without a
rewrite.