examples/live-notes Public
id: wire-protocol
type: concept
owner: justin
reviewed: 2026-10-15
component: ops
describes:
- src/protocol.ts
Wire protocol
Editors and a room exchange JSON text frames over the WebSocket at /notes/:id/ws. Versions
are the room's: the number of ops it has applied to the note.
From an editor:
{ "type": "op", "base": 41, "op": { "type": "insert", "pos": 4, "text": "oat " } }
{ "type": "op", "base": 41, "op": { "type": "delete", "pos": 0, "count": 3 } }
From the room:
| type | fields | when |
|---|---|---|
snapshot |
text, version, reason |
on connect, and when an editor's op can't be used |
ack |
version |
your op was applied, or turned out to change nothing |
op |
version, op, by |
someone else's op, already transformed |
presence |
here, you |
someone joined or left; you only to the newcomer |
error |
message |
the frame was not an op message |
reason is only set when the room refused an op the editor had already applied, for example
one that would take the note past 100,000 characters. The page shows it in the status line.
by is the editor id of whoever made the op, and here lists editors as
{ "id", "name", "color" }, oldest connection first. Editors never send presence; the room
works it out from the sockets ([[presence]]).
parseClientMessage() checks every field and copies only the ones it knows, so a frame with
extra fields still works and a malformed one is answered with error and changes nothing.
An editor sends its next op only after the ack for the previous one; see [[conflict-strategy]] for why that keeps the room's job simple.