.in/.out stream pair on a Session, separate from the reserved chat transcript. Like the transcript it is durable and cross-run, but it is addressed by a name, and writing its .in does not wake or trigger a run.
Side channels are a Session primitive, not a chat feature. Any Session can carry them: a chat.agent, a task-bound Session, or an external process holding your secret key. Use one to stream out-of-band data alongside (or instead of) a transcript: a feed of browser screenshots, progress telemetry, or a control channel the client writes to. Many clients can read the channel live while a run, or your backend, produces it.
Define the channel once
Declare the channel’s record types in one shared module withsessions.defineChannel, then import it on both the producer and the consumer so the types line up.
/trigger/channels.ts
Produce on .out from a chat.agent
Inside a chat.agent run, chat.channel(...) opens a channel on the current run’s Session. Writing .out is durable and cross-run, and wakes nothing. The client control arrives on .in.on(...) without waking a run:
/trigger/browser-agent.ts
A side channel’s
.in is subscribe-only from the run’s side (.on / .once / .peek). .wait()
is not supported on a named channel, because a side channel never suspends or wakes a run.From a task or your backend
Nothing here needs achat.agent. Open a channel on any Session by id with sessions.open(sessionId).channel(...); the handle exposes the same .out (append / pipe / writer) and .in (send / on / once / peek) surface as the reserved pair. Create the Session with sessions.start bound to any task, then produce from that task’s run:
/trigger/render-frames.ts
.out writes require:
Your backend code
Read .out in React
useSessionStreamChannel reads one side of a channel and updates a records array. Pass the channel definition as the type argument so records is typed from it. from: "latest" with maxRecords: 1 gives a live “latest frame” view with bounded memory:
app/components/Screencast.tsx
useSessionStreamChannel has the same options and return shape as useSessionStream (io, from, maxRecords, lastEventId, onRecords, onControl, throttleInMs, timeoutInSeconds), plus the typed channel generic. A bare name string works without the generic, with records typed unknown.
The client writes the .in control with a session handle: sessions.open(sessionId).channel(screenshots).in.send({ paused: true }). This appends to the channel and does not wake a run.
From MCP
An MCP client can read and write a session’s channels with two MCP tools:read_session_channel drains a channel’s records (with an optional timeoutInSeconds to wait for the next one), and write_session_channel appends a record to a channel’s .in to send control input to a running agent. Reading .out gives the producer feed (e.g. the screencast); writing .in does not wake a run, and .out stays producer-only.
Retention
A side channel’s streams are bounded by the same retention as the rest of your realtime streams: streams are created on demand when first written and age out on your plan’s retention window, with empty streams cleaned up automatically. A channel needs no separate setup or trimming.Auth
A side channel is covered by the session’s public access token: a token scoped toread:sessions:{id} / write:sessions:{id} grants every channel of that session. Mint a narrower token scoped to a single channel with read:sessions:{id}:channels:{name}. Writing a channel’s .out requires secret-key auth (only the agent run), so a browser cannot forge frames; .in is writable with the session token. See Realtime auth.
Scope tokens to the channel, not the whole session
Two properties of the session token are worth designing around when a browser only needs one channel:- A session-wide token grants every channel, including ones added later.
read:sessions:{id}reads the reserved chat transcript and all named channels. If a client should see only the screencast frames and not the chat, give itread:sessions:{id}:channels:screencastinstead. The channel-scoped token reads only that channel: it cannot read another channel or the reserved transcript. - A session write token can write the reserved
.intoo, not just a channel’s.write:sessions:{id}can send a chat message on the reserved.in, so a client meant only to send control input on one channel should holdwrite:sessions:{id}:channels:{name}, which confines it to that channel’s.in.
Mint a channel-scoped token (your backend)
A session’s
externalId cannot contain :channels:, since that is the delimiter the channel scope
uses. sessions.start rejects it. Any other string, including single colons, is fine.Next steps
Sessions
The durable, cross-run primitive side channels are built on.
Read a session channel in React
The
useSessionStream hook useSessionStreamChannel mirrors.
