CLAUDE LABJP
FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtaskLIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its ownMCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MSPLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callbackSONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as OctoberFORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtaskLIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its ownMCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MSPLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callbackSONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Articles/API & SDK
API & SDK/2026-04-24Advanced

Giving Claude Agents Long-Term Memory in Production — Seven Pitfalls and the Patterns That Fix Them

A production playbook for Claude agents with long-term memory — seven pitfalls that break memory agents live, and the design patterns that fix each one.

Claude API115Claude Agent SDK13Long-term MemoryMemory ToolAgent Design2PIIProduction23Cost Management4

Your Agent Remembers — Until It Doesn't

It usually starts well. You hand Claude a couple of tools, something like memory_write and memory_search, and your agent starts remembering users' names, project context, preferences across sessions. For about two weeks it feels magical. Then you open the logs one morning and find a user being addressed as someone else, a preference that no one actually stated showing up as a firm fact, and a memory store that looks like someone photocopied a notebook a hundred times.

This is not a Claude problem. It is a memory-system design problem that almost every team rediscovers the hard way. I rediscovered it myself on a solo-developer project, spent three weeks patching symptoms, and only stopped losing sleep once I threw away the "one big bucket" design and rebuilt around a small number of invariants.

This piece is what I wish someone had handed me that third week. It covers seven specific ways long-term memory goes wrong in production Claude agents, and the design patterns I now reach for to prevent each one. It applies whether you're using Anthropic's native Memory-style tooling, a custom MCP-backed store, or something glued together in your own backend. The core failure modes are the same.

The Three-Layer Model: Retrieve, Write, Forget

Before the pitfalls, a frame that I find keeps teams out of circular arguments. Treat long-term memory as three layers:

  • Retrieve — what you pull into the context window each turn. This is where context pollution happens.
  • Write — what the agent chooses to commit. This is where the rot starts.
  • Forget — what gets pruned, merged, or deleted. Almost nobody implements this, and it's why memory stores turn to mush.

Most production bugs become tractable the moment you can name which of the three layers is at fault. If you haven't read our Claude API Advanced Tool Use guide, that's a useful primer — everything below builds on the tool-use mental model.

Here's the minimal tool shape I start with. Even for throwaway prototypes, two invariants are non-negotiable: every tool takes user_id as its first argument, and every write records source_turn_id.

# Minimal memory tools — Anthropic Python SDK oriented
import json, time, hashlib, pathlib
 
MEMORY_DIR = pathlib.Path("./memory_store")
MEMORY_DIR.mkdir(exist_ok=True)
 
def memory_write(user_id: str, key: str, value: str, source_turn_id: str) -> dict:
    """Every write is user-scoped and traceable back to its conversation turn."""
    if len(value) > 2000:
        return {"ok": False, "error": "value too long (>2000 chars). summarize first."}
    path = MEMORY_DIR / user_id
    path.mkdir(exist_ok=True)
    record = {
        "key": key,
        "value": value,
        "source_turn_id": source_turn_id,
        "written_at": int(time.time()),
        "checksum": hashlib.sha1(value.encode()).hexdigest(),
    }
    (path / f"{key}.json").write_text(json.dumps(record, ensure_ascii=False))
    return {"ok": True, "key": key}
 
