Rewires the server-side AgentChat class in chat-client.ts onto the
Session primitive, matching the browser transport's shape.
- ChatSession persistence and SessionState internal state now key on
sessionId (friendlyId). runId is optional, just a 'live run' hint.
- triggerNewRun upserts the backing Session via sessions.create
(idempotent on externalId = chatId) before triggering so sessionId
rides along in payload.
- sendRaw, steer, sendAction, close, stop all go through
appendToSessionStream(sessionId, 'in',
serializeInputChunk({kind: …})). Stop becomes {kind: 'stop'};
messages become {kind: 'message', payload}; actions and close go
through the message payload with trigger='action' / 'close'.
- Subscribe moves from /realtime/v1/streams/{runId}/chat to
/realtime/v1/sessions/{sessionId}/out. Records arrive as JSON
strings; the loop parses them back into objects before the
trigger:turn-complete / trigger:upgrade-required dispatch.
- Upgrade-required path keeps the same Session, swaps runId only.
- Drops the CHAT_STREAM_KEY / CHAT_MESSAGES_STREAM_ID /
CHAT_STOP_STREAM_ID imports from chat-constants.js — chat-client.ts
no longer references the legacy stream keys (the constants file
itself will be deleted in Phase F along with the three references
still in ai.ts's re-exports and chat-constants.ts itself).
Server-side auth uses apiClientManager.accessToken (the env secret
key), which has full scopes — no token-scoping changes needed here.
The browser transport's token-scope updates (Phase E) already cover
the client side.
Rewires chat.agent's internal I/O and TriggerChatTransport's send +
subscribe paths onto the Session primitive. Minimum token-scope work
included so the transport's session endpoints actually authenticate.
Phase B — chat.agent internals (ai.ts)
- New ChatInputChunk tagged union (`kind: "message" | "stop"`) —
replaces the two-stream split (chat-messages + chat-stop) with a
single Session `.in` channel.
- New chatSessionHandleKey locals slot populated at run start from
`payload.sessionId ?? payload.chatId`. Every module-level helper
now resolves to the per-run session handle.
- Module-level `chatStream`, `messagesInput`, `stopInput` become thin
facades over the session. `chatStream` mirrors
`RealtimeDefinedStream<UIMessageChunk>` and delegates to
`handle.out`. `messagesInput` / `stopInput` mirror
`RealtimeDefinedInputStream<…>` and filter `.in` by kind — the two
internal `.on()`/`.waitWithIdleTimeout()` callers and the
`chat.messages` / `chat.createStopSignal` public exposures keep
their existing shapes.
- Every `streams.writer(CHAT_STREAM_KEY, …)` callsite swaps to
`chatStream.writer(…)` so all chat output flows through
`session.out` → `SessionStreamInstance` → direct-to-S2.
- Threaded `sessionId` through `ChatTaskWirePayload` /
`ChatTaskPayload` / `ChatTaskRunPayload` so advanced users can
`sessions.open(sessionId)` directly from `run()`.
Phase C — TriggerChatTransport (chat.ts)
- `ChatSessionState` keys durable identity on `sessionId` (friendlyId);
`runId` becomes an optional hint about whether a run is live.
- `ensureSession(chatId)` lazily upserts the Session via
`apiClient.createSession({type: "chat.agent", externalId: chatId})`
on the direct `accessToken` path. Idempotent — two tabs on the
same chat converge.
- `sendMessages`, `sendPendingMessage`, `stopGeneration`,
`sendAction` all go through `appendToSessionStream(sessionId, "in",
serializeInputChunk({kind: …}))` — one endpoint, one tag per
record.
- SSE subscribe URL moves from `/realtime/v1/streams/{runId}/chat` to
`/realtime/v1/sessions/{sessionId}/out`. The old run-scoped
`subscribeToStream` is replaced by `subscribeToSessionStream`.
Incoming chunks come back as JSON strings on the session channel
(server wraps records as `{data, id}` on S2), so the subscribe
loop parses them back into objects to keep the rest of the control
flow (turn-complete / upgrade-required / skipToTurnComplete)
unchanged.
- Upgrade-required re-trigger keeps the same Session and swaps only
the runId + token.
- `getSession` / `setSession` / `setOnSessionChange` / persistence
shape all grow a `sessionId` field (runId now optional).
Phase E — minimum token scopes
- `chat.createTriggerAction` (server side) now creates the Session
before triggering so it can (a) thread `sessionId` into the run
payload and (b) mint a token with both run and session scopes.
Returns `sessionId` in its result so the transport can skip its
own `sessions.create` call on the server-side-trigger path.
- `TriggerChatTaskResult` gains optional `sessionId`.
- The two in-run PAT refresh sites (preloadAccessToken,
turnAccessToken) add `read:sessions:{sessionId}` +
`write:sessions:{sessionId}` alongside the existing run scopes.
Known follow-ups (deferred to later passes)
- Phase D: `AgentChat` / `ChatStream` in chat-client.ts still uses
the old `/realtime/v1/streams/{runId}/chat` path. Used by server-
side task-to-task compositions, not the browser transport.
- Phase F: delete CHAT_STREAM_KEY, CHAT_MESSAGES_STREAM_ID,
CHAT_STOP_STREAM_ID from chat-constants.ts + ai-chat smoke verify.
Build the client-side half of the Session channel extensions that the
sessions PR shipped on the server. Pairs with POST
/api/v1/runs/:runFriendlyId/session-streams/wait and the
append-fires-waitpoints wiring on the session append handler.
Extend SessionHandle with two asymmetric channels mirroring the
run-scoped streams primitives:
- .in (SessionInputChannel) mirrors streams.input. on / once / peek /
wait / waitWithIdleTimeout for the task to consume; send for
external clients to produce. .wait / .waitWithIdleTimeout suspend
the run on a session-stream waitpoint; it resumes when a record
lands on .in, same semantics as streams.input.wait on a run-scoped
input stream.
- .out (SessionOutputChannel) mirrors streams.define. append / pipe /
writer for the task to produce records — all three route through
SessionStreamInstance -> StreamsWriterV2 for uniform parsed-object
serialization on the subscribe side. read returns an SSE subscription
for external consumers.
The two channels are disjoint classes with zero overlapping methods.
SessionHandle is { id, in, out } so directional tags stay at every
call site. No public initialize() — S2 credentials are an internal
detail of pipe / writer.
Core
- StandardSessionStreamManager + sessionStreams global: SSE-backed
tail with once/on/peek buffering, auto-reconnect, lastSeqNum
resume. Keyed on {sessionId, io}. Registered in dev- and managed-
run workers; taskExecutor clears handlers at run end alongside
input streams.
- SessionStreamInstance: S2-only parallel of StreamInstance. Fetches
session S2 creds via initializeSessionStream and pipes through
StreamsWriterV2.
- ApiClient.createSessionStreamWaitpoint — calls the new server route.
Reference
- references/hello-world/src/trigger/sessionsSmoke.ts now exercises
.out.writer alongside .out.append.
- references/hello-world/src/trigger/sessionsWaitSmoke.ts (new) —
end-to-end waitpoint validation. Orchestrator suspends on
.in.waitWithIdleTimeout; a delayed sender task fires the waitpoint
via .in.send; orchestrator resumes with the payload. match: true.
Client-side pair to the Session primitive server PR (TRI-8627).
Run-scoped streams.pipe / streams.input are untouched.
@trigger.dev/core ApiClient
- createSession / retrieveSession / updateSession / closeSession —
zodfetch against /api/v1/sessions control plane
- listSessions — CursorPagePromise<SessionItem>, follows the runs/waitpoints
convention (page[size], page[after], page[before] + filter[*])
- initializeSessionStream — PUT /realtime/v1/sessions/:session/:io,
returns S2 creds in headers (feeds StreamsWriterV2 directly)
- appendToSessionStream — POST …/append
- subscribeToSessionStream — reuses SSEStreamSubscription for SSE
subscribes (auto-retry, Last-Event-ID resume, abort propagation), so
session subscribers get the exact same semantics as runs.fetchStream.
Returns AsyncIterableStream<T>.
@trigger.dev/sdk sessions namespace
- sessions.create / retrieve / update / close / list — wraps the ApiClient
with the standard tracer + accessoryAttributes + mergeRequestOptions.
Returns ApiPromise / CursorPagePromise.
- sessions.open(id) returns a SessionHandle with .out and .in
SessionChannels. Each channel exposes append / send / subscribe /
initialize. The handle is polymorphic on friendlyId or externalId.
- auth.ts adds the `sessions` permission on PublicTokenPermissionProperties
so auth.createPublicToken({ read: { sessions: ["session_abc"] } }) works.
Reference
- references/hello-world/src/trigger/sessionsSmoke.ts — idempotent
Trigger.dev task that exercises every code path (control-plane CRUD,
polymorphic lookup, list with tag/type/status/externalId filters, cursor
pagination, out.initialize + append + subscribe SSE round-trip, in.send,
close + idempotent re-close). Trigger via
mcp__trigger__trigger_task(taskId: "sessions-smoke").
Verified live against the local webapp (project hello-world): 10/10
steps pass end-to-end, S2 round-trip returns appended chunks through the
shared SSEStreamSubscription pipeline.
Two correctness fixes caught during end-to-end validation:
1. Resolve skill.path relative to the file that called skills.define(),
not the project root. The resource catalog already captures filePath
on each SkillManifest — use it to compute the source folder.
2. In dev, copy skill bundles to {workingDir}/.trigger/skills/ (where
the worker's cwd resolves). In deploy, keep copying to
{outputPath}/.trigger/skills/ so the Dockerfile COPY . /app lands
them at /app/.trigger/skills/.
Also upgrade the skill-discovery failure log from debug to warn so
config mistakes surface in the dev console instead of disappearing
silently.
Finally, update the ai-chat reference's aiChat.run() to pass chatTools
through chat.toStreamTextOptions({ tools: chatTools }) instead of
spreading them after — the auto-injected loadSkill/readFile/bash tools
would otherwise get overwritten by the explicit tools: chatTools key.
Full Phase 1 for the new ai.skills primitive — SDK + CLI, no
backend. Developer-authored folders (SKILL.md + scripts/references/
assets) discovered at build time, bundled into the deploy image at
/app/.trigger/skills/{id}/, and auto-wired into streamText at
runtime via the loadSkill/readFile/bash tools.
Adds a time-utils example skill to the ai-chat reference: two bash
scripts (now.sh, add.sh) plus a timezones.txt cheat-sheet, wired
into the aiChat agent via chat.skills.set() in onChatStart and
onPreload. Exercises the full pipeline end-to-end.
Changeset: patch bump for @trigger.dev/sdk, @trigger.dev/core,
@trigger.dev/build, trigger.dev.
New first-class bundling step (not a build extension): after esbuild
produces the worker bundle, fork the indexer locally to discover
skills registered via ai.skills.define(), validate each skill's
SKILL.md, and copy the folder into {outputPath}/.trigger/skills/{id}/.
Hooks into both buildWorker() (deploy) and devSession's updateBundle
(dev) right after createBuildManifestFromBundle and before the
extension onBuildComplete hook, so extensions can observe the
annotated manifest.skills. The existing Dockerfile COPY picks up the
new .trigger/skills/ subdirectory without changes.
Also: managed-index-worker and dev-index-worker now emit
resourceCatalog.listSkillManifests() in the INDEX_COMPLETE message
so downstream stages can see the skill list.
Part 3/3 of Phase 1 for the new ai.skills primitive.
skills.define({ id, path }) registers a skill with the resource
catalog and returns a SkillHandle. SkillHandle.local() reads the
bundled SKILL.md from ./.trigger/skills/{id}/ at runtime, parses
frontmatter, and returns a ResolvedSkill ready for chat.skills.set().
chat.skills.set([...]) stores resolved skills for the current run.
chat.toStreamTextOptions() auto-injects the skills preamble into the
system prompt and merges three tools — loadSkill, readFile, bash —
scoped per-skill with path-traversal guards and output caps (64 KB
stdout/stderr, 1 MB readFile). Bash executes in the worker container
with the turn's abort signal, no sandbox — skills are developer code.
Shared packages/build/src/internal/copyFiles.ts extracted from the
additionalFiles extension so the CLI's built-in skill bundler and the
existing extension share one glob + copy implementation.
Part 2/3 of Phase 1 for the new ai.skills primitive.
SkillMetadata + SkillManifest zod schemas alongside the existing
task/prompt ones. registerSkillMetadata / listSkillManifests /
getSkillManifest on ResourceCatalog (both Standard and Noop), wired
through the ResourceCatalogAPI facade. BuildManifest + WorkerManifest
gain an optional `skills` array so the built-in CLI bundler can
annotate discoveries and the indexer can report them.
Part 1/3 of Phase 1 for the new ai.skills primitive.
run() is invoked with trigger: "action" after onAction processes a
typed action, but the type previously omitted it. Adding it lets
users cleanly short-circuit the LLM call for actions that don't
need a response (e.g. user-initiated compaction):
if (trigger === "action") return;
Exit the loop after the current turn completes without the
upgrade-required signal that chat.requestUpgrade() sends. Use when an
agent finishes its work on its own terms — one-shot responses, goal
achieved, budget exhausted — instead of waiting idle for the next
user message. Callable from run(), chat.defer(),
onBeforeTurnComplete, or onTurnComplete.
Resolves TRI-8391.
Surface the AI SDK's FinishReason on TurnCompleteEvent and
BeforeTurnCompleteEvent. Gives hooks a clean signal for distinguishing
a normal turn end from one paused on a pending tool call (HITL flows
like ask_user). Undefined for manual pipeChat() or aborted streams.
Drives a chat.agent definition through real turns offline — send
messages, actions, and stop signals; inspect captured chunks; assert
on hook order. Pre-seed dependencies via setupLocals so hooks read
test instances (DB clients, stubs) via locals.get() instead of
leaking through untrusted clientData.
Adds ai-chat reference tests exercising the harness across basic
flow, onValidateMessages, hydrateMessages, and actions.
In-memory managers for locals, lifecycle hooks, runtime, input streams,
and realtime streams, plus a mock TaskContext. Lets task code be driven
end-to-end without hitting the Trigger.dev runtime — send data into
input streams, inspect chunks written to output streams, and pre-seed
locals for dependency injection.
Add isStreaming flag to session state — set true when streaming starts,
false on turn-complete. reconnectToStream returns null immediately when
isStreaming is false, so resume: true is safe to pass unconditionally.
Backend-controlled message history via hydrateMessages hook — loads from
DB on every turn, replacing the linear accumulator. Imperative chat.history
API (rollbackTo, remove, replace, slice) for modifying history from any
hook or run(). Custom actions via actionSchema + onAction — typed actions
sent through transport.sendAction() that wake the agent, modify state,
then trigger run(). Adds sendAction to both TriggerChatTransport and
AgentChat.
- Pass TaskRunContext through chat lifecycle events, CompactedEvent, and
ChatTaskRunPayload; use ctx.run.id for chat access tokens
- Export TaskRunContext from @trigger.dev/sdk
- ai-chat reference: executeCode via E2B, code-sandbox module, warm on
onTurnStart, dispose on token onWait and onComplete; chat.local run id
- Docs: database persistence + code sandbox pattern pages; reference and
backend updates for ctx; chat.defer anchor; navigation
- Assert toolFromTask return as AI SDK ToolSet-compatible; import ToolSet from ai
- Add changesets for @trigger.dev/sdk
- ai-chat reference: chat-tools module, registry language model helper, streamText cleanup
- Prisma migration removing user tool demo; demo docs and next config tweaks
- chat.inject(): queue model messages from background work for injection
at the next prepareStep boundary or before the next turn's run()
- Deferred work from onTurnComplete no longer blocks waiting for next message
- Background queue persists across turns (not reset) so deferred work from
onTurnComplete can inject into the next turn
- Reference app: self-review pattern using generateObject + chat.inject()
- Hide transient data-turn-status and data-background-context-injected parts in UI
Replace triggerAndWait with triggerAndSubscribe in ai.tool to fix:
- Parallel tool calls (no more preventMultipleWaits errors)
- Stop signal while suspended (parent stays alive, child gets cancelled)
New task.triggerAndSubscribe() method: trigger + subscribeToRun in a
single span, with abort signal support and cancelOnAbort option.
Convert deepResearch to a schemaTask + ai.tool in the reference app.
refs TRI-7986