Durable chat runs can span hours and many turns. You usually want:Documentation Index
Fetch the complete documentation index at: https://trigger.dev/docs/llms.txt
Use this file to discover all available pages before exploring further.
- Conversation state — full
UIMessage[](or equivalent) keyed bychatId, so reloads and history views work. - Live session state — a scoped access token for the session and optionally
lastEventIdfor stream resume.
Conceptual data model
You can use one table or two; the important split is semantic:| Concept | Purpose | Typical fields |
|---|---|---|
| Conversation | Durable transcript + display metadata | Stable id (same as chatId), serialized uiMessages, title, model choice, owner/user id, timestamps |
| Active session | Hydrate the transport on page reload | Same chatId as key (or FK), publicAccessToken, optional lastEventId |
runId is optional — useful for telemetry / dashboard linking (“View this run”) but not required for resume. The Session row owns its current run server-side; the transport reads from session.out keyed on chatId, so a run swap (continuation, upgrade) is invisible to your DB schema.
Store
UIMessage[] in a JSON-compatible column, or normalize to a messages table — the pattern is when you read/write, not how you encode rows.Where each hook writes
This pattern covers durable DB rows (the conversation and the active session). Per-process in-memory state (chat.local, DB connection pools, sandboxes, etc.) belongs in onBoot — it fires on every fresh worker including continuation runs, where onPreload and onChatStart do not.
onPreload (optional)
When the user triggers preload, the run starts before the first user message.
- Ensure the conversation row exists (create or no-op).
- Upsert session:
chatAccessTokenfrom the event (a session-scoped PAT covering bothread:sessions:{chatId}andwrite:sessions:{chatId}). - Load any user / tenant context you need for prompts (
clientData).
onChatStart when preloaded is false.
onChatStart (chat’s first message, non-preloaded path)
- Fires once per chat, on the very first user message. Does NOT fire on continuation runs (post-
endRun, post-waitpoint-timeout, post-chat.requestUpgrade) or on OOM-retry attempts. - If
preloadedis true, return early —onPreloadalready ran. - Otherwise mirror preload: user/context, conversation create, session upsert.
- No need to gate the conversation create on
continuation— it’s always a brand-new chat at this point. - For continuation runs that need to refresh per-run state (new PAT, new
lastEventId), do it inonTurnStart/onTurnComplete— both fire on every turn including the first turn of a continuation run.
onTurnStart
awaitpersistuiMessages(full accumulated history including the new user turn) before the hook returns —chat.agentdoes not begin streaming untilonTurnStartresolves, so this is what bounds “user message is durable before the stream”.
onTurnComplete
- Persist
uiMessagesagain with the assistant reply finalized. - Upsert session with the fresh
chatAccessTokenandlastEventIdfrom the event.
lastEventId lets the frontend resume without replaying SSE events it already applied. Treat it as part of session state, not optional polish, if you care about duplicate chunks after refresh.
Token renewal (app server)
The persisted PAT has a TTL (seechatAccessTokenTTL on chat.agent, default 1h). When the transport gets a 401 on a session-PAT-authed request, it calls your accessToken callback to mint a fresh PAT — no DB lookup required, since the session is keyed on chatId (which the transport already has).
Your accessToken callback typically just wraps auth.createPublicToken:
onSessionChange callback fires every time the cached PAT changes — persist the new value there.
No Trigger task code needs to run for renewal.
Minimal pseudocode
Alternative: hydrateMessages
For apps that need the backend to be the single source of truth for message history — abuse prevention, branching conversations, or rollback support — use hydrateMessages instead of relying on the frontend’s accumulated state.
With hydration, the hook loads messages from your database on every turn. The frontend’s messages are ignored (except for the new user message, which arrives in incomingMessages):
onTurnStart persistence pattern — the hook handles both loading and persisting the new message in one place.
Design notes
chatIdis stable for the life of a thread and is the only identifier the transport persists. Runs come and go (idle continuation, upgrade, cancel/restart) but the chat keeps its identity.continuation: truemeans “same logical chat, new run” — refresh the persisted PAT, don’t assume an empty conversation.- The current
runIdis available on every hook event for telemetry / dashboard linking (“View this run”), but you don’t need to persist it for resume to work — the transport addresses bychatId. - Keep task modules that perform writes out of browser bundles; the pattern assumes persistence runs in the worker (or your BFF that the task calls).
Complete example
End-to-end implementation across the three files involved: agent task, server actions, and React component.See also
- Lifecycle hooks
- Session management —
resume,lastEventId, transport chat.defer()— non-blocking writes during a turn- Code execution sandbox — combines
onWait/onCompletewith this persistence model

