WebSocket
The two live streams, how the handshake is authorised, and the exact frames the daemon actually emits.
Two endpoints, and neither is under /api:
| Path | Carries |
|---|---|
GET /ws/live | Every event the daemon broadcasts |
GET /ws/supervisor/{id} | Only frames whose session_id matches the path id |
ws://127.0.0.1:3141/ws/live?token=<dashboard token>Authorisation
Checked before the upgrade, so a rejected handshake never reaches a handler and never becomes a socket.
- If the request carries an
Origin, its authority must equal theHost. Otherwise403 WS_ORIGIN_FORBIDDEN. A non-browser client sends noOriginand passes this check. - If a dashboard token is configured,
?token=must match it in constant time. Otherwise401 WS_TOKEN_REQUIRED.
The token goes in the query string because browsers cannot set headers on a WebSocket
handshake. /ws/supervisor/{id} adds 400 INVALID_ID for a non-UUID and
404 SESSION_NOT_FOUND for a session it does not know.
There is no protocol on top of the socket
ℹ️Nothing you send is read
There is no subscribe message, no filter, and no application-level heartbeat. The server answers a
WebSocket Ping frame with a Pong and ignores client text and binary completely. Connect and
read.
One broadcast channel fans out to every client. A client that cannot keep up skips messages
rather than being disconnected, so the stream is best-effort with no replay - fetch history
over REST, then connect for live updates. On daemon shutdown the server sends a Close
frame first, which is what tells the dashboard to start looking for the successor.
The frames
Frames are loose JSON objects. Most carry a type; three do not.
proxy_evaluation, from the supervisor
The dominant frame, one per scored call in a supervised session:
{
"type": "proxy_evaluation",
"session_id": "9c4f...",
"tool_name": "claude-code",
"project_name": "api",
"call_type": "FileRead(/home/u/.ssh/config)",
"call_id": "9c4f...:supervisor:claude-code",
"plugin_id": "supervisor:claude-code",
"composite_score": 4.2,
"score": 4.2,
"action": "queue",
"evaluation_time_ms": 0.08,
"filter_results": [ { "filter_name": "sensitive-path-heuristic", "score": 4.0 } ],
"reason": "Score 4.2 in escalation zone",
"timestamp": "2026-08-24T09:14:23.481920123+00:00"
}score is a duplicate of composite_score; prefer composite_score. filter_results
here carries only filter_name and score - no severity, no message. When the session is
running with interactive_queue_action = "log", action is the literal string
allow (logged).
A DNS infrastructure failure emits the same type with action set to deny, a score of 0,
a call_id ending :dns-infrastructure, and an empty filter_results. It is rate-limited
to one every 30 seconds, because an outage denies every lookup the tool makes.
proxy_evaluation, from the agent loop
The same type name, a different shape. grith run and the REPL emit type, call_id,
timestamp, composite_score, action, evaluation_time_ms and filter_results - with
rule_id, matched, severity and message on each result, but only for filters that
matched. There is no session_id, so these frames never appear on
/ws/supervisor/{id}. Match on the fields you need, not on the type name alone.
digest_queued, digest_reviewed, digest_escalated
Each carries the whole item:
{ "type": "digest_queued", "item": { "id": "7d1f...", "composite_score": 4.2, "status": "pending" } }item is the full digest item, the same shape GET /api/digest
returns.
server_shutdown
{"type": "server_shutdown", "message": "Dashboard server is shutting down"}, sent just
before the daemon stops.
Three frames with no type field
The supervisor emits scoped-allow, learned and scoped-deny as the action of a frame
that has no type key at all. They carry session_id, tool_name, call_type
(prefixed Scoped:, Learned: or Blocked:), plugin_id, score, action, reason
and timestamp. They render fine in the exec TUI, and a strict type-based parser will
drop them.
See also
- API overview
- Daemon IPC -
POST /api/ipc/events, the only way in