Skip to main content
This page explains how the Workflow Builder canvas stays in sync when more than one person edits it, and how changes that originate outside the canvas — from the AI Composer, the REST API, or a deployment — are pushed back into every open editor in realtime. It is the engineering companion to two other pages, and it is important to know which one you want:

Canvas collaboration

The product feature: what co-editing looks like and how to use it. Start here if you are a user, not an integrator.

Socket.io collaboration events

The complete wire reference: every client-to-server and server-to-client event, with exact payloads, acks, and error codes.

Presence, locks & versioning

The low-level presence, cursor, lock, and version/conflict model, with in-memory store key schemas and timings.
This page sits between them: it describes how co-editing actually works end to end — the edit flow, presence, locks, external sync, and conflict handling — and links to the wire reference for exact field-by-field payloads rather than repeating them. The terms used here match the glossary.

The collaboration plane in one paragraph

Live canvas editing runs over a dedicated Socket.io collaboration server — a separate realtime plane from the SSE run-streaming plane that carries workflow, Composer, and Assistant output. The two planes share no event names, no envelope, and no error shape; the Realtime overview compares them. On the collaboration plane, every open editor for a given workflow is a member of a server-side room. When you make an edit, your client emits a typed command with the room version it last observed; the server validates it (role, version, room membership), applies it to the room’s in-memory schema, acknowledges it to you, and broadcasts the result to the other editors. Roughly every two seconds the server saves accumulated edits in a batch and emits a saved event. Presence, cursors, and node locks ride the same socket.
External sync flows over Socket.io workflow:external-sync. Changes made from the Composer, the API, or a deployment reach open editors via the workflow:external-syncworkflow:external-updated Socket.io path described in External sync below.

Who can co-edit, and how it authenticates

The collaboration plane is part of the app session, not the programmatic API surface. There is no public REST or SDK operation for canvas co-editing — it is driven by the ModuleX web app over the socket.
  • Authentication is the handshake auth payload, not HTTP headers. The client sends `{ token, organizationId }` in the Socket.io handshake, where token is your Clerk session token and organizationId is the organization id (a UUID). This is different from the REST/SDK auth model, which uses Authorization: Bearer mx_live_… + X-Organization-ID headers. API keys (`<mx_live_*>`) are not accepted on the collaboration handshake.
  • Editing requires owner or admin. The collaboration server gates every write command (node, edge, workflow, lock) on your organization role. The retired member role is read-only and cannot edit — see Roles & permissions and Organizations, roles & membership. Read-only viewers can still receive presence and watch edits stream in; they just cannot emit writes.
string
required
Your Clerk session token. The server verifies it against Clerk’s JWKS during the handshake. An invalid token rejects the connection with a connect_error whose message is Invalid token.
string
required
The organization id (UUID) whose workflows you want to co-edit. A missing value rejects with Organization ID required; a value you are not a member of rejects with Not a member of this organization.
Handshake rejections arrive as a Socket.io connect_error carrying an Error message — not as an in-band error event. The in-band error event (see Edit errors) only carries handler-level failures after a successful connect. The exact production collaboration-server hostname is not standardized in these docs — use your deployment’s configured collaboration-server URL. See Open questions.

Joining a workflow room

After connecting, the server immediately emits connected (your identity and assigned presence color) followed by workflows_list (the org’s workflows for the sidebar). Joining a specific workflow to co-edit it is a separate, client-initiated step:
  1. Your client emits join-workflow with `{ workflowId }`.
  2. The server loads the workflow, verifies it belongs to your organization, creates or reuses the room, registers your presence in the shared in-memory store, and replies with joined — a full snapshot of the room.
The joined snapshot is what your canvas renders from. It contains the current room version, the workflow schema (nodes, edges, state schema, metadata), the input-parameters and config blocks, the live deployment id, the list of users currently present, and the current node locks. The exact field set is in the Socket.io events reference.
Joining a different workflow first leaves the previous room. You are in at most one workflow room at a time per socket. Leaving (or disconnecting) releases any node locks you held — see Presence & node locks.