def memory_search(user_id: str, query: str, limit: int = 5) -> dict:
    """Reads are bounded by user_id and a hard limit — no unbounded retrieval."""
    path = MEMORY_DIR / user_id
    if not path.exists():
        return {"ok": True, "hits": []}
    hits = []
    for f in sorted(path.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
        rec = json.loads(f.read_text())
        if query.lower() in rec["value"].lower() or query.lower() in rec["key"].lower():
            hits.append(rec)
            if len(hits) >= limit:
                break
    return {"ok": True, "hits": hits}
 
# Expected behavior:
# memory_write("u_42", "preferred_language", "Japanese", "t_81")
# -> {"ok": True, "key": "preferred_language"}
# memory_search("u_42", "language", 3)
# -> {"ok": True, "hits": [{"key": "preferred_language", ...}]}

Those two invariants — user_id first, source_turn_id always — quietly prevent half of the pitfalls below. They're cheap to add now and painful to retrofit after you have real users.

Pitfall 1: Letting the Model Decide What to Remember

The first and most expensive mistake is giving the model full discretion over what to write. If your system prompt says "remember anything that seems important," Claude will take that responsibility seriously. You'll see the same fact rewritten in thirty slightly different phrasings, hypothetical statements stored as fact, and speculative side remarks committed as settled truth.

Pattern: two-stage writes. The agent proposes a candidate via memory_candidate_propose. Nothing is committed. A lightweight validator — a smaller model like Haiku, or a rule-based check, or both — decides whether the proposal actually deserves to persist.

def propose_and_validate(proposal: dict) -> dict:
    """Reject duplicates and uncertain-phrased proposals before they reach the store."""
    existing = memory_search(proposal["user_id"], proposal["key"], limit=1)
    if existing["hits"] and existing["hits"][0]["value"] == proposal["value"]:
        return {"ok": False, "reason": "duplicate"}
    # Hedged proposals are almost never worth persisting
    hedges = ["maybe", "perhaps", "might", "possibly", "i think"]
    lowered = proposal["value"].lower()
    if any(h in lowered for h in hedges):
        return {"ok": False, "reason": "uncertain"}
    return {"ok": True}
 
# Expected behavior:
# propose_and_validate({"user_id": "u_42", "key": "goal", "value": "maybe a new job?"})
# -> {"ok": False, "reason": "uncertain"}
# propose_and_validate({"user_id": "u_42", "key": "goal", "value": "ship v1 by June 30"})
# -> {"ok": True}

The reason this works is that it splits the responsibility: the agent can be as eager as it likes, because it doesn't hold the commit bit. The validator owns the commit bit, and the validator's job is explicitly narrow. As a bonus, every rejected proposal becomes a diagnostic record — you can replay them later and see whether your validator is too strict, too loose, or both.

Pitfall 2: PII and Secrets Accumulating Silently

The scary moment in running a memory system is the day you realize you've been storing credit card numbers, medical conditions, or internal project codenames for weeks, because users drop all of that into conversations without thinking. An agent with generous write permissions will happily persist every word of it.

Pattern: a three-stage filter on the write path.

  1. A regex pass for the obviously-high-risk patterns (card numbers, national IDs, SSNs).
  2. A lightweight classifier (a Haiku call is fine) that returns a sensitivity score 0–1 for free-text fields regex can't catch.
  3. An explicit user-consent confirmation that fires only when the score crosses a threshold.

Stage three sounds annoying, but because it only activates on borderline cases it's rare in practice, and it's the only way to handle genuine edge cases gracefully.

import re
 
PII_PATTERNS = {
    "credit_card": re.compile(r"\b(?:\d[ -]*?){13,19}\b"),
    "us_phone":    re.compile(r"\b\(?\d{3}\)?[-. ]?\d{3}[-. ]?\d{4}\b"),
    "email":       re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"),
    "ssn":         re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
}
 
def pii_redact_or_block(value: str) -> dict:
    hits = []
    redacted = value
    for name, pat in PII_PATTERNS.items():
        if pat.search(redacted):
            hits.append(name)
            redacted = pat.sub(f"[REDACTED:{name}]", redacted)
    if "credit_card" in hits or "ssn" in hits:
        # Highest-risk categories: refuse to store, even redacted
        return {"ok": False, "hits": hits}
    return {"ok": True, "value": redacted, "hits": hits}
 
# Expected behavior:
# pii_redact_or_block("call me at 555-123-4567")
# -> {"ok": True, "value": "call me at [REDACTED:us_phone]", "hits": ["us_phone"]}
# pii_redact_or_block("card 4111 1111 1111 1111")
# -> {"ok": False, "hits": ["credit_card"]}

Regex alone will never be enough. I run a parallel Haiku-based classifier that answers a single question — "probability 0 to 1 that this contains sensitive personal or business information" — and block anything above 0.7. When a block fires, the agent is instructed to acknowledge to the user that it won't remember this, which is both more transparent and a quiet signal to the user that their data is being treated carefully.

Pitfall 3: Weak Scoping That Leaks Across Users

In any multi-tenant setup — even a hobby app with three friends testing it — weak scoping leads to cross-user leakage. User A's unfinished product idea ends up surfacing in User B's next conversation. You'll notice this weeks after the fact, in a support ticket, and it is a trust-destroying bug.

Pattern: enforce user_id as the physical first class of separation, not a convention. That means three overlapping layers:

  1. Tool schema requires user_id as a required first argument.
  2. A backend guard checks that the authenticated user's id matches the user_id sent by the agent. Mismatches are failed hard and audit-logged.
  3. Storage is physically partitioned — directory per user, KV prefix per user, or row-level security with a tenant column. Code-level bugs shouldn't be able to read another tenant's bytes.
def guarded_tool_call(authenticated_uid: str, tool_name: str, tool_args: dict) -> dict:
    """Catches the bug where the model invents or borrows a user_id."""
    requested_uid = tool_args.get("user_id")
    if requested_uid is None:
        return {"ok": False, "error": "user_id is required"}
    if requested_uid != authenticated_uid:
        audit_log(
            event="uid_mismatch",
            expected=authenticated_uid,
            got=requested_uid,
            tool=tool_name,
        )
        return {"ok": False, "error": "uid mismatch"}
    return dispatch_tool(tool_name, tool_args)

The audit log here deserves a callout. When uid mismatches start happening you want to know immediately, not at the end of the month, because this is often the first visible sign of a prompt-injection attempt — the attacker has convinced the model to spoof someone else's id. If you haven't hardened against that, our Claude API prompt-injection defense guide covers the adjacent threat model.

Pitfall 4: Silent Corruption

Partial writes, processes killed mid-flush, concurrent writers stepping on each other, flaky KV replicas — any of these can produce a record that looks valid at a glance but has been corrupted. The painful part is not the corruption itself, it's that an agent reading a corrupted memory and answering confidently from it propagates the damage for days before anyone notices.

Pattern: checksum every write, verify on every read, quarantine on mismatch.

def memory_read(user_id: str, key: str) -> dict:
    path = MEMORY_DIR / user_id / f"{key}.json"
    if not path.exists():
        return {"ok": False, "error": "not found"}
    rec = json.loads(path.read_text())
    actual = hashlib.sha1(rec["value"].encode()).hexdigest()
    if actual != rec.get("checksum"):
        quarantine = MEMORY_DIR / "_quarantine" / user_id
        quarantine.mkdir(parents=True, exist_ok=True)
        path.rename(quarantine / path.name)
        audit_log(event="memory_corrupted", user_id=user_id, key=key)
        return {"ok": False, "error": "corrupted (quarantined)"}
    return {"ok": True, "record": rec}
 
# Healthy: {"ok": True, "record": {...}}
# Corrupted: {"ok": False, "error": "corrupted (quarantined)"}

Quarantine — not delete — is the key detail. Corrupted records have diagnostic value. I've traced real bugs by comparing a quarantined record to the turn transcript it came from. Deleting them destroys the evidence trail.

In steady state, most corruption I see traces back to either a process that died mid-write or two workers writing the same key at once. File-level locks or a transactional KV handle both. Do not try to drive corruption to zero through sheer effort — being able to detect it reliably and recover quickly beats an expensive guarantee that you get right 99.9% of the time.

Pitfall 5: Runaway Cost

Once a memory layer is doing its job, you get a new problem: the read path starts loading more and more into context. Input token counts drift upward, Prompt Caching hits drop because memory content is mutating, and one month the bill arrives and you blink at it. I've done this.

Pattern: budget the read path and consolidate the store. Two complementary tactics.

First, put a hard token budget on the retrieval step. If the retrieved memories exceed it, rank by importance and truncate. Second, run a periodic consolidation job that summarizes older, rarely-accessed memories into denser form. The techniques for this closely mirror what's covered in our pgvector-based persistent memory guide, and they apply just as well to native Memory-tool stacks.

The three metrics I watch on a dashboard:

  • Memory-derived input tokens per request. Alert when it crosses your budget ceiling.
  • Total memory records per user. A long tail of users with runaway counts points to a write-path bug, not user behavior.
  • Fraction of records not accessed in 30 days. When this crosses 40%, it's time to run or strengthen consolidation.

One thing that's easy to forget: if you combine memory retrieval with Extended Thinking, your retrieved content enters the thinking input too, which costs extra. Consider retrieving a summarized memory view during Extended-Thinking turns, or skipping retrieval entirely for those turns.

Pitfall 6: Migration Panic

Eventually you'll want to move off your first memory backend. Maybe the native Anthropic Memory tool gains a feature you need; maybe your custom MCP server is becoming a maintenance burden. A hard cutover means your agent suddenly can't see anything users said before the migration — which, from a user's perspective, is your agent developing amnesia overnight.

Pattern: four-phase migration — shadow-write, dual-read, cut over, decommission.

  1. Shadow-write: every write hits both old and new stores. Users still read from old.
  2. Dual-read: reads hit both stores and you measure the diff. This is where you find bugs.
  3. Cut over: once the diff is small and stable, flip reads to new. Old is now cold storage.
  4. Decommission: after a safety window (ideally covering your longest typical user gap), drop old.
def dual_write(user_id: str, key: str, value: str, source_turn_id: str) -> dict:
    old = legacy_memory_write(user_id, key, value, source_turn_id)
    try:
        new = memory_write(user_id, key, value, source_turn_id)
    except Exception as e:
        # New side can fail; old side must still succeed during shadow phase
        audit_log(event="shadow_write_failed", error=str(e), user_id=user_id, key=key)
        new = {"ok": False, "error": str(e)}
    return {"old": old, "new": new}
 
def dual_read(user_id: str, query: str) -> dict:
    old_hits = legacy_memory_search(user_id, query)["hits"]
    new_hits = memory_search(user_id, query)["hits"]
    metric.increment("memory.migration.old_only",
                     len(set_keys(old_hits) - set_keys(new_hits)))
    metric.increment("memory.migration.new_only",
                     len(set_keys(new_hits) - set_keys(old_hits)))
    return {"hits": old_hits}  # Old remains the source of truth during migration

The safety window is worth taking seriously. Most teams underestimate it because they think in terms of daily active users. The right frame is the tail of your user-return distribution. A user who last opened the app six weeks ago will not forgive a memory wipe. One to two months of shadow-write is typical; I've done three when the product had strong seasonality.

The One Change to Make This Week

If you want to leave with exactly one improvement to ship this week, make every memory write carry source_turn_id. It takes ten minutes to add to existing code, and it's the foundation that every other pattern in this article stands on. Corruption tracing, PII audits, cross-user investigations, migration diffs — none of them work without a link back to the conversation that produced the record.

From there, the order I'd tackle the rest is: scoping enforcement (pitfall 3), because leaks are reputation-ending; PII filtering (pitfall 2), because regulations are unforgiving; then the write validator (pitfall 1), because it keeps the store clean enough that the remaining problems become tractable.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

API & SDK2026-07-07
When Claude API Suddenly Starts Returning 429 in Production — Field Notes on Measuring Rate-Limit Headroom from Headers and Throttling Before You Run Dry
Scrambling to add retries after a 429 is always a step behind. Claude API writes how much you have left into every response header. These are field notes on measuring that headroom continuously and throttling yourself before it runs out.
API & SDK2026-07-01
When Claude API Document Extraction Is Confidently Wrong — Field Notes on Catching Silent Errors with Invariants
In structured extraction from invoices and contracts, the real danger isn't a crash — it's a value that's silently wrong while the schema validates and confidence reads high. Field notes on invariants, two-pass extraction, and tracking field-level error rates.
API & SDK2026-07-01
A Fail-Closed Model Pricing Registry So New Models Don't Quietly Break Your Cost Math
When Opus 4.8 and Haiku 4.5 landed in the Messages API, rates scattered across my code silently skewed the cost rollup. Here is how to centralize per-model rates and fail closed on unknown models, with complete working code.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →