Skip to Content
Streaming

Streaming

Every artifact in Glow exposes the same live-events surface: GET /<artifact>/watch is a Server-Sent Events stream filtered to a single run. Long-running operations — generation, simulation playback, eval scoring, bulk imports — fire envelopes onto this channel, and the client disconnects when a terminal lifecycle event arrives.

What does watch do?

watch is the canonical “give me events for this run” surface. The top-level /stream and /generate routes do not exist — streaming is always per-artifact. The URL shape is:

GET /<artifact>/watch?run_id=<uuid>&group_id=<uuid>

run_id is the optional filter that scopes the stream to a single execution; omit it to receive every event in the group (the front-end default for live tables). group_id may be omitted and the resolver derives it from run_id. Auth is the same bearer-token pattern as every other endpoint — pass Authorization: Bearer <token>.


Connecting to a watch stream

The response is Content-Type: text/event-stream, kept open indefinitely until the server fires a terminal event or the client disconnects. A keep-alive comment (: keep-alive\n\n) is emitted every 15 seconds when there are no events.

Raw HTTP:

curl -N $GLOW_INSTANCE_URL/persona/watch?run_id=<run-uuid> \ -H "Authorization: Bearer $GLOW_TOKEN" \ -H "Accept: text/event-stream"

Browser:

const es = new EventSource( `/persona/watch?run_id=${runId}`, { withCredentials: true }, ); es.onmessage = (ev) => { const envelope = JSON.parse(ev.data); // envelope.event_type, envelope.artifact, envelope.payload … };

The route is GET (not POST) specifically so the browser’s native EventSource works without a polyfill.


SSE event format

Every frame is a single data: line carrying the JSON-serialized EventEnvelope followed by a blank line. The wire format does not use the SSE event: name field — the event type lives inside the envelope as event_type so multiplexing clients can wildcard-match in JavaScript (which EventSource named-channels can’t do).

data: {"id":"evt_01J…","event_type":"persona.generate.started","artifact":"persona","operation":"generate","created_at":"2026-05-17T18:04:11.293Z","group_id":"…","run_id":"…","entity_id":null,"call_id":"…","tool_id":null,"payload":{"prompt":"Confused student in CS-180"}} data: {"id":"evt_01J…","event_type":"persona.generate.progress","artifact":"persona","operation":"generate","created_at":"2026-05-17T18:04:12.871Z","group_id":"…","run_id":"…","entity_id":"persona_01J…","call_id":"…","tool_id":null,"payload":{"step":"draft","percent":42}} data: {"id":"evt_01J…","event_type":"persona.generate.completed","artifact":"persona","operation":"generate","created_at":"2026-05-17T18:04:14.502Z","group_id":"…","run_id":"…","entity_id":"persona_01J…","call_id":"…","tool_id":null,"payload":{"persona_id":"persona_01J…","version":1}}

The EventEnvelope schema (Pydantic, defined once and shared across all 20 watch streams):

FieldTypeDescription
idstringGlobally-unique event id
event_typestringDotted name — <artifact>.<op>.<phase>
artifactstringSource artifact (persona, attempt, …)
operationstringOperation that fired the event (generate, update, …)
created_atdatetimeUTC ISO-8601 timestamp
group_iduuid | nullGroup the run belongs to
run_iduuid | nullThe run this envelope was filtered against
entity_iduuid | nullThe artifact row being mutated (when known)
call_iduuid | nullSoft *_calls_entry ledger id
tool_iduuid | nullOriginating tool call (for LLM-triggered ops)
payloadobjectOperation-specific body

Terminal events

Event types follow the convention <artifact>.<operation>.<phase>. The phase suffix tells the client when to disconnect:

  • .completed (or .complete) — operation finished successfully
  • .failed (or .error) — operation reached a terminal failure

The CLI’s cmd_watch_run detects these by suffix-matching the event_type and returns ControlFlow::Break from the SSE reader, which closes the underlying connection. Exit code is 0 on .completed, 1 on .failed. If the stream closes naturally without a terminal frame (e.g. the server’s dedup/age window expires), that is also treated as success — the run is just no longer being watched.

You don’t have to wait for a terminal event — non-blocking consumers (live tables, dashboards) typically omit run_id and stay subscribed to the whole group, hydrating their UI on every envelope.


The glow <art> watch <run_id> CLI helper

Every artifact’s CLI namespace reserves watch as a sub-command that wraps the SSE GET:

# Block on a single run until it completes or fails glow personas watch 01J3K… # Scope the subscriber room explicitly (otherwise resolved from run_id) glow personas watch 01J3K… --group-id 01J3G… # Machine-readable: one JSON object per frame on stdout glow personas watch 01J3K… --json

The most common pattern — generate then watch — is fused into a single command via --wait:

glow personas generate --wait --body '{"prompt": "Confused CS-180 student"}'

Under the hood that fires POST /persona/generate, reads run_id out of the response, then immediately connects to /persona/watch and blocks until the terminal frame.


Which artifacts have watch

All 20 artifacts that ship watch endpoints use the same URL shape, query params, envelope, and terminal-event convention. The list: agent, attempt, auth, cohort, department, document, eval, field, model, parameter, persona, profile, provider, rubric, scenario, setting, simulation, system, test, tool. There is no top-level /stream or /watch route — always go through an artifact path.


Common patterns

Generate then watch (manual)

# 1. Trigger RESP=$(curl -sX POST $GLOW_INSTANCE_URL/persona/generate \ -H "Authorization: Bearer $GLOW_TOKEN" \ -H "Content-Type: application/json" \ -d '{"prompt": "Confused CS-180 student"}') RUN_ID=$(echo "$RESP" | jq -r .run_id) # 2. Block on the run curl -N "$GLOW_INSTANCE_URL/persona/watch?run_id=$RUN_ID" \ -H "Authorization: Bearer $GLOW_TOKEN" \ -H "Accept: text/event-stream"

See Generation for the full per-artifact generate contract and the --wait ergonomic.

Filter by run_id vs group_id

GoalPassBehavior
Wait for one operation to finishrun_id (+ optional group_id)Stream stops at the terminal frame for that run
Hydrate a live table for the whole groupgroup_id onlyEvery artifact event in the group — never closes on terminal frames
Mirror a single entity across editsgroup_id + client-side filter on entity_idCheaper than per-run streams when the UI is row-scoped

Reconnecting

The transport is unbuffered HTTP — there’s no replay if the client drops. For at-least-once delivery, query the underlying artifact (POST /<art>/get or its generations endpoint) after reconnect to catch up, then re-subscribe.

  • Generation — the per-artifact generate operations that produce most watch traffic, plus the --wait fuse-into-one ergonomic
  • Personas CLI Referencewatch action documented inline alongside CRUD verbs (every artifact CLI mirrors this shape)
  • Personas API Reference — the per-artifact GET /persona/watch schema; every other artifact’s reference page carries the same endpoint
  • Patterns — cross-cutting conventions that show up on every artifact (soft calls, bulk shape, watch)
Last updated on