Edit events: the write lifecycle

Every canvas edit is a typed client-to-server command that produces a server acknowledgement to you and a broadcast to the other editors in the room. Sender exclusion is reliable — you do not receive a broadcast of your own edit; you get the ack instead.

The common envelope

Most edit commands carry the same outer envelope:
Client-to-server edit command
string
required
The event name (for example node:add, edge:connect, node:move). This is also the Socket.io event you emit on.
integer
required
The base room version your client last observed — the value from the most recent joined, ack, or broadcast. The server version-gates the command against this. Omit-or-stale handling is covered under Conflict handling. Creation and deletion of whole workflows (workflow:create / workflow:delete) carry no version because they have no base to compare against.
object
required
The per-event body. The shape depends on type; see the per-event payloads in the Socket.io events reference.
On success the server replies to you with an ack:
Server acknowledgement (sender only)
and broadcasts the typed result to everyone else in the room with an outer shape of `{ type, workflowId, version, userId, userName?, payload }`:
Server broadcast (peers only)
Move broadcasts (node:moved, nodes:moved) and the edge:disconnected broadcast carry no userName. Plan your UI presence labels accordingly. The legacy patch path acks with patch_ack rather than ack — both carry `{ version }`. Socket.io callback-style acks are not used anywhere; every response is a separate emitted event, so do not pass a callback to socket.emit(...) and await it.

The edit commands and their broadcasts

These are the writes you can make to a canvas. Each requires owner/admin and is rate-limited (the per-user flood limiter; see Rate limits & errors) unless noted. The exact per-event payloads, including handle mapping for conditional and loop edges, are in the events reference.
Deleting multiple nodes. Delete nodes one at a time with node:delete, or include several node:delete ops in a single batch.

Worked example: add a node, wire it, and watch it land for a peer

Two editors, A and B, are both joined to workflow wf_123 at room version 5. A adds a tool node and connects it. The example uses the same handshake auth payload shown above.
B does not receive a batch broadcast — it receives node:added then edge:connected, the individual broadcasts the batch expands into. A receives a single ack for the batch and the room-wide saved after the next save.

Presence and node locks

Presence and locking ride the same socket and let editors see each other and avoid clobbering the same node.

Presence and cursors

  • Presence (presence) carries the room’s users and the current node locks. It is rebroadcast when someone joins, hovers a node, or leaves. Each user entry includes id, name, email, presence color, the node their cursor is on, the node they hold a lock on, idle state, and timestamps.
  • Node-hover cursor (cursor) is persisted and triggers a full presence broadcast.
  • Canvas-position cursor (cursor:movecursor_moved) is high-frequency, server-throttled to 50 ms per user, and not persisted — over-rate frames are dropped.
  • Idle state (user:idle / user:activeuser_idle_changed) toggles a user’s idle flag for presence display.
Neither cursor event is rate-limited or role-gated, so read-only viewers still appear as presence and move a live cursor. Exact payloads and timings are in Presence, locks & versioning.

Node locks

A node lock reserves one node for one editor so two people do not edit the same node’s config at once. Locks are advisory at the UI level and enforced server-side for ownership. Key behaviors to design around:
  • Locks auto-expire after 5 minutes. A lock is a key in the shared in-memory store with a 300-second TTL; re-locking the same node by the same user refreshes the TTL. A user who walks away does not block a node forever.
  • Disconnect releases all your locks and emits locks_released to the room.
  • unlock is only honored for the owner. If you are not the lock holder, the release is a silent no-op (no event is emitted).
  • lock is rate-limited but unlock is not — an asymmetry to account for if you script bulk lock/unlock.
There are two presence/lock state layers — a shared in-memory store layer (authoritative across server instances, read on join-workflow) and a per-instance in-memory layer (drives the high-frequency presence fan-out). They can briefly diverge; treat the joined/presence lock map as advisory and rely on lock_ack / lock_denied for the authoritative outcome of a lock attempt. Full detail in Presence, locks & versioning.

