Prerequisites
You should already have a Socket.io connection to the collaboration server and have joined a workflow room. Both are covered on Socket.io collaboration events; the essentials:- Connect with a token and an organization id in the Socket.io handshake
authpayload (not HTTP headers). The collaboration plane is consumed by the ModuleX web app, which authenticates with a Clerk session token rather than anmx_live_API key. See Auth model: JWT vs API key. - Join a workflow by emitting
join-workflowwith{ workflowId }. The server replies with ajoinedsnapshot that already contains the currentusersandnodeLocks— your initial presence and lock state come from that snapshot, not from a separate query.
Presence and cursors
Presence answers “who else is editing this workflow, where is their cursor, and are they idle?” Each member is assigned a color at handshake time (round-robin from a fixed palette) so the UI can tint their cursor and node highlights consistently.Where your presence state comes from
There are two reads of presence, and they can briefly differ:- The
joinedsnapshot and anypresencebroadcast carry the member list and lock map for the room. This is the canonical “who is here” view your client renders. - A separate presence record in a shared in-memory store tracks each user’s cursor node, idle flag, and heartbeat across server instances. It is what survives a single-instance restart and what seeds the
joinedsnapshot.
The live
presence broadcast is built from the server’s in-memory room state, while the joined snapshot’s users/nodeLocks are read from the shared presence store. The two are kept in step in normal operation but are not a single source of truth and can momentarily diverge (for example, right after a server instance restarts). Treat the most recent presence broadcast as the authoritative live view and re-join-workflow to resync if you suspect drift.presence (S→C) — the full room snapshot
The server emits presence to every other member of the room (the sender is excluded) whenever someone joins, leaves, or moves their node-hover cursor. It is the one event that carries the complete member list and lock map together.
string
The workflow this presence snapshot is for.
UserPresence[]
Every member currently in the room (see the
UserPresence shape below).object
A map of
nodeId → userId for every currently locked node, for example { "node_abc": "u1" }. An empty object means no node is locked.presence (S→C)
Two kinds of cursor
ModuleX tracks two separate cursor concepts. Do not conflate them — they use different events, different persistence, and different fan-out.cursor (C→S, node-hover, persisted)
Tell the room which node you are hovering, or pass null to clear it. The server records the node in your presence and re-broadcasts a full presence snapshot to the other members. It is a no-op if you have not joined a workflow.
string | null
required
The node you are hovering, or
null to clear your node-hover cursor.cursor (C→S)
cursor:move (C→S) → cursor_moved (S→C)
Send your live pointer position. This is the high-frequency event behind a moving cursor on the canvas. It is throttled server-side to about one frame every 50 ms per user; frames sent faster are dropped without an error. The position is never persisted — there is no ack to you, only a broadcast to the other members.
number
required
Canvas x coordinate of the pointer.
number
required
Canvas y coordinate of the pointer.
string | null
The node under the pointer, if any.
cursor_moved broadcast carries the mover’s identity and color so you can render their cursor without a presence lookup.
string
The workflow.
string
The mover’s user id.
string
The mover’s display name.
string
The mover’s assigned color.
number
Canvas x coordinate.
number
Canvas y coordinate.
string | null
The node under the pointer, if any.
cursor_moved (S→C)
Idle and active
A member is shown as idle either because their client told the server so, or because the server’s fallback timer marked them idle after a period of inactivity.user:idle(C→S) anduser:active(C→S) take no payload. They flip your idle flag in the presence record and broadcastuser_idle_changedto the other members.- The server also runs a fallback timer: a member with no recorded activity for 5 minutes is flagged idle automatically. Activity is recorded on connect and on
user:active.
user_idle_changed broadcast:
string
The member whose idle state changed.
string
Their display name.
boolean
The new idle state.
string
The workflow, if the change is room-scoped.
user_idle_changed (S→C)
Heartbeats and stale presence
Each connection refreshes a heartbeat key in the in-memory presence store (a 30-second time-to-live, refreshed every 10 seconds) so a hard-killed client expires from the heartbeat record on its own. Other presence records — the org and room member sets and the per-user presence hash — are cleaned up on a normal disconnect. A hard instance kill can leave those entries stale until the next normal cleanup, because there is no active reaper that sweeps them; only the heartbeat key self-expires. This is invisible in normal operation but is the reason a re-join-workflow is the reliable way to resync a suspected-stale member list.
Node locks
A node lock is a soft, advisory claim on a single node so collaborators see it as “being edited by someone.” Locks are not required to edit, and they do not block writes at the patch layer — they exist so the UI can show a held node and discourage simultaneous edits. Locks are held in a shared in-memory store (so they are shared across server instances), are owned by exactly one user at a time, and auto-expire after 5 minutes.lock (C→S) → lock_ack / lock_acquired / lock_denied
Request a lock on a node. This event is gated by both a per-user rate limit and a write-permission check (owner or admin role; member is retired — see Roles & permissions). You must already be in the workflow room.
string
required
The node you want to lock.
lock_ack and broadcasts lock_acquired to the other members. On denial — the node is already held by someone else — it replies to you only with lock_denied.
- lock_ack (S→C, to caller)
- lock_acquired (S→C, to others)
- lock_denied (S→C, to caller)
Acquire a lock (C→S)
Re-locking a node you already hold succeeds and refreshes the 5-minute expiry rather than failing — so a client can keep a lock alive by re-emitting
lock while editing. A lock held by another user always returns lock_denied.unlock (C→S) → unlock_ack / lock_released
Release a lock you hold. This event requires write permission but is not rate-limited (an intentional asymmetry with lock). On release the server replies to you with unlock_ack and broadcasts lock_released to the other members.
string
required
The node to unlock.
- unlock_ack (S→C, to caller)
- lock_released (S→C, to others)
string
The node you released.
Lock lifetime, expiry, and edge cases
5-minute auto-expiry
5-minute auto-expiry
A node lock is stored with a 300-second (5-minute) time-to-live. If the holder neither releases it (
unlock) nor refreshes it (re-emitting lock), it expires automatically and the node becomes lockable again. A long edit session should re-emit lock periodically to keep the claim alive.Locks do not block edits
Locks do not block edits
A lock is advisory. The version/patch engine described below does not consult locks — a node-edit patch from a non-holder is still applied. Locks are a UI affordance to coordinate humans, not a server-enforced mutex on writes.
One lock per node, one node per holder in presence
One lock per node, one node per holder in presence
A node maps to at most one holder. A member’s
lockedNodeId in presence reflects the single node they are shown as holding. The lock map in a presence/joined payload (nodeLocks) is the room-wide nodeId → userId view.Lock state can briefly lag presence
Lock state can briefly lag presence
Because the live
presence broadcast is built from in-memory room state while the joined snapshot’s nodeLocks is read from the shared in-memory store, the lock map you see immediately after joining and the lock map in a subsequent live presence can differ for a moment during instance churn. The shared lock keys are the cross-instance source of truth; the in-memory map drives the high-frequency broadcast. Re-join to resync if needed.What happens on disconnect
When a member disconnects — closing the tab, losing the network, or leaving the workflow — the server releases every lock that member held and tells the room in one broadcast.locks_released (S→C)
Emitted to the workflow room when a disconnecting (or leaving) member held one or more locks. Unlike lock_released, which carries a single nodeId, this is the bulk release for everything the departing member held.
string
The workflow.
string[]
Every node the departing member held, now free.
string
The departing member.
locks_released (S→C)
leave-workflow), the departing member also gets a left acknowledgement; the other members get the locks_released bulk release and an updated presence. On a hard disconnect, presence records are cleaned up and the bulk locks_released is emitted from the disconnect handler.
A disconnect can drive the lock-release path twice — once from the in-memory room cleanup and once from the shared-store disconnect handler — so your client may observe more than one
locks_released for the same member. Make your handler idempotent: releasing a node that is already free should be a no-op. The disconnect of the last member in a room also tears the room down after a final save of any pending patches (see the version model).The JSON-Patch version model
Canvas edits are not saved one at a time. The live canvas state is held in memory in a room that owns the live schema and a version counter. A client edit (move a node, connect an edge, rename, and so on) is sent with the client’s current version; the server version-gates it, applies the resulting RFC 6902 JSON-Patch operations to the in-memory schema, bumps the room version, acknowledges the sender, broadcasts the change to peers, and queues the patch. A timer saves the queue in a batch every 2 seconds. Each save records one entry in the workflow’s edit history. The full set of edit events (node:add, node:update, node:move, nodes:move, node:duplicate, node:delete, node:toggle-enabled, edge:connect, edge:disconnect, workflow:metadata-update, input-params:update, batch, and the legacy patch) is documented on Socket.io collaboration events. This section covers the version, conflict, and persistence model common to all of them.
The two version planes
This is the single most important thing to understand about the version model, and the most common source of client bugs: there are two version counters, and they advance at different rates.
Because the room version increments per operation while
edit_version increments per save, the room version is normally ahead of the saved edit_version. After a save that batched N operations, the room is roughly N ahead.
The version gate and conflicts
When you emit an edit, you include your current roomversion. The room accepts it if you are not too far behind:
The edit is accepted when clientVersion >= roomVersion - 20.
That 20-version tolerance window absorbs rapid in-flight edits whose acks have not yet landed at your client. If your version is more than 20 behind the room, the edit is rejected before it is applied and the server replies with a conflict.
conflict (S→C)
number
The version your client sent.
number
The current room version.
string
Always the literal
rebase. No other value exists. It is advisory — the server does not perform any rebase for you.conflict (S→C)
conflict is to re-sync: leave and re-join the workflow (leave-workflow then join-workflow) to fetch a fresh joined snapshot, then replay your unsent intent against the new version. The resolution: "rebase" string is a hint about what your client should do, not an action the server takes.
Persistence and the saved event
The save runs every 2 seconds. When the queue is non-empty, the server snapshots and clears it, flattens the queued operations into one patch list, and saves them in a single step that:
- Serializes concurrent saves of the same workflow, so they apply one at a time.
- Runs the version check: if the expected base version is strictly behind the saved
edit_version, it fails with aversion_conflict(the silent, re-queued case above). - Sanitizes the patches: it drops
add/replace/testoperations whose value isundefined, deep-clones object values to strip embeddedundefined, and filters outremoveoperations whose target path does not exist. - Applies the patches with strict validation. A single invalid operation (for example, a
replaceon a path that does not exist) throws and fails the whole batch withinvalid_patch; the batch is then re-queued for retry. - Saves the new schema and bumps
edit_versionby one, along withlast_edited_byandlast_edited_at. - Records an edit-history entry (
workflow_id,edit_version,user_id,patches,created_at); the entry is idempotent, so a retry of the sameedit_versionis not duplicated.
saved to the whole room.
string
The workflow.
number
The new saved
edit_version (Plane B). See the warning above about not mirroring this.string
ISO-8601 timestamp of the save.
number
The number of queued patch entries saved (not the raw operation count).
saved (S→C)
The in-memory applier the room uses for the live broadcast is more permissive than the strict applier used when saving: the in-memory applier auto-creates missing intermediate objects, skips out-of-bounds array indices, and skips
undefined values, whereas the save-time applier validates strictly and fails the whole batch on a bad operation. This is why an edit can be accepted and broadcast live yet still be reconciled (re-queued and retried) when it saves. The divergence is by design and self-corrects on a subsequent save.Composer edits and saving
While the AI Composer is rewriting a workflow, the canvas still emits echo patches that mirror what the composer already saved. Saving those would double-write (“ghost commits”). To prevent that, an external composer sync engages a composer lock on the room: while it is active (auto-clearing after 15 seconds), the next save discards any pending echo patches instead of saving them. Composer changes reach your open canvas over theworkflow:external-sync event, which the server rebroadcasts as workflow:external-updated — not over a background pub/sub bridge. See Realtime co-editing & external sync and Socket.io collaboration events.
Edit history
Every successful save records one edit-history entry, with eachedit_version unique per workflow, storing the original client patch paths. This is the data behind workflow versioning and history. Note that one history entry corresponds to one save (Plane B), not to one edit operation (Plane A) — an entry’s patches array can contain many operations batched in that 2-second window.
Errors
Collaboration errors arrive as anerror event with a { code, message, retryAfterMs? } shape — a different envelope from the REST/SSE {"detail": …} and from the billing DenialEnvelope. See Errors & status codes for all three error shapes across the platform. The retryAfterMs field appears only on the rate_limited deny and is a transport hint, not the billing 429 envelope.
Conflicts are not delivered as error — they arrive as the dedicated conflict event described above.
error (S→C)
The gates are intentionally asymmetric.
lock is both rate-limited and write-gated, while unlock is write-gated but not rate-limited. Cursor and idle events are ungated (the cursor throttle is the only limiter on cursor:move). Do not assume symmetric throttling across these events.No REST or SDK surface
Presence, cursors, locks, and the patch/version model are exposed only over Socket.io. There is noGET/POST REST endpoint for them, and the JavaScript SDK and Python SDK do not wrap them — the SDKs cover the REST API (and the SSE run streams), not the collaboration plane, which today is consumed by the ModuleX web app. For the parity picture across the whole platform, see the SDK ⇄ API parity matrix.
Because there is no REST surface, the canonical examples on this page are socket.io-client frames rather than the cURL / Python / JavaScript SDK trio used on REST-backed pages. To work with presence and locks programmatically, connect a Socket.io client and emit the events documented here.
Full presence + lock client (socket.io-client)
Open questions
A few behaviors are not pinned in source and are tracked here so you do not build on an assumption:Production collaboration-server hostname
Production collaboration-server hostname
The collaboration server is a separate service from the REST API at
https://api.modulex.dev. Its production hostname is not standardized in these docs — the source material references both .io and .dev hosts, and the value is environment-configured in the web app. Use your deployment’s configured collaboration-server URL in place of COLLAB_SERVER_URL.Stale presence after a hard instance kill
Stale presence after a hard instance kill
Heartbeat keys self-expire after 30 seconds, but the org/room member sets and per-user presence hashes have no active reaper and are cleaned up only on a normal disconnect. After a hard instance kill, a stale member can linger in the room list until the next normal cleanup. Re-
join-workflow to fetch a fresh snapshot if you suspect a stale member.Lock map vs lock TTL reconciliation
Lock map vs lock TTL reconciliation
A node lock’s underlying key expires after 5 minutes, but the room-wide lock map entry is removed on explicit release or disconnect rather than swept against the expired key. A lock that expires by TTL without a release can therefore linger in the lock map until the next release or disconnect. Treat the lock map as advisory and confirm a lock with a fresh
lock (which refreshes the TTL) when it matters.Next steps
Socket.io collaboration events
The complete client-to-server and server-to-client event reference, including every node and edge edit.
Realtime co-editing & external sync
How canvas edits sync between collaborators and from external (composer and REST) changes.
Canvas collaboration
The product view of multi-user canvas editing.
Versioning & history
Workflow versions, deployments, and the canvas edit history written by each save.
Realtime overview
The two realtime planes — SSE run streaming and Socket.io collaboration — side by side.
Errors & status codes
The three error-envelope shapes across the platform and which surface emits each.