Skip to main content
REST surface over the durable orchestration event log: query the raw event feed, reconstruct a single trace with metrics, pull the harness-bug error feed, read the fleet-health triage, fold the event-sourced delegation graph, live-tail the log over Server-Sent Events, and mirror client-observed runtime events back into the log. This group also covers POST /api/eval/smoke, the on-demand deterministic eval run. Observability is always on; there is no feature gate, and these handlers serve unconditionally. The event log is the single read source: a trace is all events sharing a traceId, the graph is a projection of the ordered stream, and metrics/error-taxonomy/fleet-health all fold from the same rows. All POST routes read a JSON body parsed by express.json({ limit: '2mb' }).
Every payload-bearing response redacts the event’s JSON data field before it is sent; credential-shaped keys/values are masked with ••••. This is display-layer defense in depth on top of the storage-layer secret scrub; numeric telemetry (token counts, cost) survives the mask. GET /api/obs/stream is Server-Sent Events, not request/response; it is documented below with an event-stream catalog, not a JSON response body.

Routes


GET /api/obs/events

Returns rows from the event log filtered by the query params, ordered by the monotonic seq cursor. The default order is asc (causal order, so a feed reads chronologically); pass order=desc for a recent-first feed.
  • Path params: none.
  • Query params:
  • Request body: none.

Responses

200 OK: the matched events. Each row is the full stored shape with its data field redacted (still a JSON string after masking):
OrchestrationEventKind is one of: task_created, task_claimed, status_changed, comment_added, dep_linked, execution_started, execution_completed, tool_call, tool_result, cost, approval_requested, approval_resolved, error, span_start, span_end, session_rotated, routine_fired, routine_dispatched, routine_completed, routine_error, team_chat_post, speaker_selected, turn_bound_hit. See Events & errors reference for the per-kind data shapes. 500 Internal Server Error: any read failure:

Example


GET /api/obs/traces/:traceId

Reconstructs one trace, every event sharing the traceId, ordered seq ASC (causal). The full multi-agent task renders from this (leader → specialists → tool spans). Aggregate metrics are computed from the un-redacted events first, then each event’s data is masked for display, so numeric cost/token telemetry in metrics is accurate.
  • Path params: traceId (string).
  • Request body: none.

Responses

200 OK: the trace events plus the aggregate metrics:
The trace read is capped at 5000 events. An unknown traceId returns 200 with an empty events array and zeroed metrics. 500 Internal Server Error:

Example


GET /api/obs/errors

The error-taxonomy feed: every error event, recent-first, projected to a compact shape. An error whose class is Unknown is a harness bug; pass harnessBug=true to filter to those.
  • Path params: none.
  • Query params:
  • Request body: none.
The read is fixed to kinds: ['error'], limit: 500, newest-first by ts. Wall-clock order is what a display feed wants, and it is the order the only index over kind can serve, so the cost stays proportional to the 500 rows returned rather than to every error ever recorded. The harnessBug filter is applied after projection.

Responses

200 OK: the error rows plus the unfiltered harness-bug count. The whole payload is run through redactObject:
harnessBugCount always reflects the full window (harness bugs found regardless of the harnessBug filter), so a UI can badge the alert count even while showing the unfiltered feed. 500 Internal Server Error:

Example


GET /api/obs/health

Fleet-health triage, a per-agent state folded from the event log, time-sensitive (computed against Date.now()). An agent with an open execution is working if a recent event landed, stalled if quiet past 5 minutes, and zombie if quiet past 30 minutes (the process is almost certainly dead, what orphan reconciliation reaps); an agent with no open execution is idle.
  • Path params: none.
  • Query params: teamId (string), scope to one team.
  • Request body: none.
The read is capped at the most recent 5000 events for the requested scope. orchestration_events is append-only and is never pruned, so the triage folds a trailing window rather than the whole history: rows are selected newest-first, then folded in causal order. An agent whose activity has scrolled out of that window stops appearing.

Responses

200 OK: one entry per agent that appears in the window:
500 Internal Server Error:

Example


GET /api/obs/graph

