grithdocs

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:

PathCarries
GET /ws/liveEvery 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.

  1. If the request carries an Origin, its authority must equal the Host. Otherwise 403 WS_ORIGIN_FORBIDDEN. A non-browser client sends no Origin and passes this check.
  2. If a dashboard token is configured, ?token= must match it in constant time. Otherwise 401 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:

proxy_evaluation (supervisor)
{
"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:

digest_queued
{ "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

Last updated: 2026-08-24Edit this page on GitHub →