Reference
Events & streaming
One WebSocket streams a run's step lifecycle as it happens. Events are small — they carry ids, not payloads — so the canonical pattern is: on any event, refetch the run. That is exactly what the console does.
The executor emits events as a run advances: a node starts, a node finishes, the run pauses at a gate, the run completes or fails, the checks are scored. An in-process EventBus fans those events out to every WebSocket subscribed to that run. There is exactly one streaming endpoint, and it is server-push only.
The stream endpoint
/api/runs/{id}/streamSubscribe to one run's live events. Server-push only — inbound messages are read and ignored. Connect while the run is pending, running, or paused.
ws://localhost:8000/api/runs/af772b3602ef/stream
The socket stays open until you close it or the process shuts down. It is scoped to a single run id — open one socket per run you are watching. Nothing is sent on the wire except JSON event frames (below); there is no heartbeat or keepalive frame.
Authentication
The HTTP auth middleware does not cover WebSockets — it only intercepts HTTP requests. The stream endpoint therefore checks auth itself, and only when the server runs in AUTH_MODE=required:
- In
AUTH_MODE=open(the local default) the socket accepts the connection with no credential. - In
AUTH_MODE=requiredyou must pass a session token as a?token=query parameter (a header is not an option for browser WebSockets). The server verifies it before accepting; a missing or invalid token closes the socket with code4401before it ever opens.
Token goes in the query string
Only the session bearer token fromPOST /api/auth/login is accepted here — not an X-API-Key. Because the token appears in the URL, prefer wss:// in production so it is not sent in cleartext.// swap ws:// for wss:// behind TLS
const token = localStorage.getItem("ballast_token");
const url = "ws://localhost:8000/api/runs/af772b3602ef/stream?token=" + encodeURIComponent(token);
const ws = new WebSocket(url);
// invalid/missing token → server closes with code 4401 (no 'open' fires)
The event envelope
Every frame is a JSON object. The bus stamps two fields on all of them —run_id and type — and each type adds a few fields of its own. Payloads are intentionally minimal: they tell you what happened and where, not the full step output. To read a step's output, cost, or the run's final state, refetch GET /api/runs/{id}.
| Field | Type | Default | Description |
|---|---|---|---|
| run_id | string | always | The run this event belongs to — stamped on every frame by the bus. |
| type | string | always | The event type: step_started, step_completed, step_failed, run_paused, run_completed, run_failed, or run_evaluated. |
| node_id | string | step / gate events | The graph node the event concerns. Present on step_* and run_paused. |
| label | string | step_started | The node's display label (falls back to its id). |
| prompt | string | run_paused | The human-gate prompt the run is waiting on. |
| error | string | failure events | The failure message. Present on step_failed and run_failed. |
| passed | int | run_evaluated | How many checks passed. |
| total | int | run_evaluated | How many checks were scored. |
Event types
step_started
A node's step has begun. Emitted for every node the moment its wave launches — including nodes running concurrently in the same wave.
{ "run_id": "af772b3602ef", "type": "step_started", "node_id": "classify", "label": "Classify Ticket" }
step_completed
A node finished successfully and its checkpoint has been written. The frame carries only the node_id— refetch the run to read the step's output and cost.
{ "run_id": "af772b3602ef", "type": "step_completed", "node_id": "classify" }
step_failed
A node exhausted its retries and failed. This is followed by a run_failed for the run as a whole.
{ "run_id": "af772b3602ef", "type": "step_failed", "node_id": "draft_reply",
"error": "Tool 'custom:score-lead' timed out after 60s" }
run_paused
The run reached a human gate and parked. In-flight branch work finishes first; then the run waits for POST /api/runs/{id}/approve. No step_completed is emitted for the gate node — this event is its signal.
{ "run_id": "af772b3602ef", "type": "run_paused", "node_id": "review",
"prompt": "Review the drafted reply before it is sent." }
run_completed
Every branch finished and the run settled as success. Refetch the run for its final output, cost_usd, and duration_ms.
{ "run_id": "af772b3602ef", "type": "run_completed" }
run_failed
The run settled as failed — from a node failure (the error reads Node '…' failed: …), an unexpected crash, or cancellation. The last checkpoint is intact, so POST /api/runs/{id}/resume can pick up where it left off.
{ "run_id": "af772b3602ef", "type": "run_failed", "error": "Node 'Draft Reply' failed: Tool timed out after 60s" }
run_evaluated
Emitted immediately after run_completedwhen the workflow has evaluation checks. The full per-check results are on the run's evals field.
{ "run_id": "af772b3602ef", "type": "run_evaluated", "passed": 2, "total": 2 }
Ordering & guarantees
- A successful node emits
step_startedthenstep_completed; a failing node emitsstep_startedthenstep_failed, and the run then emitsrun_failed. - With parallel branches, events from different nodes interleave — several
step_startedframes can arrive before the firststep_completed. Key onnode_id, not arrival order. - A run ends with exactly one terminal event:
run_completed(optionally followed byrun_evaluated),run_failed, or a park atrun_paused. After an approval the run resumes and streams more events until it reaches another terminal state.
Delivery model
The EventBus is an in-process publish/subscribe map from run id to the set of connected sockets. It has two properties worth designing around:
- No replay. Events are delivered only to sockets connected at the moment they fire — there is no buffer or backlog. If you subscribe after an event fired, you never see it, and a very fast run can finish before your socket opens.
- Per process. The bus lives inside the backend process that is executing the run. Behind multiple workers, connect to the same instance that owns the run (see Self-hosting). A dead socket is dropped from the subscriber set on the next publish.
Always refetch on connect
Because there is no replay, treat the stream as a notification channel, not a source of truth. FetchGET /api/runs/{id} once when the socket opens to catch anything you missed, and again on every event.Recommended client pattern
Open the WebSocket, and on any event just refetch the run — the response is always complete and correct, which keeps client logic trivial. Fall back to short-interval polling if the socket errors or closes while the run is still active, and tear everything down once the run reaches a terminal state.
const TERMINAL = new Set(["success", "failed"]);
function followRun(runId: string, onUpdate: (run: Run) => void) {
let closed = false;
let poll: ReturnType<typeof setInterval> | null = null;
async function refetch() {
const run = await fetch(`http://localhost:8000/api/runs/${runId}`).then((r) => r.json());
onUpdate(run);
if (TERMINAL.has(run.status)) stop(); // gate is 'paused' — keep watching
}
function startPolling() {
if (closed || poll) return;
poll = setInterval(refetch, 3000); // 3s fallback, like the console
}
const token = localStorage.getItem("ballast_token"); // required mode only
const suffix = token ? "?token=" + encodeURIComponent(token) : "";
const ws = new WebSocket(`ws://localhost:8000/api/runs/${runId}/stream${suffix}`);
ws.onopen = () => void refetch(); // catch anything before we connected
ws.onmessage = () => void refetch(); // any event → refetch (payload-agnostic)
ws.onerror = startPolling;
ws.onclose = startPolling;
function stop() {
closed = true;
ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null;
ws.close();
if (poll) clearInterval(poll);
}
return stop; // call to unsubscribe
}
type is optional. If you want a live activity log rather than just a fresh run snapshot, parse the frame and switch on type / node_id to append a line — but the run detail already contains every step, so refetching alone is enough to render the full timeline.How the console uses it
The run detail page in the Ballast console follows this exact shape. While a run is active (pending, running, or paused) it opens a WebSocket to /api/runs/{id}/stream (carrying the session token in required mode). On any message it refetches the run and re-renders the step timeline. If the socket errors or closes, it falls back to polling the run every three seconds. The effect tears down on unmount and the moment the run reaches success or failed, so a finished run holds no open socket. A live indicator reflects whether the socket is currently connected.
Governance & policy events
The run event stream above tells you where a run is. A separate, first-class stream tells you what the governance layer decided while the run executed: an inspectable, auditable record of every execution-time policy decision. It is a polling REST endpoint — not this WebSocket — and it is distinct both from the run lifecycle events on this page and from the tamper-evident audit log (which records API mutations). Think of it as the policy-decision plane.
/api/governance/eventsEvery execution-time policy decision, newest first. Server-side filters: run_id, workflow_id, event_type, decision, limit. Capability: view.
| Field | Type | Default | Description |
|---|---|---|---|
| run_id | string | all | Restrict to one run's decisions. |
| workflow_id | string | all | Restrict to one workflow. |
| event_type | string | all | One of the event types below. |
| decision | string | all | Filter by outcome, e.g. blocked, denied, redacted, granted. |
| limit | int | 50 | Maximum events to return. |
[
{ "id": "a1b2c3d4e5f6", "type": "data_redacted",
"workspace_id": "0a11b2c3d4e5", "workflow_id": "9bc62f209c52", "version": 2,
"run_id": "af772b3602ef", "node_id": "classify", "policy": "dlp",
"actor_id": null, "decision": "redacted",
"reason": "Redacted 2 value(s) before the prompt left the box",
"evidence": { "fields": ["email", "credit_card"], "count": 2 },
"created_at": "2026-07-05T14:10:05Z" },
{ "id": "b2c3d4e5f6a1", "type": "model_fallback_blocked",
"workspace_id": "0a11b2c3d4e5", "workflow_id": "9bc62f209c52", "version": 2,
"run_id": "af772b3602ef", "node_id": "draft_reply", "policy": "model_allowlist",
"actor_id": null, "decision": "blocked",
"reason": "Fallback model is not on the workspace allowlist",
"evidence": { "model": "gpt-4o-mini", "provider": "openai" },
"created_at": "2026-07-05T14:11:02Z" }
]
Event types
policy_denied— a tool call was refused because the tool's workflow allowlist did not permit this workflow.tool_argument_blocked— a tool call passed an argument value on the tool's blocked-arguments list and was refused at run time.data_redacted— DLP scrubbed one or more values (named PII field types and/or redact patterns) from a prompt before it left the box.budget_blocked— a trigger or step was stopped because a block budget was exhausted (the caller sees402).model_fallback_blocked— a fallback model was refused because it is not on the workspace model/provider allowlist.approval_override_granted— a budget was overridden with approval, lifting the per-run cap and resuming the run.
What each event carries
Every event names the decision and enough context to locate and explain it — but the evidence is redacted: it is limited to counts, ids, and model/provider names, and never includes the scrubbed plaintext or a secret.
| Field | Type | Default | Description |
|---|---|---|---|
| id | string | always | The event id (12-char hex). |
| type | string | always | The event type — one of the six above. |
| workspace_id | string | always | The workspace the decision was made in. |
| workflow_id | string | null | run events | The workflow whose run triggered the decision. |
| version | int | null | run events | The workflow graph version in effect. |
| run_id | string | null | run events | The run the decision belongs to. |
| node_id | string | null | step events | The graph node / step being enforced. |
| policy | string | always | The policy that fired: tool_permission, tool_argument, dlp, model_allowlist, budget, or fallback. |
| actor_id | string | null | when known | The actor tied to the decision — e.g. who granted an override. |
| decision | string | always | The outcome — blocked, denied, redacted, or granted. |
| reason | string | always | A human-readable explanation of the decision. |
| evidence | object | always | Redacted supporting detail — counts, ids, model/provider names. Never plaintext or a secret. |
| created_at | string | always | ISO 8601 UTC timestamp of the decision. |
Evidence is redacted by construction
Adata_redacted event records that — and how many — values were scrubbed; the evidence is a count, never the matched text. Model-related events carry the model and provider names, not keys. You can audit that a policy fired without ever re-exposing what it protected.