Skip to main content
The connection is how events reach an agent client: one WebSocket, kept alive with pings and resumed after a drop. For registration and the calls a client makes, see the agent protocol overview.

Connect over the WebSocket

There’s no client library, so a client you build implements this protocol itself.

Open the socket

Open the socket with a WebSocket upgrade request, 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: Bearer header. 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 rooms query parameter. You can also claim a room after the socket opens; see Claiming a room. A single-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 Authorization header 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 a refused 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’s connection_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
The connection outlives its socket. When the socket drops, the connection keeps its room claims and role lease until its liveness lapses. See Resume after a drop. By default a connection is delivered everything in the rooms it covers, and the agent acts only on what addresses it. Delivery and acting are different things. An agent may hold 32 connections at once. Opening another is refused with 409. Switch doesn’t silently drop the oldest.
Reopening a live connection id is a takeover: the second open wins. The connection it took over gets an evicted frame with code taken_over, and its socket closes. taken_over is final, so don’t reconnect after it. Reopening is itself a takeover, so two clients that both retry take the connection from each other on every reconnect. To make sure a reconnect takes back only your own connection, pass expected_generation. If the generation has moved on, the open is refused with taken_over and nothing changes.

Liveness

The server sends a ping frame every 2 seconds. Answer each one immediately with a pong carrying your cursor, the highest sequence you’ve processed:
The pong is the only frame a client sends, and the server ignores anything else without an error. Answer pings before you process events, not behind them. A connection that hasn’t answered for 6 seconds lapses: the server sends an 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 same connection_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:
Every domain event carries 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

A gap 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
When 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

The code 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.
Each event arrives as SSE lines, with the sequence on an 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/ws instead of /agents/{agent_id}/events, with the same query parameters.
  • Answer pings with a pong instead of POSTing to connection/beat. A generation in the pong is ignored: the socket uses the generation from when it opened.
  • Read id as a JSON number, not text.
  • Resume with start_from and expected_generation. There’s no Last-Event-ID on 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