cd /news/artificial-intelligence/persistent-memory-for-claude-code-on… · home topics artificial-intelligence article
[ARTICLE · art-80316] src=pub.towardsai.net ↗ pub= topic=artificial-intelligence verified=true sentiment=· neutral

Persistent Memory for Claude Code on MongoDB Atlas

MongoDB Atlas provides a persistent memory solution for Claude Code to prevent context loss during long sessions, using a plugin that stores and retrieves information across turns and sessions via a store interface. The design keeps storage, embedding, hybrid recall, and optional reranking close to the data, addressing the silent loss of constraints during context window compaction.

read16 min views1 publishedJul 30, 2026

Anyone who has worked through a long session in Claude Code has probably watched it lose the thread. A decision made an hour ago stops being honored, or a constraint stated near the start quietly stops applying.

The usual explanation is that the model forgot, but that framing hides what is actually happening, and the mechanism matters because it points straight at the fix.

The common practitioner instinct is to put a database behind Claude, so its memory stops slipping. This article follows that instinct and resolves it on MongoDB Atlas. The design keeps storage, embedding, hybrid recall, and optional reranking close to the data, instead of spreading them across separate services.

The demonstrator is a thin Claude Code plugin, and the companion repository holds the working code.

The context window is the working memory of the current session, not a general memory. It holds the conversation so far, the contents of files read, the calls made to tools, and the results of those calls returned. Everything the model can reason about at a given moment is what currently fits in that window. The window is finite, so as a session grows, the material competes for a fixed token budget.

When the window approaches its limit, Claude Code compacts it. Compaction happens in two moves, ordered from the least to the most costly to lose. First, it drops the oldest tool outputs, which tend to be bulky and are the easiest to shed. Then it summarizes the older stretches of the conversation, replacing detailed exchanges with a shorter synopsis.

Compaction can be triggered manually with /compact, and it also runs automatically when Claude Code needs space, without anyone asking.

Both moves discard information, and the second one especially, because a summary keeps the gist and lets the detail go. A constraint expressed once at the start of a session, such as leaving the payment module alone or using snake_case for column names, may survive that summary or may not, with no guarantee either way.

The difficulty is that the loss is silent, and nothing in the interface marks how and when a constraint was dropped. The symptom shows up later and somewhere else when the model does something that an earlier decision had ruled out, and the developer is left to reconstruct why.

Memory is the layer that retains and retrieves information across turns and across sessions. It comes in two kinds worth keeping distinct. Working memory, or short-term memory, holds the state of the current session: the running conversation and the intermediate tool calls. Long-term memory holds the knowledge that must outlast any single session.

These two kinds map onto two established patterns: a checkpointer for short-term state and a store for long-term knowledge. A checkpointer persists an agent’s state so a conversation can be resumed and so the run is fault-tolerant. A store is a durable, searchable collection of memories shared across sessions. The terms come from LangGraph’s ecosystem, and MongoDB ships an official implementation of each: the MongoDBSaver class in langgraph-checkpoint-mongodb for the checkpointer, and the MongoDBStore class in langgraph-store-mongodb for the store. The plugin borrows only the vocabulary and the API shape, not the framework: it is plain TypeScript written directly against Atlas, and it follows the store side of the split.

The store interface is small: a memory is written under a configured namespace, and memories are read back with a query and a limit. The plugin exposes exactly this pair of operations:

await saveMemory("The API allows 100 requests per minute per client.");const hits = await searchMemory("what is the traffic limit", 3);

Every memory is scoped to a namespace, set once for the plugin, and that namespace is what makes the store cross-thread. The same namespace is reachable from any session, which is how a memory written in one conversation can be found in another, weeks later.

Compaction discards earlier decisions and constraints outright: dropped tool outputs are gone, and summarized stretches keep only the gist. So the durable statements have to be written down somewhere lasting before that happens, and then found again from a later session. Persisting them is one job, and retrieving them is another, and a cross-thread, searchable store paired with a hook that writes before compaction covers both. The checkpointer, which holds session state, is a separate concern.

