Your AI Agent Forgets Everything. So We Taught It to Remember — With a Graph.
21 min read

Introducing @neo4j-labs/nams-ai-provider — one package that gives any Vercel AI SDK model persistent, cross-session memory backed by a Neo4j knowledge graph. Three integration modes, one API key, and a guarantee no other memory provider ships.
Every production chat agent has the same embarrassing flaw. A user spends twenty minutes explaining their stack, their preferences, the decision they finally made about their deployment pipeline — and the moment the session ends, all of it evaporates. Tomorrow, the agent greets them like a stranger.
The model isn’t the problem. Context windows are bigger than ever. The problem is architectural: there is no durable place for what the agent learns to live, and no mechanism that brings it back at the right moment.
Those are two separate jobs, and the second one is the hard one. Writing things down is easy. Deciding, on turn 400, which three of nine thousand stored sentences belong in this prompt is the entire discipline.
@neo4j-labs/nams-ai-provider does both jobs, for any model, behind one changed line of code. It’s a community provider for the Vercel AI SDK backed by the hosted Neo4j Agent Memory Service (NAMS) — no Neo4j cluster to run, no vector store to pick, no embedding pipeline to babysit. A free API key and a model swap.
Memory isn’t a list of strings. It’s a graph of people, tools, decisions, and the relationships between them. That’s the bet this package makes.
What it does, in one picture?
Every model call becomes a round trip through memory. The package reads before the call and writes after it:

In plain terms: the agent gets a notebook it actually re-reads. Before answering, it quietly looks up what it already knows about this person. After answering, it writes down anything new. You don’t call any of that yourself — it happens inside the model call.
Here’s the entire integration. A standard Next.js chat route, before:
import { openai } from '@ai-sdk/openai';
import { ToolLoopAgent, stepCountIs } from 'ai';
const agent = new ToolLoopAgent({
model: openai('gpt-5.4-mini'),
instructions: 'You are a helpful assistant.',
stopWhen: stepCountIs(10),
});
And After:
import { createNamsProvider } from '@neo4j-labs/nams-ai-provider';
import { openai } from '@ai-sdk/openai';
import { ToolLoopAgent, stepCountIs } from 'ai';
const nams = createNamsProvider({
apiKey: process.env.MEMORY_API_KEY!,
baseProvider: openai,
scope: { userId: 'user-123' }, // who is this memory for?
});
const agent = new ToolLoopAgent({
model: nams.languageModel('gpt-5.4-mini'), // ← the only change
instructions: 'You are a helpful assistant.',
stopWhen: stepCountIs(10),
});
That’s it. Every call now fetches relevant memories for user-123, injects them into the prompt, and saves the exchange for future sessions. No new framework, no orchestration layer, no schema to design first.
Why we built it on the Vercel AI SDK?
The AI SDK offered something better. It gives library authors three clean extension points, and — this is the part that made the design click — each one maps to a different level of control an application developer might legitimately want:

Look at the middleware contract in particular: transformParams, then wrapGenerate, then wrapStream. That is already the exact shape of a memory system — read before the call, write after it. We didn’t adapt memory to fit the SDK. The SDK’s middleware interface happens to be a memory interface with a different name.
Everything else in the package follows from the same primitives. Graph extraction uses Output.object. The retrieval guarantee in tools mode is a prepareStep hook. The persistence guarantee is an onFinish hook. It is ordinary AI SDK surface, with a graph behind it.
And because it’s a provider, the memory outlives the model. Swap GPT for Claude, Claude for Gemini, and the users’ history comes with you — it lives in the store, not in a user’s context window.
Why a graph, and not a list of strings
Most memory layers store sentences and search them with vectors. That works, right up until you need to answer a question about what you know rather than a question from it.

