2 breaking changes, 4 new features, 16 improvements, 1 bug fix, and 19 server changes.
Breaking changes
Zod 4 by default
The minimum supported Zod version is now 3.25.56 (released June 2025). Trigger.dev now uses Zod 4 internally. Projects on Zod 3.25.56 or any later v3 release remain supported. Zod stays a runtime dependency, so existing projects receive it automatically, and the updated peer dependency range lets package managers reuse a compatible Zod 3 or Zod 4 from your own dependencies. If typechecking, tests, or a deploy fail after upgrading, see the Zod compatibility guide. (We're now a platinum Zod sponsor, too. We use it everywhere, so it felt right.)
Session .in reads require a secret key
Reading a session's .in channel (GET /realtime/v1/sessions/{id}/in and /in/records) now requires a secret key. Public tokens, including read:sessions:{id}, receive a 403 on .in reads. They can still read .out and append to .in.
Chat agents
Most of this release is a push on chat.agent: a simpler way to call the model, transcript storage you own, actions that drive real turns, a way to end a conversation, and a batch of reliability fixes.
The run function now hands you a managed streamText
Your run function receives a streamText with the agent's managed options already applied. The features you configured on chat.agent (tools, steering, compaction, injected context, prompt caching) are wired in for you, so there's no spread to forget.
chat.agent({ id: "my-chat", run: async ({ messages, signal, streamText }) => streamText({ model, messages, abortSignal: signal }),});
Before, you had to spread chat.toStreamTextOptions() into an imported streamText, and leaving it out (or passing your own tools or prepareStep after it) silently switched off steering, compaction, and injected context. The managed streamText merges your tools with the skill tools and composes your prepareStep with the managed one, so neither can be turned off by accident. system may be set in exactly one place, the call site, chat.agent({ system }), or chat.prompt.set(), and setting it in two throws rather than silently dropping one. Spreading chat.toStreamTextOptions() yourself still works and is equivalent. Read the chat.agent reference.
Transcript storage you own
chat.agent now persists a conversation through a TranscriptStorage: an adapter with load and save that the runtime drives after every turn, failed turn, and history-changing action. The platform snapshot is still the default, so you keep durable history without doing anything. Point storage at your own adapter and the conversation lands in your database as it happens, owned by you and sitting next to the rest of your data.
chat.agent({ id: "my-chat", storage: myTranscriptStorage, run: async ({ messages, signal, streamText }) => streamText({ model, messages, abortSignal: signal }),});
Every save carries two things: the changes since the last save, so a row store writes only what changed and an undo is a single truncateAfter, and the whole transcript as it now stands, so a document store writes it as-is with no bookkeeping of its own. chat.createLoadTranscriptAction(storage) and useLoadTranscript read it back the same way for any storage, and runTranscriptStorageTests from @trigger.dev/sdk/ai/test checks your adapter against the contract. Read the transcript storage docs.
Actions can drive a full turn
Undo, edit, and regenerate are now first-class. onAction edits the conversation with chat.history, and returning chat.turn() runs a real turn on the edited history, with the agent's system prompt, tools, steering, compaction, injected context, onTurnStart/onTurnComplete, and persistence. A regenerate is two lines.
onAction: async ({ action }) => { if (action.type === "regenerate") { chat.history.slice(0, -1); return chat.turn(); } if (action.type === "undo") chat.history.slice(0, -2); // edit only},
Actions are sent through useChat, so a turn that follows one streams into the message list and behaves like any other turn, with working status, error, and stop. useChatActions({ sendMessage }) from @trigger.dev/sdk/chat/react is a two-line convenience over that. Returning a StreamTextResult, string, or UIMessage from onAction is no longer supported and now throws, pointing you to chat.turn(): the old shape skipped every turn guarantee and never rendered in the browser, because the frontend never consumed the stream it returned. Undo, edit, and regenerate now also survive a run ending, where before a rollback lived only in the warm worker's memory and reverted minutes later on the next continuation. Read the actions docs.
End a conversation with chat.close()
Call chat.close({ reason }) from inside an agent to close a session: the session row is closed, further sends are refused with HTTP 409, and the run exits without scheduling a continuation. Useful for budget caps, completed goals, or signed-out users.
chat.agent({ id: "budgeted-agent", run: async ({ messages, signal }) => streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }), onBeforeTurnComplete: async ({ chatId }) => { if (await overBudget(chatId)) { chat.close({ reason: "Monthly budget reached" }); } },});
The current turn still streams in full. Decide to close before the turn ends (run(), prepareStep, onBeforeTurnComplete) so the closed state rides out on that turn's final record. TriggerChatTransport picks up the close from the response stream or a refused send, exposes it as transport.sessionStatus(chatId) and transport.sessionClosedReason(chatId), and stops reconnecting. Calling sessions.close() from outside also reaches a live run, so an idle or suspended agent exits on its next wake. Read the chat.agent reference.
More chat.agent reliability and fixes
- A failed write to a realtime or chat session stream no longer crashes the process. Dropped chat session output writes are now logged instead of swallowed.
- Reading a page of a chat agent's conversation now fetches only those messages rather than the full transcript. Load time stays roughly constant however long the conversation grows. The model-side context (compacted history, injected context) is excluded from paged reads so it can't reach a browser through a server action.
- Session public tokens can be narrowed to a single stream:
read: { sessions: "chat_123:out" }grants read access to that session's.outchannel only, without access to the session record or its other channels. chat.agent: a run recovering a session with multiple in-flight user messages no longer drops the unanswered ones on restart. Recovered messages now hold the resume cursor until each has been answered.chat.agent: undo, edit, and regenerate now survive a run ending. History rolled back fromonActionwas previously only kept in memory and reverted on the next continuation, so the undone messages came back minutes later with no error.chat.agenttranscript fixes: a turn that errors before the model produces content no longer stores an empty assistant message, an error thrown without a message now shows a generic error, and custom transcript storage no longer needs to preserve exact message JSON for compaction to survive continuation.chat.agent: steering messages injected mid-answer are now persisted in the conversation for hooks and for the model on later turns. Previously a mid-answer message shaped the response but vanished from the transcript on completion, so the model forgot the instruction from the next turn onwards while the chat UI still showed it.- Chat sessions can be pinned to a deployment so a conversation keeps talking to the agent version its release shipped with, following the pin automatically on redeploy.
Improvements
- The
playwrightbuild extension now works with Playwright 1.58 and later. 1.58 changed theplaywright install --dry-runoutput, which previously caused deploy image builds to fail while downloading browsers. (#4881) - When the build log stream disconnects during a deploy, the CLI now explains that the deployment itself is unaffected and exits immediately with a non-zero code. Previously a disconnect printed the raw stream error and left the process hanging. (#4887)
- Build logs no longer include Docker's registry login output, removing the credential-storage warning on failed builds. (#4909)
- Sensitive values are reduced across CLI and SDK diagnostics. Files created by
trigger env pullare now secured, and credentials are stripped from collected Git remote metadata. - The CLI automatically archives up to three inactive dev branches when a new branch would exceed the environment limit. Connected and recently active branches are protected; the CLI reports which branches were archived.
- Deployments now expose the
--external-idthey were deployed under asexternalId, readable fromctx.deployment.externalId. Also fixes the deployments list failing when a deployment had no Git metadata. - Adds an optional
appliedSchedulePolicyfield to the schedule API response. The field is present only when a policy constraint applies a minimum spread window to the schedule. - Triggering a task with an ID that can't be represented in a URL now fails with a clear error naming the task ID, instead of a cryptic URI error.
Bug fixes
- Fixes storage of large trigger payloads for task IDs containing a slash. Affected IDs that started or ended with a slash, contained two consecutive slashes, or contained a
.or..path component. The storage path is now built from a generated ID, so no task ID can produce an unusable path. Payloads already stored are still read from their original location.
Server changes
These changes are included in the v4.6.0 Docker image and are already live on Trigger.dev Cloud:
- The organization Projects settings page now has a button that generates a ready-to-paste prompt listing every project that needs a Node.js runtime update, for handing off to a coding agent.
- Additional API keys are now enabled by default. New environments no longer display root API keys, and existing environments can permanently disable their visibility.
- New schedules use a default CRON spread window, distributing runs after their scheduled time instead of starting them all at once. Set an explicit window to override the default.
- Schedules support a configurable minimum spread window that applies even when a smaller window is requested.
- The "Cancel in-progress runs when this limit is reached" option on the billing limit form is now enabled by default when you first configure a limit.
- Creating and archiving Development and Preview branches now requires the branch management permission (the Developer role has it by default).
- Ask Trigger now opens as a floating window you can drag anywhere and resize, with options to switch it to a right-side panel or fullscreen. Choose the default position in account settings.
- The Queues page Allocated tile now explains that it is the sum of queue concurrency limits, and no longer shows a warning color when those exceed the environment limit, which is expected.
- Deleting a project now stops its pending runs. Runs waiting on a delay or sitting in the queue are cancelled, and a deleted project no longer sends task failure alerts.
- Fixes an intermittent "Invalid access token" failure caused by the deployment log stream token expiring mid-deploy.
- Dashboard pages stop polling for updates while their browser tab is hidden. Pages refresh when you return to the tab.
- Fixes double-counting of agent LLM calls in cost aggregates and the AI metrics page. Per-call figures in the run view were always correct and are unchanged.
- Retried trigger and batch trigger requests are deduplicated again: when the SDK retries a request the server already accepted, you get the original run or batch back instead of a duplicate.
- Fixes the Queues page showing "No activity" on queue-metrics charts for some organizations even though metrics were being collected.
- The queue page's "Oldest wait" card now shows a single number with an explanatory tooltip, removing the second "worst" figure that could confusingly read lower than the headline.
- Run replication now recovers on its own after a Redis restart or outage, instead of logging errors and holding the replication slot until the server restarts.
- Waitpoint registrations targeting a run outside the authenticated environment are now rejected.
- Switching environments keeps you on the current page when a task ID contains a slash, and the test page for a webhook task with a slashed ID now opens correctly.
- GitHub App installations are now linked only after the installing GitHub user authorizes the App and is verified to have access to the installation.
How to upgrade
Update the trigger.dev/* packages to v4.6.0 using your package manager:
npx trigger.dev@latest update # npmpnpm dlx trigger.dev@latest update # pnpmyarn dlx trigger.dev@latest update # yarnbunx trigger.dev@latest update # bun
Self-hosted users: update your Docker image to ghcr.io/triggerdotdev/trigger.dev:v4.6.0.




