§4 · Ten messages, five operations, no optional fields¶
The protocol uses JSON over a WebSocket connection, one message per frame, and
every message must carry a type field. There are no optional fields and no
optional features for devices to negotiate over. The only room for extension is
the messages' param field. This ensures that every implementation supports the
protocol in exactly the same form, and that none can hide behind a partial one.
| Message | Direction | Fields | Meaning |
|---|---|---|---|
ct.hello |
both | v |
Handshake and version check |
ct.ping |
client → | t0 |
Opens a latency measurement |
ct.pong |
→ client | t0, t1 |
Answers a latency measurement |
ct.session |
→ client | id, anchorEpochMs, bpm, beatsPerBar, ppq |
The anchor and the tempo for the session |
ct.load |
→ client | id, material, lengthTicks |
Which material to fetch, and how long it runs |
ct.ready |
client → | ref, readyAtTick |
The material is loaded and playable |
ct.refuse |
client → | ref, reason |
The material was not loaded, and why |
ct.cmd |
→ client | id, atTick, slot, op, value, rampTicks |
One playback or control command |
ct.snapshot |
→ client | atTick, cmds[] |
Current state: the commands in force |
ct.state |
client → | offsetMs, minRttMs, jitterMs, minLeadTicks, late[], degraded |
Client telemetry and health |
The five operations¶
The op field of ct.cmd takes these five values. The core operations
guarantee interoperability; param is the only extension point, and its
namespace belongs to the application.
op |
What it does | value |
rampTicks |
|---|---|---|---|
start |
slot begins playing its material | — | yes |
stop |
slot stops playing | — | yes |
gain |
slot level | decibels | yes |
param |
application-defined parameter | name + value | yes |
material |
assigns material to the slot | identifier | no |
material is an instantaneous assignment and takes no ramp. Note that
ct.load does not assign material to a slot — it is a content-addressed
fetch with no slot, and assignment is slot state and therefore a command.
The material model is deliberately thin: a session has numbered slots, and a slot is either empty or holds material. The protocol does not know what material is musically — no roles, no instruments, no genre.
A material identifier must be of the form sha256:<hex> in lower case. A
client must accept and retain a param name it does not recognise; the
prefix ct. is reserved for future versions of the protocol and an application
must not use it.
See Examples for a complete message flow and for what a snapshot looks like when two commands compete for one slot.
Two rules that make the control plane replayable¶
N3 · commands are never answered (a late command is always executed)
Commands sent by the server (ct.cmd) are not answered separately. If a
command's execution instant (atTick) has already passed because of network
delay, the client executes it anyway.
The command's effect is computed mathematically as though it had begun at
exactly the right instant (atTick), and playback is "wound forward" to the
right point. Any lateness is reported in the background through telemetry
(ct.state), but a command is never discarded merely for being late.
N8 · state monotonicity (independence of order)
If several commands arrive for a given channel (slot) and parameter, the
one with the highest atTick — the latest in the timeline — always wins.
Because of this, handling commands is entirely independent of order and immune to a stuttering network: even if two different client devices receive the same commands in completely different orders, they end in exactly the same state.
Nor is a snapshot a separate, complicated data structure. It is simply a
collection of the commands currently in force, each of which has kept its
original atTick timestamp.
H39 · how many answers does a ct.load get?
§4's table gives ct.load a Response column and N3 says a client
answers with ct.refuse — singular in both places. But the
specification never says how many answers a load gets. The strings
"exactly one", "one response" and "at most one" appear nowhere in it.
That is the gap that let a retry loop send twenty refusals a second for a
single ct.load, while conforming to every sentence that exists.
And the moment a client is obliged to keep trying (H38), a second question
follows that has never been asked: a client that refused may later
succeed. Is the refusal final for that ref, so the server issues a new
ct.load, or may the client send a late ct.ready? Ratified
18 September 2026: a load is answered exactly once, and a refusal is
final for that ref.