In plain terms: the left side is a diary. The right side is an address book. Both can tell you what someone said. Only one can tell you who they are — and let you correct a single wrong detail without rewriting the paragraph it lived in.
Concretely, entity-shaped memory makes these ordinary rather than research projects:
- “List everything you believe about this user.” A query, not a scan of embedded chat logs.
- “That’s wrong — fix just that.” Bangalore is one node. Update it once; every future retrieval sees the correction.
- “When did you learn this?” getEntityHistory() returns every cross-conversation mention of an entity.
- “These two are the same person.” mergeEntities() collapses duplicates and leaves a SAME_AS history link behind.
That’s the why Neo4j answer. Not “graphs are cool” — but “the questions you’ll actually get asked in production are graph queries.” When a user asks “why does it think that about me?”, you want a node with a confidence score and a history, not an opaque blob inside somebody’s black box.
The hosted NAMS service means you get all of that over HTTPS. The underlying SDK is fetch-only — no database driver, no TCP connection, no connection pool to leak in a serverless function. It runs wherever your app already runs: Node, Vercel Edge etc.
Under the hood: the anatomy of a remembered turn
Retrieval — four sources, searched in parallel
Before the model runs, retrieveMemories() fans out across four memory sources concurrently:
How it works: the anatomy of a remembered turn
In provider and middleware mode, every model call passes through the same lifecycle:

The results are deduplicated by content, ranked by relevance score when the backend provides one (falling back to source priority: long-term → current conversation → cross-session → reasoning), and capped at maxMemories — 6 by default, with a hard ceiling of 12 per turn. Injecting more than that reliably makes answers worse, not better; a memory block that dominates the prompt is just a different way of losing the user’s actual question.

That fourth source is unusual and worth pausing on: NAMS stores reasoning traces as a first-class memory type, so an agent can recall its own prior reasoning, not merely its prior outputs. “We ruled that approach out because of the rate limit” is a different — and often more valuable — memory than “we chose approach B.”
The winning memories are prepended to the last user message as a labeled block — [long-term], [cross-session], and so on — so the model knows what it’s looking at and where it came from.
Two-pass search, because questions don’t look like answers
Retrieval doesn’t stop at one search per source, and this is one of the more useful design decisions in the package.
Ask “where do I live?” about a stored fact “User is from Delhi” and a phrase search finds nothing — no shared words, no match, empty result, and an agent that looks like it forgot.
So when a direct phrase search comes back empty, retrieval runs a second pass with the query’s significant words, in both their original case and Title Case, then merges, dedupes, and ranks the survivors by word overlap with the original question so the best hit isn’t buried under noise:
// "where do i live delhi" → direct search: []
// → retry: delhi, Delhi, where, Where, live, Live
// → "User is from Delhi." found, ranked first by overlap
Persistence — including the cases that usually break
After the response, the middleware persists the turn.
- Streaming. A TransformStream taps the response stream, accumulates text-delta chunks, and persists on flush once the stream closes. The user gets tokens in real time; the memory write happens after the last one.
- Structured output. When the model returns an object instead of text (generateObject), streamed tool-call arguments are reassembled from tool-input-delta chunks so the turn still persists.
- Multi-step tool loops. Every step of a ToolLoopAgent loop carries the same user message. The middleware remembers what it persisted and stores that message once per turn, not once per step.
- Prompt mutation. The user’s original text is captured in a WeakMap before memories are injected — so what gets stored is the clean message, never the memory-augmented one. Without this, memory slowly poisons itself by remembering its own injections.
Conversation resolution — sessions resume themselves
Which conversation does a turn belong to? A strict precedence ladder decides:

Three integration modes, one client
This package ships three, over a single client, a single API key, and a single memory store:

Provider — make memory invisible
This is the shape from the opening. createNamsProvider() wraps a base provider and returns a standard ProviderV4, so nams.languageModel(‘gpt-5.4-mini’) is a drop-in for openai(‘gpt-5.4-mini’) — no tools, no system prompt changes, no orchestration. The middleware below runs inside every call; you just never see it.
const registry = createProviderRegistry({
nams: createNamsProvider({
apiKey: process.env.MEMORY_API_KEY!,
baseProvider: openai,
scope: { userId: session.userId },
}),
});
const agent = new ToolLoopAgent({
model: registry.languageModel('nams:gpt-5.4-mini'),
stopWhen: stepCountIs(1),
});
At which point memory really is one string in a config file: ‘nams:gpt-5.4-mini’ instead of ‘openai:gpt-5.4-mini’.
Middleware mode: decorate the model you already have
If your model is configured elsewhere and you just want to add memory to it, wrap the instance directly:
const nams = createNams({ apiKey: process.env.MEMORY_API_KEY! });
const model = nams.wrap(openai('gpt-5.4-mini'), { userId: session.userId });
const agent = new ToolLoopAgent({ model, stopWhen: stepCountIs(1) });
Tools mode: let the model decide, and let users watch
Sometimes transparency is the feature. Tools mode exposes memory as two Zod-validated AI SDK tools the model calls itself — ordinary tool calls, so they appear in your UI stream where users (and you, at 2am) can watch the agent remember:
const nams = createNams({ apiKey: process.env.MEMORY_API_KEY! });
const tools = nams.tools({ userId: session.userId });
const agent = new ToolLoopAgent({
model: openai('gpt-5.4-mini'),
instructions:
'Before answering, consult memory with query_memory. When the conversation ' +
'contains facts or preferences worth remembering, call store_memory before ' +
'giving your final answer.',
tools,
stopWhen: stepCountIs(10),
});
The store_memory schema is on purpose. The model must tell what it stores — fact, interaction, pattern, or user_preference — attach a 0–1 confidence score, and optionally tag it. Interactions route to short-term conversation memory; everything else lands in the long-term graph, with the confidence recorded as entity feedback. Structure at write time is what makes retrieval precise later.
Tools + MCP — remember and act
toolsWithMcp() connects to any MCP server and merges its tools with the memory tools, so one agent can do both:
const { tools, close, mcp } = await nams.toolsWithMcp(
{ userId: session.userId },
{ url: 'https://mcp.example.com/mcp', toolPrefix: 'mcp_', optional: true },
);
optional: true degrades to memory-only if the server is down; mcp.toolNames reports the post-prefix names so we can build our system prompt from the tools that are actually available rather than the ones we hoped for. The @ai-sdk/mcp dependency is an optional peer, imported lazily and only when an MCP config is passed. And because merging tool sets is { …a, …b }, a server exposing its own store_memory would silently shadow ours — so the merge diffs the key sets and warns you, by name.
The hybrid — both at once
The modes compose. Wrap the model in middleware and hand it the tools, and we get unconditional context injection on every call plus the model’s own ability to search and store on demand:
const agent = new ToolLoopAgent({
model: nams.wrap(openai('gpt-5.4-mini'), scope), // baseline context, every call
tools: nams.tools(scope), // model-driven top-ups
stopWhen: stepCountIs(10),
});
The new part: memory we can actually guarantee
This is the piece we most want to put in front of the Vercel AI SDK community, because as far as we can tell no other memory provider ships it — and it resolves a trade-off everyone building on tool-based memory has quietly been eating.
Here’s the trade-off. Every memory integration is one of two shapes:
- Wrap the model → memory is guaranteed (it runs in code, every call) but invisible (the model can’t deliberately reach for more, and your users can’t see it happen).
- Expose tools → memory is visible and model-driven, but never guaranteed.
That second failure mode is worse than it sounds. Tool descriptions and system instructions are advisory. Models routinely skip what looks like bookkeeping and answer straight from the prompt — which, in a memory product, means confidently answering with no memory at all. Our logs show a clean successful turn. Our user sees an agent that forgot them. A prompt that says “always call query_memory first” is a suggestion, not a mechanism.
So we built the mechanism, out of two hooks the AI SDK already exposes:

