Realtime collaboration
Clank includes dependency free primitives for live presence, cursors, selections, typing state, and ephemeral signals between people authorized to the same application room. The transport uses ordinary authenticated Fetch writes plus an SSE
Clank includes dependency-free primitives for live presence, cursors, selections, typing state, and ephemeral signals between people authorized to the same application room. The transport uses ordinary authenticated Fetch writes plus an SSE stream, so it works through the same Node adapter, cookies, origin policy, and rolling HTTP infrastructure as the rest of an app.
Collaboration is deliberately separate from persistent live queries:
- backend mutations and SQLite live queries are the durable source of truth;
- collaboration presence is short-lived, replaceable UI context;
- signals announce an event but do not become an event log; and
- MCP tools continue to operate on documented backend actions, not another person's cursor.
Add an authenticated room
Use the auth adapter to reuse Clank's current session and CSRF boundary. The one application rule you must provide is room authorization:
import { createAuthCollaborationHub } from "@clank.run/framework/collaboration";
const collaboration = createAuthCollaborationHub(auth, {
async authorizeRoom(session, { room, operation }) {
// Recheck membership on every connect, stream, update, signal, and heartbeat.
return await canAccessDocument(session.requireUser().id, room);
},
displayName: (user) => user.profile.name ?? "Collaborator"
});
async function handle(request: Request) {
const url = new URL(request.url);
if (url.pathname === "/__clank/collaboration") {
return collaboration.handle(request);
}
return application.handle(request);
}authorizeRoom is not optional in applications with object- or workspace-level access. A valid session proves who someone is; it does not prove they may observe a particular document. createAuthCollaborationHub resolves the current session on every request and delegates writes to the auth runtime's ordinary verifyCsrf() check.
For another identity system, use createCollaborationHub({ authorize, verifyCsrf }). Both callbacks are mandatory. The public principal contains a private stable id and a room-visible name; peers receive only a random connection-scoped participant ID.
Connect the browser
import { createCollaborationClient } from "@clank.run/framework/collaboration";
const room = createCollaborationClient({
room: documentId,
csrfToken: () => boot.auth.csrfToken,
initialPresence: {
route: location.pathname,
cursor: { x: 0, y: 0 },
selection: null
}
});
await room.connect();
canvas.addEventListener("pointermove", (event) => {
void room.update({
route: location.pathname,
cursor: { x: event.clientX, y: event.clientY },
selection: selectedId.value
});
});
effect(() => {
renderPresence(room.participants.value);
});The reactive surface is:
| Value | Meaning |
|---|---|
state | idle, connecting, connected, reconnecting, closed, or error. |
participants | Immutable current room participants and their complete presence objects. |
lastEvent | Latest snapshot, join, presence, leave, or signal event. |
error | Latest transport error, without changing the participant contract. |
update() replaces this connection's entire presence object. Replacement avoids stale cursor or typing keys surviving after a UI mode changes. Keep a local presence signal when updates are assembled by multiple components.
Use a signal for transient notification:
await room.signal("comment.created", { commentId });Signals are delivered to currently connected room participants, including the sender, and are never retained. Persist the comment through a typed backend mutation first; use the signal only to guide immediate UI attention.
Protocol and lifecycle
The endpoint defaults to /__clank/collaboration:
- a CSRF-protected
connectwrite creates a random connection and returns a complete snapshot; - the client opens an authenticated SSE stream for that exact room and connection;
- presence writes replace bounded participant data;
- signals broadcast bounded ephemeral payloads;
- heartbeats keep the connection lease current; and
- abort, explicit disconnect, or idle expiry publishes a leave event.
Every event uses clank-collaboration/1 and a monotonically increasing in-room revision. A stream always begins with a full snapshot, so reconnect does not require replaying an unbounded event history. The browser client reconnects with bounded exponential backoff after proxy restarts or rolling deployment transitions.
Limits and privacy
Defaults are conservative and configurable:
| Limit | Default |
|---|---|
| Active rooms | 1,000 |
| Participants per room | 50 |
| Connections per authenticated principal | 8 |
| Presence JSON | 4 KiB |
| Signal JSON | 8 KiB |
| Presence/signals per connection | 240/minute |
| Idle lease | 45 seconds |
Data is JSON-only, five levels deep, with bounded keys, arrays, strings, bodies, and SSE events. Origins are same-origin, cross-site Fetch Metadata is rejected, responses are no-store, mutation rates are bounded, and stream access rechecks both the principal and room.
Presence is visible to every authorized participant in the room. Do not put passwords, tokens, private drafts, email addresses, or hidden database fields in it. Connection IDs are opaque routing handles, not bearer credentials; the server still re-authenticates their owner on every request.
The built-in hub is intentionally process-memory state. It survives ordinary connection churn but not a process restart, which is correct for ephemeral presence. The client reconnects and sends a new snapshot. A deployment with multiple simultaneous app processes must provide sticky routing to one hub or a reviewed shared collaboration adapter; SQLite live data remains the durable shared state either way.
Call hub.diagnostics() for aggregate room, participant, and stream counts. It returns no room names, participant identities, presence, connection IDs, or signal payloads. Call hub.close() during graceful application shutdown.