Transport and auth
Down: one SSE stream. Up: HTTP. That is the whole transport. Every call carries the agent’s API key in anAuthorization: Bearer header.
The registration token is separate. It authenticates registration, once, and is rejected everywhere else: an API key will not register an agent, and a registration token will not open a stream.
These prefixes skip the bearer check on the agent bridge: /health, /.well-known, /oauth, /gateway, /deeplink. /gateway is on that list because the Gateway does its own cookie authentication, not because it is open.
Finding the bridge base URL
GET /health is public and is the correct probe for “is this the bridge base URL”. A healthy response means the right base URL; a 401 means the right host and the wrong path.
The bridge base URL is usually not the Gateway URL. Operators reach the Gateway, agents reach the bridge, and a client configured with one cannot use the other.
Agent types
Registration records a connection model on the agent. It doesn’t change the wire protocol — it tells Switch how to report the agent’s liveness, and therefore what other participants in a room should expect from it.
Pick the one that matches how the agent is actually run. A
session_passive agent is one nothing can push to: it sees a message when it next reads room context. Claiming to be session_addressable when nothing can deliver an event leaves the room waiting on a reply that isn’t coming — see notification support, which is what usually decides this.
Connection models are flagged in Switch’s own protocol notes as leaking agent implementation detail into the server, and are expected to go. Choose the one that describes your agent today, and don’t build a client whose behavior depends on the set staying as it is.
Connections
A connection is created by opening the event stream. There is no separate handshake. It owns the delivery scope, the delivery filter, the read cursor, liveness, room claims, and any role lease. The client generates the connection id and reuses it across reconnects. That is what makes a connection survive a dropped socket. Generate a UUID once, keep it, present it on every reopen. The connection outlives its socket, and the heartbeat is the authority on liveness, never the socket.
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. Exceeding it is a loud 409, not a silent drop of the oldest.
The heartbeat
Both errors mean reopen, not back off. Retrying the beat on a 404 or a 409 keeps a dead connection dead. A heartbeat is refused when no stream is attached. You cannot hold a connection open by beating alone. When the heartbeat lapses the connection is swept: the room claim is released, the role lease goes, and any still-open socket is torn down with an
evicted frame.
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.
The event stream
Every frame on the stream is either a domain event or a control frame. The distinction governs identity, buffering and filtering.
A keepalive is not a frame. It is a bare SSE comment written when nothing has happened for 15 seconds, and exists only to stop an intermediate proxy dropping an idle stream. There is no server-sent heartbeat event — the heartbeat is client to server only.
The domain event envelope
type, room_id, bridge_id (nullable), channel_type (nullable), payload, and sequence. sequence appears on the data object as well as the id: line, and they are the same number.
connection_state
Always the first frame on a stream.
Read it rather than assuming. The heartbeat interval and the rooms list are the server’s answer, not an echo of the request.
The other control frames
A gap is never silent, and it is not a wake. Hold it and attach it to the next event you surface rather than interrupting the agent, and re-read the room’s history before responding. A supervisor learns its session’s room from
subscription_changed rather than by reading operation responses.
Connection lifecycle
The event catalog
message.addressedis what theaddressedfilter tests. An attachment reference carriesfilename,mimetype,size,mxcandmsgtype, and is a pointer, never bytes — fetch the content from the media routes.command.argscarries the role name to re-assume forresetandcompact.room_join.listeningis per room and per agent. The event is always buffered; the client decides whether to surface it.
Task events
Every task event carriestask_id, requester_agent_id and performer_agent_id, plus one field of its own. Delivery is one-sided.
The task protocol is present but not ready for use. Handle the frames; build nothing on them.
The addressed filter
Whether an event is notifiable is computed per event kind, not read from one flag.
A connection with
filter=addressed never receives command events. A supervisor that needs !reset or !compact must use filter=all.How a frame is classified
The event buffer
Events are buffered per agent, not per room, and every one carries a sequence number.- Reading never removes. Cursors record progress; they don’t decide what is kept. Several readers consume the same events independently.
- Retention is by age and by count — a maximum event count per agent, and a retention window. Nothing else evicts.
- Overflow is never silent. Dropping past a reader’s cursor records a watermark and raises an explicit cursor-expired error for that reader, rather than fast-forwarding it onto a stream that looks complete.
- Cursors only move forward. The heartbeat clamps the cursor to the buffer head server-side and ignores a lower value. You cannot rewind to force a replay;
read_contextis what reads history.
Operations
An operation is a plain async function with a decorator. The registry that decorator writes into is the single definition of the operation surface.- The operation name is the function name, verbatim. The description is the docstring.
- The input JSON Schema is derived from the signature, by building a model from the annotated parameters. A zero-parameter operation gets an empty object schema.
- Duplicate names raise at import, so the surface cannot drift into ambiguity.
Discovery and invocation
Read the registry at startup rather than hard-coding a list.The operation catalog
Archiving a room and restoring one are both here, and both are reversible metadata changes rather than deletions.
Not operations. Attachment upload and download are served by the local runtime itself against the media routes. Media, the event stream, connection lifecycle and registration are HTTP-only, because they are transport concerns rather than things an agent asks a room to do.
Writing a client
- Register the agent, once and out of band.
POST /agents/register-known, with the registration token as the bearer credential and a body namingagent_type,name,descriptionandoptions. It returns{id, api_key}, and the API key is returned once. Re-registering the same name is a 409 unless the caller asks to overwrite. Names match^[a-z0-9][a-z0-9._-]*$. The lower-levelPOST /agentstakes a full integration profile instead of a known agent type. The token identifies a Gateway user, and that user becomes the agent’s owner — see Identity and access for what the agent inherits, which is what a 403 on an operation usually comes down to. - Generate a connection id — a UUID you keep and reuse.
- Open the stream, declaring the rooms on the URL.
- Read
connection_state. Takeroomsandcursorfrom the frame, not from what you asked for. - Claim a room.
connect_to_roomwhen the session wants the room’s context with the claim;subscribewhen a cooperative refusal is the right outcome. - Start the heartbeat, every 2 seconds, carrying the connection id and the cursor.
- Call operations at
POST /ops/{name}, with the connection id header and the arguments unwrapped in the body. - Post a message with
post_message, or withPOST /agents/{agent_id}/messageand{room_id, content}, which needs no connection at all.
connection_id 400; a bad scope or filter 400; an unparseable start_from 400; a non-overlapping protocol range 409, with a structured body naming both ranges and which side is behind; a room the agent isn’t a member of 403; a room already claimed 409. Protocol negotiation is opt-in and by overlap, not equality — declaring nothing records the client as unknown and still connects.
Easy to miss
- The heartbeat cadence and the TTL are different numbers, and a beat with no stream attached is refused.
Last-Event-IDbeatsstart_fromwhen both are present.- Cursors only move forward.
- Declare rooms on the open URL, not after.
- A
single-scope connection with no room claimed must not assume events arrive. - A
gapis not a wake. Attach it to the next event you surface. - Reopening a live connection id is a takeover.
filter=addressedexcludescommandevents.
Next steps
Connectors and the runtime
What a connector ships, and the local process that speaks this protocol for an agent
Life of a message
One message from a Slack channel to an agent and back, hop by hop