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.
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 asaved event. Presence, cursors, and node locks ride the same socket.
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
authpayload, not HTTP headers. The client sends`{ token, organizationId }`in the Socket.io handshake, wheretokenis your Clerk session token andorganizationIdis the organization id (a UUID). This is different from the REST/SDK auth model, which usesAuthorization: Bearer mx_live_…+X-Organization-IDheaders. API keys (`<mx_live_*>`) are not accepted on the collaboration handshake. - Editing requires
owneroradmin. The collaboration server gates every write command (node, edge, workflow, lock) on your organization role. The retiredmemberrole 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 emitsconnected (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:
- Your client emits
join-workflowwith`{ workflowId }`. - 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.
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 theack 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.ack:
Server acknowledgement (sender only)
`{ 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 requiresowner/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.
Worked example: add a node, wire it, and watch it land for a peer
Two editors, A and B, are both joined to workflowwf_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 fullpresencebroadcast. - Canvas-position cursor (
cursor:move→cursor_moved) is high-frequency, server-throttled to 50 ms per user, and not persisted — over-rate frames are dropped. - Idle state (
user:idle/user:active→user_idle_changed) toggles a user’s idle flag for presence display.
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_releasedto the room. unlockis only honored for the owner. If you are not the lock holder, the release is a silent no-op (no event is emitted).lockis rate-limited butunlockis 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.saved version (Plane B) can be numerically behind the version your client tracks from acks (Plane A).
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.
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: theconflict 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.- The server accepts a base
versionthat 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 triggersconflict. - On
conflict, the recommended recovery is to re-sync the room: emitleave-workflowthenjoin-workflowagain to pull a freshjoinedsnapshot, 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_versionpast the room’s last save) is handled internally by re-queue-and-retry and is never surfaced as aconflictorerrorto 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
- After an external change is saved, the app emits
workflow:external-syncon the singleton socket. - The collaboration server re-broadcasts it to the whole room, including the sender, as
workflow:external-updated. - 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-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)
Whensource 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 anerror with code rate_limited and an optional retryAfterMs hint and skips the handler.
Edit errors
Handler-level failures arrive as an in-banderror 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.Related pages
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
.ioCORS origin versus a.devdomain used elsewhere) and a documentedwss://ws.modulex.*endpoint that is not confirmed against the live REST hostapi.modulex.dev. The canonical public hostname is not pinned here — use your deployment’s configured collaboration-server URL until it is confirmed. nodes:deleterouting. To delete multiple nodes over the wire, emit onenode:deleteper node, or wrap severalnode:deleteoperations in a singlebatch.editedByvsupdatedByonworkflow:external-updated. The server emitseditedBy; some client code readsupdatedBy. Match your client to the server fieldeditedBy.