This guide is for customers who triedDocumentation Index
Fetch the complete documentation index at: https://trigger.dev/docs/llms.txt
Use this file to discover all available pages before exploring further.
chat.agent during the prerelease period.
The public surface of chat.agent({...}), useTriggerChatTransport,
AgentChat, chat.defer, and chat.history is largely
unchanged — but the transport’s auth callbacks and the server-side helpers
that feed them were reshaped, so most prerelease apps need a small wiring
update.
TL;DR
accessTokenis now a pure session-PAT mint — called only on 401/403 to refresh. It must return a token scoped to the session, not atrigger:tasksJWT.startSessionis a new callback that wraps a server action callingchat.createStartSessionAction(taskId). The transport invokes it ontransport.preload(chatId)and lazily on the firstsendMessagefor any chatId without a cached PAT.ChatSessionpersistable state dropsrunId— store only{publicAccessToken, lastEventId?}.- Per-call options on
transport.preload(chatId, ...)are gone. Trigger config (machine, idleTimeoutInSeconds, tags, queue, maxAttempts) lives server-side inchat.createStartSessionAction(taskId, options).
The architectural shift is that
chat.agent no longer rolls its own
per-run streams. It runs on top of a durable Session row that owns
its current run, persists across run lifecycles, and orchestrates
upgrades server-side. The customer-facing surface is similar; the wire
path beneath it changed completely.Step 1: Replace your access-token server action with two server actions
The old pattern was a single helper that minted a trigger token:app/actions.ts (before)
app/actions.ts (after)
chat.createStartSessionAction(taskId) returns a server action that:
- Creates the Session row for
chatId(idempotent on the(env, externalId)unique pair). - Triggers the agent task’s first run with
basePayload: {messages: [], trigger: "preload"}defaults plus any overrides you pass. - Returns
{sessionId, runId, publicAccessToken}to the browser.
Step 2: Update the transport wiring
The transport now takes two callbacks instead of one:app/components/chat.tsx (after)
| Trigger | Callback fired |
|---|---|
transport.preload(chatId) | startSession |
First sendMessage for a chatId with no cached PAT | startSession (auto) |
Any 401/403 from .in/append, .out SSE, or end-and-continue | accessToken |
Page hydrates with sessions: { [chatId]: ... } | Neither (uses hydrated PAT) |
startSession is deduped via an in-flight promise — concurrent
preload + sendMessage calls converge to one server action invocation.
Step 3: Drop transport-level trigger config
The prerelease transport acceptedtriggerConfig, triggerOptions, and
per-call options on preload. All of that moved server-side:
before
after
clientData on the transport (see
the next step) — it’s typed and threaded through startSession automatically.
Step 4: Use clientData for typed payload metadata
If your agent uses withClientData({schema}), the transport’s clientData
option is now the canonical place to set it. The same value:
- Is passed to your
startSessioncallback asparams.clientData, where you forward it intochat.createStartSessionAction’striggerConfig.basePayload.metadata. The agent’s first run sees it inpayload.metadata(visible toonPreload/onChatStart). - Merges into per-turn
metadataon every.in/appendchunk (visible toonTurnStart/ insiderunviaturn.clientData).
clientData value is live-updated when the option changes (the hook
calls setClientData under the hood), so dynamic values work without
reconstructing the transport.
Step 5: Update your ChatSession persistence
If you persist session state across page loads, drop the runId field:
before
after
runId column, you can drop it (the transport doesn’t
read it) or keep it for telemetry. The current run ID lives on the
Session row server-side now.
Hydration on page reload is unchanged:
chat.requestUpgrade(): same call, faster handoff
Calling chat.requestUpgrade() inside onTurnStart /
onValidateMessages still ends the current run so the next message starts
on the latest version. What changed is the mechanism:
- Before: the agent emitted a
trigger:upgrade-requiredchunk on.out; the transport consumed it browser-side and triggered a new run. - After: the agent calls
endAndContinueSessionserver-to-server; the webapp triggers a new run and atomically swapsSession.currentRunIdvia optimistic locking. The browser’s existing SSE subscription keeps receiving chunks across the swap — no transport-side bookkeeping.
SessionRun audit row with
reason: "upgrade" for dashboard provenance.
Hitting raw URLs
If your code talks to the realtime API directly instead of going through the SDK, the URL shapes changed:| Before | After |
|---|---|
GET /realtime/v1/streams/{runId}/chat | GET /realtime/v1/sessions/{chatId}/out |
POST /realtime/v1/streams/{runId}/{target}/chat-messages/append | POST /realtime/v1/sessions/{chatId}/in/append (body: {kind: "message", payload}) |
POST /realtime/v1/streams/{runId}/{target}/chat-stop/append | POST /realtime/v1/sessions/{chatId}/in/append (body: {kind: "stop"}) |
read:sessions:{chatId} + write:sessions:{chatId}) authorizes both the
externalId form (/sessions/my-chat-id/...) and the friendlyId form
(/sessions/session_abc.../...). The transport always uses the
externalId form; the friendlyId form is available for dashboard tooling
and direct API consumers.
What didn’t change
chat.agent({...})definition —id,idleTimeoutInSeconds,clientDataSchema,actionSchema,hydrateMessages,onPreload,onChatStart,onValidateMessages,onTurnStart,onTurnComplete,onChatSuspend,run. All callbacks have the same signature and fire at the same lifecycle points.onActionis still defined the same way, but its semantics changed in the May 6 prerelease — actions are no longer turns, andonActionreturning aStreamTextResultproduces a model response.chat.customAgent({...})and thechat.createSession(payload, ...)helper for building a session loop manually inside a custom agent.chat.defer(deferred work) andchat.history(imperative history mutations from insideonAction).AgentChat(server-side chat client) —agent,id,clientData,session,onTriggered,onTurnComplete,sendMessage,text().useTriggerChatTransportReact semantics (created once, kept in a ref, callbacks updated under the hood).- Multi-tab coordination (
multiTab: true), pending messages / steering, background injection, compaction. - Per-turn
metadataflowing throughsendMessage({ text }, { metadata })toturn.metadataserver-side.
Verifying the migration
After updating, the smoke check is the same as before: send a message, confirm the assistant streams a response, reload mid-stream, confirm resume. A few new things worth verifying once you’ve cut over:- Eager preload. Click the button (or call
transport.preload(id)programmatically) — yourstartSessioncallback should fire and a Session row + first run should be created before you send a message. - Idle-timeout continuation. Wait past the agent’s
idleTimeoutInSecondsso the run exits, then send another message — the transport’s.in/appendshould boot a new run on the same Session, with aSessionRunrow ofreason: "continuation". - PAT refresh. Force a stale PAT in your DB (corrupt the signature)
and reload — the first request should 401, your
accessTokencallback should fire, and the retry should succeed.
- Your
accessTokencallback returns a token minted viaauth.createPublicToken({ scopes: { read: { sessions: chatId }, write: { sessions: chatId } } }), notchat.createAccessTokenorauth.createTriggerPublicToken. The transport rejects trigger tokens now. - Your
startSessioncallback returns{publicAccessToken: string}— the result ofchat.createStartSessionAction(taskId)({chatId, ...})already has this shape. - You haven’t left a stale
getStartTokenoption on the transport; it’s not part ofTriggerChatTransportOptionsanymore.
v4.5 wire format change
A second migration lands on top of the Sessions release. v4.5 removes the full-history wire payload — clients now ship at most one newUIMessage per .in/append, and the agent rebuilds prior history from a durable JSON snapshot in object storage plus a replay of the session.out tail.
If you use the built-in TriggerChatTransport / AgentChat and don’t reach into the wire shape directly, most apps need no changes — the change is below the customer-facing surface. Customers who built custom transports, hit /realtime/v1/sessions/{id}/in/append directly, or rely on specific behaviors of hydrateMessages / onChatStart should read this section.
Why the change
Long chats with heavy tool results were hitting the realtime API’s 512 KiB body cap on/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 agent rebuilds prior history at run boot. The 512 KiB ceiling stops being pressure — typical payloads are a few KB regardless of chat length.
Object-store configuration
Snapshot read/write uses Trigger.dev’s existing object-store infrastructure — the same presigned-URL routes used for large payloads. Set the standardOBJECT_STORE_* env vars on your webapp deployment if you haven’t already; MinIO and S3-compatible stores work via OBJECT_STORE_DEFAULT_PROTOCOL.
| Env var | Purpose |
|---|---|
OBJECT_STORE_BASE_URL | Endpoint URL (S3, MinIO, R2, etc.) |
OBJECT_STORE_ACCESS_KEY_ID | Access key |
OBJECT_STORE_SECRET_ACCESS_KEY | Secret key |
OBJECT_STORE_DEFAULT_PROTOCOL | s3 (default), minio, etc. |
packets/{projectRef}/{envSlug}/sessions/{sessionId}/snapshot.json. Each snapshot is small (typically tens of KB) and overwritten every turn — no append-only growth.
Custom transports
If you’ve built your own transport (Slack bot, CLI, native app) against the Client Protocol, theChatTaskWirePayload shape changed:
before
after
| Trigger | What to put in the payload |
|---|---|
submit-message | The new user message (or a tool-approval-responded assistant message) in message |
regenerate-message | No message — the agent trims its own tail |
preload / close / action | No message |
handover-prepare (head-start only) | Full prior history in headStartMessages (route handler — not on /in/append) |
hydrateMessages consumers
The hook signature is unchanged. Two behavior tightenings worth knowing:
incomingMessagesis now consistently 0-or-1-length. Previously some triggers (regenerate-message, continuation) shipped full history; now all triggers ship at most one. If you assumedincomingMessagescould contain multiple messages and acted on them as a batch, the loop now runs zero or one times. Patterns like the one below work the same — they just iterate fewer messages:
- Registering
hydrateMessagesshort-circuits snapshot+replay. The runtime trusts your hook to be the source of truth, so it doesn’t read or write the JSON snapshot or replaysession.out. Zero object-store traffic. Trade-off: you own persistence end-to-end.
onChatStart is now once-per-chat
onChatStart no longer fires on continuation runs (post-endRun, post-waitpoint-timeout, post-chat.requestUpgrade, post-cancel, post-crash) or on OOM-retry attempts. It fires exactly once per chat, on the very first user message of the chat’s lifetime. The continuation and previousRunId fields on ChatStartEvent are now @deprecated (always false / undefined when the hook fires).
This makes once-per-chat setup code (create the Chat DB row, mint chat-scoped resources) safe to write without continuation gates. Drop any if (continuation) return; checks from onChatStart:
before
after
onTurnStart — that hook still fires on every turn, including the first turn of a continuation run.
Move chat.local init from onChatStart to onBoot
Because onChatStart no longer fires on continuation runs, chat.local state initialized there will be missing when a continuation run starts — run() then crashes with "chat.local can only be modified after initialization". The fix is to move per-process initialization to the new onBoot hook, which fires once per worker boot (initial, preloaded, AND continuation):
before
after
onBoot for the same reason. Branch on continuation inside onBoot if you need to re-load state from your DB on takeover.
Client-side setMessages doesn’t round-trip
The new wire makes one thing explicit that was implicit before: mutating useChat()’s messages on the client doesn’t change the agent’s history. Full-history mutations were silently overwritten by the wire’s accumulator before this release; now they aren’t even shipped.
For history compaction, summarization, or branch-swap, mutate the agent’s accumulator inside onTurnStart using chat.setMessages() or chat.history.set(). The client’s useChat will reconcile against the next session.out payload.
Verifying the v4.5 migration
After updating, the smoke check is the same as for v4.4:- Send a message, confirm the assistant streams a response.
- Reload mid-stream, confirm resume.
- Send 30+ turns with tool calls —
.in/appendbody sizes stay under ~5 KB the entire time. (Pre-change baseline: payloads grew past 512 KB around turn 10-30.) - Idle out a run, send another message — the new run reads the snapshot, replays the tail, and continues seamlessly.
- Confirm
OBJECT_STORE_*env vars are set on the webapp. - Confirm the bucket key
packets/{projectRef}/{envSlug}/sessions/{sessionId}/snapshot.jsonexists after a successful turn. - Or — register
hydrateMessagesand let your DB be the source of truth.
Reference
- TriggerChatTransport options
chat.createStartSessionAction- Backend setup
- Frontend setup
- Client Protocol — wire format reference
- Persistence and replay — snapshot model end-to-end

