Protocol
Version 1 wire frames.
WebSocket is the only required carrier in v1. SSE and long-polling are later adapters.
client -> hello | auth | sub | unsub | mutate | action | resume | ping
-> doc.open | doc.submit | doc.presence | doc.close
-> run.watch | run.unwatch
server -> welcome | snapshot | patch | stream | error | bye | pong
-> doc.reset | doc.commit | doc.reject | doc.awareness
-> run.deltadecodeFrame() validates untrusted input and rejects unknown versions,
discriminants, fields with invalid types, and unsafe sequence values. A frame
the client cannot parse is dropped rather than applied.
Completions
A mutation is answered with a stream frame carrying done: true. Its delta
holds the handler's return value as JSON, or is empty when the handler returned
nothing — which is not the same as a value being dropped in transit.
A client that ignores delta makes every mutation resolve with undefined, so
an id from an insert is reachable over HTTP and not over the connection the
application actually uses.
Error frames
An error frame may carry a requestId, a subscriptionId, or neither:
| Field | Meaning |
|---|---|
requestId | The mutation or action that failed |
code | What kind of failure, from HighXErrorCode — the part a client branches on |
subscriptionId | The subscription that failed, typically an unresumable cursor |
| neither | A connection-level failure |
The subscription form exists so a client can recover the exact subscription whose resume failed, rather than inferring it from a timeout.
Document frames
Collaborative fields do not travel the subscription path. A subscription is a query whose answer can always be recomputed; a document is an ordered stream where a client may hold the only copy of part of it.
| Frame | Direction | Carries |
|---|---|---|
doc.open | client | documentType, documentId, the revision it already holds |
doc.submit | client | one op and the base revision it was written against |
doc.presence | client | opaque awareness — a caret, an entity, a viewport |
doc.close | client | leaving, so carets stop being drawn |
doc.reset | server | the whole document, for a client with nothing usable to catch up from |
doc.commit | server | a sequenced op, its revision and actor, and the document's hash |
doc.reject | server | an op refused as malformed, unauthorised, or against the rules |
doc.awareness | server | someone else's awareness; null data means they left |
Only doc.open names a type. Everything after it is looked up from the open
the server recorded, so a client cannot name one document's type while writing
to another, and writing to something it never opened is refused.
A commit goes to everyone including its sender — that echo is how the sender learns where its op landed after rebasing. Awareness goes to everyone except its sender.
Ops replayed as catch-up carry an empty hash: the log records ops, not what
the document was at each revision. Do not compare an empty hash, or every
reconnection reports divergence and the one alarm that catches real corruption
becomes noise. The next live commit carries a real one.
Sequences
Each subscription has its own monotonic sequence within its owning Durable Object. There is no global sequence across objects, so a client maintains one cursor per subscription rather than a single global one.
Versioning
Frames are versioned in @highx/protocol, separately from the packages. A
deployed server and a browser that loaded an hour ago will not always agree on
which release they are running, so additive changes need a default and removals
need a version bump. There is no "everyone will refresh" assumption available.
Run output
A run's state travels as ordinary subscription frames — workflowRuns is a
tenant table like any other. These carry the part that is not durable.
| Frame | Direction | Carries |
|---|---|---|
run.watch | client | the run whose output it wants, gated on the permission to start runs |
run.unwatch | client | giving up interest; always accepted, since refusing only keeps a dead watcher |
run.delta | server | one fragment of a run's model output, with its turn and optional stable deltaId |
Ephemeral by construction, like doc.presence: never stored, never replayed,
and dropped when nobody is watching. A replayed turn produces none at all,
because no model call is made. Clients treat missing deltas as normal rather
than as a stalled run, and use deltaId to ignore duplicate delivery without
dropping legitimate repeated text fragments.