docs/ai-chat/patterns/version-upgrades.mdx
Chat agent runs are pinned to the worker version they started on. When you deploy a new version, suspended runs resume on the old code. If your deploy includes breaking changes (new tools, changed schemas, updated API contracts), this can cause issues.
chat.requestUpgrade() lets the agent opt out of the current run so the transport triggers a new one on the latest version.
When chat.requestUpgrade() is called in onTurnStart or onValidateMessages:
run() is skipped — no response is generated on old codeendAndContinueSession endpoint, which atomically swaps the Session's currentRunId to a freshly-triggered run on the latest deployment (optimistic-claim against currentRunVersion)session.out keeps receiving chunks across the swap — no client-side reconnectThe new run lives on the same Session as the old one. chatId is the durable identity; only the underlying currentRunId rotates. The audit log records the new run with reason: "upgrade".
When called from inside run() or chat.defer(), the current turn completes normally first and the run exits afterward. The next message triggers the continuation on the same session.
sequenceDiagram
participant User
participant Transport
participant RunV1 as Run (v1)
participant RunV2 as Run (v2)
User->>Transport: send message
Transport->>RunV1: input stream
RunV1->>RunV1: onTurnStart → requestUpgrade()
RunV1-->>Transport: trigger:upgrade-required
RunV1->>RunV1: exit (run() never called)
Transport->>RunV2: trigger new run (continuation, same message)
RunV2-->>Transport: response stream
Transport-->>User: response (seamless)
Define an explicit version for the contract between your frontend and agent. The frontend sends a protocolVersion via clientData, and the agent declares which versions it supports. When a breaking change ships (new tools, changed data parts, updated response format), bump the version.
This gives you full control — the frontend can be backwards-compatible across multiple agent versions, and the agent only upgrades when it sees a version it doesn't support.
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import { useChat } from "@ai-sdk/react";
export function Chat() {
const transport = useTriggerChatTransport({
task: "my-chat",
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
startSession: ({ chatId, clientData }) =>
startChatSession({ chatId, clientData }),
// Bump this when you ship a breaking change to the chat UI or tools
clientData: { userId: user.id, protocolVersion: "v2" },
});
const { messages, sendMessage } = useChat({ transport });
// ...
}
On the agent side, declare which versions the current code supports:
import { chat } from "@trigger.dev/sdk/ai";
import { streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
// The set of frontend protocol versions this agent code supports.
// When you deploy a breaking change, remove old versions from this set.
const SUPPORTED_VERSIONS = new Set(["v2", "v3"]);
export const myChat = chat
.withClientData({
schema: z.object({
userId: z.string(),
protocolVersion: z.string(),
}),
})
.agent({
id: "my-chat",
onTurnStart: async ({ clientData }) => {
if (clientData?.protocolVersion && !SUPPORTED_VERSIONS.has(clientData.protocolVersion)) {
chat.requestUpgrade();
}
},
run: async ({ messages, signal }) => {
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
},
});
The transport includes clientData in every payload — both the initial trigger and subsequent records on the session's .in channel — so the agent always has the current value.
This pattern is useful when:
For automatic upgrade on every deploy, pass your platform's build ID via clientData instead of a manual version. The agent stores the ID from the first message and upgrades when it changes:
// Vercel sets this at build time, or use your own build ID
const APP_VERSION = process.env.NEXT_PUBLIC_VERCEL_DEPLOYMENT_ID
?? process.env.NEXT_PUBLIC_BUILD_ID
?? "dev";
export function Chat() {
const transport = useTriggerChatTransport({
task: "my-chat",
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
startSession: ({ chatId, clientData }) =>
startChatSession({ chatId, clientData }),
clientData: { userId: user.id, appVersion: APP_VERSION },
});
// ...
}
const initialAppVersion = chat.local<{ version: string }>({ id: "appVersion" });
export const myChat = chat
.withClientData({
schema: z.object({
userId: z.string(),
appVersion: z.string(),
}),
})
.agent({
id: "my-chat",
onBoot: async ({ clientData }) => {
initialAppVersion.init({ version: clientData.appVersion });
},
onTurnStart: async ({ clientData }) => {
if (clientData?.appVersion && clientData.appVersion !== initialAppVersion.version) {
chat.requestUpgrade();
}
},
run: async ({ messages, signal }) => {
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
},
});
This upgrades on every deploy, not just breaking changes. Good for fast-moving projects where you always want the latest code.
chat.agent() and chat.createSession() — use chat.requestUpgrade() as shown abovechat.customAgent() — you control the turn loop, so just return from run() when you want to exitchat.requestUpgrade() is a graceful exit — the old run returns cleanly, never writing a partial assistant. The new continuation run boots with an empty session.out tail and the upgrade-trigger message on session.in. The trigger message dispatches as turn 1 on the new version via the normal continuation-wait path. onRecoveryBoot does NOT fire on this path — the hook is reserved for mid-stream interruptions (cancel / crash / OOM) where a partial assistant exists on the tail.
onTurnStart and onChatResume fit in the turn cyclerequestUpgrade)