The two-plane version model

Understanding versions is the single most important thing for building anything against this plane, because there are two version counters and they diverge by design.
1

Plane A — in-memory room version

The collaboration server holds an authoritative room version that increments by one per accepted edit operation. This is the value reported in ack, in conflict, and in every broadcast. Your client should track Plane A from these events and send it as the base version on your next edit.
2

Plane B — saved edit_version

Separately, the saved edit_version increments by one per save, not per operation. One save (roughly every two seconds) can carry many operations. This is the value reported in the saved event.
The consequence: after several edits but only one save, Plane A has advanced by several while Plane B has advanced by one. The saved version (Plane B) can be numerically behind the version your client tracks from acks (Plane A).
Never overwrite your local room version from the saved event. saved.version is the saved edit_version (Plane B), which lags the ack/broadcast version (Plane A) you must keep sending as your edit base. Treat saved as a “changes are persisted” confirmation for your save indicator only — keep tracking version from ack and broadcasts.

Persistence and the saved event

Accepted edits are queued in the room and saved in a batch on a ~2-second timer. A successful save emits saved to the whole room (including the sender):
saved (server, room-wide)
string
The workflow that was saved.
integer
The new saved edit_version (Plane B) after the save. May be behind the room version you track from acks.
string
ISO-8601 timestamp of the save.
integer
How many patch operations were saved in this batch.
Each save also records an edit-history entry, so a workflow’s edit history is the record of these saves — see Versioning & history. If a save fails (for example a version conflict caused by an external writer), the patches are silently re-queued and retried on the next tick — there is no client-facing error for a failed save; only an in-memory conflict (next section) is ever surfaced to you.

Conflict handling

A single, user-visible conflict signal exists: the conflict event. It is emitted instead of ack when the base version you sent is too far behind the room.
conflict (server, sender only)
integer
The base version your command carried.
integer
The current room version.
string
Always the literal string rebase. No other value exists. It is advisory — the server does not rebase for you and applies nothing from the rejected command.
How conflicts actually trigger:
  • The server accepts a base version that is within a tolerance window of 20 versions behind the current room version. This absorbs the normal case where several of your in-flight edits have not yet been acked. Only a base more than 20 versions behind triggers conflict.
  • On conflict, the recommended recovery is to re-sync the room: emit leave-workflow then join-workflow again to pull a fresh joined snapshot, then replay any local intent against the new version. Do not retry the same command with the old version.
  • The save-time version conflict (caused by an external writer pushing edit_version past the room’s last save) is handled internally by re-queue-and-retry and is never surfaced as a conflict or error to clients. The only client-visible conflict is the in-memory one above.
Because the room version (Plane A) advances faster than the saved version (Plane B), the save-time conflict branch effectively fires only when an external writer — the Composer, the API, or a deployment — has advanced edit_version between saves. That case is reconciled for you via the external-sync path, which pushes the authoritative state into your editor.

External sync: Composer, API, and deployment changes

Not every change to a workflow comes from a person editing the canvas. The AI Composer rewrites the graph from a prompt, the REST API can patch a workflow, and a deployment can change the live version. External sync is how those changes reach editors who already have the canvas open — and it runs over Socket.io, not a background pub/sub channel.

The live path

  1. After an external change is saved, the app emits workflow:external-sync on the singleton socket.
  2. The collaboration server re-broadcasts it to the whole room, including the sender, as workflow:external-updated.
  3. Each open editor replaces its in-memory schema with the synced workflow, sets its room version to the synced version, merges any input parameters, and re-renders.
The live server-to-client event is workflow:external-updated.

workflow:external-sync (client-to-server)

