| title | Changelog |
|---|---|
| sidebarTitle | Changelog |
| description | Pre-release updates for AI chat agents. |
<Update label="May 23, 2026" description="4.5.0-rc.2" tags={["SDK", "Webapp", "Bug fix"]}>
chat.addToolOutput(...) and chat.addToolApproveResponse(...) continuations on reasoning-heavy agent loops used to fail two ways: either the wire body crossed the /in/append cap (encrypted reasoning blobs + tool input routinely > 512 KiB), or apps that slimmed the wire as a workaround landed a tool call with no arguments on the next LLM step (the per-turn merge replaced the hydrated message wholesale instead of overlaying only the new tool-state advance). Both modes are fixed.
The transport (TriggerChatTransport.sendMessages, AgentChat.sendRaw) now slims the assistant message itself on submit-message turns whose assistant carries resolved or approval-responded tool parts. The wire shape ships as { id, role: "assistant", parts: [<resolved tool part only>] } — state plus output / errorText / approval, depending on the new state. Everything else (reasoning blobs, prior text, tool input, provider metadata) is reconstructed server-side from hydrateMessages or the durable snapshot. Continuation payloads typically drop from 600 KiB – 1 MiB to ~1 KiB.
The per-turn merge now overlays only the tool-part state advances (output-available / output-error / approval-responded / output-denied) from the wire copy onto the matching hydrated entry. Hydrated input, text, reasoning, and provider metadata stay put. The agent still accepts a fuller UIMessage on the wire (the merge only reads the resolved fields), so custom transports that ship more don't break — they just waste bytes.
If your hydrateMessages hook persists the incoming message, upsert by id — don't unconditionally push. HITL continuations ship the existing assistant's id with a slim payload; a blind stored.push(newMsg) duplicates the row in the chain you return, the merge updates the first match, and the slim duplicate hits toModelMessages with no input.
A new upsertIncomingMessage helper is exported from @trigger.dev/sdk/ai to handle this for the common case:
import { chat, upsertIncomingMessage } from "@trigger.dev/sdk/ai";
chat.agent({
hydrateMessages: async ({ chatId, trigger, incomingMessages }) => {
const record = await db.chat.findUnique({ where: { id: chatId } });
const stored = record?.messages ?? [];
if (upsertIncomingMessage(stored, { trigger, incomingMessages })) {
await db.chat.update({ where: { id: chatId }, data: { messages: stored } });
}
return stored;
},
});The helper pushes fresh user messages, no-ops on HITL continuations (so the runtime can overlay the new tool-state advance), and skips on non-submit-message triggers. Returns true if it mutated stored. The examples in lifecycle hooks, Database persistence, and Persistence and replay have all been updated. Custom hydrate logic (branching, rollback, etc.) can still write the upsert by hand — the helper is a convenience for the common shape.
The slim wire is what arrives in onValidateMessages on HITL turns. validateUIMessages from ai rejects the slim shape (the AI SDK schema requires input on resolved tool parts), so filter to user messages first (or skip validation entirely on those turns). See the updated example in lifecycle hooks.
In parallel:
- The 413 response now carries CORS headers, so browser fetches can read the status instead of failing as opaque
TypeError: Failed to fetch. App-side retry-on-disconnect loops no longer spin forever on a permanently-rejected payload. - The per-record cap is now computed precisely against S2's actual ceiling instead of the conservative 512 KiB floor. Legitimate ~600 – 900 KiB tool outputs (search results, file content) now succeed; pathological all-quote content that would double under JSON escape still rejects cleanly with a clear error.
See the updated 413 row in the client protocol.
<Update label="May 21, 2026" description="4.5.0-rc.1" tags={["SDK", "Bug fix"]}>
Patch release on top of 4.5.0-rc.0. Upgrade with:
npx trigger.dev@4.5.0-rc.1 update # npm
pnpm dlx trigger.dev@4.5.0-rc.1 update # pnpm
yarn dlx trigger.dev@4.5.0-rc.1 update # yarn
bunx trigger.dev@4.5.0-rc.1 update # bun- Agent Skills silently missing in
trigger devfor projects whose task files readprocess.envat module top level (e.g. a third-party SDK client initialized at import). Skill folders now bundle into.trigger/skills/reliably regardless of which env vars are set when the CLI launches. (#3690) COULD_NOT_FIND_EXECUTORwhen a task's definition is loaded viaawait import(...)from inside another task'srun()— common when lazy-loading sub-agent tasks. Runtime workers now register such tasks with a sentinel file context, and the catalog logs a one-time warning per task id. (#3688)
<Update label="May 21, 2026" description="4.5.0-rc.0" tags={["SDK", "Release"]}>
First release candidate of v4.5. Everything covered by the 0.0.0-chat-prerelease-* entries below now ships under a stable semver tag. Install:
pnpm add @trigger.dev/sdk@rc(Or pin 4.5.0-rc.0 explicitly.)
chat.agent— multi-turn AI chat backends as durable Trigger.dev tasks. Lifecycle hooks, recovery from cancel/crash/OOM, version upgrades, all in. See Overview and Quick Start.- Sessions — the durable bi-directional stream primitive that backs
chat.agent. Use it directly for any pattern that needs durable bi-directional streaming across runs. See Sessions. useTriggerChatTransport— a custom AI SDKChatTransportforuseChat. No API routes. See Frontend.- Head Start — opt-in route handler that runs the first
streamTextstep in your warm server while the agent boots in parallel. Cuts cold-start TTFC roughly in half. See Fast starts. - AI Prompts — code-defined, deploy-versioned templates with dashboard overrides for text + model. Integrates with
chat.agentviachat.prompt.set()+chat.toStreamTextOptions(). See Prompts. ai.toolExecute— wire any Trigger subtask in as theexecuteof an AI SDKtool(). See Sub-agents.
@trigger.dev/sdk@4.5.0-rc.0 requires ai ^5.0.0 || ^6.0.0 (Vercel AI SDK), React ^18.0 || ^19.0 (for the chat/react subpath), and Node.js >=18.20.0. Full matrix on the API Reference.
This release ships with a refreshed AI Agents documentation set covering Backend, Frontend, Sessions, Lifecycle hooks, chat.local, the Patterns library, Testing, and a full API Reference.
<Update label="May 19, 2026" description="0.0.0-chat-prerelease-20260520150857" tags={["SDK"]}>
When a chat.agent run dies mid-stream (the user cancels, the worker OOMs, an unhandled exception kills the process), the next continuation run now reconstructs the conversation context automatically. Follow-ups like "keep going" continue the partial response; fresh follow-ups like "scrap that, what's 7+8?" abandon it and answer the new question. No customer code required.
Under the hood: the boot now reads BOTH stream tails — session.out for any partial assistant the dead run was streaming, session.in for any user messages it never acknowledged — and splices [firstInFlightUser, partialAssistant] onto the chain when both are present. The model sees full prior context plus the latest user message.
For policies different from "preserve context" — drop the partial entirely, synthesize tool results for an interrupted tool call, emit a recovery banner to the UI — register the new onRecoveryBoot hook:
import { chat } from "@trigger.dev/sdk/ai";
export const myChat = chat.agent({
id: "my-chat",
onRecoveryBoot: async ({ partialAssistant, inFlightUsers, writer, cause, previousRunId }) => {
writer.write({
type: "data-chat-recovery",
data: { cause, previousRunId, partialPresent: partialAssistant !== undefined },
transient: true,
});
// return nothing → smart default applies
},
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
});The hook receives settledMessages, inFlightUsers, partialAssistant, pendingToolCalls, previousRunId, cause, and a lazy writer. Return any of chain, recoveredTurns, or beforeBoot to override the default. Agents using hydrateMessages skip the hook — customer-owned persistence is the source of truth.
Also retracts the OOM resilience caveat: model context on retry is no longer "incomplete" without hydrateMessages. The smart default reconstructs full context from session.out replay.
See Recovery boot for the full guide.
<Update label="May 16, 2026" description="0.0.0-chat-prerelease-20260519091352" tags={["SDK", "Breaking"]}>
Long-lived chats were accumulating session.out records forever (every turn appends; nothing trimmed). The Sessions dashboard re-streamed the entire history from seq_num=0 on every page load, and OOM-retry boot scanned the whole stream to find the last turn-complete.
After this release session.out stays roughly one turn long forever at steady state. After each turn-complete, the agent appends an S2 trim command record pointing back to the previous turn-complete's seq_num. Full conversation history continues to live in the durable S3 snapshot, not on the stream. Resume across a single turn boundary still works (the previous turn-complete is still on the stream and S2's eventually-consistent trim window gives 10-60s of grace); resume across multiple turns of inactivity falls back to the snapshot.
trigger:turn-complete and trigger:upgrade-required are no longer JSON data chunks on session.out. They're now header-form control records under a uniform trigger-control namespace:
headers:
["trigger-control", "turn-complete"]
["public-access-token", "eyJ..."] // optional, refreshed JWT on turn-complete
body: ""
headers:
["trigger-control", "upgrade-required"]
body: ""
The control event names ("turn-complete", "upgrade-required") are unchanged conceptually — they just moved from chunk.type into a trigger-control header value. Body is always empty; metadata that previously rode in the chunk (e.g. publicAccessToken) now rides on sibling headers.
turn-complete also picks up a new optional sibling header — ["session-in-event-id", "<seq>"] — carrying the agent's committed-consume cursor on .in as of this turn. It's an agent-internal contract that lets the next worker boot seed its .in SSE subscription past already-processed user messages, without relying on a wall-clock-derived dedup cutoff. Custom transports should ignore the header; it has no client-side meaning.
Built-in SDK transports (TriggerChatTransport, AgentChat) handle this transparently — onTurnComplete fires the same way with the same payload. Custom transports filtering on chunk.type === "trigger:turn-complete" need to switch to the header-based filter:
import { controlSubtype } from "@trigger.dev/core/v3";
const control = controlSubtype(record.headers);
if (control === "turn-complete") {
// refresh token from record.headers, end turn, etc.
}The full uniform filter rule (data records vs control records vs S2 command records like trim) is documented at Records on session.out.
The Sessions detail page in the trigger.dev dashboard now reads the agent's S3 snapshot first via a presigned URL, then SSE-tails from snapshot.lastOutEventId. Bandwidth and time-to-first-render are O(unread turns) instead of O(session lifetime). Sessions that registered a hydrateMessages hook (which skips snapshot writes) show only the most recent turn — those customers typically have their own DB-backed dashboards.
- Custom transports parsing
chunk.typefor turn-complete / upgrade-required must switch to thetrigger-controlheader check. - Snapshot consumers should import
ChatSnapshotV1/ChatSnapshotV1Schemafrom@trigger.dev/core/v3(now an exported shape, not SDK-internal).
Hard cutover — no compat shim. v4.5 is prerelease.
- Records on
session.out— full filter rule for data / control / command records. - Resuming a stream — explicit single-turn vs multi-turn-away semantics.
turn-completecontrol record andupgrade-requiredcontrol record — replaced the old chunk-shape docs.
<Update label="May 8, 2026" description="0.0.0-chat-prerelease-20260519091352" tags={["SDK", "Breaking"]}>
chat.agent long-running chats with heavy tool results were hitting the realtime API's 512 KiB body cap on /realtime/v1/sessions/{id}/in/append once the accumulated UIMessage[] history (which the wire shipped in full on every send) crossed the limit. The 413 surfaced as a CORS error in browsers and stalled chats around turn 10–30 with tool use.
The wire is now delta-only: each .in/append carries at most one new UIMessage (the new user turn or a tool-approval response) instead of the full history. The agent rebuilds prior history at run boot from a durable JSON snapshot in object storage plus a replay of the session.out tail. The 512 KiB ceiling stops being pressure — slim payloads are normally a few KB regardless of chat length.
// Before — full history shipped on every send
{ messages: [u1, a1, u2, a2, /* ... 30 turns ... */, u31], chatId, trigger: "submit-message" }
// After — only the new turn
{ message: u31, chatId, trigger: "submit-message" }ChatTaskWirePayload:messages: UIMessage[]is removed. Replaced bymessage?: UIMessage(singular, optional) and a dedicatedheadStartMessages?: UIMessage[]field used only bychat.headStartfirst-turn handover.- Run boot: when
hydrateMessagesis not registered, the runtime readspackets/{projectRef}/{envSlug}/sessions/{sessionId}/snapshot.jsonfrom object storage and replays anysession.outchunks landed since the snapshot's cursor. Snapshot writes happen after everyonTurnComplete, awaited so they survive an idle suspend. hydrateMessagesshort-circuit: registering the hook skips snapshot read/write and replay entirely. Customer is the source of truth for history, same as today.hydrateMessages.incomingMessages: now consistently 0-or-1-length across every trigger type. Previouslyregenerate-messageand continuations occasionally shipped full history; they now ship none.onChatStartis now once-per-chat: fires only on the chat's very first user message; does NOT fire on continuation runs (post-endRun, post-waitpoint-timeout, post-chat.requestUpgrade) or on OOM-retry attempts. ThecontinuationandpreviousRunIdfields onChatStartEventare now@deprecated(alwaysfalse/undefinedwhen the hook fires). Drop anyif (continuation) return;gates fromonChatStart— they're now unreachable. For per-turn setup that runs on continuations too, move toonTurnStart.- Continuation boot payload: the server now strips
message/messages/triggerfrom the cachedbasePayloadon continuation runs, and the SDK enters a new continuation-wait branch that waits silently onsession.infor the next user message. Fixes a phantom-turn bug where stale boot-payload fields were replayed on every resume. - OOM-retry boot: uses the snapshot's
lastOutTimestampas thesession.incutoff, saving one stream subscription per retry. - Built-in transports:
TriggerChatTransport,AgentChat, mid-stream pending-message handling, andchat.headStartroute handler all updated to the slim shape. Existing customer code callingtransport.sendMessage(...)/agentChat.sendMessage(...)is unaffected — the change is below those surfaces.
Snapshot read/write reuses Trigger.dev's existing object-store infrastructure — the same presigned-URL routes used for large payloads. Set OBJECT_STORE_* env vars on your webapp deployment if you haven't already; MinIO works locally via OBJECT_STORE_DEFAULT_PROTOCOL.
If no object store is configured and no hydrateMessages hook is registered, conversations don't survive run boundaries (the runtime logs a warning at registration time). Either configure an object store or register hydrateMessages.
- Custom transports: any code constructing
ChatTaskWirePayloaddirectly must dropmessagesand usemessage. See the rewritten Client Protocol. - Client-side
setMessagesno longer round-trips: full-history mutations on the client never reached the agent before this release either, but the slim wire makes that explicit. Use server-sidechat.history.set()insideonTurnStartfor compaction. - Custom server-to-server senders: code calling
apiClient.appendToSessionInput(sessionId, ...)or hitting/realtime/v1/sessions/{id}/in/appenddirectly must switch to the slim shape.
Hard cutover — there is no compat shim. v4.5 is prerelease.
- Rewritten Client Protocol — slim payload, new
headStartMessagesfield, new "How history is rebuilt" and "Head-start protocol caveat" sections. - New Persistence and replay — end-to-end walkthrough of the snapshot model, OOM-retry interaction, crash semantics,
hydrateMessagesshort-circuit. - New Tool result auditing — the
extractNewToolResults+onTurnComplete/hydrateMessagespattern for HITL audit logging. - v4.5 section of the upgrade guide — migration steps for custom transports and
hydrateMessagesconsumers. hydrateMessages,onChatStart— clarifications on the newincomingMessagesandmessagesshapes.
<Update label="May 7, 2026" description="0.0.0-chat-prerelease-20260507131256" tags={["SDK"]}>
Customers building human-in-the-loop tools were re-implementing the same accumulator-walking logic to figure out which tool calls were pending, which were resolved, and which results in an incoming wire message were actually new. Lifted into the SDK as five new methods on chat.history:
| Method | Description |
|---|---|
chat.history.getPendingToolCalls() |
Tool calls on the most recent assistant message in input-available state — gates fresh user turns during HITL. |
chat.history.getResolvedToolCalls() |
All tool calls in the chain in output-available or output-error state. |
chat.history.extractNewToolResults(message) |
Tool results in message whose toolCallId is not already resolved on the chain. Most useful in hydrateMessages against an incoming wire message, before the runtime merges it. |
chat.history.getChain() |
Same as chat.history.all() — alias that reads better alongside parent-aware APIs. |
chat.history.findMessage(messageId) |
Direct lookup; undefined if absent. |
// Refuse a regenerate while a tool call is awaiting an answer
onAction: async ({ action }) => {
if (action.type === "regenerate") {
if (chat.history.getPendingToolCalls().length > 0) return;
chat.history.slice(0, -1);
}
},
// Side-effect once per net-new tool result on incoming wire messages
hydrateMessages: async ({ incomingMessages }) => {
for (const msg of incomingMessages) {
for (const r of chat.history.extractNewToolResults(msg)) {
await auditLog.record({ id: r.toolCallId, output: r.output, errorText: r.errorText });
}
}
return incomingMessages;
},See chat.history and Human-in-the-loop.
In some HITL flows the AI SDK regenerated the assistant message id when the user's addToolOutput answer round-tripped back to the agent. The fresh id slipped past the runtime's id-based merge, leaving the resolved tool answer attached to a sibling assistant message instead of the head, which broke downstream dedup and rendered the tool answer twice.
The runtime now records toolCallId → head messageId whenever an assistant with tool parts lands in the accumulator and rewrites the incoming id back via that map before the merge. Customers who had a content-match workaround for this can drop it.
<Update label="May 6, 2026" description="0.0.0-chat-prerelease-20260506093419" tags={["SDK", "Breaking"]}>
Submitting an action via transport.sendAction() previously fell through to the regular turn machinery, calling onTurnStart, run(), onTurnComplete, etc. — meaning every action fired an LLM call by default. The workaround was a chat.local-based skipModelCall flag read in run().
Actions now fire hydrateMessages and onAction only. No onTurnStart / prepareMessages / onBeforeTurnComplete / onTurnComplete, no run() invocation, no turn-counter increment. The trace span is named chat action instead of chat turn N.
onAction's return type widens: returning void is side-effect-only (default); returning a StreamTextResult, string, or UIMessage produces a model response that's auto-piped back to the frontend.
If you had run() branching on payload.trigger === "action" for a model response, return your streamText(...) from onAction instead. If you persisted in onTurnComplete, do that work inside onAction. For state-only actions, just remove the skip-the-model workaround.
// before
onAction: async ({ action }) => {
if (action.type === "regenerate") {
runState.skipModelCall = false;
chat.history.slice(0, -1);
}
},
run: async ({ messages, signal }) => {
if (runState.skipModelCall) return;
return streamText({ model, messages, abortSignal: signal });
},
// after
onAction: async ({ action, messages, signal }) => {
if (action.type === "regenerate") {
chat.history.slice(0, -1);
return streamText({ model, messages, abortSignal: signal });
}
},
run: async ({ messages, signal }) =>
streamText({ model, messages, abortSignal: signal }),Actions arriving when no onAction handler is configured now console.warn once and are ignored — previously they silently fell through to run() with an empty wire payload.
<Update label="May 5, 2026" description="0.0.0-chat-prerelease-20260505140031" tags={["SDK"]}>
Every message sent to a chat.agent after the run idle-suspended produced two turns on the agent side instead of one — same user message, two LLM calls. Internal session-stream reconnect logic was racing the waitpoint and feeding the just-consumed message back into the next turn's input buffer. No public API change.
<Update label="May 5, 2026" description="0.0.0-chat-prerelease-20260505084711" tags={["SDK"]}>
A new opt-in flow that cuts first-turn TTFC roughly in half by running step 1's LLM call in your warm process while the chat.agent run boots in parallel. On the LLM's tool-calls boundary, ownership of the durable stream hands over to the agent for tool execution and step 2+. Pure-text first turns finish on the customer side with no LLM call from the trigger run at all.
Measured on claude-sonnet-4-6 (same model both sides): TTFT 2801ms → 1218ms (−57%), total turn 4180ms → 2345ms (−44%). With Head Start, first-text time is essentially the LLM TTFB floor.
import { chat } from "@trigger.dev/sdk/chat-server";
import { streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { headStartTools } from "@/lib/chat-tools/schemas";
export const POST = chat.headStart({
agentId: "my-chat",
run: async ({ chat: helper }) =>
streamText({
...helper.toStreamTextOptions({ tools: headStartTools }),
model: anthropic("claude-sonnet-4-6"),
system: "You are a helpful assistant.",
}),
});const transport = useTriggerChatTransport({
task: "my-chat",
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
startSession: ({ chatId, taskId, clientData }) =>
startChatSession({ chatId, taskId, clientData }),
headStart: "/api/chat",
});Tool schemas (description + inputSchema) live in their own module that imports only ai and zod. The agent task imports those schemas and adds heavy execute fns. The route handler imports schemas only — keeping the warm-process bundle light is what makes the win possible. Runtime "strip executes" helpers don't solve this — bundlers resolve imports at build time. See Fast starts → Head Start setup for the full split.
Preload eagerly triggers the run on page load (good when you're confident the user will send a message — trades idle compute for fast TTFC). Head Start gates the run on a real first message — no idle compute, customer's process runs step 1 directly. Pick one per chat.
chat.headStart returns a standard Web Fetch handler — (req: Request) => Promise<Response> — so it slots into Next.js App Router, Hono, SvelteKit, Remix / React Router v7, TanStack Start, Astro, Nitro/Nuxt, Elysia, Cloudflare Workers, Bun, Deno, and any other runtime that speaks Web Fetch. Verified runtimes: Node 18+, Bun, Deno, Workers, Vercel (Node and Edge), Netlify (Functions and Edge).
For Node-only frameworks (Express, Fastify, Koa, raw node:http), the SDK ships chat.toNodeListener(handler) — converts any Web Fetch handler into a Node (req, res) listener with proper streaming, header translation, and client-disconnect propagation.
import express from "express";
import { chat } from "@trigger.dev/sdk/chat-server";
const handler = chat.headStart({ agentId: "my-chat", run: ... });
const app = express();
app.post("/api/chat", chat.toNodeListener(handler));- New Head Start guide — bundle isolation, schema/execute split, route handler setup, transport option, lifecycle, limitations.
- Reference —
headStarttransport option.
<Update label="May 2, 2026" description="0.0.0-chat-prerelease-20260502065709" tags={["SDK"]}>
The chat transport now retries indefinitely on network drops with bounded exponential backoff (100ms initial, 5s cap, 50% jitter) instead of giving up after 5 attempts. Reconnects are immediate on online, on tab refocus after a long background, and on Safari bfcache restore (pageshow with event.persisted).
A 60s stall detector catches silent-dead-socket cases on mobile where the OS killed the TCP socket without the reader noticing. A 30s per-attempt fetch timeout prevents stuck connections from blocking the retry loop.
Resume continues to use Last-Event-ID, so no chunks are lost when the connection comes back. No public API change — these are defaults on TriggerChatTransport. Customers who built hasActiveStream / isStreaming flag tracking on their side can drop it: the transport handles the silent-but-stale case internally now.
SSEStreamSubscription (used by TriggerChatTransport and AgentChat) gained retryNow() and forceReconnect() for callers writing custom transports, plus options to tune maxRetries / retryDelayMs / maxRetryDelayMs / retryJitter / fetchTimeoutMs / stallTimeoutMs / nonRetryableStatuses. 404 and 410 short-circuit retry by default (stream gone / session closed).
<Update label="April 24, 2026" description="0.0.0-chat-prerelease-20260501122331" tags={["SDK", "Platform"]}>
Every chat is backed by a durable Session row that outlives any single run. externalId = your chat ID, type = "chat.agent". Under the hood:
- Output chunks stream on
session.out(was a run-scopedstreams.writer("chat")). - Client messages and stops land on
session.inas aChatInputChunktagged union (was two run-scopedstreams.inputdefinitions). - Wire endpoints moved from
/realtime/v1/streams/{runId}/...to/realtime/v1/sessions/{sessionId}/.... See the rewritten Client Protocol.
Public surface (chat.agent(), TriggerChatTransport, AgentChat, chat.stream / chat.messages / chat.stopSignal) is unchanged — existing apps keep working. What's new is:
- Cross-run resume is free. A chat you were in yesterday resumes against the same
sessionIdtoday, even if the original run long since exited. No more lost conversations when a run idle-times-out. - Inbox views via
sessions.list({type: "chat.agent"}). Enumerate every chat in your environment, filter by tag or status. TriggerChatTaskResult.sessionId+ChatTaskRunPayload.sessionId— you can reach into the raw session viasessions.open(payload.sessionId)for advanced cases (writing from a sub-agent, custom transport).- Dashboard Agent tab resolves via
sessionIdand stays in sync with the live stream across runs.
The full wire-level protocol (session create, channel routes, JWT scopes) is documented in Client Protocol.
When a client reconnects to session.out and the tail record is a trigger:turn-complete marker (agent finished a turn, idle-waiting or exited), the server sets X-Session-Settled: true and uses wait=0 on the underlying S2 read. The SSE drains any remaining records then closes in ~1s instead of long-polling for 60s.
Practical impact: TriggerChatTransport.reconnectToStream no longer needs a client-side isStreaming flag. You can drop the field from your persisted ChatSession state entirely — the server decides. Existing callers that still persist isStreaming are unaffected; reconnectToStream keeps the fast-path short-circuit when it's false.
See the Sessions Upgrade Guide for the full step-by-step — auth callback split, persisted ChatSession shape, server-side helpers (chat.createStartSessionAction, chat.createAccessToken for renewal), and the clientData validation pivot.
- Rewritten Client Protocol — full wire format for the new
/realtime/v1/sessions/{sessionId}/...endpoints, JWT scopes, S2 direct-write credentials, andLast-Event-IDresume. - Database persistence pattern — new
chatId-keyedChatSessionshape (no morerunId) and a warning on theonTurnCompleterace that requires a single atomic write ofmessages+lastEventId. - Reference — added
chat.createStartSessionAction,chat.createAccessToken,ChatInputChunk,TriggerChatTaskResult.sessionId,ChatTaskRunPayload.sessionId. The old run-scoped stream-ID constants are gone. - Refreshed Backend, Frontend, Server Chat, Quick start, Overview, Types, Error handling, and Testing for the session-based wiring.
<Update label="April 19, 2026" description="0.0.0-chat-prerelease-20260419173457" tags={["SDK", "CLI"]}>
Ship reusable capabilities as folders — a SKILL.md plus optional scripts, references, and assets. The agent sees short descriptions in its system prompt, loads full instructions on demand via loadSkill, and invokes bundled scripts via bash — no manual wiring.
skills.define({ id, path }) registers the skill; the CLI bundles the folder into the deploy image. chat.skills.set([...]) activates skills for the run; chat.toStreamTextOptions() auto-injects the preamble and tools.
See the new Agent Skills guide.
<Update label="April 18, 2026" description="0.0.0-chat-prerelease-20260418174118" tags={["SDK"]}>
New imperative API to exit the loop after the current turn completes, without the upgrade-required signal that chat.requestUpgrade() sends. Use for one-shot agents, budget-exhausted exits, or goal-reached completions.
chat.agent({
id: "one-shot",
run: async ({ messages, signal }) => {
chat.endRun();
return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal });
},
});The current turn streams normally, onBeforeTurnComplete / onTurnComplete fire, the turn-complete chunk is written, and the run exits instead of suspending. Callable from run(), chat.defer(), onBeforeTurnComplete, or onTurnComplete. See Ending a run on your terms.
TurnCompleteEvent and BeforeTurnCompleteEvent now include the AI SDK's finishReason ("stop" | "tool-calls" | "length" | "content-filter" | "error" | "other"). Clean signal for distinguishing a normal turn end from one paused on a pending tool call (HITL flows like ask_user):
onTurnComplete: async ({ finishReason, responseMessage }) => {
if (finishReason === "tool-calls") {
// Paused — assistant message has a pending tool call waiting for user input
await persistCheckpoint(responseMessage);
} else {
await persistCompleted(responseMessage);
}
};Undefined for manual chat.pipe() flows or aborted streams. See the new Human-in-the-loop pattern.
The Compaction guide now covers how to wire a "Summarize conversation" button or /compact slash command via actionSchema + onAction. The agent summarizes on demand, rewrites history with chat.history.set(), and short-circuits the LLM call for action turns.
Needed a small type fix for this: ChatTaskPayload.trigger now correctly includes "action", so run() handlers can short-circuit with if (trigger === "action") return when an action doesn't need a response.
New Human-in-the-loop page walks through ask_user-style mid-turn user input end-to-end: defining a no-execute tool, rendering pending tool calls on the frontend with addToolOutput + sendAutomaticallyWhen, detecting paused turns via finishReason, and two persistence strategies (overwrite vs. checkpoint nodes).
<Update label="April 18, 2026" description="0.0.0-chat-prerelease-20260418083610" tags={["SDK"]}>
@trigger.dev/sdk/ai/test now ships mockChatAgent, a harness that drives a chat.agent definition through real turns without network or task runtime. Send messages, actions, and stop signals; inspect emitted chunks; assert on hook order.
import { mockChatAgent } from "@trigger.dev/sdk/ai/test";
import { MockLanguageModelV3 } from "ai/test";
import { myAgent } from "./my-agent";
const harness = mockChatAgent(myAgent, {
chatId: "test-1",
clientData: {
model: new MockLanguageModelV3({
/* ... */
}),
},
});
const turn = await harness.sendMessage({
id: "u1",
role: "user",
parts: [{ type: "text", text: "hi" }],
});
expect(turn.chunks).toContainEqual(expect.objectContaining({ type: "text-delta", delta: "hello" }));
await harness.close();setupLocals pre-seeds locals before run() starts — the pattern for injecting database clients, service stubs, and other server-side dependencies that shouldn't leak through untrusted clientData:
import { dbKey } from "./db";
const harness = mockChatAgent(agent, {
chatId: "test-1",
setupLocals: ({ set }) => {
set(dbKey, testDb);
},
});Hooks then read the seeded value with locals.get(dbKey). Falls through to the production client in real runs.
See Testing.
@trigger.dev/core/v3/test now exports runInMockTaskContext for unit-testing any task code offline (not just chat agents). Installs in-memory managers for locals, lifecycleHooks, runtime, inputStreams, and realtimeStreams, plus a mock TaskContext. Drivers let you push data into input streams and inspect chunks written to output streams.
<Update label="April 17, 2026" description="0.0.0-chat-prerelease-20260417152143" tags={["SDK"]}>
Prevent duplicate messages when the same chat is open in multiple browser tabs. Enable with multiTab: true on the transport.
const transport = useTriggerChatTransport({ task: "my-chat", multiTab: true, accessToken });
const { messages, setMessages } = useChat({ id: chatId, transport });
const { isReadOnly } = useMultiTabChat(transport, chatId, messages, setMessages);Only one tab can send at a time. Other tabs enter read-only mode with real-time message updates via BroadcastChannel. When the active tab's turn completes, any tab can send next. Crashed tabs are detected via heartbeat timeout (10s).
See Multi-tab coordination and useMultiTabChat.
Large error stacks no longer OOM the worker process. Stacks are capped at 50 frames (top 5 + bottom 45), individual lines at 1024 chars, messages at 1000 chars. Applied in parseError, sanitizeError, and OTel span recording.
<Update label="April 15, 2026" description="0.0.0-chat-prerelease-20260415164455" tags={["SDK"]}>
When refreshing a page after a turn completed, useChat with resume: true would hang indefinitely — reconnectToStream opened an SSE connection that never received data.
Added isStreaming to session state. The transport sets it to true when streaming starts and false on trigger:turn-complete. reconnectToStream returns null immediately when isStreaming is false, so resume: initialMessages.length > 0 is now safe to pass unconditionally.
The flag flows through onSessionChange and is restored from sessions — no extra persistence code needed.
<Update label="April 15, 2026" description="0.0.0-chat-prerelease-20260415152704" tags={["SDK"]}>
Load message history from your database on every turn instead of trusting the frontend accumulator. The hook replaces the built-in linear accumulation entirely — the backend is the source of truth.
chat.agent({
id: "my-chat",
hydrateMessages: async ({ chatId, trigger, incomingMessages }) => {
const stored = await db.getMessages(chatId);
if (trigger === "submit-message" && incomingMessages.length > 0) {
stored.push(incomingMessages[incomingMessages.length - 1]!);
await db.persistMessages(chatId, stored);
}
return stored;
},
});Tool approval updates are auto-merged after hydration — no extra handling needed.
See hydrateMessages.
Modify the accumulated message history from any hook or run():
chat.history.rollbackTo(messageId); // Undo — keep up to this message
chat.history.remove(messageId); // Remove one message
chat.history.replace(id, newMsg); // Edit a message
chat.history.slice(0, -2); // Remove last 2 messages
chat.history.all(); // Read current stateSee chat.history.
Send typed actions (undo, rollback, edit) from the frontend via transport.sendAction(). Actions wake the agent, fire onAction, then trigger a normal run() turn.
chat.agent({
id: "my-chat",
actionSchema: z.discriminatedUnion("type", [
z.object({ type: z.literal("undo") }),
z.object({ type: z.literal("rollback"), targetMessageId: z.string() }),
]),
onAction: async ({ action }) => {
if (action.type === "undo") chat.history.slice(0, -2);
if (action.type === "rollback") chat.history.rollbackTo(action.targetMessageId);
},
});Frontend: transport.sendAction(chatId, { type: "undo" })
Server: agentChat.sendAction({ type: "undo" })
See Actions and Sending actions.
<Update label="April 14, 2026" description="0.0.0-chat-prerelease-20260414181032" tags={["SDK"]}>
Added chat.response.write() for writing data parts that both stream to the frontend AND persist in onTurnComplete's responseMessage and uiMessages.
// Persists to responseMessage.parts — available in onTurnComplete
chat.response.write({ type: "data-handover", data: { context: summary } });
// Transient — streams to frontend only, not in responseMessage
writer.write({ type: "data-progress", data: { percent: 50 }, transient: true });Non-transient data-* chunks written via lifecycle hook writer.write() now automatically persist to the response message, matching the AI SDK's default semantics. Add transient: true for ephemeral chunks (progress indicators, status updates).
See Custom data parts.
Added support for AI SDK tool approvals (needsApproval: true). When the model calls a tool that needs approval, the turn completes and the frontend shows approve/deny buttons. After approval, the updated assistant message is sent back and matched by ID in the accumulator.
const sendEmail = tool({
description: "Send an email. Requires human approval.",
inputSchema: z.object({ to: z.string(), subject: z.string(), body: z.string() }),
needsApproval: true,
execute: async ({ to, subject, body }) => {
/* ... */
},
});Frontend setup requires sendAutomaticallyWhen and addToolApprovalResponse from useChat. See Tool approvals.
Added stopGeneration method to TriggerChatTransport for reliable stop after page refresh / stream reconnect. Works regardless of whether the AI SDK passes abortSignal through reconnectToStream.
const stop = useCallback(() => {
transport.stopGeneration(chatId);
aiStop(); // also update useChat state
}, [transport, chatId, aiStop]);See Stop generation.
generateMessageId can now be passed via uiMessageStreamOptions to control response message ID generation (e.g. UUID-v7). The backend automatically passes originalMessages to toUIMessageStream so message IDs are consistent between frontend and backend.
onTurnCompletenot called: FixedturnCompleteResult?.lastEventIdTypeError that silently skippedonTurnCompletewhenwriteTurnCompleteChunkreturned undefined in dev.- Stop during streaming: Added 2s timeout on
onFinishPromisesoonBeforeTurnCompleteandonTurnCompletefire even when the AI SDK'sonFinishdoesn't fire after abort. toStreamTextOptionswithoutchat.prompt.set():prepareStepinjection (compaction, steering, background context) now works even when the user passessystemdirectly tostreamTextinstead of usingchat.prompt.set().- Background queue vs tool approvals: Background context injection is now skipped when the last accumulated message is a
toolmessage, preventing it from breakingstreamText'scollectToolApprovals.