Storing memories turns out to be the easy half. The hard half is retrieving the right one when it matters. Retrieval has two jobs that pull in different directions. Recall is the job of surfacing every memory that might be relevant, and it draws on two signals:

Precision is the job of ordering those candidates so the most relevant ones come first.

Each signal fails on its own in a characteristic way. Semantic similarity catches paraphrases, where the same idea is worded differently, but it also returns near-misses, concepts that sit close in the embedding space without being the thing that was asked for. Keyword match is exact about terms but blind to paraphrases, so a question about the traffic ceiling will not find a note that records a cap on requests per minute.

Getting the order right is a separate concern that a fusion of recall signals only partially addresses, and it is the reason a third step exists.

A concrete case makes the split clear. Suppose an earlier session recorded that the API allows 100 requests per minute per client, and a later session asks about the traffic limit. The phrase “traffic limit” shares no words with the stored sentence, so a keyword search ranks the memory poorly or misses it, while a vector search recognizes that the two mean the same thing and surfaces it. Reverse the situation, with a query that uses an exact identifier or an error code that has no near synonym, and the keyword side is what rescues the result.

Recall combines the two so that both kinds of match have a chance, and precision then decides which of the surfaced candidates leads.

The pipeline has three stages: embed, hybrid recall, and rerank, and on Atlas, all three run inside the database. The application sends text and gets back ranked memories.

It does not call an embedding service or a reranking service on its own.

The first stage is embedding. With Automated Embedding, Atlas generates vector embeddings with a Voyage model both when a document is indexed and when a query arrives, and it keeps the vectors in sync as the underlying text changes. The index is defined with an autoEmbed field that names the model, and a query carries plain text, which Atlas turns into a vector at query time. No embedding code runs in the application, and there is no separate embedding pipeline to operate.

The second stage is hybrid recall. The $rankFusion aggregation stage takes several input pipelines and fuses their results into one ranked set. In this design, one input pipeline is a

The stage reads as one aggregation, with the two pipelines named under input.pipelines:

