Skip to main content
This page is the wire-level reference for three parts of the Socket.io canvas-collaboration plane: presence (who is in a workflow, their cursor, idle state, and assigned color), node locks (claiming a single node so others see it as held), and the JSON-Patch version model (how concurrent edits are applied, batched to the database, and reconciled when versions diverge). It is the protocol detail behind the product experience on Canvas collaboration and the builder UX on Realtime co-editing & external sync. Everything here lives on the collaboration plane only. Presence, cursors, locks, and patches are emitted and consumed over Socket.io; there is no REST endpoint and no JavaScript or Python SDK method for any of them (see No REST or SDK surface). For the other realtime plane — one-way run streaming over SSE — see Realtime overview. For the complete event catalog, including node and edge edits not repeated here, see Socket.io collaboration events.

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 auth payload (not HTTP headers). The collaboration plane is consumed by the ModuleX web app, which authenticates with a Clerk session token rather than an mx_live_ API key. See Auth model: JWT vs API key.
  • Join a workflow by emitting join-workflow with { workflowId }. The server replies with a joined snapshot that already contains the current users and nodeLocks — your initial presence and lock state come from that snapshot, not from a separate query.
Throughout this page, C→S marks an event your client emits to the server and S→C marks an event the server emits to your client. Field names are camelCase on the wire.

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 joined snapshot and any presence broadcast 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 joined snapshot.
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.
The resulting 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) and user:active (C→S) take no payload. They flip your idle flag in the presence record and broadcast user_idle_changed to 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.
The server’s fallback timer only ever marks a member idle, never back to active — only an explicit user:active event clears the idle flag. Separately, the fallback timer does not observe edit, lock, or cursor traffic, so a member who is actively editing but whose client never emits user:active can still be flagged idle after 5 minutes. If you build a client, emit user:active on real interaction, not only on focus.
The 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.
On success the server replies to you with 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.
Confirms your lock succeeded.
string
The node you now hold.
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.
string
The node you released.
unlock is silent in two failure cases, by design:
  • If you are not in a workflow or no room exists, the server returns with no event — no ack and no error.
  • If you try to unlock a node you do not own, the release is refused and nothing is emitted at all — no unlock_ack, no lock_released, no error.
Do not wait on an ack to confirm an unlock of a node you may not own. If you need certainty that your own lock was released, key off unlock_ack only after confirming you held the lock.

Lock lifetime, expiry, and edge cases

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.
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.
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.
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)
On a clean leave (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 in a saved event is the saved edit_version, which can be numerically behind the version your client has been tracking from ack/broadcast frames (the room version). Do not overwrite your local version from saved — doing so makes your client regress and can trigger spurious conflicts on your next edit. Track the version from ack/conflict/broadcasts; treat saved as a “your changes are persisted up to this version” signal, not as the counter to mirror.

The version gate and conflicts

When you emit an edit, you include your current room version. 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)
The documented recovery on a 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.
A conflict is the only user-visible conflict signal, and it comes only from the in-memory version gate. A separate save-time conflict can occur during the background save when an external writer (the AI Composer, a REST call, or a deployment) has advanced edit_version past the room’s last saved base. That save-time conflict is silent to the client — the server re-queues the affected patches and retries on the next 2-second tick; it never emits a conflict event for it. So a missing conflict does not guarantee your patch persisted on the first try.

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:
  1. Serializes concurrent saves of the same workflow, so they apply one at a time.
  2. Runs the version check: if the expected base version is strictly behind the saved edit_version, it fails with a version_conflict (the silent, re-queued case above).
  3. Sanitizes the patches: it drops add/replace/test operations whose value is undefined, deep-clones object values to strip embedded undefined, and filters out remove operations whose target path does not exist.
  4. Applies the patches with strict validation. A single invalid operation (for example, a replace on a path that does not exist) throws and fails the whole batch with invalid_patch; the batch is then re-queued for retry.
  5. Saves the new schema and bumps edit_version by one, along with last_edited_by and last_edited_at.
  6. Records an edit-history entry (workflow_id, edit_version, user_id, patches, created_at); the entry is idempotent, so a retry of the same edit_version is not duplicated.
On success the server emits 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 the workflow:external-sync event, which the server rebroadcasts as workflow:external-updatednot 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 each edit_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 an error 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 no GET/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:
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.
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.
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.