Commit 16352df
authored
feat(sdk,core,webapp,react-hooks): named side channels on a Session (#4815)
## 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"
/>1 parent 6a87048 commit 16352df
35 files changed
Lines changed: 2071 additions & 162 deletions
File tree
- .changeset
- apps/webapp
- app
- components/runs/v3
- presenters/v3
- routes
- _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam
- resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam
- services/realtime
- docs
- ai-chat
- realtime/react-hooks
- packages
- cli-v3/src/mcp
- tools
- core/src/v3
- apiClient
- realtimeStreams
- sessionStreams
- react-hooks/src
- hooks
- trigger-sdk/src/v3
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
36 | 36 | | |
37 | 37 | | |
38 | 38 | | |
| 39 | + | |
39 | 40 | | |
40 | 41 | | |
41 | 42 | | |
| |||
169 | 170 | | |
170 | 171 | | |
171 | 172 | | |
| 173 | + | |
| 174 | + | |
172 | 175 | | |
173 | 176 | | |
174 | 177 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
840 | 840 | | |
841 | 841 | | |
842 | 842 | | |
| 843 | + | |
| 844 | + | |
| 845 | + | |
| 846 | + | |
| 847 | + | |
| 848 | + | |
| 849 | + | |
| 850 | + | |
| 851 | + | |
| 852 | + | |
| 853 | + | |
| 854 | + | |
| 855 | + | |
| 856 | + | |
| 857 | + | |
| 858 | + | |
| 859 | + | |
| 860 | + | |
| 861 | + | |
| 862 | + | |
| 863 | + | |
| 864 | + | |
| 865 | + | |
| 866 | + | |
| 867 | + | |
| 868 | + | |
| 869 | + | |
| 870 | + | |
| 871 | + | |
| 872 | + | |
| 873 | + | |
| 874 | + | |
| 875 | + | |
| 876 | + | |
| 877 | + | |
| 878 | + | |
| 879 | + | |
| 880 | + | |
| 881 | + | |
843 | 882 | | |
844 | 883 | | |
845 | 884 | | |
| |||
Lines changed: 101 additions & 9 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
57 | 57 | | |
58 | 58 | | |
59 | 59 | | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
60 | 68 | | |
61 | 69 | | |
62 | 70 | | |
| |||
115 | 123 | | |
116 | 124 | | |
117 | 125 | | |
118 | | - | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
119 | 150 | | |
120 | 151 | | |
121 | 152 | | |
122 | | - | |
| 153 | + | |
123 | 154 | | |
124 | 155 | | |
125 | 156 | | |
| |||
158 | 189 | | |
159 | 190 | | |
160 | 191 | | |
161 | | - | |
| 192 | + | |
162 | 193 | | |
163 | 194 | | |
164 | 195 | | |
| |||
177 | 208 | | |
178 | 209 | | |
179 | 210 | | |
180 | | - | |
| 211 | + | |
181 | 212 | | |
182 | 213 | | |
183 | 214 | | |
184 | 215 | | |
185 | 216 | | |
| 217 | + | |
| 218 | + | |
186 | 219 | | |
187 | 220 | | |
188 | 221 | | |
189 | 222 | | |
190 | 223 | | |
191 | | - | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
192 | 254 | | |
193 | 255 | | |
194 | 256 | | |
| |||
198 | 260 | | |
199 | 261 | | |
200 | 262 | | |
| 263 | + | |
201 | 264 | | |
202 | 265 | | |
203 | 266 | | |
204 | | - | |
| 267 | + | |
205 | 268 | | |
206 | 269 | | |
207 | 270 | | |
| |||
214 | 277 | | |
215 | 278 | | |
216 | 279 | | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
217 | 283 | | |
218 | 284 | | |
219 | 285 | | |
220 | 286 | | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
221 | 290 | | |
222 | 291 | | |
223 | 292 | | |
224 | 293 | | |
225 | 294 | | |
226 | 295 | | |
227 | | - | |
| 296 | + | |
228 | 297 | | |
229 | 298 | | |
230 | 299 | | |
231 | 300 | | |
232 | 301 | | |
233 | 302 | | |
234 | | - | |
| 303 | + | |
235 | 304 | | |
236 | 305 | | |
237 | 306 | | |
238 | 307 | | |
239 | 308 | | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
240 | 319 | | |
241 | 320 | | |
242 | 321 | | |
| |||
266 | 345 | | |
267 | 346 | | |
268 | 347 | | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
269 | 351 | | |
270 | 352 | | |
271 | 353 | | |
272 | 354 | | |
273 | 355 | | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
274 | 359 | | |
275 | 360 | | |
276 | 361 | | |
| |||
496 | 581 | | |
497 | 582 | | |
498 | 583 | | |
499 | | - | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
| 588 | + | |
| 589 | + | |
| 590 | + | |
| 591 | + | |
500 | 592 | | |
501 | 593 | | |
502 | 594 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
12 | 12 | | |
13 | 13 | | |
14 | 14 | | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
15 | 19 | | |
16 | 20 | | |
17 | 21 | | |
| |||
168 | 172 | | |
169 | 173 | | |
170 | 174 | | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
171 | 182 | | |
172 | 183 | | |
173 | 184 | | |
| |||
0 commit comments