Skip to content

feat(realtime): start-from-latest streams and a useSessionStream hook - #4811

Merged
ericallam merged 10 commits into
mainfrom
feature/tri-13511-realtime-streams-start-from-latest-tail-subscribe-option
Aug 28, 2026
Merged

feat(realtime): start-from-latest streams and a useSessionStream hook#4811
ericallam merged 10 commits into
mainfrom
feature/tri-13511-realtime-streams-start-from-latest-tail-subscribe-option

Conversation

@ericallam

@ericallam ericallam commented Aug 28, 2026

Copy link
Copy Markdown
Member

Summary

Realtime streams get a live "last value" mode: subscribe from the latest record instead of replaying the whole history, keep memory bounded, and resume across reloads. Plus a new useSessionStream hook for reading a Session's channels from React.

useRealtimeStream: start-from-latest, bounded, resumable

const { parts, lastEventId } = useRealtimeStream<Frame>(runId, "frames", {
  from: "latest",   // skip history, only new records after connect
  maxParts: 1,      // keep just the most recent (bounded memory)
  lastEventId: saved, // resume from a persisted cursor (survives reload)
  onParts: (batch) => save(batch.at(-1)?.id), // per-batch event ids
  accessToken,
});

from, lastEventId (option and return), and the batching also apply to streams.read() and fetchStream().

useSessionStream: read a Session channel from React (new)

A read-only hook for a Session's out (default) or in channel, with the same start / bound / resume options. useSession is reserved for two-way (read and write).

const { records, lastEventId } = useSessionStream<Frame>(sessionId, {
  io: "out",
  from: "latest",
  maxRecords: 5,
  onRecords: (batch) => {/* each throttled batch, with event ids */},
  accessToken,
});

Access-token refresh

Long-lived subscriptions can survive token expiry: pass refreshAccessToken and a 401/403 triggers one re-mint and reconnect. With no refresher, auth errors stay terminal exactly as before.

const { parts } = useRealtimeStream<Frame>(runId, "frames", {
  accessToken,
  // called on a 401/403 to mint a fresh public token from your backend
  refreshAccessToken: async () => {
    const res = await fetch("/api/realtime-token");
    return (await res.json()).token;
  },
});

It is also available on useApiClient / TriggerAuthContext, so every hook under a provider shares one refresher.

Notes

Server support (S2 tail_offset / Redis $, and the start-position header on the run and session SSE routes) ships here; a client passing from: "latest" against an older server degrades safely to a full replay. Resume, bounded memory, batched callbacks, and token refresh are client-only.

Supersedes #4808 and #4809, folded in here. Verified end to end on an isolated stack: from: "latest" on the run and session paths against real S2, lastEventId resume across a reload, bounded memory, batched callbacks, and a real 401 to token-refresh to reconnect.

Realtime stream subscribers could only replay from the beginning, so a new
or reconnecting subscriber always re-read the full history. Expose the
backends' native start-from-tail on the live subscribe path.

useRealtimeStream, streams.read, and fetchStream gain `from: "latest"`
(seed the current tail, then live-tail), and useRealtimeStream gains
`maxParts` to bound the accumulated parts array. The client sends the start
position only on the first connect and resumes from the last record it saw
on reconnect or remount, so nothing is replayed or missed. S2 maps this to
tail_offset, Redis to the $ special id.
… refresh

Folds two related changes into the realtime stream work.

useSessionStream reads one channel of a session's realtime stream (out by
default, or in), accumulating records with automatic resume from the last
record seen. It is read-only; useSession is reserved for two-way (read and
write) communication.

Realtime stream subscriptions can refresh an expired access token and
reconnect once, via an optional refreshAccessToken on the client configuration
and the React hooks. Behavior is unchanged when no refresher is supplied:
auth errors stay terminal.
useRealtimeStream now takes a lastEventId option and returns the lastEventId of
the last part seen, so a caller can persist the cursor (for example across a
page reload) and resume with no replay and no gap. A new onParts callback
delivers each throttled batch of parts with their event ids.
…onStream

useSessionStream can start at the current tail (from: "latest"), bound the
retained records (maxRecords), and report each throttled batch of records with
their event ids (onRecords, replacing the per-record onRecord). The session SSE
route reads the start-position header and maps it to the S2 tail start, matching
the run-stream path.
@changeset-bot

