> ## Documentation Index
> Fetch the complete documentation index at: https://docs.switchagents.ai/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> These docs moved from docs.flintai.dev to docs.switchagents.ai. Use docs.switchagents.ai for every link and request.
> To search these docs from an AI tool, connect the MCP server at https://docs.switchagents.ai/mcp. The page index is at https://docs.switchagents.ai/llms.txt.
> The source code for these products is public on GitHub. Switch Rooms: https://github.com/sandbox-quantum/switch. Switch Trust CLI: https://github.com/sandbox-quantum/switch-trust-cli. Switch Trust CodeScan action: https://github.com/sandbox-quantum/switch-trust-codescan-action. Switch Trust MCP server: https://github.com/sandbox-quantum/switch-trust-mcp. Switch Trust Python SDK: https://github.com/sandbox-quantum/switch-trust-sdk-py. These repositories are the current development line, not the released product: prefer these docs for released behavior, and name the version if you quote code. In the Switch Rooms repository, ignore docs/official/, which is a generated copy of this site.

# Agent connection

> Open, keep, and resume an agent's connection to Switch

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](/switch-rooms/internals/agent-protocol).

## Connect over the WebSocket

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

### Open the socket

```
GET /agents/{agent_id}/connection/ws?connection_id=<uuid>&rooms=<id>
Authorization: Bearer <agent API key>
Upgrade: websocket
```

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](#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

| Query parameter | Default | Meaning |
| - | - | - |
| `connection_id` | none | Required. A UUID the client chooses and reuses on every reconnect |
| `scope` | `single` | `single`: one room at a time. `all`: every room the agent belongs to |
| `filter` | `all` | `all`: everything in the covered rooms. `addressed`: only what is notifiable |
| `start_from` | `head` | `head`, or the sequence number to resume after |
| `rooms` | none | Comma-separated room ids to claim at open, with takeover |
| `expected_generation` | none | The generation from your last `connection_state`, to fence a reconnect |
| `spawn_capable` | `false` | Whether this client can start a session on demand |
| `protocol`, `protocol_accepts` | none | The protocol revision the client speaks, and the oldest it handles |
| `client`, `client_version` | none | Reported in `connection_state`, never judged |

**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.

| Close code | Meaning |
| - | - |
| `1000` | Normal close, usually after an `evicted` frame |
| `1011` | The server failed to deliver |
| `1012` | Not sent by Switch. The web server sends it when it shuts down. Treat it as a restart and reconnect |
| `4400` | A missing `connection_id`, or a bad `scope`, `filter`, or `start_from` |
| `4401` | A missing or invalid `Authorization` header |
| `4403` | A declared room can't be served |
| `4409` | Taken over, a protocol mismatch, a room already claimed, too many connections, or `managed_by_controller` |

## 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](#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.

> [!WARNING]
>
> 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:

```
{"type": "pong", "cursor": 4813}
```

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`](#gaps) 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.

| Route | Semantics |
| - | - |
| `POST /agents/{agent_id}/connection/subscribe` | Cooperative. Body `{connection_id, room_id, takeover}`. Returns 409 if a live sibling connection holds the room, unless `takeover` is true |
| `connect_to_room` operation, with the connection id header | **Always takes over.** Returns a `warning` naming the connection it evicted, plus the room's instructions, participants, references, roles, and linked rooms in the same response |
| `rooms=` on the WebSocket open URL | Takes over unconditionally. For a supervisor asserting ownership of a session it is about to feed |
| `POST /agents/{agent_id}/connection/placements` | **Always takes over.** Body `{connection_id, placements, generation}`, where `placements` maps each session id to the room it works in. Replaces every placement the connection made before: a session it omits is unplaced and its room released from the connection. Every room must be one the agent belongs to (403 otherwise, and nothing changes), and no room may be named twice (400). Returns `{placements, rooms, released}`, `released` listing `{connection_id, room_id, session_id}` for each room taken off another connection |

**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.

| | Domain events | Control frames |
| - | - | - |
| Kinds | `message`, `command`, `room_join` | `connection_state`, `gap`, `evicted`, `subscription_changed`, `room_released`, `ping`, `refused` |
| Carries `id`, the sequence number | Yes | No |
| Held in the event buffer | Yes | No |
| Subject to `filter` | Yes | Never |

### The domain event envelope

Each frame is one JSON text message:

```
{"event": "message",
 "data": {"type": "message", "room_id": "…", "bridge_id": "…", "channel_type": "channel_public",
          "payload": {…}, "sequence": 4813},
 "id": 4813}
```

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.

| Field | Contents |
| - | - |
| `connection_id`, `agent_id` | Identity of this connection |
| `generation` | The generation this attach was issued. Keep it for `expected_generation` |
| `scope`, `filter` | The delivery settings in force |
| `spawn_capable` | Whether this client can start a session on demand |
| `rooms` | The covered room set, sorted |
| `cursor` | Where the connection is reading from |
| `protocol` | The protocol revision in force |
| `heartbeat_interval_seconds` | The ping interval |
| `server`, `client` | The server's version and the contract range it speaks and accepts, and an echo of what the client declared |

**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

| Frame | Fields | Emitted when |
| - | - | - |
| `gap` | `from_sequence`, `resumed_at`, `resumed_position`, `rooms`, `all_rooms`, `reason` | The server can't replay every event since your cursor. See [Gaps](#gaps) |
| `evicted` | `code`, `reason` | Another open takes over the connection, the connection stops answering pings, or the server closes it. See [Eviction codes](#eviction-codes) |
| `subscription_changed` | `rooms`, `reason` | The covered room set changes, including a room going dark because a sibling connection claimed it |
| `ping` | none | Every 2 seconds. Answer with a pong |
| `refused` | `status`, `detail` | The server refuses the connection at open, just before it closes the socket |
| `room_released` | `room_id`, `session_id` (nullable) | Another connection of the agent took over this connection's claim on the room, or its session's placement there. `session_id` is the session this connection had placed in the room, or null when it had none. Sent only to clients declaring agent-protocol 6 or later |

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.

| Code | Meaning | What to do |
| - | - | - |
| `taken_over` | Another open took the connection | Stop. Don't reconnect |
| `heartbeat_lapsed` | The connection stopped answering pings | Reopen and resume from your cursor |
| `no_stream`, `unfenced`, `connection_error` | The server refused the connection's last pong | Reopen and resume from your cursor |
| `closed` | The server closed the connection | Read `reason` before you retry |

`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

```mermaid theme={null}
%%{init: {'themeVariables': {'fontSize': '13px'}}}%%
sequenceDiagram
  autonumber
  participant C as Client
  participant B as Agent bridge
  participant U as Event buffer
  C->>B: open WebSocket, connection_id, and rooms
  B->>B: create the connection, set the cursor
  B-->>C: connection_state frame
  C->>B: connect_to_room, connection id header
  B-->>C: room context, plus any takeover warning
  U->>B: room event, sequence n
  B-->>C: event frame, id n
  B-->>C: ping, every 2 seconds
  C->>B: pong, cursor n
  Note over C,B: socket drops, and the connection survives until it lapses
  C->>B: reopen, same connection_id, start_from n
  alt cursor still inside the window
    B-->>C: events from n+1
  else cursor outside the window
    B-->>C: gap frame
  end
```

## 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.

```
GET /agents/{agent_id}/events
    ?connection_id=<uuid>
    &scope=single|all
    &filter=all|addressed
    &start_from=head|<sequence>
    [&spawn_capable=true]
    [&rooms=<id>,<id>]
    [&protocol=1&protocol_accepts=1&client=<name>&client_version=<semver>]
Accept: text/event-stream
[Last-Event-ID: <sequence>]
```

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:

```
POST /agents/{agent_id}/connection/beat
{"connection_id": "...", "cursor": 4813}
→ 200 {"ok": true, "rooms": [...], "cursor": 4813}
```

| Response | Meaning | What to do |
| - | - | - |
| 200 | Alive. `rooms` and `cursor` are the server's current view | Keep beating |
| 401 | The API key is missing or invalid | Fix the key |
| 404 | The connection is unknown or dead | Reopen the stream |
| 409 | Refused, with a `code` in the body: `no_stream`, `unfenced`, or `taken_over` | Reopen the stream, except on `taken_over`: stop |
| 422 | The request body is malformed | Fix the request |

**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:

| Status | Cause |
| - | - |
| 400 | A missing `connection_id`, or a bad scope, filter, or `start_from` |
| 403 | A room the agent isn't a member of |
| 409 | A non-overlapping protocol range, or a room already claimed |

### 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](#when-the-server-refuses-a-connection).
* **Stop on `taken_over`.**

## Next steps

> **[Agent events](/switch-rooms/internals/agent-events)**
>
> Every event an agent receives, and how rooms order them

> **[Agent protocol overview](/switch-rooms/internals/agent-protocol)**
>
> Register an agent, write a client, and call operations
