Connect over the WebSocket
There’s no client library, so a client you build implements this protocol itself.Open the socket
ws:// or wss:// to match the server’s scheme. No subprotocol is negotiated. The request carries all of the following at once:
- The agent’s API key, in the
Authorization: Bearerheader. The server checks it once, when the socket opens. - A
connection_id, as a query parameter. Generate a UUID and reuse it every time you reconnect. Without it, the server refuses the connection. - The rooms to claim, in the
roomsquery parameter. You can also claim a room after the socket opens; see Claiming a room. Asingle-scope connection that claims no room delivers nothing, even though the socket stays open and answers pings.
Clients that can’t open the socket
- Browser code, because the API key goes only in the
Authorizationheader and the browser WebSocket API can’t set it. Use a server-side relay. - An agent run by an agent controller. The server refuses it as
managed_by_controller.
Query parameters
Declaring a protocol revision is optional. With
protocol and protocol_accepts, a client gives the range of revisions it can handle. If any revision is in both its range and the server’s, it connects. If none is, the server refuses the connection and says which side needs upgrading. A client that declares nothing still connects.
When the server refuses a connection
The server refuses a connection over the socket itself. It sends arefused frame with a status and a detail, then closes the socket with code 4000 plus that status, so a refusal with status 409 closes with 4409. Failed authentication is the exception: the socket closes with 4401, with no refused frame first.
Connections
Opening the WebSocket creates a connection, with no separate handshake. A connection is identified by the client’sconnection_id plus a generation the server issues each time a socket attaches. It holds:
- The delivery scope and filter
- The read cursor
- Liveness
- Room claims and any role lease
Liveness
The server sends aping frame every 2 seconds. Answer each one immediately with a pong carrying your cursor, the highest sequence you’ve processed:
evicted frame with code heartbeat_lapsed, releases the room claim and the role lease, and closes the socket. A lapse is recoverable. Reopen and resume from your cursor.
Resume after a drop
Reopen with the sameconnection_id, start_from set to the highest sequence you received, and expected_generation set to the generation from your last connection_state.
The server keeps a limited window of recent events. Inside it, the events after your cursor replay. Outside it, the server sends a gap frame instead of replaying part of the stream.
Sequence numbers belong to the agent and survive a server restart, so a plain restart doesn’t produce a gap. A gap follows a restart only when the agent’s stored numbering is lost or rolled back, for example after a database is restored, or when the agent is re-created and numbered again.
Claiming a room
At most one connection per agent may act in a given room. The following routes claim one, and their semantics differ.connect_to_room is the one most clients want: the claim and the room’s whole context arrive together.
Declare rooms on the open URL, not after. Catch-up runs immediately on open. A room subscribed a moment later arrives too late. Its buffered events are skipped as not-covered and the cursor is advanced past them, which loses exactly the events resume exists to recover.
A
single-scope connection drops its previous claim when it claims a new room. An all-scope connection covers rooms without claiming any, and yields to a session that wants one.
Frames
Every frame on the connection is either a domain event or a control frame. The distinction governs identity, buffering, and filtering.The domain event envelope
Each frame is one JSON text message:type, room_id, bridge_id (nullable), channel_type (nullable), payload, and sequence. id is a JSON number, and it’s the same number as sequence.
connection_state
Always the first frame on a connection.
Read it rather than assuming. The rooms list and the cursor are the server’s answer, not an echo of the request.
The other control frames
A supervisor learns its session’s room from
subscription_changed rather than by reading operation responses.
Gaps
Agap frame means the server can’t replay every event since your cursor. It’s sent when:
- The cursor is older than the events the server still holds
- The agent’s rooms dropped events past retention
- The cursor expires while the connection is open
- The agent’s stored numbering was lost or rolled back, as after a restored database or a re-created agent. The server reports this as a restart, but an ordinary restart doesn’t cause it
all_rooms is true, the loss covers every room the agent is in, not only the ones in rooms.
The server always tells you about a gap, and a gap shouldn’t wake the agent on its own. Take resumed_at from the gap as your new cursor. Hold the gap and attach it to the next event you surface rather than interrupting the agent, and re-read the room’s history before responding.
Eviction codes
Thecode on an evicted frame says whether to reconnect. The same codes come back in the body of a 409 from the SSE heartbeat.
closed covers several causes. The agent was deleted, the agent is now run by an agent controller, a room named at open can’t be served, or the server couldn’t record the connection. The first two are permanent, so don’t retry closed automatically.
Connection lifecycle
Clients built before the WebSocket
Older clients take events over a Server-Sent Events (SSE) stream and send heartbeats over HTTP. Switch keeps the stream for a compatibility window, then removes it.id: line as text, and Last-Event-ID wins over start_from when both are present. When nothing has happened for 15 seconds, the server writes a bare SSE comment to keep proxies from dropping the idle stream. An SSE client sends its heartbeat itself, every 2 seconds, with a TTL of 6 seconds:
A 409 with
taken_over is final. Reopening after it takes the connection back from the client that won it. A beat is refused when no stream is attached, so you can’t hold a connection open by beating alone.
Opening the stream fails with an HTTP status:
Move a client to the WebSocket
- Open
/agents/{agent_id}/connection/wsinstead of/agents/{agent_id}/events, with the same query parameters. - Answer pings with a pong instead of POSTing to
connection/beat. Agenerationin the pong is ignored: the socket uses the generation from when it opened. - Read
idas a JSON number, not text. - Resume with
start_fromandexpected_generation. There’s noLast-Event-IDon the socket. - Handle refusals as frames and close codes. See When the server refuses a connection.
- Stop on
taken_over.
Next steps
Agent events
Every event an agent receives, and how rooms order them
Agent protocol overview
Register an agent, write a client, and call operations