changeset-bot Bot commented Aug 28, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c493f90

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 27 packages
Name Type
@trigger.dev/core Patch
@trigger.dev/react-hooks Patch
@trigger.dev/sdk Patch
@trigger.dev/build Patch
trigger.dev Patch
@trigger.dev/python Patch
@trigger.dev/redis-worker Patch
@trigger.dev/schema-to-json Patch
@internal/clickhouse Patch
@internal/llm-model-catalog Patch
@internal/metrics-pipeline Patch
@trigger.dev/rbac Patch
@internal/redis Patch
@internal/replication Patch
@internal/run-engine Patch
@internal/run-store Patch
@internal/schedule-engine Patch
@internal/tracing Patch
@internal/webhook-engine Patch
@internal/webhook-sources Patch
@internal/dashboard-agent Patch
@internal/cache Patch
@trigger.dev/rsc Patch
@trigger.dev/database Patch
@trigger.dev/otlp-importer Patch
@trigger.dev/sso Patch
@internal/testcontainers Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 373b5ea4-7a17-485c-8969-3040fea3d641

📥 Commits

Reviewing files that changed from the base of the PR and between 03d1a51 and 1ebef36.

📒 Files selected for processing (3)
  • apps/webapp/app/services/realtime/s2realtimeStreams.server.ts
  • packages/react-hooks/src/hooks/useRealtime.ts
  • packages/trigger-sdk/src/v3/streams.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.

