How the collaboration plane works
The collaboration plane runs on a separate Socket.io server from the REST API. It is bidirectional:- The client emits commands — join a canvas, move a node, connect an edge, acquire a lock.
- The server validates each command (auth role, version, room membership), persists the change, then acknowledges it to the sender and broadcasts the result to the other editors in the same workflow room.
transports: ['websocket', 'polling']). Every command and broadcast is a named Socket.io event; the event name is the discriminator, not a type field inside a JSON envelope (that is the SSE convention — do not mix them).
Field casing on the wire
Almost every payload field is camelCase on the wire (workflowId, nodeId, userId, sourceHandle), even though the underlying stored columns are snake_case. The one exception is the WorkflowListItem rows inside workflows_list, which carry snake_case fields (creator_id, live_deployment_id, created_at, updated_at) straight from SQL. Special-case those four fields when you consume the workflow list.
Connection handshake
You authenticate the collaboration plane in the Socket.io handshakeauth payload, not with HTTP headers. This is the single most important difference from the REST and SSE planes, which use the Authorization and X-Organization-ID headers.
The auth payload
Pass two fields in theauth object when you open the socket:
string
required
A Clerk JWT (the user session token). The collaboration plane is consumed by the ModuleX web app, which authenticates with a Clerk session token rather than an
mx_live_* API key. Missing this field rejects the handshake with a connect_error whose message is Authentication required.string
required
The organization id (a UUID) selecting the org context for this connection — the same value you would send as the
X-Organization-ID header on a REST call. Missing this field rejects the handshake with a connect_error whose message is Organization ID required. See Org context & X-Organization-ID.Connect to the collaboration server
The exact production collaboration-server hostname is not standardized in these docs (the source material references hosts that conflict with the
api.modulex.dev REST host). Use your deployment’s configured collaboration-server URL — see Open questions.What the server does on handshake
Before the connection is accepted, the server runs an authentication middleware that, in order:- Verifies the Clerk token against Clerk’s JWKS. An invalid token rejects with
connect_errormessageInvalid token. - Confirms you are a member of the organization. A non-member rejects with
connect_errormessageNot a member of this organization. - Populates your connection context: internal user id, email, display name, organization id, organization role (
owneroradmin;memberis retired — see Roles & permissions), and a presence color assigned round-robin from an 8-color palette. - Joins you to two internal rooms automatically:
user:<userId>(for targeted messages) andorg:<organizationId>(for org-wide broadcasts).
Post-connect bootstrap (server to client)
Immediately after a successful handshake, the server always emits two events to you, in order:1
connected
ConnectedPayload: `{ userId, userName, color, organizationId }`. Your identity and assigned presence color for this session.2
workflows_list
WorkflowsListPayload: `{ workflows: WorkflowListItem[], total }` — every workflow in your organization. If the underlying query fails, the error is logged server-side and no event is emitted (there is no client-facing error on initial load); re-request it with request-workflows-list.connected, then workflows_list
join-workflow (below).
Authorization and rate limits
Most write commands pass through two gates before their handler runs:
The gates are checked in that order, so a flooded write returns
rate_limited before permission_denied. The gating is not uniform across events — see the per-event tables below and these asymmetries:
cursorandcursor:moveare gated by neither (cursor moves are throttled server-side at 50 ms per user instead).unlockrequires write permission but is not rate-limited, whilelockis gated by both.workflow:external-synchas no gate at all — its handler is the trust boundary because it is the ingress for composer, API, and deployment changes.
The write lifecycle: ack, broadcast, conflict
Every typed node, edge, and workflow-metadata write follows the same shape:- The client emits the command with a base
version(the room version your client last observed). - The server checks the version, applies the change to the in-memory room schema, bumps the room version, and:
- emits
ack`{ version }`to the sender only with the new room version, and - broadcasts the typed result event (for example
node:moved) to the other editors in the room (the sender is excluded).
- emits
- If your base version is too far behind, the server emits
conflictinstead ofackand applies nothing.
Two version planes diverge by design. An in-memory room version increments per accepted operation and is what
ack, conflict, and broadcasts report. A separate database edit_version increments per flush (batched, roughly every two seconds) and is reported in saved. The saved version can be numerically behind the version your client tracks from acks — never overwrite local state from saved. Full detail on Presence, locks & versioning.conflict whose resolution is always "rebase"; refetch the room (re-join-workflow) and replay your edit.
A stale write returns conflict, not ack
ack, while the legacy patch path emits patch_ack. Both carry `{ version }`.
Client-to-server events
The full client-to-server command set. The gate columns mark whether the rate-limit (RL) and write-permission (WP) gates apply before the handler. The result column lists the events the server emits in response.*
cursor:move is not permission-gated; it is throttled server-side to 50 ms per user, and over-rate frames are silently dropped.Server-to-client events
Every event the server can emit. Broadcast events go to the other editors in the workflow room unless noted; ack-style events go to the sender only.The external-update event is
workflow:external-updated — use that name.Connection and room events
The bootstrap puts you in your org room; you enter a specific canvas withjoin-workflow and leave with leave-workflow.
join-workflow
Emitjoin-workflow with the canvas you want to edit. The server loads the workflow, verifies it belongs to your organization, joins you to the workflow:<workflowId> room, registers your presence, and replies joined to you alone with a full room snapshot.
string
required
The id of the workflow to join.
joined reply (JoinedPayload) carries the room snapshot:
string
The joined workflow id.
number
The current in-memory room version. Track this as your base version for writes.
object
The inner workflow schema: nodes, edges, state schema, and metadata.
object
The input-parameters schema (from the workflow’s
input column).object
The workflow config (from the
config column).object
Stream settings for the workflow.
string | null
The current live deployment id, or
null.UserPresence[]
Everyone currently in the room, with their cursor, lock, and idle state.
Record<string, string>
A map of
nodeId to the userId holding its lock.boolean
Present and
true if you were already in this room (a re-emit for the same room).Join a canvas and render the snapshot
workflow_not_found (no such workflow), access_denied (the workflow belongs to a different organization), plus the rate-limit gate (rate_limited).
leave-workflow
Emitleave-workflow with no payload to leave your current canvas. The server releases your presence and replies left to you: `{ workflowId, success }` where workflowId is the room you were in (or null if you were in none).
request-workflows-list
Emitrequest-workflows-list with no payload to refresh the org workflow list. On success the server re-emits workflows_list to you; on failure it emits error with code workflows_list_failed.
Node events
All node-write handlers share the ack / broadcast / conflict lifecycle. The client envelope is`{ type, version, payload }`; the broadcast envelope is `{ type, workflowId, version, userId, userName?, payload }`. Every node writes its result into the workflow’s state schema under the node’s id.
node:add
Adds a node. Thepayload.node is a NodeSchema: `{ id, type, name, description, enabled, x, y, config }`, where type is one of the nine node types (llm, agent, tool, function, transformer, conditional, interrupt, guardrails, knowledge). The server normalizes the node and also adds its state-schema field.
node:add (client to server), then ack and node:added
node is the normalized node. Errors: not_in_workflow, room_not_found, conflict, invalid_patch / patch_failed.
node:delete
Deletes a node by id (payload.nodeId). The server removes the node, its incident edges, its state-schema field, and any conditional-route cleanup. The node:deleted broadcast carries `{ nodeId, deletedEdges }`, where each entry of deletedEdges is a "<source>:<target>" string. Errors: node_not_found, plus the shared lifecycle errors.
This is the node delete handler. Delete multiple nodes by emitting one
node:delete each, or via batch.node:update
Updates a node’s fields (payload.updates may set name, description, enabled, config). A name change also rewrites the node’s state-schema field description. The node:updated broadcast carries `{ nodeId, updates }`. An update that produces no effective change returns a bare ack with the current room version and no broadcast.
node:move
Moves a single node.payload is `{ nodeId, position: { x, y } }`. The node:moved broadcast carries `{ nodeId, position }` and omits userName. Coordinates are rounded to integers; an unknown id or non-finite coordinates returns node_not_found.
nodes:move
Moves several nodes at once.payload is `{ moves: [ { nodeId, position: { x, y } } ] }`. The nodes:moved broadcast carries `{ moves }` and omits userName. An empty net move returns a bare ack.
node:duplicate
Duplicates a node.payload is `{ sourceNodeId, newNode: { id, name, x, y } }`. The server deep-clones the source, overlays the new id/name/position, normalizes it, and adds it plus its state field. The node:duplicated broadcast carries the full duplicated node: `{ sourceNodeId, newNode }`. Errors: node_not_found (missing source).
node:toggle-enabled
Toggles a node’s enabled flag.payload is `{ nodeId, enabled }`.
Edge events
Edge writes share the same ack / broadcast / conflict lifecycle as node writes.edge:connect
Connects two nodes.payload is `{ source, target, sourceHandle?, targetHandle? }`. Undefined handles are omitted to avoid patch errors. For conditional and loop nodes, the server also writes the corresponding routing config (conditional routes, default target, expression branches, or loop config) based on the handle. The edge:connected broadcast carries `{ edge }`.
edge:connect (client to server)
edge:disconnect
Disconnects an edge.payload is the same shape as edge:connect. The server clears any associated conditional routes or loop config symmetrically. The edge:disconnected broadcast carries `{ source, target, sourceHandle? }` and omits userName.
The
edge:disconnected broadcast also delivers targetHandle on the wire when the handler has one, even though the typed payload documents only sourceHandle. Read targetHandle defensively if you depend on it.edge_not_found (no such edge and not an implicit loop-config edge), plus the shared lifecycle errors.
Workflow lifecycle events
workflow:create
Creates a workflow.payload is `{ name?, description?, tags? }` — there is no version because creation needs no base version. The server auto-generates a name if omitted and persists a default schema. It emits workflow:created to the creator only (the room is not broadcast to) with `{ workflowId, workflow, version, createdBy, createdAt }`, then broadcasts an updated workflows_list to the whole org. Errors: create_failed.
workflow:delete
Deletes a workflow.payload is `{ workflowId }` — again no version. The server emits workflow:deleted to the room including the sender with `{ workflowId, deletedBy, deletedAt }`, removes the room, then broadcasts an updated workflows_list to the org. Errors: access_denied (different org), workflow_not_found, delete_failed.
workflow:metadata-update
Updates workflow metadata.payload is `{ name?, description?, version?, tags? }`. The workflow:metadata-updated broadcast echoes the changed fields. An empty change returns a bare ack.
input-params:update
Updates the workflow’s input parameters.payload is `{ inputParameters }`, a map of parameter key to `{ value, type }` where type is one of string, integer, number, boolean, array, or object. The input-params:updated broadcast carries `{ inputParameters }`.
Input parameters are persisted to a separate database column on their own version-checked transaction, not through the workflow-schema patch path. Because of that,
input-params:update cannot be carried inside a batch — the batch handler logs a warning and skips it. Always emit it standalone. Errors: update_failed.Batch operations
Emitbatch to apply several operations atomically in one version bump. payload is `{ operations }`, an array of node, edge, input, or metadata command objects (the same envelopes you would emit individually).
The server separates delete operations and applies them first in descending index order (to avoid array-index shifting), then applies the rest, all in a single patch application with one version bump and one ack. After success it emits one individual broadcast per operation — not a single batch broadcast. A batch of add-then-connect produces a node:added followed by an edge:connected. An empty net change returns a bare ack.
batch (client to server)
not_in_workflow, room_not_found, conflict, batch_failed.
External sync
When the AI Composer, a REST API call, or a deployment changes a workflow, that change reaches open canvases over the Socket.ioworkflow:external-sync command, which the server rebroadcasts as workflow:external-updated to the whole room (including the sender). This is the live external-sync mechanism.
Listen for
workflow:external-sync to receive externally-originated workflow changes on an open canvas — it is the supported external-sync event. See Realtime co-editing & external sync and Realtime & collaboration model.workflow:external-sync command (WorkflowExternalSyncEvent) carries:
string
required
The event type,
"workflow:external-sync".string
required
The workflow being synced.
string
required
The origin of the change: one of
composer, api, or deployment.number
required
The edit version of the externally-applied change.
string[]
An optional list of change descriptions for the external edit.
string
The user id of who made the change.
object
The full workflow data (
{ nodes, edges, metadata?, state_schema?, start_position? }). When present, the server syncs directly without a database fetch; when absent, it fetches from the database.object
Updated input parameters, a map of key to
{ value, type }.source is composer, the server activates a short-lived composer lock that discards pending echo patches for up to 15 seconds, preventing the composer’s own edits from being re-committed as ghost ops.
The workflow:external-updated broadcast carries:
string
The synced workflow id.
number
The room version after the sync.
string
The change origin (
composer, api, or deployment).object
The full updated workflow graph. Nodes are preserved in database format (intentionally not re-normalized).
object
Updated input parameters, if any.
string[]
An optional list of change descriptions for the edit.
object
Who made the change (
{ userId, userName? }).string
When the sync was applied.
error with code external_sync_failed.
Cursor and presence events
These power the live cursors, the “who is here” list, and idle indicators. See Presence, locks & versioning for the full presence model.cursor
Emitcursor with `{ nodeId }` (or `{ nodeId: null }`) to indicate which node you are hovering. The server persists it and triggers a full presence broadcast. It is not gated and is a no-op if you are not in a workflow.
cursor:move
Emitcursor:move with `{ x, y, nodeId? }` for free cursor position on the canvas. The server throttles to 50 ms per user and broadcasts cursor_moved `{ workflowId, userId, userName, color, x, y, nodeId? }` to peers (the sender is excluded). Cursor positions are broadcast only — not persisted.
presence
The server emitspresence `{ workflowId, users, nodeLocks }` on join, on cursor node-hover, and on leave. Each UserPresence entry is `{ userId, userName, email, color, cursor, lockedNodeId, isIdle, connectedAt, lastActivity }`.
user:idle / user:active
Emituser:idle or user:active with no payload to flag your idle state. The server updates presence and broadcasts user_idle_changed `{ userId, userName, isIdle, workflowId? }` to peers.
Lock events
Node locks let one editor claim a node so others see it as taken. Locks are held in a shared in-memory store with a five-minute TTL and acquire-if-absent semantics; re-locking by the same user refreshes the TTL. See Presence, locks & versioning.lock
Emitlock with `{ nodeId }` to claim a node. Gated by rate-limit and write-permission.
- Success:
lock_ack`{ nodeId }`to you, pluslock_acquired`{ workflowId, nodeId, userId, userName? }`to peers. - Denied (already held by someone else):
lock_denied`{ nodeId, lockedBy, lockedByName, error }`to you, whereerrorisnode_locked.
not_in_workflow, room_not_found.
unlock
Emitunlock with `{ nodeId }` to release a node. Gated by write-permission only — not rate-limited.
- Success (you own the lock):
unlock_ack`{ nodeId }`to you, pluslock_released`{ workflowId, nodeId, userId }`to peers. - If you are not the lock owner, the release is a no-op and nothing is emitted.
locks_released
When a user disconnects while holding locks, the server emitslocks_released `{ workflowId, nodeIds, userId }` to the workflow room so peers can clear the freed locks.
Status query events
get-status
Emitget-status with no payload. The server replies status `{ connected, organizationId, currentWorkflowId, cursorNodeId, lockedNodeId, connectedAt }` to you.
cursorNodeId and lockedNodeId in the status reply are hard-coded null — they are not read from live state. Use the presence event for live cursor and lock state.get-org-users
Emitget-org-users with no payload. The server replies org_users `{ users }` to you with everyone currently connected in your organization.
The
org_users wire frame carries more fields than its typed payload declares: alongside `{ userId, userName, email, color, currentWorkflowId, connectedAt }` it also includes cursor, lockedNodeId, isIdle, and lastActivity. Treat the extra fields as available.Legacy patch path
A legacypatch command exists for backward compatibility. Emit patch with `{ version, patches }` (a raw JSON-Patch array). It is version-checked and applied like a typed write, but uses its own ack and broadcast events:
patch_ack`{ version }`to the sender (note: notack).patch`{ workflowId, version, patches, userId, userName }`broadcast to peers.- A stale version returns
conflict(same shape as typed writes); other failures returnerrorwith the underlying code orpatch_failed.
patch path only if you already depend on raw JSON-Patch.
Error events
Handler-level failures (after a successful connect) arrive as the in-banderror event with the payload `{ code, message, retryAfterMs? }`. The retryAfterMs hint is present only on the rate_limited deny.
A version conflict is not an
error. The in-memory write path surfaces it as a conflict frame, and a database-layer conflict is silent (the server re-queues and retries). The collaboration error envelope ({code, message}) is also distinct from the REST/SSE {"detail": …} envelope and the billing DenialEnvelope; branch on the shape per transport. See Errors & status codes.Disconnecting
On disconnect, the server cleans up your cursor throttle, idle tracking, and stats, releases any node locks you held (emittinglocks_released to the room), and removes you from presence. There is no graceful “goodbye” frame to send — just close the socket.
Open questions
A couple of 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. The exact production hostname for the Socket.io endpoint is not standardized in these docs; the source material references hosts that conflict with the .dev hosts used elsewhere. Use your deployment’s configured collaboration-server URL.Effective rate-limit values
Effective rate-limit values
The default flood limit is 100 points per 60 seconds in the server config, but the shipped example environment file sets 300 points per 20 seconds. The effective value depends on which environment file your deployment loads, and the canonical production value is not pinned in source. Treat the limit as deployment-configured.
Membership cache lag
Membership cache lag
Org membership and role are cached for five minutes. A role change in the backend may not be reflected in the collaboration plane’s write-permission gate until that cache expires.
Next steps
Presence, locks & versioning
The presence model, lock TTLs, and the two-version-plane conflict engine in depth.
Realtime overview & event taxonomy
How the Socket.io plane compares to SSE run streaming, and which to use when.
Realtime co-editing & external sync
The builder UX: how canvas edits and external (composer/REST) changes sync while you work.
Known limitations
The documented gaps, including the unhandled
nodes:delete command and the dead pub/sub channel.