16352df366
## Summary Adds **named side channels** to a Session: durable, two-way realtime streams that outlive a single run and are shared across every run of the session. Today a Session has exactly one reserved `.in`/`.out` pair (the chat transcript). This lets a session hold any number of *named* channels alongside it, each its own `.in`/`.out` pair, so an agent can stream out-of-band data (a feed of frames, telemetry, a control channel) on a stream separate from the transcript while many clients read it live. The two properties a named channel adds over the reserved pair: 1. It is addressed by a name that outlives a run and is shared across runs, not welded to the chat turn loop. 2. Writing its `.in` does **not** wake or trigger a run. A run observes it by subscribing; an external client writes it without spawning anything. This is the generalization half of the Momentic ask (stream browser screenshots from a `chat.agent` to the frontend on a channel separate from the chat). It builds directly on the start-from-latest / `useSessionStream` subscribe seam from #4811. ## Usage Declare the channel's record types once and infer them on both sides: ```ts // channels.ts (shared, client imports it type-only) import { sessions } from "@trigger.dev/sdk"; export const screenshots = sessions.defineChannel<{ out: ScreenshotFrame; in: ViewportControl }>( "screenshots" ); ``` Open a channel from a session handle (`sessions.open(id)` returns one for a known session id). Writing its `.out` is durable, cross-run, and wakes nothing; a run observes its `.in` by tailing, without suspending: ```ts import { sessions } from "@trigger.dev/sdk"; import { screenshots } from "./channels"; const channel = sessions.open(sessionId).channel(screenshots); await channel.out.append(frame); // frame: ScreenshotFrame (typed from the definition) channel.in.on((control) => { /* ... */ }); // control: ViewportControl, tail, no suspend ``` Passing the definition types `.out.append` / `.in.on` on the producer side; a bare name string also works, with records typed `unknown`. An external client writes the `.in` without waking a run, and reads the `.out` from React: ```ts sessions.open(sessionId).channel("screenshots").in.send({ paused: true }); const { records } = useSessionStreamChannel<typeof screenshots>("screenshots", { sessionId, accessToken, io: "out", from: "latest", maxRecords: 1, }); ``` `session.channel(name)` returns the same `{ in, out }` handle shape as the reserved pair, so `append` / `pipe` / `writer` / `read` / `writeControl` / `trimTo` on `.out` and `send` / `on` / `once` / `peek` on `.in` all carry over. Passing a name other than the declared one is a type error; a bare-string call without the generic stays valid with `records` typed `unknown`. ### With `chat.agent` This is the motivating case: a `chat.agent` answers on the reserved transcript as usual, and streams screenshot frames on a side channel in parallel. `chat.channel(name)` opens a channel on the current run's own Session, so there's no id to thread: ```ts import { chat } from "@trigger.dev/sdk/ai"; import { streamText } from "ai"; import { screenshots } from "./channels"; export const browserAgent = chat.agent({ id: "browser-agent", run: async ({ messages, signal }) => { const frames = chat.channel(screenshots); // client pause/resume arrives here without waking a turn frames.in.on((control: ViewportControl) => applyViewport(control)); // frames stream on their own channel, not the chat transcript driveBrowser({ signal, onFrame: (frame) => frames.out.append(frame) }); // the assistant reply still goes to the reserved transcript return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }); }, }); ``` `chat.channel(name)` is a shortcut for `chat.session().channel(name)`; `chat.session()` returns the current run's full `SessionHandle` if you need it. The frontend renders the transcript with `useChat` as before, and the screenshots with `useSessionStreamChannel<typeof screenshots>("screenshots", { sessionId: chatId, io: "out", from: "latest", maxRecords: 1 })`: a live view of the newest frame that survives across turns (each turn is a new run), because the channel is keyed on the session, not the run. ### From MCP An MCP client can observe and write a session's channels with two tools, built on the same apiClient surface as the hook and the dashboard viewer: - `read_session_channel` reads records from a channel (or the reserved pair). It is a point-in-time drain with cursor pagination (`afterEventId` / `nextCursor`, `maxRecords`); pass `timeoutInSeconds` to wait for the next record when none exist yet. - `write_session_channel` appends one record to a channel's `.in` (an object or a raw string), so an agent can send control input without waking a run. `.out` is producer-only, so it is not writable here. ### On the session page The session detail page lists a session's channels (via an S2 prefix list in the loader) and shows each as a tab beside `Rendered` and `Raw`. Selecting a channel renders its records in the same table as the Raw transcript view, sourced from that channel's `out` and `in` streams. ## How it works **Addressing.** A channel is a stream name segment: `sessions/{id}/channels/{name}/{io}`. The reserved pair keeps its two-part `sessions/{id}/{io}` name for back-compat, and the `channels/` segment means a user channel named `in`/`out` can never collide with it. The channel dimension is threaded through the session stream manager (keyed on `(session, channel, io)`, reserved = absent), `subscribeToSessionStream`, the session apiClient methods, and the `realtime.v1.sessions.$session.channels.$channel.$io.{ts,append,records}` routes. The reserved-pair routes are untouched. The start-from-latest tail path from #4811 is channel-agnostic, so `from: "latest"` and `maxRecords` compose unchanged. **No-wake.** The reserved `.in` append route ensures a run and drains waitpoints so a chat turn advances. The channel `.in` append route deliberately does neither: the record lands durably and a run picks it up when it next subscribes, so writing a side channel can't spawn or resume a run. A named channel's `.in` is therefore subscribe-only from the run side (`.on` / `.once` / `.peek`); `.wait()` / `waitWithIdleTimeout()` throw with a message pointing at the observe methods. **Auth.** Channel scope folds into the existing resource id (`sessions:<key>:channels:<channel>`), so no RBAC grammar change. A channel route authorizes both the channel-folded id and the bare session id, which means a session-wide token grants every channel while a channel-scoped token grants only its own. The per-io rule is preserved per channel: writing `.out` requires secret-key auth so a browser can't forge frames; `.in` is writable with the session token. **Retention.** Channel streams are created on demand on first write and inherit the org's stream retention (bounded age plus delete-on-empty from the store's default config), the same as the reserved chat streams. There is no per-channel control-plane call on the write path. Custom per-channel retention is deferred until the stream store can set config inline on the on-demand create, which avoids a control-plane round trip. **Spans.** Channel writes carry `channel` and `io` attributes, an accessory chip, and the session icon. Clicking a channel span in the run's span inspector renders the channel's actual records with the same viewer the run realtime streams use, rather than the raw properties JSON. ## Verification - **Unit (core):** the stream manager isolates channels: two channels on the same `(session, io)` never cross buffers, and a named channel is isolated from the reserved pair. - **Full-stack e2e** against a real stack (webapp, stream store, Postgres, real runs): - a named `.out` record is readable back **after the triggering run has gone terminal** (durable, cross-run); - a channel `.in` append creates **no** run, while a reserved `.in` append **does** wake one (the differential is the red/green); - `from: "latest"` on a named channel delivers the live record and does **not** replay the backlog from the start; - the span inspector renders a channel span's records, and the MCP read/write tools round-trip records on a real session; - an invalid channel name is rejected. ## Notes - **Channel listing works on the self-hosted store too.** The stream store's list operation is available on s2-lite, so the session page's channel list is an OSS feature. It is a control-plane call made once per session-page load (best-effort; a failure just hides the tabs), not on the write path. - **The ~1 MiB per-record cap is unchanged.** Large payloads (e.g. raw screenshots) still need object-store pointers on the channel rather than inline bytes; that's independent of this change. - Docs ride this branch: the side channels guide, the `useSessionStreamChannel` reference, and the MCP tools list are all updated here. ## Screenshots <img width="3444" height="1870" alt="CleanShot 2026-08-28 at 21 46 27@2x" src="https://github.com/user-attachments/assets/c192aaee-b946-4824-87b7-ca057514d25e" />
Development
Install and initial setup
pnpm install
Running the app
pnpm run dev --filter docs
View the app locally
It runs locally here:
http://localhost:3050