Realtime protocol
The WebSocket is at /ws. It carries two kinds of traffic that behave very
differently, and the distinction is the whole design.
Durable events
Section titled “Durable events”Every change that matters is appended to a per-workspace event log inside the same transaction that made the change, and each gets a monotonic sequence number. Sending a message and recording that a message was sent either both happen or neither does.
The socket is a notification channel, not a data channel. Events come from the database, which is why a resume works across a server restart and not just a dropped connection.
Ephemeral frames
Section titled “Ephemeral frames”Typing indicators, presence, call signaling, and note edits are sent directly between sockets and never logged. They have no sequence number, and losing one costs nothing.
Connecting
Section titled “Connecting”The first frame is hello, carrying a token and the last sequence number this
client saw:
{ "type": "hello", "token": "slk_...", "workspaceId": "01J...", "lastSeq": 4821 }The server replies with everything after lastSeq, in order, then keeps
streaming. A brand new client sends lastSeq: 0 after fetching
/api/workspaces/:id/bootstrap.
If a client is too far behind the retention window, the server answers
full_sync_required instead. The client then re-bootstraps. This is the
always-correct fallback: rather than guessing at a gap, the protocol has one
honest way to say “start over.”
Events
Section titled “Events”{ "type": "event", "seq": 4822, "event": { "type": "message.created", "message": { … } } }Event types include message.created, message.updated, message.deleted,
reaction.added, reaction.removed, channel.created, channel.updated,
channel.archived, member.joined, member.left, workspace.updated,
workspace_member.added, workspace_member.updated,
workspace_member.removed, user.updated, and read_state.updated.
Membership is enforced on fan-out, so a socket never receives an event for a channel it cannot see.
Retention
Section titled “Retention”Events are pruned after roughly seven days. That window exists so that a laptop
closed over a long weekend resumes cleanly, while the log does not grow without
bound. Anything older resolves through full_sync_required.
Writing a client
Section titled “Writing a client”Treat any gap in seq as a reason to resume rather than something to patch
around, persist lastSeq across restarts, and reconnect with backoff and
jitter. @slick/sdk does all of this; apps/web/src/sync/engine.ts is the
reference implementation and is deliberately free of DOM references so it can
be reused.