The Claude memory tool is client-side: the model asks for a file operation, and your application performs it. That one design fact is the whole job. "Give my agent memory" doesn't mean flipping a flag — it means writing a handler for six commands and pointing it at a store, then owning the security surface that comes with running a filesystem the model can drive.
Anthropic hands you demo handlers to get started, and then tells you, in as many words, to replace them. The Go, Ruby, and PHP examples in the docs use an in-memory dict and skip path validation on purpose, with a note that "a production handler also needs the path validation these demonstration stores skip." The Python and TypeScript BetaLocalFilesystemMemoryTool is real, but it's one shared directory with no per-user isolation, no size caps, and no expiration. This piece is the handler you ship instead — complete, in Python, with the traversal guard that turns a demo into something you can put a user's data behind.
The shape of the problem#
The model never touches your disk. It emits a tool_use block like this:
{ "type": "tool_use", "id": "toolu_01C4…",
"name": "memory",
"input": { "command": "view", "path": "/memories" } }
Your code runs the operation and answers with a tool_result whose content is a plain string the model reads. That's the entire protocol, and it's why the return strings are a contract: the model decides what to do next by reading "File created successfully at: /memories/notes.txt" or "No replacement was performed, old_str … did not appear verbatim". Return something off-spec and the model's recovery logic degrades. So the handler has two halves: dispatch the six commands correctly, and return the strings the model expects.
The /memories path is a fiction. It's a prefix you map onto real storage — here, a per-user directory under a base you control. Get that mapping wrong and /memories/../../secrets.env reads your environment file. Get it right, once, and every command inherits the guard.
Step 1: the path guard, and nothing gets past it#
Everything dangerous in a memory handler is a path. Validate it in exactly one place, and make every command route through that one function.
from pathlib import Path
from urllib.parse import unquote
class MemoryError(Exception):
"""Carried back to the model as a tool_result with is_error: true."""
class MemoryStore:
MAX_FILE_BYTES = 256 * 1024 # cap a single memory file
ROOT = "/memories" # the prefix the model always uses
def __init__(self, base_dir: str, user_id: str):
# One directory per user. Never share a store across tenants.
self.base = (Path(base_dir) / user_id).resolve()
self.base.mkdir(parents=True, exist_ok=True)
def _real(self, model_path: str) -> Path:
"""Map a model-supplied /memories/... path to a real, in-bounds path."""
# 1. Decode URL-encoded traversal (%2e%2e%2f) before anything else.
p = unquote(model_path)
# 2. The model must stay inside the /memories namespace.
if p != self.ROOT and not p.startswith(self.ROOT + "/"):
raise MemoryError(f"The path {model_path} does not exist. "
"Please provide a valid path.")
rel = p[len(self.ROOT):].lstrip("/") # strip the prefix
candidate = (self.base / rel).resolve() # canonicalize (follows ..)
# 3. The one check that matters: is it still inside the user's root?
try:
candidate.relative_to(self.base)
except ValueError:
raise MemoryError(f"The path {model_path} does not exist. "
"Please provide a valid path.")
return candidate
Path.resolve() collapses ../ and follows symlinks, so a crafted /memories/../../secrets.env resolves to somewhere outside self.base, and relative_to() raises ValueError — which we turn into the model-facing "does not exist" string, giving nothing away. This is the check the docs call for: resolve to canonical form, confirm it stays in the memory directory, reject ../ and URL-encoded variants. It's four lines, and it's the difference between a toy and a tenant-safe store. The same instinct — assume the model's inputs are attacker-influenceable — is the whole of zero trust for AI agents: a prompt-injection string in a fetched web page can be what asks your agent to read secrets.env.
Step 2: the six commands#
Now the dispatch. Every method calls _real() first, so none of them can escape the sandbox. The return strings match the spec so the model's follow-up logic stays on the rails.
def handle(self, cmd: dict) -> str:
c = cmd["command"]
if c == "view": return self._view(cmd["path"], cmd.get("view_range"))
if c == "create": return self._create(cmd["path"], cmd["file_text"])
if c == "str_replace": return self._replace(cmd["path"], cmd["old_str"],
cmd.get("new_str", ""))
if c == "insert": return self._insert(cmd["path"], cmd["insert_line"],
cmd["insert_text"])
if c == "delete": return self._delete(cmd["path"])
if c == "rename": return self._rename(cmd["old_path"], cmd["new_path"])
raise MemoryError(f"Error: unknown command {c}")
def _view(self, path, view_range=None):
real = self._real(path)
if real.is_dir():
lines = [f"{self._size(real)}\t{path}"]
for child in sorted(real.rglob("*")):
if child.name.startswith(".") or "node_modules" in child.parts:
continue
shown = f"{path.rstrip('/')}/{child.relative_to(real)}"
lines.append(f"{self._size(child)}\t{shown}")
header = (f"Here're the files and directories up to 2 levels deep "
f"in {path}, excluding hidden items and node_modules:")
return header + "\n" + "\n".join(lines)
if not real.exists():
raise MemoryError(f"The path {path} does not exist. "
"Please provide a valid path.")
body = real.read_text().splitlines()
if view_range: # [start, end]; end == -1 means "to EOF"
start, end = view_range
body = body[start - 1: (None if end == -1 else end)]
offset = start
else:
offset = 1
numbered = "\n".join(f"{i + offset:>6}\t{line}"
for i, line in enumerate(body))
return f"Here's the content of {path} with line numbers:\n{numbered}"
def _create(self, path, text):
self._guard_size(text)
real = self._real(path)
real.parent.mkdir(parents=True, exist_ok=True)
real.write_text(text)
return f"File created successfully at: {path}"
def _replace(self, path, old, new):
real = self._real(path)
if not real.is_file():
raise MemoryError(f"Error: The path {path} does not exist. "
"Please provide a valid path.")
content = real.read_text()
if content.count(old) == 0:
raise MemoryError(f"No replacement was performed, old_str `{old}` "
f"did not appear verbatim in {path}.")
if content.count(old) > 1:
raise MemoryError(f"No replacement was performed. Multiple "
f"occurrences of old_str `{old}`. Please ensure "
"it is unique")
updated = content.replace(old, new, 1)
self._guard_size(updated)
real.write_text(updated)
return "The memory file has been edited."
def _insert(self, path, line_no, text):
real = self._real(path)
if not real.is_file():
raise MemoryError(f"Error: The path {path} does not exist")
lines = real.read_text().splitlines()
if line_no < 0 or line_no > len(lines):
raise MemoryError(f"Error: Invalid `insert_line` parameter: {line_no}. "
f"It should be within the range of lines of the "
f"file: [0, {len(lines)}]")
lines.insert(line_no, text.rstrip("\n"))
joined = "\n".join(lines)
self._guard_size(joined)
real.write_text(joined)
return f"The file {path} has been edited."
def _delete(self, path):
if self._real(path) == self.base: # never delete the root
raise MemoryError("Error: cannot delete the /memories directory")
real = self._real(path)
if not real.exists():
raise MemoryError(f"Error: The path {path} does not exist")
real.unlink() if real.is_file() else __import__("shutil").rmtree(real)
return f"Successfully deleted {path}"
def _rename(self, old_path, new_path):
src, dst = self._real(old_path), self._real(new_path)
if src == self.base:
raise MemoryError("Error: cannot rename the /memories directory")
if not src.exists():
raise MemoryError(f"Error: The path {old_path} does not exist")
if dst.exists():
raise MemoryError(f"Error: The destination {new_path} already exists")
dst.parent.mkdir(parents=True, exist_ok=True)
src.rename(dst)
return f"Successfully renamed {old_path} to {new_path}"
# --- helpers ---
def _guard_size(self, text):
if len(text.encode()) > self.MAX_FILE_BYTES:
raise MemoryError(f"Error: file exceeds the "
f"{self.MAX_FILE_BYTES // 1024}K limit")
def _size(self, p):
n = p.stat().st_size if p.is_file() else sum(
c.stat().st_size for c in p.rglob("*") if c.is_file())
return f"{max(n, 1) / 1024:.1f}K"
Three things are worth naming. The view header and the 6-wide, right-aligned line numbers are copied from the spec exactly, because the model's other tools (and its own reasoning) assume that format. The str_replace handler enforces uniqueness — a non-unique old_str is a refusal, not a silent first-match edit, which is how you avoid corrupting a file the model can't see. And _guard_size runs on every write path, so the memory file can't grow without bound — a cap the reference stores don't have.
Step 3: wire it into the tool-use loop#
The handler is storage; the loop is plumbing. On each turn, run every memory tool_use block through handle() and feed the results back. Errors go back with is_error: true so the model can correct itself rather than crash.
import anthropic
client = anthropic.Anthropic()
store = MemoryStore(base_dir="/var/lib/agent-memory", user_id="acme-corp")
messages = [{"role": "user",
"content": "Remember that Acme Corp prefers email follow-ups."}]
while True:
msg = client.messages.create(
model="claude-opus-5", max_tokens=1024, messages=messages,
tools=[{"type": "memory_20250818", "name": "memory"}],
)
if msg.stop_reason != "tool_use":
print("".join(b.text for b in msg.content if b.type == "text"))
break
results = []
for block in msg.content:
if block.type == "tool_use" and block.name == "memory":
try:
out = store.handle(dict(block.input))
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": out})
except MemoryError as e:
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": str(e), "is_error": True})
messages += [{"role": "assistant", "content": msg.content},
{"role": "user", "content": results}]
Because memory_20250818 is generally available, there's no beta header — the tool entry above is the entire configuration. When the tool is present, the API auto-injects the memory protocol prompt ("ALWAYS VIEW YOUR MEMORY DIRECTORY BEFORE DOING ANYTHING ELSE … ASSUME INTERRUPTION"), so you don't send it yourself. The model will open with a view /memories on the very first turn.
What's left, and where memory sits in the stack#
Two operational chores round it out, both flagged in the docs and neither hard: expiration — a cron that deletes memory files untouched past some TTL, so a store doesn't accrete forever — and sensitive-data stripping before a write, because the model "usually" refuses to store secrets, and usually is not a security control. Add a view-length cap too (the spec truncates at 16,000 characters and lets the model page with view_range), so one giant file can't blow your context budget the moment the model reads it.
Then step back and see what you've built. This is filesystem memory — legible, editable, greppable — which is a different animal from vector memory: the model navigates named files it can see, not embeddings it can't. It's also only one of the three tiers a long-running agent needs, and it earns its keep specifically as the thing that survives a context reset. Pair it with context editing: editing clears re-fetchable tool results from the window to keep it small, and memory holds the handful of facts that must outlive the clearing. That pairing — not the memory tool alone — is where Anthropic's agent-task numbers climb. The handler above is the part they leave to you. Now it's done, and it's safe.



