Overview
Everyone who opens the editor works on one shared draft of the flow, kept on the connector in
<data_dir>/flow.draft.json. Changes by anyone (adding, moving, connecting and deleting nodes, typing in a field,
adding or renaming a flow) reach every open editor within about a tenth of a second and survive a page reload and a
connector restart. The running flow and the version history change only at Deploy.
- Avatars in the top bar list who is in the editor (the name comes from the OIDC sign-in or the API token). The list shows what each person is doing and offers Follow, which mirrors that person’s flow and viewport until you pan or zoom.
- Cursors of the others are drawn on the canvas. A node somebody else has selected or has open gets a ring in their colour with their name, and the side panel says Ann is editing this node while she works in its form.
- The Draft chip next to Deploy counts the unsaved changes against the running flow and names who wrote them.
- Viewers and operators watch live but cannot edit. A protected environment is read-only for everyone (presence still works); its draft follows the live flow after every promotion or rollback.
Deploy and discard
| Action | What happens |
|---|---|
Deploy (POST /api/collab/deploy {message?, if_version?}, editor, audited flow.deploy) |
Takes the shared draft as it is now, creates the version with the caller as actor and everyone else who changed the draft since the previous version as co-authors (co_authors in GET /api/versions, shown in History), then announces deployed vN by X to every open editor. 409 no_changes when the draft equals the live flow, 409 for a stale if_version. Document-level fields the canvas does not edit (for example mocks) are taken from the live flow. |
Discard draft changes (POST /api/collab/discard, editor, audited flow.discard) |
Resets the draft to the running flow for everyone. The editor asks for confirmation first. Changes still queued on offline machines are void (those clients are told). |
A deploy by another route (PUT /api/flow, MCP, rollback, promotion, import) |
Announced to everyone as well; the draft is kept and the unsaved count is taken against the new live flow. The authors list restarts, because a new version exists. |
GET /api/collab/draft[?flow=true] returns {epoch, seq, base_version, live_version, unsaved:{count, added, removed, changed}, authors[], peers[], protected} (plus the draft document with flow=true).
How changes merge
The server holds the draft as nodes, edges and flows with plain JSON fields and sequences every change; clients apply the same sequence, so they converge without version vectors. The rules, per field, are deterministic:
- Different fields of one node (
x,label,script, …) both survive, whoever sent first. - The same field: the change the server saw last wins (last-writer-wins per field).
- A delete wins over a concurrent move or edit of the same node: those find no target and are dropped. Deleting a node removes its edges; deleting a flow removes its nodes. Adding the same id again brings it back.
- A connection to a node that was just deleted is dropped. A connection that would make a cycle, duplicate an existing one or cross flows, and deleting the last flow, are refused: the client that sent them is told and resynchronised.
- Undo takes back your own edits; other people’s changes made since are kept.
This was chosen over Yjs / yrs because the draft is a small graph of JSON fields that one server already orders: the
merge needs no tombstones or binary document format and adds no dependency. The price is that text is merged per
field, not per character: two people typing into the same script at the same moment is last-writer-wins.
Offline and reconnect
The editor reconnects with backoff. While offline the top bar shows Offline · N queued; your edits stay on the canvas,
are merged (consecutive edits of one node become one change) and are sent after the editor caught up with what happened
meanwhile. A reconnecting client sends the last epoch and seq it applied and gets either the ops it missed or, when
too much happened (more than 4096 changes, a connector restart or a new epoch), the whole draft; its queued changes are
replayed on top, and the server ignores ones it already applied (each change carries a unique id). If the draft was
discarded while a client was offline (new epoch), its queued changes are dropped and the user is told. Closing the
tab with unconfirmed queued changes asks for confirmation; everything the connector confirmed is safe.
Wire protocol
GET /api/ws/collab (any role; same bearer token / ?token= / session cookie as the other sockets). JSON text frames.
| Client → server | |
|---|---|
{type:"hello", epoch?, seq?} |
First frame. The last epoch and seq this client applied. |
{type:"ops", ops:[{cid, op}]} |
Edit the draft (editor role, not in a protected environment; at most 500 ops per frame). Anyone else gets {type:"error", code:"read_only", reason:"role"|"protected", cids}. |
{type:"presence", cursor, selection, editing, flow, view} |
Cursor and viewport in flow coordinates, selected node/edge ids, the node being edited. Every role; rate-limited. |
{type:"resync"}, {type:"ping"} |
Full state / keep-alive (a silent client is dropped after 90 s). |
Ops (op.t): node_add {node}, node_set {id, set, unset}, node_del {id}, edge_add {edge}, edge_del {id},
flow_add {flow}, flow_set {id, set, unset}, flow_del {id}.
| Server → client | |
|---|---|
welcome |
client, you, readonly + reason, epoch, seq, mode full (+state) or delta (+ops), peers, authors, base_version |
ops |
{client, user, ops:[{seq, cid, op}]} to everyone including the sender (the sender’s echo is its acknowledgement) |
ack, refused, error |
To the sender: nothing left to do for these cids / these cids were refused (code invalid or conflict) / read-only and others |
presence, joined, left, authors |
Who is where; who changed the draft |
deployed |
{version, actor, message, co_authors, via:"collab"|"external"} |
reset |
The draft was discarded: new epoch and the full state |
state |
Full state after a resync or when a client fell behind |
The draft file is written 150 ms after the last change and again at shutdown; an unreadable file is kept as
flow.draft.json.corrupt and the draft starts from the live flow. Back it up with the rest of the data directory.