Disclosure: I'm Mohammed, founder of MemorySync. I drafted this article with AI assistance and reviewed it for accuracy against the official MemorySync and AI SDK docs. The code is adapted from the docs and I haven't run it.
The AI SDK's chatbot persistence guide shows how to save a chat: useChat sends the chat id and messages, and you store the messages when the stream ends. Load them by chat id and the conversation picks up where it left off. Start a new chat, though, and the model knows nothing about the user. A transcript belongs to one chat. Long-term memory is the facts about a user that should show up in every chat.
This post adds that second part to an AI SDK app with MemorySync's AI SDK integration, memorysync-ai-sdk. MemorySync is a hosted memory layer, and I'm building it. The AI SDK behaviour comes from the official Language Model Middleware and Chatbot Message Persistence docs. Everything about MemorySync comes from its Vercel AI SDK Memory guide. The code is adapted from those docs. I haven't run it for this post, so check it against your versions.
How it fits
The AI SDK has a middleware layer: wrapLanguageModel({ model, middleware }) returns a model that behaves like any other, but with its calls intercepted. The SDK's docs list RAG, guardrails, caching and logging as typical uses. Memory fits the same slot:
- Before generation: recall memories for this user and inject them as a system context block.
- After a successful finish: send the user's message for fact extraction. Only the durable facts in it are stored. Assistant replies aren't stored.
Keep your transcript persistence. The two do different jobs.
Install
npm install memorysync-ai-sdk ai
Set MEMORYSYNC_API_KEY in the environment (or pass apiKey). The guide lists ai 6.0+ as the requirement and Node 18+ for the package. Note that ai v7 itself needs Node 22+. The npm registry currently lists memorysync-ai-sdk 1.1.0 with a peer range of ai >=6.0.0 <8.0.0.
Step 1: Wrap the model once
import { generateText, wrapLanguageModel } from "ai";
import { openai } from "@ai-sdk/openai";
import { createMemorySyncMiddleware } from "memorysync-ai-sdk";
const model = wrapLanguageModel({
model: openai("your-model"),
middleware: createMemorySyncMiddleware({
userId: "customer-7", // required: scopes memory to one end user
sessionId: "thread-42", // groups stored facts by thread
}),
});
// First conversation
await generateText({ model, prompt: "I'm vegetarian and I fly aisle." });
// A later call for the same user
const { text } = await generateText({
model,
prompt: "Book my trip: flight plus a dinner spot.",
});
The second call doesn't repeat the diet or the seat. If memory works, the model has seen both in the injected context block. Two behaviours from the guide are worth knowing:
- Injection happens once per user turn. On tool-loop steps where the last message is a tool result, the prompt passes through untouched.
- Your own system prompt stays first. The memory block goes after it.
Step 2: A Next.js chat route
// app/api/chat/route.ts
import { convertToModelMessages, streamText, wrapLanguageModel } from "ai";
import { openai } from "@ai-sdk/openai";
import { createMemorySyncMiddleware } from "memorysync-ai-sdk";
export async function POST(req: Request) {
const { messages, userId, sessionId } = await req.json();
const model = wrapLanguageModel({
model: openai("your-model"),
middleware: createMemorySyncMiddleware({ userId, sessionId }),
});
const result = streamText({
model,
messages: await convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}
Two notes on this:
- The MemorySync guide calls
convertToModelMessages(messages)withoutawait. The current AI SDK persistence docsawaitit, so I've added that here. Check which one youraiversion expects. - The guide reads
userIdfrom the request body to keep the example short. In a real app, take it from your auth session instead. Whoever controlsuserIdcontrols whose memory gets read.
On streaming, the guide says the user's message is sent once, after the stream's finish part. A stream that errors or is aborted never reaches its finish, so nothing is sent for it.
Step 3: Read-only, write-only and tuning
// Recall context, never write
createMemorySyncMiddleware({ userId, persist: false });
// Send user messages for extraction, inject nothing
createMemorySyncMiddleware({ userId, recall: false });
// Recall depth and the shape of the injected block
createMemorySyncMiddleware({
userId,
k: 12,
template: "What you know about this user:\n{context}",
});
Read-only is handy for an internal admin view or an eval run where you don't want test prompts stored.
Step 4: If you don't want to wrap the model
The same recall and persistence are available as helpers:
import {
createMemorySyncOnFinish,
getMemoryContext,
saveTurn,
searchMemories,
} from "memorysync-ai-sdk";
// Prompt-ready context block ("" for a new user)
const context = await getMemoryContext("what should I cook?", { userId: "customer-7" });
// Scored raw results: [{ id, text, score }, ...]
const hits = await searchMemories("dietary preferences", { userId: "customer-7" });
// Explicit persistence. This one throws on failure.
await saveTurn(
{ user: "I'm vegetarian", assistant: "Noted!", sessionId: "thread-42" },
{ userId: "customer-7" },
);
// Or persist from onFinish, which fires once after the full tool loop
const result = streamText({
model,
prompt,
onFinish: createMemorySyncOnFinish({ userId: "customer-7", user: prompt }),
});
Watch the error behaviour, because it differs. The middleware reports persistence failures through onError (by default, a console warning) and never throws, and a failed recall just means the call runs without context. saveTurn throws. The guide says mixing styles is safe: a turn sent by both the middleware and saveTurn is recognised server-side and extracted once.
Step 5: Give an agent memory tools
If you want the model to decide when to save or look something up, add the five tools:
import { generateText, isStepCount } from "ai"; // AI SDK 6: stepCountIs
import { openai } from "@ai-sdk/openai";
import { memorySyncTools } from "memorysync-ai-sdk";
const { text } = await generateText({
model: openai("your-model"),
tools: { ...memorySyncTools({ userId: "customer-7" }) },
stopWhen: isStepCount(5), // AI SDK 6: stepCountIs(5)
prompt: "Remember that I prefer aisle seats.",
});
// For agents you don't fully trust: search and list only
memorySyncTools({ userId: "customer-7", readOnly: true });
The MemorySync guide uses stepCountIs. AI SDK 7's migration guide renames it to isStepCount, so I've used the new name with the old one in a comment.
The tools are addMemory, searchMemory, listMemories, updateMemory (tags and importance only; memory text can't be changed) and deleteMemory. They return readable error strings instead of throwing, so a memory failure doesn't abort the tool loop.
Step 6: See which memories were used
const result = await generateText({ model, prompt });
const memories = result.providerMetadata?.memorysync?.memories;
// [{ id, text, score }, ...]
// Streaming: the same field on the awaited stream result
const streamed = streamText({ model, prompt });
const meta = (await streamed.providerMetadata)?.memorysync;
This is useful for a "why did it say that" panel, or for logging recalls in evals. One version note: in the current AI SDK reference, providerMetadata on the streamText result is marked deprecated in favour of finalStep.providerMetadata, and onFinish is a deprecated alias for onEnd. The snippets above follow the MemorySync guide, so check both names against your ai version.
Troubleshooting
-
The second chat doesn't know anything. Check that
userIdis the same value in both requests. Then check that the first request finished. An aborted stream sends nothing. -
TypeScript complains on AI SDK 6. Pass
specificationVersion: "v3"to the middleware for exact types. The guide says behaviour is the same either way. -
Duplicate context in multi-step agents. It shouldn't happen: injection is skipped on tool-result steps. If you also call
getMemoryContextyourself, you're adding a second block.
Try it
If you want to see the save, reload and recall loop before adding a package, the MemorySync playground runs it in your browser: memorysync.io/try.
Top comments (0)