enforceQueryMemory() is a prepareStep hook that guarantees retrieval without telling tool order:
- While query_memory hasn’t appeared in the executed tool calls, every step is held at toolChoice: ‘required’. The model may still call whatever it likes — read a file, hit an MCP tool, in any order — but it cannot finish with a text-only answer before memory has been consulted.
- After graceSteps steps (default: 3) without a query, the next step forces query_memory directly. The loop can never exhaust its budget without the query having run.
- The moment query_memory executes, every constraint drops.
enforceQueryMemory() // 3 free steps, then forced
enforceQueryMemory({ graceSteps: 0 }) // forced as the literal first step
ensureMemoryStored() closes the other half. prepareStep can’t guarantee the write side — the loop ends when the model emits final text, so there’s no later step to force store_memory into. That guarantee has to live after the loop:
const agent = new ToolLoopAgent({
model, tools,
prepareStep: enforceQueryMemory(), // retrieval guaranteed mid-loop
onFinish: ensureMemoryStored(tools), // persistence guaranteed after it
stopWhen: stepCountIs(10),
});
If the model never called store_memory, the hook persists the turn itself and reports { stored: true, input }; otherwise it stands down with { stored: false, reason: ‘already-stored’ }. Its default is deliberately conservative — it stores the assistant’s final text as an interaction (short-term conversation memory, matching what middleware mode records) rather than as a fact, because an agent’s summary of what it remembers is not new knowledge about the user. A fallback option hands that decision back to us, including returning null to store nothing.
The result: tools mode no longer trades the guarantee away for the visibility.
Middleware makes memory unconditional. Tools make it visible. These two hooks
make it both - and they are built entirely from prepareStep and onFinish. Any
provider in the ecosystem could adopt the pattern tomorrow, and we think they
should.
One more thing nobody else guards against
Related, and just as easy to miss. A user asks “what do you remember about me?” The agent answers “Retrieved long-term memories indicate a preference for concise technical answers…” The model helpfully stores its own summary. And extraction faithfully mints entities — about remembering:
long-term memories [Concept]
past interactions [Event]
profile details [Object]
The next “what do you remember?” is semantically closest to precisely those meta-entities, so they outrank Alex [Person] and Bangalore [Location]. Every ask makes the next ask worse — and that question is the single most-asked question in any memory product.
The guard is deliberately structural, not a vocabulary list. A denylist of English phrases would be a maintenance treadmill that breaks on the first user who speaks another language. Two rules do the work:
// A proper noun differs from its own lowercase form. A script without case
// ("北京", "القاهرة") reports equal upper and lower forms, so the test declines
// to fire rather than rejecting every entity in that language.
const isCommonNoun = (name: string) =>
name === name.toLowerCase() && name !== name.toUpperCase();
// …plus: an entity named after its own type ("Organization [Organization]"),
// compared after crude singularization.
Skipped entities are logged, so the behaviour is observable rather than silent, and extractionOptions.skipEntity lets us replace the rule entirely when our domain’s entities genuinely are common nouns. Belt and braces: the store_memory tool description tells the model outright never to store what query_memory just returned.
From strings to entities: graph extraction
Pass an extractionModel and every stored memory is decomposed into typed entities instead of stored as a sentence:
const nams = createNamsProvider({
apiKey: process.env.MEMORY_API_KEY!,
baseProvider: openai,
scope: { userId },
extractionModel: openai('gpt-5.4-mini'), // one extra call per stored memory
});
(Alex:Person)-[:WORKS_AT]->(TechCorp:Organization)
So “User is named Alex, works at TechCorp” doesn’t become one sentence-shaped node — it becomes Alex [Person] and TechCorp [Organization], each carrying the memory’s confidence score as feedback, each independently retrievable and independently correctable. Extraction is off by default, conservative by design (real named entities only, no invention), and falls back to a flat entity if it fails, so nothing is ever lost.
What we get on the hosted service today is a typed, deduplicated, confidence-scored entity store with real graph APIs over it — getEntityGraph(), getRelatedEntities(), getEntityHistory(), mergeEntities(), setEntityFeedback(). That is already a different object from a vector index of chat logs: we can enumerate what the agent believes about a user, see when it learned each thing, correct it, and merge duplicates.
Honest status on edges. The extractor produces typed relationships
(WORKS_AT, PREFERS, USES) and attempts to write each one, but the hosted NAMS
REST API has no relationship endpoint yet — so edge writes are skipped,
reported once per client, and then suppressed rather than flooding your logs.
Entities land; edges do not, on the hosted path, today. When the endpoint
ships, edges start persisting with no change on our side. We'd rather tell
you than let you discover it.
FIG 1: Where Memory Attaches

Two lanes , one core. The lanes differ in who decides that memory is touched: the SDK middleware or the model . Both land on the same two package functions.
FIG 2: What One Retrieval actually does

