JSON HTTP API
The daemon can expose a JSON HTTP API so non-terminal clients (scripts, a Tauri or iOS app, another host) can read the inbox and issue commands. It’s the same event bus the TUI consumes, surfaced over HTTP.
Start the gateway
Section titled “Start the gateway”lazybox server api [addr:port] [--insecure-no-auth]The listen address resolves in order: the [addr:port] argument, then the
LAZYBOX_API_ADDR environment variable, then the default 127.0.0.1:8787.
Authentication
Section titled “Authentication”The gateway refuses to start without an explicit auth decision:
- Set
LAZYBOX_API_TOKENand clients sendAuthorization: Bearer <token>, or - Pass
--insecure-no-authto serve unauthenticated (a warning is printed).
There is no built-in TLS, so non-loopback binds are refused. Forward the
loopback port through an encrypted SSH tunnel for remote use. Direct routable
transport remains disabled until the daemon provides encryption and
principal-scoped authorization. See
SECURITY.md.
Browser web control
Section titled “Browser web control”The loopback gateway serves a responsive web-control client at /. Open the
gateway URL on the daemon host, enter its bearer token, and the page loads the
current workspaces, streams live daemon events, and lets you drive the daemon:
select a workspace, spawn an agent or a shell, read a running agent’s output,
and send it an instruction. It renders with the shared Lazybox Dark palette
(the same chrome the TUI and desktop use), so it is a first-class peer of those
clients rather than a bare debug page. Leave the token blank only when the
gateway was explicitly started with --insecure-no-auth. A submitted token is
cleared from the form, retained only in page memory, and reused when
reconnecting.
The desktop app links to this same page — its 🌐 topbar button opens the
web-control client for whichever gateway the desktop is attached to (embedded
loopback or a remote one), so “one control shell — TUI, desktop, and browser”
all speak the same /v1 gateway.
What web control can and cannot do
Section titled “What web control can and cannot do”Web control drives the text-oriented slice of the gateway, so it stays a single self-contained page with no binary terminal codec:
- Can: browse workspaces, watch the live event stream, spawn agents/shells
(
POST /v1/commands), read a running agent’s cleaned output tail (POST /v1/agents/output), and hand an agent free-form work or a snippet body (POST /v1/agents/inject). - Cannot (yet): attach a live, interactive xterm view of raw terminal bytes
(the binary
POST /v1/terminalstream the desktop consumes), or reach the full TUI/desktop action catalog (merge/reviewers/policies/snippet-picker). Those remain desktop/TUI affordances — see the parity notes.
The page at / is intentionally available without authentication so a browser
can load it. When a token is configured, every /v1/* request it makes — reads
and the control POSTs above — is still bearer-authenticated. The client is
served by the gateway itself, so it does not need a permissive cross-origin
policy.
The gateway does not expose this client on a routable listener; it only binds
loopback. For remote use, forward the loopback port over SSH
(ssh -L 8787:127.0.0.1:8787 host) and open the forwarded port in a local
browser — the bearer token and same-origin /v1 requests work unchanged across
the tunnel. Remote product transport still requires encryption and
principal-scoped authorization.
The exact request/response shapes the page depends on are pinned by a contract
gate (web_control_contract_fixture_is_current in the Rust test suite, mirrored
by web/scripts/api-client.test.mjs), so a /v1 wire change that would break
web control fails the build instead of drifting silently.
Endpoints
Section titled “Endpoints”Returns the browser web-control client (api_client.html). It is the only
unauthenticated route when a bearer token is configured; every /v1/* call the
page then makes still carries the token.
GET /v1/protocol
Section titled “GET /v1/protocol”Discovers the current protocol version, Rust IPC fingerprint, daemon build,
binary terminal media type, and terminal frame/write limits. Versioned clients
send the returned version in x-lazybox-protocol-version; unsupported versions
receive HTTP 426 with the requested and supported values.
POST /v1/commands
Section titled “POST /v1/commands”Issues a command and waits for the command handler to finish before returning:
{ "ok": true, "completed": true }Provider/domain outcomes (a merge landing, a poll completing) still arrive as normal events rather than in the response body. Streaming requests are connection- and command-bounded: a client must reconnect after the documented stream limit instead of growing an unbounded daemon queue.
GET /v1/workspaces
Section titled “GET /v1/workspaces”Returns the current workspaces. The response includes a warnings array when an
unreadable row was preserved and omitted from the decoded workspace list — so a
single corrupt record surfaces as a warning instead of failing the whole
listing (see Recover persistent state).
GET /v1/agents
Section titled “GET /v1/agents”Returns which agent is running in which workspace — terminal id, workspace key, agent id, lifecycle state, and the last prompt — so a caller can tell what each agent is doing without scraping any PTY. Web control uses it to badge the workspace rows.
POST /v1/agents/output
Section titled “POST /v1/agents/output”Reads a running agent’s recent output as a cleaned, line-limited text tail
({ "workspace": "<key>", "tail": 200 }). The web-control client polls this to
show terminal output without the binary stream. Returns 404 when the workspace
has no running agent.
POST /v1/agents/inject
Section titled “POST /v1/agents/inject”Delivers an instruction or snippet body to a workspace’s running agent
({ "workspace": "<key>", "text": "…", "submit": true }) through the same
settle-gated inject path the TUI uses, so a paste never lands in a permission
prompt. accepted reports only that the workspace resolved to a running agent
and the prompt was handed off; a later drop surfaces on /v1/events as
TerminalInputRejected.
POST /v1/terminal
Section titled “POST /v1/terminal”Streams bounded, length-prefixed binary terminal frames for xterm-compatible
clients. Input, resize, resync, and close commands use the request body;
snapshots, output, scrollback, and resync results use the response body. Raw
terminal bytes never travel as JSON number arrays. (Consumed by the desktop app;
the browser web-control client uses /v1/agents/output instead.)
Coordination MCP server (for spawned agents)
Section titled “Coordination MCP server (for spawned agents)”Separate from this gateway, the daemon also serves a loopback MCP server
for the agents it spawns. It is not user-configured: at spawn, lazybox mints a
per-session bearer, writes a private .mcp.json, and passes --mcp-config
(Claude today — the built-in agent that accepts an injected MCP config). The
connection is the session, so no tool takes a “who am I” argument.
| Tool | Backed by |
|---|---|
whoami |
The caller’s bearer → session key |
list_sessions(filter?) |
The same agent roster as /v1/agents |
read_session(workspace, tail?) |
The same snapshot as /v1/agents/output |
post_note(text, scope?, tags?) / read_notes(scope?, tags?, since?) |
A kv-backed blackboard (lazybox:note:*), 50 notes per scope, 16 KB each |
notify_session(workspace, text, submit?) |
The same settle-gated inject as /v1/agents/inject; reports a handoff, not a delivery |
send_snippet(workspace, key, vars?, submit?) |
The same DeliverSnippet path as ]]s — the target’s Recent MRU and ]N count move; vars fills {{name}} placeholders |
ask_session(workspace, text? | snippet?, timeout_s?, mode?) |
The inject above, wrapped in a <lazybox-request> envelope, plus a request row in the kv (lazybox:request:*). wait blocks up to timeout_s (default 120 s, max 600 s — your MCP client’s call timeout is the real ceiling); async returns a request_id |
reply_request(request_id, text) |
Answers a request; only the session it was asked of may. Wakes a waiting asker and emits AgentRequestReplied |
poll_request(request_id) |
The request row plus the target’s live agent state, so “pending” can be told from “parked at a prompt” |
task_status(task, repo?) |
“Is anyone working on owner/repo#N?” — the record’s workspace(s), live agent turn, working-claim, blocker and tracker state as separate facts, plus a verdict with its evidence. Resolves an issue through the PR workspace it folded into. Read-only. Same report as lazybox task status |
task() / get_issue(repo, number) / get_pr(repo, number) / list_issues(repo, state?, limit?) |
Tracker records already held by the daemon. These never fall back to a provider fetch, so agent reconnaissance cannot spend the poller’s shared quota |
epic_status(epic?) |
The live epic snapshot: members, derived state, done/total, blockers, and critical path |
epic_ready(epic?) |
Only unblocked, unclaimed epic members that can start now |
report_blocker(reason, kind?) / clear_blocker() |
Add or lift the caller’s durable blocker and recompute epic status |
spawn_worker(task? | create_issue?, brief, agent?) |
Coordinator-only. Resolve or create an issue in the caller’s epic, attach its one existing workspace, set Worker, and spawn there. Refuses duplicate live work and the agent.max_epic_workers cap |
An unanswered request does not hang the asker: when the target ends a turn
without replying, the tail of its output is captured as the answer with
source: "turn_end_capture". Ask chains are capped at three hops.
Bearers are revoked when a session’s last agent terminal ends and persist with the bound port across a daemon restart, so a tmux-surviving agent keeps working. How agents use it: Orchestrate multiple agents → the MCP bus. For the epic workflow, see Run a cross-repo epic.
Example
Section titled “Example”export LAZYBOX_API_TOKEN=$(openssl rand -hex 32)lazybox server api 127.0.0.1:8787 &
curl -s http://127.0.0.1:8787/v1/workspaces \ -H "Authorization: Bearer $LAZYBOX_API_TOKEN"See also
Section titled “See also”- CLI reference →
lazybox server— the full command and its flags. - Remote over SSH — forward the daemon socket (or this port) to another machine.