Lesson 3: External Memory: Files and Retrieval
Learning goals:
- Explain why memory that has to survive across sessions must be written outside the window, into files
- Tell apart human-written memory files like CLAUDE.md from model-written memory files like Auto memory
- State the tradeoff between "retrieve on demand" and "load everything up front"
- Add a safe path boundary check to a tool that reads and writes memory files
Prerequisites: finished Lesson 2, you understand the difference between compaction and tool-result clearing | Prev: Lesson 2 << | Next: Lesson 4 >>
When the Session Ends, Everything in the Window Is Gone
Lesson 2 closed on an unsolved problem: a scheduling assistant that a user told last week "I don't eat spicy food," then this week the user opens a fresh conversation with an empty window — and the agent has no idea that sentence was ever said.
Truncation, compaction, and tool-result clearing can't fix this. All three of those deal with "we ran out of room inside this one conversation." The problem here is different: this conversation had none of last week's content in it from the very first turn. The official Cookbook draws the line bluntly — clearing and compaction both operate on the current context; neither helps when a new session starts and the window is empty. Memory solves that problem1. The window, as a container, only lives as long as this one session. Close the session, and anything in the window that wasn't moved elsewhere is truly gone.
The only way to keep information alive past this session is to write it, before the session ends, somewhere outside the window — into external memory: storage that isn't bound to this conversation's lifecycle, usually just a file on disk. When the next session starts, you read that file back and load its contents into the new context window.
CLAUDE.md: Human-Written, Loaded in Full Every Time
The most direct pattern for external memory is to have a human maintain a memory file, kept in the project and read in full at the start of every session. CLAUDE.md in Claude Code is the representative case: the official docs say a CLAUDE.md file is loaded into the context window at the start of every session, spending tokens right alongside the conversation itself, and the recommended size target is to keep each file under 200 lines — the longer the file, the more context it consumes and the lower the agent's adherence to instructions.2 Note that 200 lines is a soft recommendation; the real hard limit is 4 MiB: a CLAUDE.md larger than that is skipped entirely.2
One detail in this file is worth noticing: the official docs explain that block-level HTML comments in CLAUDE.md are stripped before the content is injected into the agent's context.2 In other words, whatever you write inside <!-- --> is visible when a human opens the file, but the version the agent reads doesn't include that comment — which gives a human a way to "leave myself a note without spending the agent's token budget."
CLAUDE.md has another property that ties directly back to the compaction from Lesson 2: the docs note that a project-root CLAUDE.md survives compaction — after /compact, Claude re-reads it from disk and re-injects it into the session.2 Put differently, a file like this isn't "incidentally preserved" by compaction; it's separately re-read and re-injected — it doesn't depend at all on whether that compaction kept its content in the summary.
Auto memory: Model-Written, Retrieved on Demand
CLAUDE.md is human-written and loaded in full every time. There's a complementary pattern: let the model write down what's worth remembering as the conversation goes, storing it in its own memory files — Claude Code calls this mechanism Auto memory. Its division of labor with CLAUDE.md is complementary, and a comparison table puts the difference plainly: CLAUDE.md is written by you, Auto memory is written by Claude.2
Memory the model writes itself usually splits into two layers: an index file (say, MEMORY.md) plus a pile of specific memory files broken out by topic. The index file isn't loaded without limit either — the rule the docs give is: at the start of every conversation, only the first 200 lines of MEMORY.md, or the first 25KB, whichever comes first, get loaded; content past that threshold is not loaded at session start.2
That's retrieval on demand: at the start of a session the agent sees only the index entries' summaries (something like "the detailed notes on this topic live in some file"), not the full contents of each specific memory file. The docs are direct about it: topic files aren't loaded at startup; Claude reads them on demand with its standard file tools when it needs the information2. Only when the current task actually calls for a given topic does that specific memory file's content get pulled into this round's context window.
Side by side, CLAUDE.md and Auto memory handle two different dimensions of memory:
- CLAUDE.md — human-curated, size-disciplined rules and conventions that apply every time; a fit for stable information along the lines of "this is just how the project is supposed to work," loaded in full up front.
- Auto memory — specific details that may be large in number and only matter for particular tasks; a fit for on-demand retrieval, so window budget isn't wasted on memory this task doesn't need.
Both are external memory. The only differences are "who writes it" and "when it gets loaded" — which echoes the mental model from Lesson 2: the point of memory is to move information out of the window so it survives across sessions, and whether that information is loaded in full up front or retrieved on demand comes down to how stable it is and how often it's used.
Adding a Safe Boundary to Memory File Reads and Writes
Whether it's a human-written file like CLAUDE.md or a model-written one like Auto memory, once an agent has a tool for reading and writing memory files, there's a concrete engineering question to face: can that tool be talked into reading or writing files outside the project directory?
A path check that only does a string-prefix match looks like it blocks "escape the memory directory" requests, but it has a classic hole. If the memory root is /project/memory, a naive startsWith("/project/memory") check will also wave through a path like /project/memory-evil, because it does start with that string — even though that's a completely different directory sitting outside the memory root. The safe way is to require the path to either equal the root exactly, or start with "the root plus a path separator":
The combination abs === MEMORY_ROOT || abs.startsWith(MEMORY_ROOT + path.sep) is what actually guarantees that only a path "equal to the root itself" or "starting with the root plus a separator" gets through — /project/memory-evil won't be mistaken for a path inside /project/memory, because it satisfies neither condition. This pattern gets reused directly in Lesson 6 when we build the read/write tools for a persistent memory layer, and Lesson 5 will make clear what kind of attack target a memory file becomes if this boundary check is toothless.
Recap
- When a session ends, anything in the window that wasn't moved out is gone for good; to keep information across sessions, you have to write it into external memory outside the window before the session ends
- CLAUDE.md is human-written, loaded in full into context every session, with an official size target of 200 lines (hard limit 4 MiB, larger files skipped entirely); block-level HTML comments are stripped before injection, and a project-root CLAUDE.md is re-read and re-injected after
/compact2
- Auto memory is model-written, split into an index file plus specific topic files; the index loads only its first 200 lines or 25KB, and topic files aren't loaded at startup, they're read on demand when needed2, so window budget isn't wasted on memory that won't be used
- The two are complementary: CLAUDE.md fits stable rules useful every time; Auto memory fits large-volume details needed only for particular tasks
- A memory file's read/write tool must do a safe path boundary check; the combined condition
abs === ROOT || abs.startsWith(ROOT + path.sep) needs both halves, since a lone startsWith check has a same-prefix bypass hole
>> Lesson 4: Structured State: How an Agent Remembers Where a Task Stands