workflow:external-sync
string
required
Always workflow:external-sync.
string
required
The workflow being synced.
string
required
Where the change originated. The server accepts composer, api, or deployment. The web app currently sends only composer.
integer
required
The authoritative edit_version after the external change.
array
A summary of what changed. The Composer currently sends an empty array; treat this as informational only and re-render from workflow.
object
The full synced schema (nodes, edges, metadata, state_schema). When present, the server broadcasts this directly without loading stored state; the nodes are passed through unchanged (not re-normalized) to preserve the stored format. When absent, the server loads the stored workflow.
object
Optional input-parameters bag, same shape as input-params:update (a map of `{ value, type }` entries).
workflow:external-sync has no rate-limit or role gate. Unlike the per-node edit commands, the external-sync ingress is ungated at the socket layer — its handler is the trust boundary. Do not treat it as an authorization point. If the room has no active editors, the server returns silently (there is nothing to broadcast to).

workflow:external-updated (server-to-client)

workflow:external-updated (room-wide, incl. sender)
integer
The synced room version. Set your tracked room version to this and re-render from workflow.
string
composer, api, or deployment.
object
The authoritative synced schema to render.
object
Synced input parameters, when present.
object
Who triggered the sync: `{ userId, userName? }`. (Some clients read this under updatedBy — match your client to editedBy.)
string
ISO-8601 time of the sync.

The composer lock (avoiding ghost edits)

When source is composer, the server activates a composer lock on the room for about 15 seconds. While the lock is active, the room discards its pending local echo patches instead of saving them. This prevents a race where the canvas re-emits the just-synced state as fresh edits and “ghost-commits” a stale version over the Composer’s authoritative write. In practice: right after a Composer run, your local in-flight patches are dropped in favor of the synced state — which is correct, because the Composer already wrote the authoritative graph.

Rate limits and errors

The collaboration rate limiter

Write commands are subject to a per-user flood limiter on the collaboration plane (a fixed window keyed by user id). When you exceed it, the server emits an error with code rate_limited and an optional retryAfterMs hint and skips the handler.
The rate_limited code on this plane is a transport-level flood limiter — it is not the billing rate-limit. It does not carry the billing DenialEnvelope (`{ code, layer, key, current, limit, reason }`) and is unrelated to plan limits. Live canvas co-editing does not pass through the billing usage gate at all — see Credit impact. For the billing 402/403/429 envelope, see Usage gating & limits.

Edit errors

Handler-level failures arrive as an in-band error event with `{ code, message, retryAfterMs? }`:
error (server)
version_conflict is not an error code on the wire. An in-memory version conflict surfaces as the conflict event (Conflict handling); a save-time version conflict is handled silently by re-queue-and-retry and never reaches you.

Credit impact

Realtime canvas co-editing — joining a room, edit events, presence, cursors, locks, and external sync — does not consume credits and does not pass through the billing usage gate. It is metered only by the transport-level rate limiter above. Credits are charged for managed run/turn usage: running a workflow, an AI Composer turn, or an Assistant turn (one run credit per turn). So a Composer edit that arrives over the external-sync path costs the Composer turn that produced it, not the sync itself.

Socket.io collaboration events

The exhaustive wire reference: every event, payload, ack, and error code.

Presence, locks & versioning

Presence, cursors, node locks, in-memory store key schemas, and the version/conflict model in depth.

Canvas collaboration

The product feature: co-editing from the user’s point of view.

Realtime collaboration walkthrough

Invite a teammate and co-edit a workflow live, step by step.

Versioning & history

How saves become edit-history entries, and how versions and deployments relate.

Realtime & collaboration model

The conceptual model of the two realtime planes.

Open questions

  • Production collaboration-server hostname. The source material references conflicting hosts for the collaboration server (an .io CORS origin versus a .dev domain used elsewhere) and a documented wss://ws.modulex.* endpoint that is not confirmed against the live REST host api.modulex.dev. The canonical public hostname is not pinned here — use your deployment’s configured collaboration-server URL until it is confirmed.
  • nodes:delete routing. To delete multiple nodes over the wire, emit one node:delete per node, or wrap several node:delete operations in a single batch.
  • editedBy vs updatedBy on workflow:external-updated. The server emits editedBy; some client code reads updatedBy. Match your client to the server field editedBy.