Conversation Persistence in TypeScript AI Agent Frameworks
May 6, 2026

Every major TypeScript AI agent framework now includes built-in tools for managing conversations. Vercel AI SDK, Mastra, OpenAI Agents SDK for JavaScript, and a wave of newer entrants all give you APIs for handling messages within a session. The friction starts when the session ends and a user comes back for turn fifty-one.
Message persistence is a recurring pain point across all of them: format mismatches between UI and model message shapes, incomplete patterns for tool result storage, and no shared expectations about what a stored message should look like. These aren’t unique to any one framework; they’re structural gaps in how the TypeScript agent ecosystem treats conversation data.
The Message Shape Problem
Most frameworks define at least two distinct message types, and they don’t map cleanly onto each other.
Vercel AI SDK distinguishes between UIMessage (what your frontend renders) and ModelMessage (what the model receives). UIMessages preserve the full state of your app with a parts array that can include text, tool calls, and tool results. ModelMessages are a stripped-down version optimized for sending to the LLM. When you store messages in a database, you’re picking one of these shapes or inventing a third, and on reload you need to convert back to whatever format the framework expects.
// Vercel AI SDK: UIMessage (full app state)
interface UIMessage {
id: string;
role: "user" | "assistant" | "system";
parts: MessagePart[]; // text, tool calls, tool results, etc.
}
// ModelMessage is a stripped-down version sent to the LLM.
// Use convertToModelMessages() to translate between them.
// Your database: pick a shape and own the conversion layer.
This is why Vercel’s official guide recommends persisting UIMessages rather than ModelMessages. UIMessages keep the full structure of tool calls, tool results, and rich content intact, so when a user returns to a conversation you can rebuild both the UI state and the message list the model needs.
Mastra uses similar UI and model message shapes and provides a convertMessages utility to translate between Mastra messages, AI SDK messages, and other formats. OpenAI Agents SDK for JavaScript uses yet another structure: a list of conversation items. Each framework’s approach is internally consistent, but none of them agree on what a “stored message” looks like. If you switch frameworks or use more than one, the stored data doesn’t transfer.
How Each Framework Handles Persistence
Here’s what you get out of the box from the three most widely used TypeScript agent frameworks.
Vercel AI SDK
Vercel AI SDK provides hooks like useChat for managing message state on the client. For server-side persistence, the official guide recommends saving the full UIMessage array in the onFinish callback of toUIMessageStreamResponse, then loading those messages back from your storage and passing them to useChat when the user returns.
The framework gives you the integration points, but you bring the database. The schema design, migration management, and retrieval logic are yours to implement. Tool results require extra care because the parts-based message format includes structured data that most simple content columns can’t represent without serialization.
Mastra
Mastra takes the most opinionated stance on memory. The Mastra memory docs define three configurable types: Observational Memory (the recommended option, which uses background agents to maintain a dense observation log instead of raw message history), Working memory (a structured scratchpad for user details and preferences), and Semantic recall (vector search over past messages, disabled by default because it adds embedding latency). Storage requires a provider, with @mastra/libsql as the recommended quickstart option.
This is closer to a complete solution, but it’s tightly coupled to Mastra’s runtime. If you’re using Mastra end to end, the memory system works well within that context. If you need to access conversation data from outside Mastra, or share it with a service that isn’t part of the Mastra ecosystem, you’re working around the abstraction rather than with it. The storage layer is also designed for Mastra’s own access patterns, so querying conversation data for analytics or compliance means querying it directly and understanding its internal schema.
OpenAI Agents SDK for JavaScript
The OpenAI Agents SDK manages conversation history through a Sessions abstraction. The SDK includes implementations for in-memory state during local development and for syncing conversation items to OpenAI’s hosted Conversations API.
The hosted Conversations API option lets you offload persistence to OpenAI without standing up your own database. The tradeoff is that conversation data lives in OpenAI’s infrastructure, which matters if you need to query it for analytics, share it across services, or stay portable across model providers.
The Cross-Session Gap
All three frameworks handle the current session reasonably well. Messages accumulate in memory, get passed to the model, and the conversation flows. The gap opens up at the boundaries.
Session resumption. When a user returns, you need to load the right conversation, reconstruct the message list in the framework’s expected format, and resume context. Each framework has different expectations about what that message list looks like. Getting this wrong means the model loses context or tool call chains break mid-sequence.
Cross-conversation search. None of these frameworks offer a way to search across conversations. If a user says “what did we discuss last week about the deployment,” your agent has no mechanism to find that previous conversation. You’d need a separate vector search system, an embedding pipeline, and retrieval logic to make this work.
Threading. Branching a conversation into sub-threads (a customer support ticket spawning a technical investigation, or an agent forking a conversation to explore two approaches) isn’t supported at the framework level. You build this by creating separate conversation instances and managing the parent-child relationship yourself.
Multi-tenancy. In production, conversations need to be scoped to organizations, teams, or individual users. No framework provides namespace isolation. If you’re running a multi-tenant application, every query needs manual scope filtering, and getting it wrong means data leaks between tenants.
Analytics and observability. Understanding how your agent performs across thousands of conversations requires querying stored data in aggregate. Frameworks treat conversations as runtime state, not as a queryable dataset. Once the conversation ends, the framework moves on.
None of these are edge cases; they’re baseline requirements for any production AI application, and they all point to the same underlying issue: the frameworks treat persistence as someone else’s problem.
A Dedicated Conversation Data Layer
The pattern that solves these gaps is separating the conversation data layer from the agent framework. Instead of asking each framework to also be a database, you use a dedicated service that handles persistence, search, and multi-tenancy while your framework handles what it’s actually good at: orchestrating the agent and managing the current session.
DialogueDB is built for exactly this. It stores conversations, messages, memory, and state as first-class entities with an API designed around conversation access patterns. The TypeScript SDK works alongside any agent framework because it operates at the data layer, not the orchestration layer.
Here’s what the integration looks like with Vercel AI SDK:
import { openai } from "@ai-sdk/openai";
import { streamText, convertToModelMessages, type UIMessage } from "ai";
import { DialogueDB } from "dialogue-db";
const db = new DialogueDB({ apiKey: process.env.DIALOGUEDB_API_KEY });
export async function POST(req: Request) {
const { messages, conversationId }: {
messages: UIMessage[];
conversationId: string;
} = await req.json();
const tenantId = req.headers.get("x-tenant-id");
// Load or create the conversation with namespace isolation
const dialogue = await db.getOrCreateDialogue({
id: conversationId,
namespace: tenantId,
});
// Save the latest user message (extract text from UIMessage parts)
const lastMessage = messages[messages.length - 1];
const userText = lastMessage.parts
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("");
await dialogue.saveMessage({
role: lastMessage.role,
content: userText,
});
// Stream the response using Vercel AI SDK
const result = streamText({
model: openai("gpt-4o"),
messages: convertToModelMessages(messages),
onFinish: async ({ text }) => {
await dialogue.saveMessage({
role: "assistant",
content: await text,
});
},
});
return result.toUIMessageStreamResponse();
}
On session resumption, you load the conversation from DialogueDB and convert messages into whatever format your framework expects. The framework handles the current session, and DialogueDB handles everything that persists beyond it.
The same data is then available for cross-conversation search, threading with threadOf, and memory storage for facts and preferences that carry across sessions:
// Search across all conversations for a tenant
const results = await db.searchDialogues("deployment discussion", {
namespace: tenantId,
});
// Create a sub-thread from an existing conversation
const investigation = await db.createDialogue({
label: "Technical deep-dive",
threadOf: parentDialogueId,
});
// Store cross-session memory
await db.createMemory({
label: "deployment_preferences",
value: "User prefers blue-green deployments over rolling updates",
tags: ["preferences", "infrastructure"],
});
This works the same way with Mastra, OpenAI Agents SDK, or any other framework. The agent runtime manages the session, and the data layer manages the history.
Choosing Your Approach
The right architecture depends on how central conversations are to your application.
If your agent handles short, self-contained interactions where each session stands alone, framework-level persistence (or even no persistence) might be enough. A simple key-value store can handle basic session state without additional complexity.
If your application needs conversation history across sessions, search over past interactions, or multi-tenant isolation, those requirements point toward a dedicated data layer. Building this on a general-purpose database is possible (we covered the tradeoffs in how to store AI chat history), but it means owning the schema, the search infrastructure, and the retrieval logic yourself.
A dedicated conversation database like DialogueDB gives you these capabilities without the infrastructure work. The SDK documentation covers the full API, including message storage, semantic search, threading, and memory.
The TypeScript agent framework ecosystem will keep evolving, and the persistence problem will keep getting solved in partial, framework-specific ways. Your conversation data layer should be the stable foundation underneath, regardless of which framework you’re running on top.
Ready to Build Better Conversations?
Get started with DialogueDB in minutes. Free tier included.
Get Your API Key