HTTP and WebSocket API
The observability server exposes the Lorenz runtime's live state over HTTP, a /ws WebSocket push transport, and an MCP mount. This page is the integrator's contract: every route, its parameters, its response shape, its status codes. Payloads are snake_case JSON produced by the presenter. The internal runtime snapshot is camelCase, but nothing on the wire is.
The server is a Hono app started by startObservabilityServer(runtime, options) in packages/server/src/index.ts. The CLI starts it when the dashboard is enabled (see the CLI reference). It binds server.host (default 127.0.0.1) and server.port (default 4040; 0 selects an ephemeral port that is written back into settings after bind). All bodies use content-type: application/json; charset=utf-8, except GET /, which returns text/html.
Conventions
- Error shape. Every error response is
{"error": {"code": "<code>", "message": "<text>"}}. The full code list is in the status code reference below. - Method fallback. Each concrete route registers an
app.all(...)fallback that returns405 method_not_allowedfor the wrong verb. An unknown path returns404 not_found("Route not found"). - Snapshot reads. State-bearing routes call
runtime.snapshot(). If that throws, the error code maps tosnapshot_timeout(when the underlying code or message issnapshot_timeout/timeout) orsnapshot_unavailable(everything else). Each route handles that failure its own way, documented per route. - Path parameters are percent-decoded with
decodeURIComponent. Malformed encoding returns400 invalid_path_parameter("Malformed percent encoding in path parameter"). - Route order. Trace routes and
/api/v1/state|runs|refresh|daemon|stopregister before the catch-allGET /api/v1/:identifier, so a literal path like/api/v1/ticketsnever falls into the identifier route.
REST routes
| Method | Path | Purpose |
|---|---|---|
GET |
/health |
Liveness check |
GET |
/ |
Dashboard SPA (index.html) |
GET |
/assets/* |
Built dashboard static assets |
GET |
/api/v1/state |
Live ops state (running / retrying / blocked) |
GET |
/api/v1/runs |
Run list and run views |
POST |
/api/v1/refresh |
Queue an out-of-band poll + reconcile |
GET |
/api/v1/daemon |
Daemon owner, endpoint, heartbeat, and leadership status |
POST |
/api/v1/stop |
Request graceful daemon shutdown |
GET |
/api/v1/:issue_identifier |
Detail for one in-flight issue |
GET |
/api/v1/issues/recent |
Recent issues from the issue store |
GET |
/api/v1/issues/search |
Search issues by query |
GET |
/api/v1/tickets |
Trace tickets with metadata |
GET |
/api/v1/tickets/:id/exists |
Whether a trace ticket exists |
GET |
/api/v1/tickets/:id/events |
Parsed trace events for a ticket |
GET |
/ws |
WebSocket upgrade (push transport) |
POST |
/mcp |
MCP endpoint for workflow tool packs |
The /api/v1/issues/* and /api/v1/tickets/* routes exist only when the server is started with both traceDir and an issueStore. Without them, those paths return 404 not_found, and /ws accepts connections but ignores subscribe messages.
GET /health
Returns 200 with {"status": "ok"}. No snapshot read; always available while the process is listening.
GET /
Serves <staticDir>/index.html. When the file is missing (the dashboard was not built), returns 503 with code dashboard_not_built and message Dashboard assets not found. Run: pnpm build. staticDir defaults to the apps/web/dist directory bundled with the server build; override it with the staticDir option. Hashed assets are served from /assets/*.
GET /api/v1/state
The live ops snapshot used by the dashboard Overview. On success returns 200 with an OpsStatePayload:
{
"generated_at": "2026-06-17T12:00:00.000Z",
"counts": { "running": 2, "retrying": 1, "blocked": 0 },
"blocked_by_reason": { "global_concurrency_cap": 0 },
"running": [ /* RunningEntryPayload[] */ ],
"retrying": [ /* RetryEntryPayload[] */ ],
"blocked": [ /* BlockedEntryPayload[] */ ],
"usage_totals": {
"input_tokens": 0,
"output_tokens": 0,
"total_tokens": 0,
"seconds_running": 0
},
"rate_limits": null,
"claim_store": {
"kind": "memory",
"owner_id": "memory:12345:1",
"capabilities": {
"crash_recovery": false,
"shared_across_processes": false,
"retry_durability": false
},
"hydrated_at": "2026-06-17T12:00:00.000Z",
"transactions_applied": 0,
"last_operation": null,
"last_checkpoint_at": null
},
"daemon": null
}
This route does not fail with 503 on a snapshot error. It returns 200 with a degraded body carrying only generated_at and error:
{
"generated_at": "2026-06-17T12:00:00.000Z",
"error": { "code": "snapshot_unavailable", "message": "Snapshot unavailable" }
}
The error.code is snapshot_timeout ("Snapshot timed out") or snapshot_unavailable ("Snapshot unavailable").
Each RunningEntryPayload has: issue_id, issue_identifier, issue_url, state, slot_index, ensemble_size, worker_host, workspace_path, session_id, turn_count, agent_kind, executor_pid, usage_totals, last_event, last_message, started_at, last_event_at, tokens.
Each RetryEntryPayload has: issue_id, issue_identifier, issue_url, attempt, due_at, error, worker_host, workspace_path.
Each BlockedEntryPayload has: issue_id, issue_identifier, issue_url, state, reason, label, worker_host. The reason is the raw enum (global_concurrency_cap, local_concurrency_cap, worker_host_capacity); label is the human form (global concurrency cap, local state concurrency cap, worker host capacity).
tokens is {input_tokens, output_tokens, total_tokens}. usage_totals adds seconds_running.
claim_store reports the runtime-owned orchestrator's claim-store status. The default in-memory
store reports kind: "memory" with all durability capabilities set to false. Durable stores use
the same shape with their own kind, owner_id, capabilities, hydrated_at,
transactions_applied, last_operation, and last_checkpoint_at. The field is null only when
the mounted runtime source has no claim-store status.
daemon is null when the runtime source has no daemon status. Long-running CLI daemons report
owner_id, pid, hostname, started_at, workflow_path, workspace_root, lock_path,
endpoint, heartbeat_at, heartbeat_age_ms, stale, and leadership_store_kind.
GET /api/v1/runs
Returns running entries plus the run history (capped at the last 50). The response view field switches on query parameters.
| Param | Type | Effect |
|---|---|---|
issue |
string | Keep runs whose issue_identifier or issue_id exactly matches |
failed |
flag | Keep runs with outcome failed or stalled |
cost |
flag | Return the cost view (token totals; dollar fields are null) |
retries |
flag | Return the retries view (issues with retry attempts) |
id |
string | Return the run view for that run id |
limit |
int | List size, default 20, clamped to 200 |
Flag params are truthy for true, 1, yes, or on. cost is checked before retries, then id; with none set, the response is the default runs list view.
Default (view: "runs"):
{
"generated_at": "2026-06-17T12:00:00.000Z",
"view": "runs",
"summary": { "total": 3, "running": 1, "success": 2, "failed": 0, "stalled": 0, "canceled": 0 },
"runs": [ /* RunPayload[], sliced to limit */ ]
}
Each RunPayload carries: id, issue_id, issue_identifier, issue_title, state, slot_index, ensemble_size, agent_kind, outcome, retry_attempt, worker_host, workspace_path, session_id, executor_pid, usage_totals, turn_count, failure_reason, last_event, last_message, last_event_at, started_at, ended_at, duration_ms, cost, tokens, and log_hints.
outcomeisrunningfor live entries, or one ofsuccess,failed,stalled,canceledfor history.idfor a running entry is itsrunId, falling back torunning-<issue_identifier>-<slot_index>.cost.estimated_cost_usdis alwaysnull; dollar cost is not computed.tokenscarries the real token counts.log_hintsis{lorenz_log_file, workspace_path, session_id, issue_identifier}, pointing at where to find the trace and log output.
The run view (?id=<run-id>) returns {generated_at, view: "run", run, related_runs}, where related_runs is up to 10 other runs sharing the same issue_id. An unknown id returns 404 run_not_found ("Run not found"). An empty id (?id=) falls through to the list view.
The cost view returns {generated_at, view: "cost", summary} with per-agent token breakdowns and top_runs (top 10 by total tokens). The retries view returns {generated_at, view: "retries", issues} listing issues that have a non-zero retry attempt.
On a snapshot error this route returns 503 with code snapshot_timeout or snapshot_unavailable.
POST /api/v1/refresh
Queues an immediate poll and reconcile pass rather than waiting for the next scheduled poll. Requires Authorization: Bearer <control-token>. On success returns 202:
{
"requested_at": "2026-06-17T12:00:00.000Z",
"queued": true,
"coalesced": false,
"operations": ["poll", "reconcile"]
}
coalesced is true when a poll is already in flight; no new poll starts. If the runtime cannot accept the request, returns 503 orchestrator_unavailable ("Orchestrator is unavailable"). If the bearer token is missing or invalid, returns 401 unauthorized.
GET /api/v1/daemon
Returns daemon leadership status for a long-running CLI daemon:
{
"owner_id": "hostname:12345:abcdef",
"pid": 12345,
"hostname": "hostname",
"started_at": "2026-06-17T12:00:00.000Z",
"workflow_path": "/repo/WORKFLOW.md",
"workspace_root": "/repo",
"lock_path": "/repo/.lorenz/daemon/<workflow-sha256>.lock.json",
"endpoint": { "kind": "http", "address": "http://127.0.0.1:4040/" },
"heartbeat_at": "2026-06-17T12:00:10.000Z",
"heartbeat_age_ms": 25,
"stale": false,
"leadership_store_kind": "local-file"
}
If daemon status is unavailable, returns 503 daemon_status_unavailable ("Daemon status
unavailable"). This can happen when a non-daemon runtime source is mounted into the observability
server.
POST /api/v1/stop
Requests a graceful daemon shutdown. Requires Authorization: Bearer <control-token>. On success returns 202:
{
"requested_at": "2026-06-17T12:00:00.000Z",
"stopping": true
}
If the mounted runtime source does not expose stop control, returns
503 daemon_control_unavailable ("Daemon control unavailable"). If the bearer token is missing or invalid, returns 401 unauthorized.
GET /api/v1/:issue_identifier
Detail for a single in-flight issue, matched by issue_identifier against the running and retrying sets. The path segment is percent-decoded; malformed encoding returns 400 invalid_path_parameter.
On success returns 200:
{
"issue_identifier": "ENG-123",
"issue_id": "abc",
"status": "running",
"workspace": { "path": "/tmp/ws", "host": null },
"attempts": { "restart_count": 0, "current_retry_attempt": 0 },
"running": { /* slot detail, or null */ },
"retry": { /* retry detail, or null */ },
"logs": { "codex_session_logs": [] },
"recent_events": [ { "at": "...", "event": "...", "message": "..." } ],
"last_error": null,
"tracked": {}
}
status is running or retrying. logs.codex_session_logs and tracked are present but always empty. If no running or retrying entry matches, returns 404 issue_not_found ("Issue not found"). A snapshot error also returns 404 issue_not_found from this route.
Trace routes
These read from the SQLite issue store and the TraceWatcher over traceDir. They are mounted only when both are configured. The CLI opens server.issue_store_path when set, otherwise ~/.lorenz/issues.db.
GET /api/v1/issues/recentreturns{issues}from the issue store, newest first.limit(1-100, default 5).GET /api/v1/issues/searchreturns{issues}matchingq(default empty).limit(1-100, default 20).GET /api/v1/ticketsreturns{tickets}, each aTicketInfo(issueId,identifier, optionaltitle,url,agentKind,startedAt,turnCount,statusofrunning/completed/failed/idle) enriched withtitleandurlfrom the store when present.GET /api/v1/tickets/:id/existsreturns{exists}. The:idis the URL-encoded issue id, percent-decoded by the route.GET /api/v1/tickets/:id/eventsreturns{issueId, identifier, events}, whereeventsis the parsedDisplayEvent[]for the ticket. The dashboard computes trace stats client-side from this list.
For the DisplayEvent shape and how raw trace lines map to it, see the events reference.
POST /mcp
The Model Context Protocol endpoint serving the workflow's tool packs. Settings resolve per request, so a hot-reloaded workflow updates the served tools without restarting the server. The auth scope derives from settings, host, and port (or random when the port is ephemeral). Any non-POST method returns 405 method_not_allowed. The tool contracts live in the tracker tools reference.
The /ws WebSocket
/ws is the single push transport for the dashboard. It streams both trace events and ops-state snapshots. Upgrade with a standard WebSocket handshake against ws://<host>:<port>/ws.
Lifecycle
- Connect. On open the server sends
initwith the current trace tickets, thenops_stateif a runtime snapshot is available. - Subscribe. The client sends
{"type": "subscribe", "issueId": "<id>"}. The server subscribes the watcher to that issue and replies with a fulleventssnapshot. Re-subscribing to the same issue re-sends the snapshot. - Deltas. When the watcher detects new trace lines, it broadcasts
update(refreshed ticket list) to every client, and sendsevents_append(only the changed tail) to clients subscribed to that issue. - Ops broadcasts. When the runtime emits a new snapshot, the server broadcasts
ops_stateto all clients, but only while at least one connection is open. - Unsubscribe.
{"type": "unsubscribe", "issueId": "<id>"}releases the subscription for that issue.
The dashboard client reconnects on a 3 second cadence after a drop. The server does not restore subscriptions automatically; the client re-subscribes after reconnect.
Server to client messages
type |
Fields | When |
|---|---|---|
init |
tickets: TicketInfo[] |
On connect, first message |
ops_state |
state: OpsStatePayload |
On connect (if available) and on each runtime snapshot |
events |
issueId, events: DisplayEvent[] |
Reply to subscribe; full snapshot |
events_append |
issueId, events: DisplayEvent[], fromIndex |
On watcher change, to subscribers; events is the slice from fromIndex |
update |
issueId, tickets: TicketInfo[] |
On watcher change, broadcast to all |
events_append carries only the tail that changed. fromIndex is the first changed event index; the client splices events in at that offset. A fromIndex the client cannot reconcile makes it request a fresh full snapshot.
Client to server messages
type |
Fields | Effect |
|---|---|---|
subscribe |
issueId |
Stream events then events_append deltas for the issue |
unsubscribe |
issueId |
Stop streaming that issue |
Both fields are required and issueId must be a string. Malformed JSON and any other message type are ignored. When the server was started without trace support, subscribe and unsubscribe are silently dropped.
The OpsStatePayload, RunningEntryPayload, RetryEntryPayload, and BlockedEntryPayload shapes are identical to those returned by GET /api/v1/state.
Status codes
| Code | HTTP | Routes | Meaning |
|---|---|---|---|
dashboard_not_built |
503 | GET / |
index.html not found; build the dashboard |
method_not_allowed |
405 | all concrete routes | Wrong HTTP method |
not_found |
404 | any unknown path | No matching route |
snapshot_timeout |
200 / 503 | state, runs |
Snapshot read timed out |
snapshot_unavailable |
200 / 503 | state, runs |
Snapshot read failed |
unauthorized |
401 | refresh, stop |
Missing or invalid daemon control token |
orchestrator_unavailable |
503 | refresh |
Runtime could not queue the refresh |
daemon_status_unavailable |
503 | daemon |
No daemon status hook or snapshot status was available |
daemon_control_unavailable |
503 | stop |
The mounted runtime source cannot accept stop requests |
issue_not_found |
404 | :issue_identifier |
No running or retrying entry matches |
run_not_found |
404 | runs?id= |
No run matches the id |
invalid_path_parameter |
400 | path-param routes | Malformed percent encoding in the path |
GET /api/v1/state returns the snapshot_* codes with 200 (in the body's error); GET /api/v1/runs returns them with 503.
See also
- Observability - the dashboards and trace viewer these routes feed
- Events reference -
DisplayEventand runtime event types on the wire - Configuration reference -
server.*andobservability.*keys - Tracker tools reference - the tools served on
/mcp - CLI reference - flags that start, bind, and disable the server