Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
946c900
feat(core,webapp): address session realtime streams by named channel
ericallam Aug 28, 2026
25fcd1b
feat(sdk,core): session.channel() handle, channel spans, and native r…
ericallam Aug 28, 2026
dde4f17
test(core): session stream manager isolates named channels
ericallam Aug 28, 2026
d80c490
chore: changeset for session side channels
ericallam Aug 28, 2026
86cf30f
chore(webapp): drop channel list-streams (unsupported on self-hosted …
ericallam Aug 28, 2026
9b7be3d
feat(react-hooks): add useSessionStreamChannel for named session chan…
ericallam Aug 28, 2026
5b1f61b
feat(sdk): chat.channel() and chat.session() for the current run's Se…
ericallam Aug 28, 2026
436e455
refactor(sdk,core): sessions.defineChannel + typed channel handles
ericallam Aug 28, 2026
225edfe
fix(react-hooks,sdk): review + CI fixes for session channels
ericallam Aug 28, 2026
2f77568
fix(react-hooks): make useSessionStreamChannel sessionId optional for…
ericallam Aug 28, 2026
5705e51
docs(ai-chat): document session side channels
ericallam Aug 28, 2026
b8dd764
docs(ai-chat): cover non-agent channel usage (task + backend)
ericallam Aug 28, 2026
85912a3
chore(core): format subscribeToSessionStream channel URL line
ericallam Aug 28, 2026
d66a43b
chore: satisfy code-quality (oxlint no-duplicates, knip)
ericallam Aug 28, 2026
3ba8508
feat(webapp): render session channel streams in the span inspector
ericallam Aug 28, 2026
b18ca54
fix(webapp): apply native retention to a channel's .in stream
ericallam Aug 28, 2026
f993d00
refactor(sdk,core,webapp): channels inherit basin retention
ericallam Aug 28, 2026
c2b2246
feat(cli): MCP tools to read and write session channels
ericallam Aug 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/session-side-channels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@trigger.dev/react-hooks": patch
"@trigger.dev/core": patch
"@trigger.dev/sdk": patch
---

Named side channels on a Session: durable, two-way realtime streams that outlive a single run and are shared across runs. Open a channel with `sessions.open(id).channel(name)` (or `chat.channel(name)` inside a `chat.agent`) to get an `.in`/`.out` pair addressed by name rather than the reserved default pair. Writing a side channel's `.in` does not wake or trigger a run, so a channel can carry out-of-band data (a stream of frames, a control signal) that many clients read while the agent produces it.

```ts
// Inside a chat.agent: stream frames on a named channel, wakes nothing
const frames = chat.channel("screenshots");
await frames.out.append(frame);
frames.in.on((control) => { /* client control, no suspend */ });
```

Declare channel record types once with `sessions.defineChannel(...)` and infer them on both the producer and the consumer, including `useSessionStreamChannel` in React. Channels get a default retention that keeps them bounded, overridable per channel.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Release note overstates retention controls

The changeset promises per-channel retention overrides, but channel accepts no options. Remove the claim or add the documented API before release.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

3 changes: 3 additions & 0 deletions apps/webapp/app/components/runs/v3/RunIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { PythonLogoIcon } from "~/assets/icons/PythonLogoIcon";
import { TraceIcon } from "~/assets/icons/TraceIcon";
import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";
import { StreamsIcon } from "~/assets/icons/StreamsIcon";
import { AIChatIcon } from "~/assets/icons/AIChatIcon";

type TaskIconProps = {
name: string | undefined;
Expand Down Expand Up @@ -169,6 +170,8 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
className={cn(className, "text-text-dimmed group-hover/spannode:text-text-bright")}
/>
);
case "sessions":
return <AIChatIcon className={cn(className, "text-sessions")} />;
case "hero-sparkles":
return (
<SparklesIcon
Expand Down
43 changes: 43 additions & 0 deletions apps/webapp/app/presenters/v3/SpanPresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ export class SpanPresenter extends BasePresenter {
const span = await this.#getSpan({
eventStore,
spanId,
runFriendlyId,
traceId,
environmentId: parentRun.runtimeEnvironmentId,
projectId: parentRun.projectId,
Expand Down Expand Up @@ -640,6 +641,7 @@ export class SpanPresenter extends BasePresenter {
eventRepository,
traceId,
spanId,
runFriendlyId,
environmentId,
projectId,
createdAt,
Expand All @@ -648,6 +650,7 @@ export class SpanPresenter extends BasePresenter {
eventRepository: IEventRepository;
traceId: string;
spanId: string;
runFriendlyId: string;
environmentId: string;
projectId: string;
eventStore: TaskEventStoreTable;
Expand Down Expand Up @@ -840,6 +843,46 @@ export class SpanPresenter extends BasePresenter {
},
};
}
case "session-stream": {
if (!span.entity.id) {
logger.error(`SpanPresenter: No session stream id`, {
spanId,
sessionStreamId: span.entity.id,
});
return { ...data, entity: null };
}

const parts = span.entity.id.split(":");
const io = parts.at(-1);
const channel = parts.at(-2) ?? "";
const sessionId = parts.at(-3);

if (!sessionId || (io !== "out" && io !== "in")) {
logger.error(`SpanPresenter: Invalid session stream id`, {
spanId,
sessionStreamId: span.entity.id,
});
return { ...data, entity: null };
}

const metadata = span.entity.metadata
? (safeJsonParse(span.entity.metadata) as Record<string, unknown> | undefined)
: undefined;

return {
...data,
entity: {
type: "session-stream" as const,
object: {
runId: runFriendlyId,
sessionId,
channel: channel.length > 0 ? channel : undefined,
io,
metadata,
},
},
};
}
case "prompt": {
const promptData = extractPromptSpanData(span.properties as Record<string, unknown>);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { json } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core/utils";
import { nanoid } from "nanoid";
import { z } from "zod";
import { logger } from "~/services/logger.server";
import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server";
import {
SESSION_CHANNEL_NAME_REGEX,
sessionChannelResources,
} from "~/services/realtime/sessionChannels.server";
import {
canonicalSessionAddressingKey,
resolveSessionWithWriterFallback,
} from "~/services/realtime/sessions.server";
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
import {
claimSessionStreamPart,
releaseSessionStreamPart,
} from "~/services/sessionStreamWaitpointCache.server";
import { anyResource, createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { ServiceValidationError } from "~/v3/services/common.server";

const ParamsSchema = z.object({
session: z.string(),
channel: z.string().regex(SESSION_CHANNEL_NAME_REGEX),
io: z.enum(["out", "in"]),
});

const MAX_APPEND_BODY_BYTES = 1024 * 1024;

const { action, loader } = createActionApiRoute(
{
params: ParamsSchema,
method: "POST",
maxContentLength: MAX_APPEND_BODY_BYTES,
allowJWT: true,
corsStrategy: "all",
findResource: async (params, auth) =>
resolveSessionWithWriterFallback(auth.environment.id, params.session),
authorization: {
action: "write",
resource: (params, _s, _h, _b, session) => {
const ids = new Set<string>([params.session]);
if (session) {
ids.add(session.friendlyId);
if (session.externalId) ids.add(session.externalId);
}
return anyResource(sessionChannelResources(params.channel, ids));
},
},
},
async ({ request, params, authentication, resource: session }) => {
if (!session) {
return new Response("Session not found", { status: 404 });
}

if (session.closedAt) {
return json({ ok: false, error: "Cannot append to a closed session" }, { status: 400 });
}

if (session.expiresAt && session.expiresAt.getTime() < Date.now()) {
return json({ ok: false, error: "Cannot append to an expired session" }, { status: 400 });
}

if (params.io === "out" && authentication.type !== "PRIVATE") {
return json(
{ ok: false, error: "Appending to the out channel requires secret key authentication" },
{ status: 403 }
);
}

const realtimeStream = getRealtimeStreamInstance(authentication.environment, "v2", {
session,
});

if (!(realtimeStream instanceof S2RealtimeStreams)) {
return json(
{ ok: false, error: "Session channels require the S2 realtime backend" },
{ status: 501 }
);
}

const addressingKey = canonicalSessionAddressingKey(session, params.session);
const claimKey = `${addressingKey}:channels:${params.channel}`;

const part = await request.text();

const clientPartId = request.headers.get("X-Part-Id");
const partId = clientPartId ?? nanoid(7);

const wonClaim = clientPartId
? await claimSessionStreamPart(
authentication.environment.id,
claimKey,
params.io,
clientPartId
)
: true;

let appendSeq: number | undefined;
if (wonClaim) {
const [appendError, seq] = await tryCatch(
realtimeStream.appendPartToSessionStream(
part,
partId,
addressingKey,
params.io,
params.channel
)
);
appendSeq = seq ?? undefined;

if (appendError) {
if (clientPartId) {
await releaseSessionStreamPart(
authentication.environment.id,
claimKey,
params.io,
clientPartId
);
}
if (appendError instanceof ServiceValidationError) {
return json(
{ ok: false, error: appendError.message },
{ status: appendError.status ?? 422 }
);
}
logger.error("Failed to append to session channel stream", {
sessionId: session.id,
io: params.io,
channel: params.channel,
error: appendError,
});
return json(
{ ok: false, error: "Something went wrong, please try again." },
{ status: 500 }
);
}
}
Comment on lines +100 to +139

@devin-ai-integration devin-ai-integration Bot Aug 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Channel .in append skips run-wake and input sanitization

The channel append handler omits the ensureRunForSession and drainSessionStreamWaitpoints steps of the reserved .in append (the intended no-wake design) and also skips stripClientWebhookActionSource. Side-channel .in records therefore bypass the webhook-source sanitization applied to reserved .in.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


return json({ ok: true, seq: appendSeq }, { status: 200 });
}
);

export { action, loader };
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { $replica } from "~/db.server";
import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server";
import {
SESSION_CHANNEL_NAME_REGEX,
sessionChannelResources,
} from "~/services/realtime/sessionChannels.server";
import {
canonicalSessionAddressingKey,
isSessionFriendlyIdForm,
resolveSessionByIdOrExternalId,
} from "~/services/realtime/sessions.server";
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
import { anyResource, createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";

const ParamsSchema = z.object({
session: z.string(),
channel: z.string().regex(SESSION_CHANNEL_NAME_REGEX),
io: z.enum(["out", "in"]),
});

const SearchSchema = z.object({
afterEventId: z.string().regex(/^\d+$/).optional(),
});

export const loader = createLoaderApiRoute(
{
params: ParamsSchema,
searchParams: SearchSchema,
allowJWT: true,
corsStrategy: "all",
findResource: async (params, auth) => {
const row = await resolveSessionByIdOrExternalId(
$replica,
auth.environment.id,
params.session
);
if (!row && isSessionFriendlyIdForm(params.session)) {
return undefined;
Comment on lines +34 to +40

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Fresh sessions fail immediate reads

resolveSessionByIdOrExternalId checks only the replica and returns 404 for a newly created friendly ID. Record reads fail until replication catches up.

Prompt for agents
The named-channel records route resolves sessions through $replica only, while the append and SSE routes use resolveSessionWithWriterFallback. For a newly created session addressed by friendly ID, replica lag turns a valid immediate read into a 404. Update the route to use the same replica-first, writer-on-miss resolution as the other named-channel routes, while preserving row-optional external-ID behavior and authorization aliases.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}
return {
row,
addressingKey: canonicalSessionAddressingKey(row, params.session),
};
},
authorization: {
action: "read",
resource: ({ row, addressingKey }, params) => {
const ids = new Set<string>([addressingKey]);
if (row) {
ids.add(row.friendlyId);
if (row.externalId) ids.add(row.externalId);
}
return anyResource(sessionChannelResources(params.channel, ids));
},
},
},
async ({ params, authentication, resource, searchParams }) => {
const realtimeStream = getRealtimeStreamInstance(authentication.environment, "v2", {
session: resource.row,
organization: resource.row ? null : authentication.environment.organization,
});

if (!(realtimeStream instanceof S2RealtimeStreams)) {
return new Response("Session channels require the S2 realtime backend", { status: 501 });
}

const afterSeqNum =
searchParams.afterEventId !== undefined ? Number(searchParams.afterEventId) : undefined;

const records = await realtimeStream.readSessionStreamRecords(
resource.addressingKey,
params.io,
afterSeqNum,
params.channel
);

return json({ records });
}
);
Loading
Loading