Events (SSE)
GET /events is a Server-Sent Events
stream. The materialize route publishes lifecycle frames on every run; the
React frontend reads the stream to flash a “running” indicator on the
dashboard.
Connect
curl -N http://127.0.0.1:3000/eventsYou’ll see comments and event blocks:
: connected
event: materialize.startdata: {"kind":"materialize.start","ts":"2026-05-10T12:34:56Z","actor":"agent:demo","targets":null,"record_ids":null,"force":false}
event: materialize.enddata: {"kind":"materialize.end","ts":"2026-05-10T12:35:01Z","actor":"agent:demo","materialized":12,"skipped":0,"failures":0,"total_cost":0.0}
: keepaliveFrame format
Folio uses the SSE convention:
event: <kind>— the SSEevent:field for clients that subscribe to named events.data: <json>— the payload as one line of JSON.: <comment>— comment lines that clients ignore. Used for the initial: connectedand the 15-second: keepalive.
Empty line terminates the frame. Multi-line data: blocks are not used.
Frame kinds
materialize.start
{ "kind": "materialize.start", "ts": "2026-05-10T12:34:56Z", "actor": "agent:demo", "targets": null, "record_ids": null, "force": false}Published immediately after the materialize route begins. targets and
record_ids are null if the request didn’t filter.
materialize.end
{ "kind": "materialize.end", "ts": "2026-05-10T12:35:01Z", "actor": "agent:demo", "materialized": 12, "skipped": 7, "failures": 0, "total_cost": 0.0}Published after the §10.6 envelope is computed. The failures count is
the length of failures[] on the envelope, not the list itself.
Clients that need details should poll /api/status or
/api/provenance for the failed cells.
materialize.error
{ "kind": "materialize.error", "ts": "2026-05-10T12:35:01Z", "actor": "agent:demo", "error_type": "FolioError", "message": "..."}Published when the materialize route raises (cycle in derivations, lock
timeout, etc.). No materialize.end follows.
Subscribing from a browser
const source = new EventSource("/events", { withCredentials: true });source.addEventListener("materialize.start", (e) => { const frame = JSON.parse(e.data); console.log("started", frame.actor);});source.addEventListener("materialize.end", (e) => { const frame = JSON.parse(e.data); console.log("done", frame.materialized);});The Folio Viewer’s useEventStream hook
wraps this pattern — it tracks the latest event and forces components to
re-render.
Subscribing from Python
import urllib.request, json
with urllib.request.urlopen("http://127.0.0.1:3000/events") as r: for raw in r: line = raw.decode("utf-8").rstrip() if line.startswith("data: "): print(json.loads(line[len("data: "):]))Implementation notes
- In-process bus. The EventBus is a
dict[asyncio.Queue]keyed by subscriber. Bounded (maxsize=64) — a slow reader drops the oldest frame instead of pinning memory. - Single process. Multiple workers don’t share frames. If you scale the Viewer beyond one process (don’t), an external bus is needed.
- Keepalive. A
: keepalivecomment goes out every 15 s so reverse proxies don’t time out idle connections. - Backpressure. A wedged subscriber doesn’t block the publisher.
Comparison with WebSockets
The design overview (§19.1) chose SSE over WebSockets because:
- The traffic is one-way (server → client).
- SSE is HTTP — easy to debug with curl, easier to reverse-proxy.
- The browser API auto-reconnects on transient disconnects.
WebSockets would add complexity without paying for it. If a future use case needs bidirectional messaging, that’s a separate channel.