hydrateMessages hook with a storage on the same tables, so the runtime writes the conversation for you and your app stops re-deriving what the runtime already knows.
hydrateMessages keeps working with a one-time deprecation warning, so you can migrate one agent at a time. Setting both hydrateMessages and storage on one agent is a startup error, which makes the switch a swap rather than an overlap.
What changes hands
WithhydrateMessages, your hook was the source of truth and the runtime wrote nothing, so your app re-derived from its own rows everything the runtime already knew. A transcript storage inverts that: the runtime owns the transcript and calls your storage at the durable boundaries.
Before you start
- Your message table needs three things per row: the message id, the
UIMessageitself, and a column to order by. If you stored messages asModelMessages, convert your existing rows first; a storage holdsUIMessages only. - Add somewhere to keep an opaque JSON blob per chat (the runtime’s
state) and two opaque string cursors per chat. - If you have a
final,statusorpartialflag already, keep it. If not, add one; it is how a cut-short answer stays marked across a continuation.
1
Implement load
load returns the conversation in order, the state blob, the cursors, and the ids of any rows you stored as partial. Scope the read by clientData where your backend enforces tenancy.lib/transcript-storage.ts
load with no options and wants the whole conversation. Your frontend’s history read passes limit (and later before), so the example fetches one extra row to tell whether earlier messages exist and returns the oldest included id as nextCursor.2
Implement save
Apply the changeset’s Three rules keep the rows in the runtime’s order:
changes to your rows and write the cursors, in one transaction. The cursor tells a reconnecting client what it has already seen; a cursor that lands ahead of the rows it accounts for makes that client skip chunks stored nowhere.lib/transcript-storage.ts
- Assign a position only when a row is first inserted, in the order the
puts arrive. Within one changeset that is conversation order, so a steer sent mid-answer lands before the answer it shaped. - Leave the position alone on a
putfor a known id. A tool approval, a settled partial and a compaction all replace a message in place. - Do not order by a write timestamp. A replaced message would jump to the end, and a steer written after the answer would sort after it.
changes and write changeset.transcript as-is. Pick one of the two and stay with it.3
Decide whether you need loadContext
Most Do not port the body of your
hydrateMessages hooks did two jobs: they persisted the incoming message and they returned the history. save and load cover both, so most agents need nothing more.Add loadContext only when your database decides what the model sees each turn: a branching conversation where only the active branch is context, or a trust boundary where the browser’s history is not to be believed. The runtime calls it on every turn and action with the incoming messages and the transcript it had, and uses what you return as the conversation. save keeps receiving every change and crash recovery keeps running.lib/transcript-storage.ts
hydrateMessages hook into loadContext wholesale. The writes in it belong in save, and the recovery and compaction logic in it is gone.4
Swap the option
Set
storage, remove hydrateMessages, and let the agent boot. It throws on start if both are still present.trigger/chat.ts
5
Delete what the runtime now owns
Work through your hooks and remove every write that duplicates a change the storage receives:
- Message writes in
onTurnStartandonTurnComplete, including the steering messages you filed fromnewUIMessagesorpendingMessages.onReceived. - The
lastEventIdwrite inonTurnComplete. The cursor now arrives on the changeset. - Row writes paired with
chat.historycalls inonAction. An undo is atruncateAfter, an edit is aputand atruncateAfter. To answer after an edit, returnchat.turn(); returning a model response fromonActionis no longer supported. - Per-step partial writes in
onStepFinish, and any run-state or “still streaming” flag. A crash is recovered from the durable session stream, and a partial that survives reachessavewithfinal: false. - Dangling tool-call repair. The runtime cleans a partial before it saves it.
- Your compaction summary column, its watermark, and the invalidation you ran on every rollback path. The summary travels in
state, and the runtime discards it when a rollback crosses the point it covers. - Any filter that dropped an empty assistant message. A turn that fails before the model writes anything is not handed to
save.
onTurnStart for the session token if your frontend hydrates from your own table, and keep onTurnComplete for side work such as naming the conversation.6
Update the history read on your frontend
Your page most likely reads history from your tables directly. That keeps working. To use the same read the runtime uses, and to seed the live subscription’s cursor so a reload does not replay what it just loaded, wrap the storage in a server action and load it with the hook:
7
Run the conformance suite
Point
runTranscriptStorageTests at your storage. It checks appends and in-place replacement, idempotent remove and truncateAfter, state round-trips, cursors, replaying the same changeset twice, paging, and chat isolation, against whatever database you give it.lib/transcript-storage.test.ts
memoryTranscriptStorage() is the reference implementation. Use it in your agent tests to see exactly which changes the runtime hands a storage for a given turn.Behaviour that changes
A few things your users and your tests will notice after the swap:- Actions are edits.
onActionmutateschat.historyand returns nothing, or returnschat.turn()to have a full turn answer the edited history. The regenerated answer is saved and remembered like any other. See Actions. onRecoveryBootis informational. It fires when a run boots after a crash and finds a half-written answer on the session stream. The runtime already reconciled the transcript; use the hook to tell the user, not to write rows.- A failed turn keeps its partial, marked. An answer cut short reaches
savewithfinal: false; report those ids back fromloadinnonFinalIds. - Compaction survives a reboot without your help. The summary travels in
state. - Your rows do not have to be byte-identical. Store messages in whatever shape your database prefers. The runtime does not depend on an exact JSON round-trip.
Checklist
-
loadreturns messages in conversation order, plusstate,cursorsandnonFinalIds -
saveapplies all four change kinds and writes the cursors in the same transaction - Positions are assigned on first insert only, never by timestamp
-
hydrateMessagesremoved;storageset; the agent boots - Message, cursor, partial, repair and compaction writes deleted from hooks
-
onActionreturnschat.turn()where it used to stream an answer -
runTranscriptStorageTestspasses against the real database
See also
- Transcript storage: the full contract and what
savereceives - Actions: what undo, edit and regenerate become in the changeset
- Lifecycle hooks: the deprecated hook’s reference entry
- Database persistence: the hook-based pattern this replaces