Who this is for
If you’re a developer who just wants your agent to stop forgetting. The floor is a free API key and one changed line. There’s no Neo4j to provision, no vector store to choose, no embedding pipeline to run, and no schema to design before you can find out whether memory even improves your agent. Here it’s an HTTP call from wherever your code already runs.
If you’re a team shipping to production. Memory is scoped per user (scope: { userId }), so multi-tenancy is a parameter rather than a design project. It’s inspectable, so “why does it think that about me?” has an answer you can show. And it’s fail-safe by design (below), so a memory outage degrades your agent instead of breaking it.
If you build on the Vercel AI SDK. The SDK’s own memory guide lists Letta, Mem0, Supermemory, Hindsight, and MongoDB — five ways to give an agent persistent memory, none of them graph-backed. That’s the page a developer lands on the moment they decide their agent needs to remember something, and a graph-shaped option belongs on it.
This package is also a worked example of the community-provider carrying real weight: ProviderV4 + wrapLanguageModel + tool() + prepareStep + onFinish turned out to be enough to build a complete memory system with no framework of its own.
How our package dictates it acts as Memory Provider
- Three integration shapes, not one. Guaranteed-but-invisible and visible-but-optional are both choices for different apps. Getting both from one client and one API key means we don’t have to predict which we’ll want.
- The guarantee hooks. enforceQueryMemory() + ensureMemoryStored() make model-driven memory dependable. This is the genuinely new idea, and it’s built from public SDK primitives.
- An entity-shaped store, not a document-shaped one. Every provider can answer “what did the user tell me about X?” — that’s vector search over text, and it’s table stakes. Storing Python [ProgrammingLanguage] as a first-class node with confidence, history, and merge semantics is a different data model, and it’s what turns “list everything you know about this user, and let me fix the wrong bits” into a query.
Where it’s not a good fit: if you want memory tiers designed for you (Session/Semantic/Procedural/Episodic/Scratchpad split is genuinely well thought through), or you’re on Anthropic and the file-directory metaphor fits your task, those are better starting points.
Designed to fail safe — and tested like it
A memory layer sits between the user and the model, so it must never be the reason a response fails. The package’s core invariant:
If memory breaks, the model call still succeeds. Every retrieval and
persistence failure degrades to a logged warning — never a thrown error
in the request path.
That claim is backed by a Vitest suite (63 tests across five files) that exercises each layer against an in-memory fake of the NAMS client:
Try It today
npm install @neo4j-labs/nams-ai-provider ai @ai-sdk/provider @neo4j-labs/agent-memory zod
- Get a free API key at memory.neo4jlabs.com → MEMORY_API_KEY=sk-nams-…
- Swap one line in your chat route
- Your agent remembers from the next deploy
npm install # dev dependencies
npm test # vitest — retrieval, provider, middleware, tools, extraction
npm run typecheck # tsc --noEmit
npm run build # tsup → dist/ (ESM + type declarations)
Four runnable examples cover each integration path end to end:

The package is Apache-2.0. The source is on GitHub alongside the core TypeScript SDK, and the Vercel AI SDK community provider docs cover what it implements. Questions and war stories are welcome on the Neo4j Community Forum; bugs and feature requests belong in GitHub Issues.
Three integration shapes was a bet, not a conclusion. If you reach for a fourth — or find that one of them is dead weight — that’s the report that shapes the next version.
Our users have been introducing themselves to our agent every single day. It’s time it remembered them.
Tags: AI Agents · Vercel AI SDK · Neo4j · Knowledge Graphs · TypeScript
Neo4j Labs project. @neo4j-labs/nams-ai-provider is actively maintained
but experimental — no SLAs or backwards-compatibility guarantees.
It targets AI SDK v7 / LanguageModelV4 and requires it — ai@^7,
@ai-sdk/provider@^4. The minimal middleware in
@neo4j-labs/agent-memory/middleware/vercel-ai,
written against the AI SDK 4.x LanguageModelV1Middleware shape,
remains available, but new projects should prefer this package.
Your AI Agent Forgets Everything. So We Taught It to Remember — With a Graph. was originally published in Neo4j Developer Blog on Medium, where people are continuing the conversation by highlighting and responding to this story.