{   $rankFusion: {     input: {       pipelines: {    vectorPipeline: [      { $vectorSearch: {           index: "memory_vector_index",           path: "text",           query: queryText, // Atlas embeds this at query time           model: "voyage-4",           numCandidates: 200,           limit: 20      } } ],     searchPipeline: [     { $search: {         index: "memory_search_index",         text: { query: queryText, path: "text" } } },      { $limit: 20 } ]   }   }, combination: { weights: { vectorPipeline: 0.7, searchPipeline: 0.3 } }, scoreDetails: true }}

The same query text feeds both pipelines: Atlas embeds it for the vector side and matches it literally for the keyword side. The combination.weights block is where the two signals get balanced. The weights multiply each pipeline’s contribution to the RRF score, that is, the w in w / (60 + rank), so 0.7 and 0.3 favor semantic match while keeping exact terms in play. The right split depends on the data: memories full of identifiers, error codes, or configuration keys deserve more keyword weight than free-form decisions do, and the weights are the single point of control worth revisiting once real queries accumulate.

The following $project reads the fused result with { $meta: “score” }, while { $meta: “scoreDetails” } exposes the per-pipeline breakdown when the query needs to be debugged. The hybrid search documentation covers the stage in full.

Running $vectorSearch inside a $rankFusion input pipeline requires MongoDB 8.1. On clusters that predate native fusion, the same effect can be assembled by hand with a $search stage, a $unionWith that runs $vectorSearch in its subpipeline, and a $group that computes the weighted score directly. As of this writing, Automated Embedding and $rankFusion are both in public preview; check the docs for current availability and syntax before relying on them in production.

The third stage is reranking. After recall has produced a broad candidate set, the Embedding and Reranking API on Atlas reorders those candidates by relevance. That API, an Atlas-hosted service exposing Voyage AI reranking models and in public preview as of this writing, is called over HTTP rather than run as a native aggregation stage, so the team relies on a hosted service instead of standing up its own reranker In the companion plugin this stage is written as a placeholder behind a capability check: when reranking is available it reorders the candidate set, and when it is not, the pipeline returns the hybrid-recall order unchanged. With these three stages, the application stores text, asks with text, and receives memories already ranked, while the embedding, the fusion, and the reordering all stay inside the database.

Co-locating the operational data, the vectors, the full-text index, and the memory in one cluster removes the layer of glue that otherwise sits between them. There are no sync jobs shuttling documents between a primary store and a separate vector database and back. There is one access-control surface to reason about, where the assembled alternative needs several. The memory lives next to the data the agent already works with, under the same operational umbrella.

It also gives memory the lifecycle management of any other collection. The state an agent produces accumulates quickly over a long-running project, and it needs what operational data needs: indexing, access control, backup, and expiry. Expiring stale memories with a time-to-live, restricting who can read a namespace, or backing up the memory alongside the rest of the data are all things the database already does. A separate memory service would have to grow each of them as a feature.

The table below sets the assembled alternative against the co-located design.

The assembled alternative is familiar: a relational store holds the records, a vector extension or a dedicated vector database holds the embeddings, an embedding API converts text into vectors, a reranker reorders results, and a memory server ties it together for the agent.

Each of those is a surface that has to be kept consistent with the others, and the consistency work grows with the number of surfaces. A single-database design collapses that assembly into one place, and the table above is the trade to weigh.

A Claude Code plugin packages two components that matter here: the Model Context Protocol (MCP), which provides tools that the model can call, and the lifecycle hooks that fire on events. The plugin in the companion repository uses both, and it implements no retrieval machinery of its own. Embeddings, vector scoring, keyword scoring, rank fusion, and reranking stay in Atlas. What the plugin owns is the retrieval policy around that pipeline: namespace scoping, candidate limits, pipeline construction, weights, retries, fallback behavior, and error handling. Keeping that boundary clear makes the plugin easier to test and keeps it from growing into a hidden memory platform.

The pull side is two MCP tools: save_memory writes a memory, and search_memory runs the three-stage pipeline, and returns ranked results into the context window. When you ask Claude Code to recall something, it calls search_memory, and the query runs inside Atlas. You can also have it save a memory explicitly, though the more useful path is the one that does not depend on the model remembering to do so.

The push side is a PreCompact hook, which fires just before the window is compacted, the one moment when you know information is about to be discarded. The hooks documentation defines the event and the two cases it matches, manual for /compact and auto for automatic compaction. Covering the automatic case is what separates this from a setup built on MCP tools alone, because automatic compaction is exactly when nothing in the conversation would be thought to call a tool. The hook receives the event in JSON via standard input and treats it as the signal that part of the working context is about to be compressed. It does not persist the transcript wholesale. The useful unit is a user-authored statement, a decision, constraint, project convention, or open instruction that a later session may need, and the hook keeps those statements verbatim while skipping obvious conversational noise: bare acknowledgments, slash commands, and fragments too short to be self-contained.

In the companion plugin, the PreCompact path extracts memory candidates from the pre-compaction slice and writes them to Atlas with provenance metadata. The hook remains write-only from Claude Code’s point of view. It does not try to change the compaction decision, and it exits cleanly even when saving fails, so compaction can continue.

Registering it is a few lines in the plugin’s hooks/hooks.json, where the matcher covers both triggers, ${CLAUDE_PLUGIN_ROOT} resolves to the plugin’s own directory, and — env-file-if-exists loads the connection string from the plugin’s .env so the same command works whether the string is set there or in the shell:

{   "hooks": {     "PreCompact": [     {       "matcher": "manual|auto",       "hooks": [       {         "type": "command",         "command": "node - env-file-if-exists=${CLAUDE_PLUGIN_ROOT}/.env ${CLAUDE_PLUGIN_ROOT}/dist/src/hook-precompact.js",         "timeout": 30   }     ]     }     ]   }}

The MCP server is declared the same way in the plugin’s .mcp.json, and the plugin documentation describes how the pieces fit together.

One thing the hook does not do is lower the cost of compaction. The PreCompact hook runs alongside compaction, not instead of it, so Claude Code still compacts and still spends those tokens. The hook rescues the memory first, and the compaction itself proceeds as before. An approach that saves tokens, disabling auto-compaction and curating context by hand, for example, is a separate design that this plugin does not build.

Installation is two commands and one connection string:

/plugin marketplace add matteoroxis/claude-memory-plugin/plugin install atlas-memory

You set the connection string once, either in the plugin’s .env file or as an exported ATLAS_MEMORY_URI, without a build step or setup script. The two Atlas indexes the pipeline needs, a vector index and a search index, are created automatically the first time the plugin runs. While they build, which takes a minute or two on a fresh install, search_memory reports that the indexes are still building, and saved memories wait safely until the indexes are ready. From then on, the plugin loads on every launch with no flags to pass, and running /mcp shows the server with save_memory and search_memory.

A persisted memory is an ordinary MongoDB document. The plugin stores the text and a little provenance. There is no vector field in the document, because Automated Embedding keeps the embedding in sync with the text field on its own, outside the document that the application writes.

{ "_id": "6863f0c2a1b2c3d4e5f60789", "namespace": "default", "text": "The API allows 100 requests per minute per client.", "metadata": { "source": "pre-compact-hook", "trigger": "auto", "sessionId": "b7f3c1e0", "origin": "user" }, "createdAt": "2026–07–06T09:24:01.320Z", "dedupeKey": "9f2c8ad1e4b7…c1"}

Writes are deduplicated on the dedupeKey, a hash of namespace and text, so when the hook re-captures a statement that has not changed, it does not pile up as a duplicate. Searches retry during the brief warm-up window that follows an index becoming queryable, so the first query after setup does not fail for a transient reason. The plugin is integration code: its job is to connect Claude Code to the moment when memory is written, and to the Atlas pipeline that retrieves it later.

Claude Code already ships two mechanisms that handle a large share of day-to-day friction without any database. A CLAUDE.md file loads at the start of a session and carries project instructions into the window, while the built-in auto-memory saves compressed notes that are injected back later. Both are useful, and both share the same two limits: they consume tokens because they live in the window, and they are lossy on detail because they compress.

Limits start to bite when a project that runs for weeks accumulates more decisions than a CLAUDE.md should carry. A team sharing one history needs memory that is not tied to one developer’s local files. Recalling a specific decision made a month ago is a retrieval problem, and retrieval is what the store is built for.

The walkthrough covers durable memory for a single agent but does not cover memory shared across multiple agents. This is a situation that raises its own questions of consistency and ownership. It does not cover evaluating memory quality: precision, recall, and cost measured on a real workload, which is a discipline of its own. It does not cover deletion and retention policy beyond a TTL, that is, deciding when a memory should stop existing. And it does not cover governance of what gets stored, which matters as soon as memories might contain anything sensitive. Each of these is a separate system, shaped by how the memory is used.

The design removes one specific operational burden: keeping memory, embeddings, keyword search, and ranking behavior aligned across services. The plugin stores text and reads back ranked memories, while Atlas owns the embedding, the hybrid recall, and the optional reranking path.

A constraint stated at the start of a session still has to be written down somewhere. The plugin writes it before compaction can remove it, and retrieves it when a later session needs it. Handled this way, memory becomes infrastructure the agent uses rather than plumbing the developer maintains.

Matteo Rossi** — Senior Solution Architect @ Generali Operations Service Platform**

Matteo Rossi is a Computer Engineer and holds the position of Senior Solution Architect within General Operations Service Platform. In recent years, he has dedicated himself to the modernization and implementation of architectures in enterprise ecosystems, with a keen eye on technological innovation and new trends. He has worked mainly in the insurance sector, with experience in the automotive and fashion industries. Matteo lives in beautiful Bologna, Italy, with his partner, with whom he shares a passion for traveling.

Persistent Memory for Claude Code on MongoDB Atlas was originally published in Towards AI on Medium, where people are continuing the conversation by highlighting and responding to this story.

── more in #artificial-intelligence 4 stories · sorted by recency
── more on @mongodb atlas 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/persistent-memory-fo…] indexed:0 read:16min 2026-07-30 ·