Folds the ordered event stream into a delegation/status/cost graph projection. The team graph is a pure projection of the log (replaying the same log always reproduces the same graph), so the surface cannot drift from reality. Two views fold from one ordered list: the task-delegation graph and the derived agent-to-agent delegation graph.
  • Path params: none.
  • Query params: teamId (string), scope to one team.
  • Request body: none.
The read is capped at the most recent 5000 events for the requested scope. orchestration_events is append-only and is never pruned, so the projection folds a trailing window rather than the whole history: rows are selected newest-first, then folded in causal order. A task whose events have all scrolled out of that window stops appearing, and one only partly inside it projects from the events that remain.

Responses

200 OK: the projected graph (this response is the projection output verbatim, not wrapped or redacted; it carries no raw event data):
500 Internal Server Error:

Example


GET /api/obs/stream

Server-Sent Events live-tail of the event log, scoped by team/task/agent. The handler opens an text/event-stream response and polls the log every 750 ms on the monotonic seq cursor, pushing any new rows. It is cross-process correct (it catches writes from the standalone MCP stdio bins, not just the in-process server) and indexed. Resume from a known position via the standard EventSource Last-Event-ID header or the ?since=<seq> query param.
  • Path params: none.
  • Query params:
  • Request body: none.
This is an SSE route, not request/response. There is no JSON response body; the catalog below describes the wire frames. Each polled batch reads up to 500 rows past the cursor (order: 'asc'). The cursor only advances forward, so resuming with a stale since/Last-Event-ID replays from there. The stream is cleaned up on req/res close.

Connection

On open, the handler writes HTTP/1.1 200 with:
then emits a : connected comment frame and flushes any rows already past the cursor.

Event catalog

The data: payload is one stored event row, identical in shape to a GET /api/obs/events row, with its inner JSON data field masked (••••). The id: value is the row’s seq; the browser sends it back as Last-Event-ID on auto-reconnect, so the tail resumes without gaps. Example frames:

Example


POST /api/obs/ingest

Mirrors client-observed runtime events into the durable log. The OpenClaw runtime is observed in the browser (the server never sees those frames), so the SPA forwards them here to keep the activity terminal uniform across runtimes. Ingestion is whitelisted to the three per-tool kinds the browser legitimately observes: tool_call, tool_result, error. Board lifecycle events (task_created, status_changed, execution_*, …) are emitted server-side by the board REST handlers and are never accepted here. Each event is best-effort: a malformed row is skipped, never failing the batch.
  • Path/query params: none.
  • Request body:
A non-array events is treated as empty. At most 200 events are accepted per call (the rest are sliced off). Any event whose kind is missing or not in the whitelist is skipped. A supplied ts is clamped to a band around server time (60 s ahead for clock skew, 24 h behind) and replaced with server time when it falls outside. ts is not just metadata: fleet health derives staleness from it, so a future timestamp would pin an agent at working and mask a genuine zombie. A mirror reports what a browser just observed, so a timestamp far from now is wrong regardless of intent.

Responses

200 OK: the count of events actually appended (after whitelist + slice):
400 Bad Request: an unexpected throw while ingesting (the error string is redacted):

Example


POST /api/eval/smoke

Runs the deterministic eval smoke suite (SMOKE_TASKS), the exact subset CI runs, and returns the real SuiteReport. It uses no live model, no provider keys, no executor/RuntimeAdapter, and no network; each trial gets its own temp-dir SQLite board (disjoint from the real clawboo.db), and the contexts are cleaned up after the run. The ablation self-test (variants over the harness’s own subsystem flags) runs only from the manual evals.yml workflow; it is explained in the UI but never driven from this route, and no live-model judge is wired into it yet.
  • Path/query params: none.
  • Request body:
Both inputs are floored and clamped so the route can never be turned into a load generator.

Responses

200 OK: the suite report:
500 Internal Server Error: the run threw (contexts are still cleaned up in finally):

Example


Error envelope

Every error response on these routes is the standard envelope { error: string }. On the obs routes the error string is run through the display redactor before it is sent. The two success-shaped exceptions are /api/obs/ingest ({ ok: true, count }) and /api/eval/smoke (the bare SuiteReport).

See also

Last modified on August 11, 2026