📜 Recent review details
⏰ Context from checks skipped due to timeout. (51)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (23, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (20, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (19, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (17, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (22, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (15, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (13, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (11, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (14, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (18, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (12, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (21, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (3, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (1, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (24, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (5, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (7, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (9, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (16, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (10, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (4, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (8, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (6, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (2, 24)
  • GitHub Check: sdk-compat / Node.js 20.20 (warp-ubuntu-latest-x64-4x)
  • GitHub Check: e2e / 🧪 CLI v3 tests (warp-ubuntu-latest-x64-4x - pnpm)
  • GitHub Check: sdk-compat / Node.js 24.18 (warp-ubuntu-latest-x64-4x)
  • GitHub Check: sdk-compat / Node.js 22.23 (warp-ubuntu-latest-x64-4x)
  • GitHub Check: e2e / 🧪 CLI v3 tests (warp-ubuntu-latest-x64-4x - npm)
  • GitHub Check: e2e / 🧪 CLI v3 tests (warp-windows-latest-x64-8x - npm)
  • GitHub Check: sdk-compat / Node.js 26.4 (warp-ubuntu-latest-x64-4x)
  • GitHub Check: sdk-compat / Bun Runtime
  • GitHub Check: obsmap / 🧪 Unit Tests: Observability Map
  • GitHub Check: sdk-compat / Deno Runtime
  • GitHub Check: e2e-webapp / 🧪 E2E Tests: Webapp (2, 2)
  • GitHub Check: e2e / 🧪 CLI v3 tests (warp-windows-latest-x64-8x - pnpm)
  • GitHub Check: sdk-compat / Cloudflare Workers
  • GitHub Check: packages / 🧪 Unit Tests: Packages (2, 3)
  • GitHub Check: typecheck / typecheck
  • GitHub Check: packages / 🧪 Unit Tests: Packages (3, 3)
  • GitHub Check: e2e-webapp / 🧪 E2E Tests: Webapp (1, 2)
  • GitHub Check: packages / 🧪 Unit Tests: Packages (1, 3)
  • GitHub Check: fk-cascade-guard / fk-cascade-guard
  • GitHub Check: runops-guard / runops-guard
  • GitHub Check: internal / 🧪 Unit Tests: Internal
  • GitHub Check: report
  • GitHub Check: code-quality / code-quality
  • GitHub Check: Analyze (javascript-typescript)
  • GitHub Check: 🛡️ E2E Auth Tests (full)
  • GitHub Check: 🛡️ E2E Auth Tests (full)
  • GitHub Check: Build and publish previews
🧰 Additional context used
📓 Path-based instructions (12)
Always import from `@trigger.dev/sdk`. Never use `@trigger.dev/sdk/v3` or deprecated `client.defineJob`.

📄 CodeRabbit inference engine (AGENTS.md)

Files:

  • packages/trigger-sdk/src/v3/streams.ts
Never use `request.signal` to detect client disconnects. Use `getRequestAbortSignal()` from `app/services/httpAsyncStorage.server.ts`, which is wired to Express response close events.

📄 CodeRabbit inference engine (apps/webapp/CLAUDE.md)

Files:

  • apps/webapp/app/services/realtime/s2realtimeStreams.server.ts
For dashboard changes, visually verify the running Remix app with Chrome DevTools MCP, using snapshots, screenshots, interaction, and console-message checks as appropriate.

📄 CodeRabbit inference engine (apps/webapp/CLAUDE.md)

Files:

  • apps/webapp/app/services/realtime/s2realtimeStreams.server.ts
**Prefer static imports over dynamic imports.** Only use dynamic `import()` when:

📄 CodeRabbit inference engine (AGENTS.md)

Files:

  • apps/webapp/app/services/realtime/s2realtimeStreams.server.ts
  • packages/trigger-sdk/src/v3/streams.ts
  • packages/react-hooks/src/hooks/useRealtime.ts
Add crumbs as you write code — not just when debugging. Mark lines with

📄 CodeRabbit inference engine (AGENTS.md)

Files:

  • apps/webapp/app/services/realtime/s2realtimeStreams.server.ts
  • packages/trigger-sdk/src/v3/streams.ts
  • packages/react-hooks/src/hooks/useRealtime.ts
Always import from `@trigger.dev/sdk`. Never use `@trigger.dev/sdk/v3` (deprecated path alias)

📄 CodeRabbit inference engine (packages/trigger-sdk/CLAUDE.md)

Files:

  • packages/trigger-sdk/src/v3/streams.ts
Use zod for validation in packages/core and apps/webapp

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Files:

  • apps/webapp/app/services/realtime/s2realtimeStreams.server.ts
In the Trigger.dev SDK (packages/trigger-sdk), prefer isomorphic code like fetch and ReadableStream instead of Node.js-specific code

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Files:

  • packages/trigger-sdk/src/v3/streams.ts
Access environment variables through the `env` export of `env.server.ts` instead of directly accessing `process.env`

📄 CodeRabbit inference engine (.cursor/rules/webapp.mdc)

Files:

  • apps/webapp/app/services/realtime/s2realtimeStreams.server.ts
Use function declarations instead of default exports

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Files:

  • apps/webapp/app/services/realtime/s2realtimeStreams.server.ts
  • packages/trigger-sdk/src/v3/streams.ts
  • packages/react-hooks/src/hooks/useRealtime.ts
Use types over interfaces for TypeScript

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Files:

  • apps/webapp/app/services/realtime/s2realtimeStreams.server.ts
  • packages/trigger-sdk/src/v3/streams.ts
  • packages/react-hooks/src/hooks/useRealtime.ts
When creating or editing OTEL metrics (counters, histograms, gauges), ensure metric attributes have low cardinality by using only enums, booleans, bounded error codes, or bounded shard IDs

📄 CodeRabbit inference engine (.cursor/rules/otel-metrics.mdc)

Files:

  • apps/webapp/app/services/realtime/s2realtimeStreams.server.ts
  • packages/trigger-sdk/src/v3/streams.ts
  • packages/react-hooks/src/hooks/useRealtime.ts
🔇 Additional comments (3)
apps/webapp/app/services/realtime/s2realtimeStreams.server.ts (1)

530-542: LGTM!

Also applies to: 682-691

packages/trigger-sdk/src/v3/streams.ts (1)

385-385: LGTM!

packages/react-hooks/src/hooks/useRealtime.ts (1)

11-19: LGTM!

Also applies to: 619-625, 658-701, 875-883, 915-931, 953-959, 985-992, 1012-1012, 1188-1233


Walkthrough

Realtime streams now support refreshed credentials after rejected connections and latest-position subscriptions. Server adapters interpret the latest-start header and select tail reads for Redis and S2 streams. Core APIs and the SDK forward stream-position options. React hooks persist cursors, limit accumulated parts or records, and deliver batched callbacks. A new useSessionStream hook reads session input or output channels. Changesets and tests cover these additions.

Merge Risk: 🟡 Moderate · up to 1ebef

This PR adds latest-position streaming, cursor resume, bounded buffering, and token-refresh reconnects. At the current head, unresolved edge cases can replay or skip events, restore stale cursors, interfere with subscription cleanup, or exceed the requested memory bound, so the change is not merge-ready without fixes or explicit owner acceptance.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 16 functions across 21 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the primary changes: start-from-latest realtime streams and the new useSessionStream hook.
Description check ✅ Passed The description is detailed and directly covers the main changes, usage examples, compatibility behavior, token refresh, and end-to-end testing. It does not include the template headings or checklist,…
Full details: Description check

Explanation

The description is detailed and directly covers the main changes, usage examples, compatibility behavior, token refresh, and end-to-end testing. It does not include the template headings or checklist, but it provides equivalent summary, testing, and changelog information.

✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/tri-13511-realtime-streams-start-from-latest-tail-subscribe-option

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

coderabbitai[bot]

This comment was marked as resolved.

Document the from/maxParts/maxRecords/lastEventId/onParts/onRecords options on
useRealtimeStream and useSessionStream, add the session-stream React hook page,
from: "latest" on streams.read(), and refreshAccessToken on the realtime hooks.
coderabbitai[bot]

This comment was marked as resolved.

Address review feedback on the realtime stream hooks:

- useRealtimeStream clears its resume cursor when the stream identity (id,
  runId, streamKey) changes, so a new stream no longer inherits the previous
  cursor.
- useSessionStream resumes from its persisted cursor on remount, matching
  useRealtimeStream and its documented behavior.
- Both hooks update the parts ref inside the throttle flush so back-to-back
  flushes build on the latest batch, not a stale one.
- Clarify docs and the changeset: from "latest" starts at the current tail
  (the latest record, then live updates); older servers fall back to a full
  replay.
Two more review fixes:

- startIndex: 0 is a valid start position. useRealtimeStream and streams.read
  now check startIndex !== undefined instead of treating 0 as falsy, so
  { startIndex: 0 } starts from the beginning rather than falling through to
  from: "latest".
- Docs: scope the localStorage resume-cursor keys by stream identity so a
  component that changes its resource does not load another stream's cursor.
@pkg-pr-new

pkg-pr-new Bot commented Aug 28, 2026

Copy link
Copy Markdown

Open in StackBlitz

@trigger.dev/build

npm i https://pkg.pr.new/@trigger.dev/build@c493f90

trigger.dev

npm i https://pkg.pr.new/trigger.dev@c493f90

@trigger.dev/core

npm i https://pkg.pr.new/@trigger.dev/core@c493f90

@trigger.dev/python

npm i https://pkg.pr.new/@trigger.dev/python@c493f90

@trigger.dev/react-hooks

npm i https://pkg.pr.new/@trigger.dev/react-hooks@c493f90

@trigger.dev/redis-worker

npm i https://pkg.pr.new/@trigger.dev/redis-worker@c493f90

@trigger.dev/rsc

npm i https://pkg.pr.new/@trigger.dev/rsc@c493f90

@trigger.dev/schema-to-json

npm i https://pkg.pr.new/@trigger.dev/schema-to-json@c493f90

@trigger.dev/sdk

npm i https://pkg.pr.new/@trigger.dev/sdk@c493f90

commit: c493f90

@ericallam
ericallam marked this pull request as ready for review August 28, 2026 10:09
coderabbitai[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

startIndex: 0 must read from the beginning, not send a "-1" resume cursor.
Treat 0 as "from the beginning" (undefined cursor) again; the !== undefined
form produced lastEventId "-1", which Redis rejects as an invalid id and S2
maps past the first record.
devin-ai-integration[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

@ericallam ericallam changed the title feat(realtime): start-from-latest subscribe and last-value client hooks feat(realtime): start-from-latest streams and a useSessionStream hook Aug 28, 2026
- Add clamp: true to the S2 tail_offset read so from: "latest" on an empty or
  short stream saturates to the tail and long-polls instead of erroring.
- An explicit startIndex now suppresses from in useRealtimeStream and
  streams.read, so { startIndex: 0 } reads from the beginning rather than
  falling through to from: "latest".
Address two review findings on the realtime stream hooks:

- Seed the resume-cursor ref only on stream-identity change, not on every
  persisted-cursor update, so a mid-stream flush can no longer overwrite a
  newer live cursor with an older persisted value (which could replay parts on
  restart). Applies to useRealtimeStream and useSessionStream.
- Apply maxParts / maxRecords to the SWR-cached parts on mount and when the
  bound changes, so a remount with a bound of 1 does not briefly return the
  full cached history before the next batch.
@ericallam
ericallam merged commit 1d13b79 into main Aug 28, 2026
77 of 88 checks passed
@ericallam
ericallam deleted the feature/tri-13511-realtime-streams-start-from-latest-tail-subscribe-option branch August 28, 2026 12:16
@github-actions github-actions Bot mentioned this pull request Aug 28, 2026
ericallam pushed a commit that referenced this pull request Aug 28, 2026
## Summary
4 improvements, 1 bug fix.

## Improvements
- Native build server deploys now show a single updating build log line
by default; pass `--build-logs full` to stream every line (always used
in CI and when output is not a terminal).
([#4817](#4817))
- Realtime stream subscriptions can now refresh an expired access token
and reconnect, via a new optional `refreshAccessToken` option on the
client configuration and the React hooks.
([#4811](#4811))
- Subscribe to a realtime stream from its latest record instead of
replaying the whole history. Pass `from: "latest"` to
`useRealtimeStream`, `streams.read()`, or `fetchStream` to start at the
current tail (the latest record, then live updates) instead of replaying
(a live "last value" view), and `maxParts` to keep the accumulated
`parts` array bounded. A reconnect or remount resumes from the last
record it saw, so no records are missed and none are replayed. `from:
"latest"` needs a server that supports it; older servers safely fall
back to a full replay.
([#4811](#4811))
  
`useRealtimeStream` also gains a `lastEventId` option and returns the
`lastEventId` of the last part seen, so you can persist the cursor (for
example across a page reload) and resume exactly where you left off. An
`onParts` callback delivers each throttled batch of parts with their
event ids.
  
  ```tsx
const { parts, lastEventId } = useRealtimeStream<Frame>(runId, "frames",
{
  from: "latest", // skip history, start at the current tail
  maxParts: 1, // keep only the most recent frame
  lastEventId: savedCursor, // resume from a persisted cursor
  onParts: (batch) => save(batch.at(-1)?.id), // track the cursor
  accessToken,
  });
  ```
- Added a `useSessionStream` React hook for reading a session's output
or input channel in realtime. It accumulates records with automatic
resume from the last record you received, and supports `from: "latest"`
(start at the current tail, only new records after you connect),
`maxRecords` (keep a bounded number of records in memory), a
`lastEventId` resume cursor, and an `onRecords` callback that delivers
each throttled batch of records with their event ids.
([#4811](#4811))

## Server changes

These changes affect the self-hosted Docker image and Trigger.dev Cloud:

- Task retries that wait in the queue no longer count against the
queue's internal redelivery limit, so runs with many long-delay retries
are not wrongly failed with TASK_RUN_DEQUEUED_MAX_RETRIES.
([#4810](#4810))

<details>
<summary>Raw changeset output</summary>

# Releases
## @trigger.dev/build@4.5.14

### Patch Changes

- Updated dependencies:
  - `@trigger.dev/core@4.5.14`
## trigger.dev@4.5.14

### Patch Changes

- Native build server deploys now show a single updating build log line
by default; pass `--build-logs full` to stream every line (always used
in CI and when output is not a terminal).
([#4817](#4817))
- Updated dependencies:
  - `@trigger.dev/core@4.5.14`
  - `@trigger.dev/build@4.5.14`
  - `@trigger.dev/schema-to-json@4.5.14`
## @trigger.dev/core@4.5.14

### Patch Changes

- Realtime stream subscriptions can now refresh an expired access token
and reconnect, via a new optional `refreshAccessToken` option on the
client configuration and the React hooks.
([#4811](#4811))
- Subscribe to a realtime stream from its latest record instead of
replaying the whole history. Pass `from: "latest"` to
`useRealtimeStream`, `streams.read()`, or `fetchStream` to start at the
current tail (the latest record, then live updates) instead of replaying
(a live "last value" view), and `maxParts` to keep the accumulated
`parts` array bounded. A reconnect or remount resumes from the last
record it saw, so no records are missed and none are replayed. `from:
"latest"` needs a server that supports it; older servers safely fall
back to a full replay.
([#4811](#4811))

`useRealtimeStream` also gains a `lastEventId` option and returns the
`lastEventId` of the last part seen, so you can persist the cursor (for
example across a page reload) and resume exactly where you left off. An
`onParts` callback delivers each throttled batch of parts with their
event ids.

  ```tsx
const { parts, lastEventId } = useRealtimeStream<Frame>(runId, "frames",
{
    from: "latest", // skip history, start at the current tail
    maxParts: 1, // keep only the most recent frame
    lastEventId: savedCursor, // resume from a persisted cursor
    onParts: (batch) => save(batch.at(-1)?.id), // track the cursor
    accessToken,
  });
  ```
## @trigger.dev/python@4.5.14

### Patch Changes

- Updated dependencies:
  - `@trigger.dev/core@4.5.14`
  - `@trigger.dev/sdk@4.5.14`
  - `@trigger.dev/build@4.5.14`
## @trigger.dev/react-hooks@4.5.14

### Patch Changes

- Realtime stream subscriptions can now refresh an expired access token
and reconnect, via a new optional `refreshAccessToken` option on the
client configuration and the React hooks.
([#4811](#4811))
- Subscribe to a realtime stream from its latest record instead of
replaying the whole history. Pass `from: "latest"` to
`useRealtimeStream`, `streams.read()`, or `fetchStream` to start at the
current tail (the latest record, then live updates) instead of replaying
(a live "last value" view), and `maxParts` to keep the accumulated
`parts` array bounded. A reconnect or remount resumes from the last
record it saw, so no records are missed and none are replayed. `from:
"latest"` needs a server that supports it; older servers safely fall
back to a full replay.
([#4811](#4811))

`useRealtimeStream` also gains a `lastEventId` option and returns the
`lastEventId` of the last part seen, so you can persist the cursor (for
example across a page reload) and resume exactly where you left off. An
`onParts` callback delivers each throttled batch of parts with their
event ids.

  ```tsx
const { parts, lastEventId } = useRealtimeStream<Frame>(runId, "frames",
{
    from: "latest", // skip history, start at the current tail
    maxParts: 1, // keep only the most recent frame
    lastEventId: savedCursor, // resume from a persisted cursor
    onParts: (batch) => save(batch.at(-1)?.id), // track the cursor
    accessToken,
  });
  ```

- Added a `useSessionStream` React hook for reading a session's output
or input channel in realtime. It accumulates records with automatic
resume from the last record you received, and supports `from: "latest"`
(start at the current tail, only new records after you connect),
`maxRecords` (keep a bounded number of records in memory), a
`lastEventId` resume cursor, and an `onRecords` callback that delivers
each throttled batch of records with their event ids.
([#4811](#4811))
- Updated dependencies:
  - `@trigger.dev/core@4.5.14`
## @trigger.dev/redis-worker@4.5.14

### Patch Changes

- Updated dependencies:
  - `@trigger.dev/core@4.5.14`
## @trigger.dev/rsc@4.5.14

### Patch Changes

- Updated dependencies:
  - `@trigger.dev/core@4.5.14`
## @trigger.dev/schema-to-json@4.5.14

### Patch Changes

- Updated dependencies:
  - `@trigger.dev/core@4.5.14`
## @trigger.dev/sdk@4.5.14

### Patch Changes

- Subscribe to a realtime stream from its latest record instead of
replaying the whole history. Pass `from: "latest"` to
`useRealtimeStream`, `streams.read()`, or `fetchStream` to start at the
current tail (the latest record, then live updates) instead of replaying
(a live "last value" view), and `maxParts` to keep the accumulated
`parts` array bounded. A reconnect or remount resumes from the last
record it saw, so no records are missed and none are replayed. `from:
"latest"` needs a server that supports it; older servers safely fall
back to a full replay.
([#4811](#4811))

`useRealtimeStream` also gains a `lastEventId` option and returns the
`lastEventId` of the last part seen, so you can persist the cursor (for
example across a page reload) and resume exactly where you left off. An
`onParts` callback delivers each throttled batch of parts with their
event ids.

  ```tsx
const { parts, lastEventId } = useRealtimeStream<Frame>(runId, "frames",
{
    from: "latest", // skip history, start at the current tail
    maxParts: 1, // keep only the most recent frame
    lastEventId: savedCursor, // resume from a persisted cursor
    onParts: (batch) => save(batch.at(-1)?.id), // track the cursor
    accessToken,
  });
  ```

- Updated dependencies:
  - `@trigger.dev/core@4.5.14`

</details>

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
ericallam added a commit that referenced this pull request Aug 29, 2026
…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"
/>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants