Skip to main content

Why a conversation needs a home

A chat.agent conversation outlives a single run. One run answers many turns and survives the idle gaps between them, but a run does end eventually (a version upgrade, its turn limit, a crash), and the next message then boots a fresh run with nothing in memory (see How it works). For that new run to answer in context, the conversation so far has to be read back from somewhere durable. The same store is what a page reload and the dashboard read to show history. That somewhere is a transcript storage. You get one by default with no setup: the platform keeps the conversation as a snapshot in object storage, the same blob the Sessions view in the dashboard renders. Bring your own when you want the conversation in your own database instead.

What gets saved

The transcript is a list of UIMessages, keyed by chatId. A UIMessage is the rich, renderable message the frontend works with: an id, a role, and an array of parts (text, reasoning, tool calls and their results, and any custom data-* parts). It is the same shape your React app holds and the same shape the dashboard renders, so what you store is exactly what a user sees.
UIMessages are not what the model reads. Each turn the runtime derives a ModelMessage[] from the transcript, the flattened { role, content } form an LLM takes, and hands it to your run() as messages. The transcript storage never deals in ModelMessages. It holds the UI messages; the model’s view is derived from them.
Keeping the UI shape is deliberate. It is lossless (a tool call and its result survive as parts), it is what renders, and the model’s view can be rebuilt from it. Two things cannot be rebuilt from the messages alone, so the runtime hands them to the storage as well:
  • state: an opaque record for what the model saw that the transcript does not capture, a compaction summary and injected context. Store it as-is and give it back on load.
  • cursors: the stream positions the next run resumes from. Persist them opaquely; a storage never reads them.
So a save is: the messages, a state blob, and two cursors. Nothing else.

The default storage

Do nothing and you get the platform snapshot: the whole conversation written to object storage after each change, read back when a run continues. It is the blob the dashboard’s Sessions view renders, and it needs no configuration.
The default rewrites the whole conversation on every turn. That is fine for most chats and costs one write. When it stops being fine, or when you want the conversation in a database you already run, you bring your own.

Bring your own storage

Set storage on the agent to persist the conversation yourself:
Reasons to:
  • Your database is the source of truth. History lives next to the rest of your data, queryable, backed up, and deletable on your terms.
  • Cheaper writes on long chats. A row-per-message store writes only what changed on a turn instead of rewriting the whole conversation.
  • Render history in one query from your own tables, the same load the runtime uses.
  • Own the model’s context for branching, trust boundaries, or rollback (see Owning the model’s context).
The runtime drives the storage. You never decide when to write, what a regenerate means for your rows, or how a crash mid-answer is recovered. Those decisions are the same for every backend, so they live in the runtime; your job is to store what it hands you and give it back.

The interface

load returns the conversation. save records a change to it. loadContext is optional and covered below. All the types are exported from @trigger.dev/sdk/ai. scope is the tenant of a read: the chatId and the clientData your app passed. ctx on a save is the same plus the run it happened in. clientData is how the runtime hands you the tenant; use it to scope or authorize where your backend needs to.

What the runtime hands save

A changeset carries the same save two ways, and a storage uses whichever suits its shape. changes is the ordered list of what changed since the last save. A row-per-message store applies them, as one transaction where the backend supports one: transcript is the whole conversation as it stands after those changes, entries plus state. A store that keeps the conversation as one document (object storage, a key-value store, a JSON column) writes it as-is and keeps no state of its own between saves. The default storage is exactly that: it serialises transcript and rewrites the blob. The changes are the intent, spelled out. A normal turn is two puts, the user’s message and the assistant’s answer, split across the turn’s two saves. A steering message the user sent mid-turn is another put in the same changeset. An undo through chat.history.slice(0, -2) is one truncateAfter. A regenerate is a truncateAfter and a put. A tool approval that updates the assistant message in place is one put for that id. Messages are addressed by id; how you order rows is your concern. Every turn saves twice. The turn-start save carries the message being answered, before the model runs. The turn-complete save carries the answer. Both put the same user message id, and a put upserts, so a storage that applies changes in order needs no special handling for the repeat. The turn-start save is what makes a reload during an answer show the question that is being answered. It leaves cursors on the previous turn’s position, because the answer’s own cursor does not exist yet, so a reload mid-answer still resumes from the last completed turn rather than skipping chunks it never received. A few properties worth knowing:
  • The turn-start save runs alongside the model rather than before it, so it costs no time to first token. Nothing from the turn reaches the browser until it settles, which is what makes the question durable before the answer can render. A save that fails or runs long lets the answer through rather than stalling the conversation.
  • The turn-complete save happens after the turn’s answer has reached the browser, so it never delays the response. The runtime awaits each save before the run suspends.
  • A save that throws is logged and the turn continues. The changes fold into the next changeset, and every change is idempotent, so a retried changeset converges on the same result.
  • A load that throws boots the run from the durable stream’s recent tail rather than failing.

Reading the transcript

load is the one read for every backend, the default included. Call it on your server, scoped to the signed-in user through clientData, and pass the result to the browser:
app/actions.ts
app/chat/[chatId]/ChatPage.tsx
The action receives chatId from the browser, so authorize it before returning: check that the signed-in user owns this chat. defaultStorage loads purely by chatId and does no tenant check of its own, so an exported action with no authorization lets any authenticated user read any chat’s transcript. A custom storage can enforce tenancy inside load using clientData, but the server action is still the place to reject a chatId the caller may not read.
limit returns the most recent messages and a nextCursor; pass it as before for the page before that one. With the default storage, a paged read is served by the platform, so a long conversation is not downloaded in full to render its last fifty messages. A paged read returns the transcript only: state is always null, because the model lane is not part of what a page renders, and the platform reads just the bytes holding that page rather than the whole conversation. The runtime reads the state separately when it restores context at boot. When you pass transport and it already knows the session, the hook seeds its resume cursor from the transcript, so the live subscription opens just past the persisted history instead of replaying it. Swap defaultStorage for your own storage and nothing else about the read changes.
The saved format changed in this release, and an older SDK cannot read it. Rolling a deployment back to a version from before this release means its runs will not find a readable transcript for conversations already saved by the newer one, and will continue from the live stream tail instead, so earlier history is lost for those conversations. Roll forward rather than back, or keep your own transcript storage.
The default storage is deliberately basic about long conversations. Once compaction has run, it keeps roughly the last hundred messages and drops the rest, so what it rewrites on each save stops growing. A conversation that never compacts is kept whole. If your app renders history further back than that, give the agent your own storage and keep the messages yourself.

Owning the model’s context

By default the model’s context each turn is the transcript the runtime accumulated, converted to ModelMessages. A storage that declares loadContext takes that over: the runtime calls it on every turn and action, with the messages the frontend sent and the transcript the runtime had, and uses the UIMessages it returns as the conversation (converting them to ModelMessages the same way). Reach for it when your database decides what the model sees, for branching conversations, a trust boundary where the browser’s history is not to be believed, or a curated context window.
save keeps receiving every change, and crash recovery keeps running. This is the replacement for the deprecated hydrateMessages hook; setting both hydrateMessages and storage on an agent is a startup error.

Writing your own storage

The contract is small and the conformance suite checks it. Point the suite at a factory for your storage and run it under vitest or jest:
transcript-storage.test.ts
The suite covers appends and in-place replacement, idempotent remove and truncateAfter, state round-trips, cursors, replaying the same changeset twice, paging, and chat isolation. memoryTranscriptStorage() is the reference implementation, and it is handy in your own tests to see exactly what the runtime hands a storage. A few things to get right:
  • Pick one view and stay with it. Apply changes if you store rows, write transcript if you store a document; don’t mix them within one save.
  • put for a known id replaces the message in place; position and ordering don’t change.
  • Order is the message’s position in the transcript. A document store gets it from transcript.entries. A row store needs an order column set once, when a put first inserts an id, following the order the puts arrive in, and left unchanged when a later put replaces that id in place. Don’t sort by a write timestamp: a replaced message has to keep its place, and a steering message sent mid-turn sorts before the answer it shaped even though its row is written later.
  • truncateAfter and remove are idempotent. Applying a changeset twice gives the same result as applying it once.
  • load with no options returns the whole conversation in order. With limit, return the most recent messages and a nextCursor (the id of the oldest returned message) when earlier messages exist.
  • Scope reads and writes by clientData where your backend enforces tenancy.

Guarantees and limits

  • Crash recovery of a half-written answer is runtime-owned in every configuration. It comes from the durable session stream, which no application database can reconstruct. A storage holds settled turns; the runtime overlays the recovered tail and hands it to save like any other change.
  • Bringing your own database does not remove platform custody. Session streams still hold message content for their retention window.
  • The default storage rewrites the whole conversation each turn. A row-per-message storage writes only what changed. That is the reason to plug in your own.

Migrating from hydrateMessages

hydrateMessages keeps working with a one-time deprecation warning. Crash recovery runs for it, but the runtime does not write to your store on its behalf. The move, in short:
  1. Implement TranscriptStorage over your existing tables. The writes you did in hooks become save; the read your hook did becomes load. Add loadContext only if your database decides what the model sees each turn.
  2. Set storage on the agent and remove hydrateMessages. Setting both is an error.
  3. Delete the recovery, compaction and cursor code the runtime now owns, and run runTranscriptStorageTests against your implementation.
The migration guide walks through each step with code, and lists what to delete from each hook.

See also