Skip to content

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.

Terminal window
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.

The gateway refuses to start without an explicit auth decision:

  • Set LAZYBOX_API_TOKEN and clients send Authorization: Bearer <token>, or
  • Pass --insecure-no-auth to 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.

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.

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/terminal stream 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.

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.

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.

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.

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

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.

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.

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.

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.

Terminal window
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"