The Terret Socket Protocol (v1)
The v1 interface to a Terret agent is one WebSocket per agent (plan §9). This
document is the wire contract for terret-ws. The protocol invents no session
vocabulary: everything the server sends about a session IS the durable session
log, event by event. If it isn’t in the log, it doesn’t reach a client.
Connection
- URL:
GET /agents/{agent_id}/ws(WebSocket upgrade). - Auth:
Authorization: Bearer <token>, checked before the agent is resolved. Tokens are scoped per agent id; a token for one agent cannot open another’s stream. An unauthorized connection receives oneerrorframe with codeunauthorizedand is closed. Revocation reaches connections already open: rotating a token drops every live connection that presented the old one, with the sameunauthorizedframe it would have got at the door. - In v1 the agent id names the session: connecting to
{agent_id}resumes the session with that id, or creates it on first connect. - One connection per agent. A second authorized connection for the same agent
supersedes the first, which receives
errorcodesupersededand is closed. (A half-open connection after a network blip must not block reconnection.) - Heartbeat: the server sends WebSocket ping frames on an interval (default 20s, configurable) so load-balancer idle timeouts do not reap healthy connections. Clients need not act; WebSocket libraries answer pongs.
All frames in both directions are JSON text frames, one object per frame.
Server → client
Two frame families, distinguished by the seq key: session events have it,
protocol frames never do.
Session events are the durable envelope serialized as-is:
{"id":"5f2c...","session_id":"s1","seq":7,"at":"2026-08-18T04:10:11.123456Z",
"type":"assistant/chunk","payload":{"text":"22C"}}
seq is a per-session monotonic integer starting at 0, gapless. at is UTC
ISO8601 with microseconds. Event types and payloads are the durable set in
docs/events.md; new durable events flow through automatically.
Protocol frames:
{"type":"hello","proto":1,"session_id":"s1","last_seq":41}is sent once on connect, before anything else.last_seqis the highest seq in the log at that moment (session/createdguarantees at least 0). Nothing streams until the client subscribes.{"type":"replay_truncated","requested_from_seq":0,"from_seq":5001}is sent when asubscribereached further back than the server’sreplay_limit(below).requested_from_seqis what the client asked for;from_seqis the seq its replay begins at. The client is missing everything in[requested_from_seq, from_seq)and must not treat this stream as holding it. Sent before that window’s first event, so a client that sees it knows its first replayed event is not the one it asked for.{"type":"error","code":"...","message":"..."}, wherecodeis one of:unauthorized: bad or missing token; connection closes.superseded: a newer connection took over this agent; connection closes.lagged: the client read too slowly and its outbound queue overflowed; connection closes. Resubscribe from your last durable seq.bad_frame: unparseable or invalid client frame; connection stays open.not_running:cancelarrived while no turn was running; stays open.stale_call:approve/denynamed a call with no standing approval request (already resolved, never requested, or a typo); nothing is appended and the connection stays open.unsupported: the frame needs a plugin this deployment does not mount (approve/denywithout the approvals row); stays open.internal: the server hit an unexpected error serving this connection; connection closes. Resubscribe from your last durable seq.
Client → server
The closed set from plan §9.2. Anything else is answered with bad_frame.
Frames larger than 1 MiB are rejected as bad_frame.
| Frame | Fields | Lands on |
|---|---|---|
subscribe |
from_seq (int ≥ 0, required) |
sessions.read(sid, from_seq:), then live tail |
inject |
text (required), wake (bool, default false) |
agent.inject / the loop |
cancel |
reason (optional) |
agent.cancel, plus durable denials when parked |
approve |
call_id (required) |
durable approval/resolved (validated) |
deny |
call_id (required), reason (optional) |
durable approval/resolved (validated) |
set_model |
role (required), model (“provider/model”, required) |
the live model-role table |
set_policy |
patterns (array of strings, required) |
durable policy/updated (§6.3 AllowList) |
subscribe — replay-then-tail
from_seq is the first seq the client wants, inclusive. A fresh client
sends 0. A reconnecting client sends <highest seq it has durably recorded> + 1.
The server replays the log from from_seq and then tails live dispatch, with
no gap and no duplicate: the guarantee is exact, not best-effort, because seq
is gapless and the log is append-only. Subscribing again replaces the
previous subscription (the tail is re-established from the new from_seq).
Resubscribing mid-stream replaces the subscription server-side, but frames
from the replaced subscription that were already queued or in flight may
still arrive before the new replay’s first event. This is inherent to a
full-duplex transport, not a server defect. A client that resubscribes
mid-stream should discard incoming events until it sees seq == from_seq
(the first event of its new replay).
Replay is capped. A single subscribe never triggers an unbounded
history read. The server bounds one reconnect’s replay at replay_limit
events (config, default 10000): a from_seq reaching further back than that
many events behind the tip is pulled forward to the newest replay_limit
window, and the server sends a replay_truncated frame (above) naming the seq
the replay starts at before that window’s first event. The
replayed window is still gapless and duplicate-free and tails live with no
gap. The cap moves only where the window starts, and that move is always
signaled, never silent. A client that needs the skipped history must read it
from a durable store out of band; the socket will not resend it. A
reconnecting client that has been away a long time should expect its
subscribe to be capped. It cannot recover the whole log over the wire.
Concurrent replays are capped. Across all connections the server runs at
most max_concurrent_replays replay reads at once (config, default 4). A
reconnect storm (the predictable failure mode after a deploy) therefore does
not become N simultaneous log reads; surplus connections wait their turn for
a replay slot (the connection is held open, not rejected) and proceed as
slots free. Only the log read is gated, not the live tail, so a slow client
draining its replay never holds a slot away from another reconnect. Combined
with jittered client backoff, this keeps a thundering herd off the store.
inject
wake: true on an idle agent starts a turn with text as its input. On a
busy agent (or with wake: false) the text is queued in the agent’s inbox
and rides into the next step of the current or next turn. That is the
mid-turn steer. Injection is acknowledged by the log itself, and the event
type records which of the two it was: the waking text that starts a turn
lands as that turn’s own durable user/message, while a steer drained from
the inbox lands as durable context/injected. Both project into model
history as user messages; the distinction is provenance, kept because the
log is the record of what happened.
If the log holds an open turn (a turn/start with no turn/end after
it, because the process died mid-turn or was deployed over), wake: true on
an idle agent resumes that turn: no second turn/start is appended, the
tool calls the open step still owes are executed, and the wake text rides
the resumed turn’s next step as context/injected. Any stimulus resumes; an
approval verdict is not required.
No wake is ever dropped. Two wake: true frames arriving in one read burst
can both find the agent idle before either turn starts; the loser’s turn
refuses, and its text is requeued into the inbox to ride the winner’s next
step. A client therefore never has to detect or retry a lost wake.
cancel
Requests a cooperative stop of the running turn. The loop honors it at step
boundaries: a cancel that races a tool result loses the race to the log
entry but wins the turn: the tool/result is recorded, then the turn closes
with turn/end {status: "cancelled", reason: ...}. A cancel with no turn
running is answered not_running.
A cancel observed part-way through a step’s tool batch truncates the rest of
that batch: every remaining call in it still logs its tool/call and a
tool/result carrying the error cancelled before execution, so nothing runs
but the projection never holds a call without a result.
The M8 tool barrier moves where that truncation can land without changing
the guarantee. Calls declared concurrency: :parallel execute in maximal
runs under one barrier (docs/subagents.md §5), and a run is not interruptible
from outside once it starts. A cancel is therefore observed between runs
rather than between individual calls, and a batch cancelled mid-run produces
fewer cancelled before execution results than the same batch would have
before the barrier existed. Every call in the batch still ends with a
tool/result either way.
turn/end’s status is one of completed, cancelled, rejected, empty,
or failed (see docs/lifecycle.md, “The status machine”). A failed resume
is the one case that logs no turn/end at all: it leaves the turn open so the
next stimulus can pick it up.
A turn parked on an approval also cancels: the turn is marked cancelled first,
then every standing request for the session is denied durably (one
approval/resolved {verdict: "denied", reason:} each) so the parked call
unparks into a turn that already knows it is stopping. The reason carries
through to both the denials and turn/end.
approve / deny
Resolves a parked tool call. Approvals are an opt-in plugin row: where it
is not mounted, nothing ever parks and both frames answer unsupported.
Where it is mounted, a tool whose definition demands a decision parks inside
the tools pipeline and the server appends durable approval/requested
{call_id:, name:, args:}. A verdict appends durable approval/resolved with
{call_id:, verdict: "approved"|"denied", reason?:}, which unparks the call:
approved runs it, denied returns an error result to the model. call_id is
the id of the corresponding tool/call event; it is named call_id rather
than id because inside an approval payload a bare id would read as the
approval’s own identifier, not the call it references.
Verdicts are validated against the log: only a call_id with a standing
request and no verdict yet is accepted. Anything else (an already-resolved
call, a call that never asked, a typo) answers stale_call and appends
nothing, so a double approve cannot pollute the log. “Standing” means within
the open turn: a request whose turn has since closed is settled, and a
verdict arriving for it answers stale_call too. Provider tool call ids are
not contractually unique, so a decision never carries across a turn boundary.
Both request and verdict are durable, and that is what survives a process death. If the server restarted while a call was parked, no fiber is waiting when the verdict lands; the server sees an idle agent with an open turn in the log and resumes the turn, which re-executes the owed call and finds the recorded verdict instead of parking again (see docs/lifecycle.md, “Durable approvals” and “Resuming an open turn”).
set_model
Repoints a model role (main, titler, …) at a provider/model spec on
the live service. Takes effect at the next step. Invalid specs get bad_frame.
set_policy
Replaces the agent’s active tool allow list with patterns (a list of
File.fnmatch globs). Appends durable policy/updated {patterns}: the last
one appended wins, it is effective on the very next tool call with no
reinstall, and it survives a restart because replay rebuilds it (see
docs/lifecycle.md, “Hot-reloadable permissions”). patterns must be an
array of strings; anything else gets bad_frame and nothing is appended. It
is bounded at 128 patterns of at most 256 characters each (the set is
durable and every later tool call scans it), and a frame over either bound
gets bad_frame with nothing appended.
Liveness
The agent’s life is independent of the socket. A dropped connection never
cancels a turn: work continues, events accumulate in the log, and a
reconnecting client catches up via subscribe. Only an explicit cancel
frame stops a turn.
Backpressure
Outbound events go through a bounded per-connection queue. Session dispatch
never blocks on a slow socket: when the queue overflows, the server drops
the client with lagged to keep the loop from stalling. Reconnect-then-replay
and snapshot-then-tail are the same mechanism, so recovery is one
subscribe. Replay is flow-controlled: the server waits for the client
while replaying history, so a long log never looks like a slow reader. Only
the live tail is drop-eligible.
Versioning
hello.proto is 1. Additive changes (new durable event types, new optional
fields) do not bump it; changes to frame semantics do.