Skip to main content
Learn how to replace a 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

With hydrateMessages, 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 UIMessage itself, and a column to order by. If you stored messages as ModelMessages, convert your existing rows first; a storage holds UIMessages 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, status or partial flag 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
The runtime calls 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 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
Three rules keep the rows in the runtime’s order:
  • 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 put for 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.
If you keep the whole conversation as one document instead of rows, ignore changes and write changeset.transcript as-is. Pick one of the two and stay with it.
3

Decide whether you need loadContext

Most 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
Do not port the body of your 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 onTurnStart and onTurnComplete, including the steering messages you filed from newUIMessages or pendingMessages.onReceived.
  • The lastEventId write in onTurnComplete. The cursor now arrives on the changeset.
  • Row writes paired with chat.history calls in onAction. An undo is a truncateAfter, an edit is a put and a truncateAfter. To answer after an edit, return chat.turn(); returning a model response from onAction is 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 reaches save with final: 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.
Keep 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:
The action receives chatId from the browser. Authorize it there, before calling the storage. The storage’s own clientData check is a backstop, not the boundary.
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. onAction mutates chat.history and returns nothing, or returns chat.turn() to have a full turn answer the edited history. The regenerated answer is saved and remembered like any other. See Actions.
  • onRecoveryBoot is 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 save with final: false; report those ids back from load in nonFinalIds.
  • 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

  • load returns messages in conversation order, plus state, cursors and nonFinalIds
  • save applies all four change kinds and writes the cursors in the same transaction
  • Positions are assigned on first insert only, never by timestamp
  • hydrateMessages removed; storage set; the agent boots
  • Message, cursor, partial, repair and compaction writes deleted from hooks
  • onAction returns chat.turn() where it used to stream an answer
  • runTranscriptStorageTests passes against the real database

See also