GET /workbooks/{id}/channel upgrades to a WebSocket. All messages are JSON
objects with a type field. The design goal: a UI can mirror the workbook
live — including what an agent is doing to it — with nothing but this
channel plus one file fetch.
The server holds the one authoritative engine session per workbook; every
mutation from every door (stdio MCP, HTTP MCP, REST, this channel) is
serialized through it and fans out here with a per-workbook monotonic seq
that is durable (journaled with the workbook, survives restarts).
Clients are replicas: apply applied frames in seq order.
Reconnect replay: connect with ?last_seq=N and the server streams the
applied frames you missed (straight from the exec journal, byte-identical
to the original broadcast) right after welcome. On a gap frame (slow
consumer) or a seq discontinuity, resync instead:
GET /workbooks/{id}/file?format=ic, reload, resubscribe with the new seq.
resync: true frames: some transitions cannot be expressed as engine
diffs — checkpoint restore, journal-backed undo, whole-file replace. Their
applied frame carries resync: true and an empty diffs_b64; on receiving
one (live or in replay), refetch the file instead of applying diffs. The
delta field is still present for UI change-pulse rendering.
| type | fields | meaning |
|---|---|---|
welcome |
v, workbook_id, engine_version, seq, sheets[], highlights[] |
sent on connect and on hello |
applied |
seq, principal, cmd_id?, ok, summary, delta[[addr,old,new],…] (capped), delta_total, diffs_b64, resync |
a script ran and changed state (read-only scripts fan nothing out) |
rejected |
principal, cmd_id?, line, error |
a script line failed (earlier lines may still have applied — watch for a paired applied) |
agent.status |
principal, phase: executing|idle, script_line? |
exec lifecycle, drives "the agent is working" UI |
presence |
principal, joined?/left?/selection?/viewport?/editing_cell? |
fan-out of peer presence |
highlight.set / highlight.clear |
id, range, color, note?, author |
the shared pointing finger |
gap |
missed |
you lagged; resync |
error |
error |
direct reply to a bad client message |
| type | fields | effect |
|---|---|---|
hello |
— | re-request welcome |
cmd |
id?, script |
run a DSL script; results come back as applied/rejected fan-out |
presence |
selection?, viewport?, editing_cell? |
broadcast to peers |
highlight.set |
range, color?, note? |
create a highlight |
highlight.clear |
id |
remove one |
The base64 payload in applied is the engine's own diff-queue blob
(UserModel::flush_send_queue). A replica running the exact same engine
version (see engine_version in welcome) applies it with
applyExternalDiffs/apply_external_diffs for cell-perfect mirroring without
refetching. Any version mismatch ⇒ treat as opaque, rely on delta +
resyncs instead. The blob is empty for runs that changed nothing.
Static bearer token via Authorization: Bearer … or ?token= (WebSocket
clients in browsers can only use the query form). The presence identity comes
from ?principal= or the configured principal header. Permissions are
all-or-nothing per token in this version.