We put a codeword in six CLAUDE.md files and found that the parent-directory, working-directory, and CLAUDE.local.md files were in the first request at about 1,600 tokens each, while the two subdirectory files cost 0 tokens until Claude read a file below them. The --add-dir file never reached the model, not even after a Read inside that directory, until we set one environment variable.
The memory page of the Claude Code docs describes where CLAUDE.md files can live and when each one loads. The core of it is two sentences: "CLAUDE.md and CLAUDE.local.md files in the directory hierarchy above the working directory are loaded at launch. Files in subdirectories load on demand when Claude reads files in those directories." A separate paragraph adds that for directories passed with --add-dir, "By default, CLAUDE.md files from these directories are not loaded."
Earlier we measured path-scoped rules and @import. This time we wanted the same kind of numbers for the CLAUDE.md files themselves, placed in every location a real machine tends to collect them. The question was simple. For each location, is the file in the first request, does it arrive later, or does it never arrive? And what does each case cost?
All runs were on Claude Code v2.1.273 on 2026-09-16, using the same model each time, in a throwaway directory made with mktemp -d.
The layout has one repository, one directory above it, two directories below it, and one sibling directory that is only reachable through --add-dir.
lab/
parent/CLAUDE.md CW-PARENT-4417
parent/proj/ (git init; the working directory)
CLAUDE.md CW-PROJECT-2290
CLAUDE.local.md CW-LOCAL-8053
root.txt
sub/CLAUDE.md CW-SUBDIR-6621
sub/data.txt, sub/more.txt
sub/deep/CLAUDE.md CW-DEEP-1938
sub/deep/data.txt
extra/CLAUDE.md CW-EXTRA-5074
extra/notes.txt
Every memory file has the same shape: a heading, one line naming its codeword, and forty filler lines that ask for nothing. That makes each file between 4,543 and 4,669 bytes. Because the files are the same size, a difference in token count points to where a file was loaded, not to how big it is. The .txt files each hold one line of plain text with no codeword in it.
The probe prompt asked the model to use no tools and to list every string starting with CW- that it could see in its instructions or context, in order. We did not rely on the model's answer alone. Every claude -p session writes a transcript under ~/.claude/projects/, and we read two things from it. The first is the per-request usage on each assistant message. The second is the attachment records that show which memory files were delivered.
Our first ten runs taught us something about measuring. Two runs of the same configuration came back at 23,624 and 24,410 input tokens. When we compared the two transcripts, the only difference was the deferred tool list: one run included the tools of a remote connector from our claude.ai account and the other did not. We assume this is a timing race while the connector starts, but we did not confirm that. The CLI reference describes --strict-mcp-config as "Only use MCP servers from --mcp-config, ignoring all other MCP configurations". With that flag added, and with hooks turned off through a per-run settings override, every pair of runs after that matched exactly. We threw away the ten early runs and kept 28 clean ones.
claude -p "$PROBE" --output-format json --max-turns 1 \
--settings '{"disableAllHooks": true}' --strict-mcp-config
Turning hooks off was not only about noise. Our user settings run a notification hook on every stop, and we did not want 38 notifications. The hooks docs name this exact override for headless runs: turn hooks off "for that run with --settings '{"disableAllHooks": true}'".
We added the files one at a time and ran each configuration twice. The table shows the total input of the first request, which is uncached input plus cache reads plus cache writes.
| Configuration | First-request input | Change |
|---|---|---|
| No CLAUDE.md anywhere | 18,442 | — |
+ proj/CLAUDE.md |
20,080 | +1,638 |
+ proj/CLAUDE.local.md |
21,639 | +1,559 |
+ parent/CLAUDE.md (above the git root) |
23,233 | +1,594 |
+ sub/CLAUDE.md andsub/deep/CLAUDE.md |
23,233 | +0 |
The first three rows are what the docs describe, and the transcript shows how the files are delivered. Launch-time memory arrives as one attachment of type instructions with a files list. Each entry has a path, a type, and the file content. With all three launch files present, the list order was parent/CLAUDE.md, then proj/CLAUDE.md, then proj/CLAUDE.local.md. That matches the documented order of filesystem root down to the working directory, with the local file "appended after CLAUDE.md" within the same directory. The model gave its codewords in the same order.
Two details in that list are worth noting. The parent file sits above the repository's git root, yet its type was Project, the same as the repository's own file. The walk-up does not stop at the repository boundary, and nothing in the attachment marks that file as coming from outside the repository. CLAUDE.local.md had the type Local, and apart from that it was handled like any other file: about 1,560 tokens for 4,585 bytes.
The last row is the one we care about most. Adding two more 4.6KB memory files below the working directory changed the first request by zero tokens. Both runs of that configuration read 23,231 tokens from cache and sent 2 uncached. In other words, the prefix was exactly the same as the configuration without those files, and neither subdirectory codeword appeared in the answer.
CLAUDE.local.md also responds to --setting-sources. The docs mention this in the --add-dir paragraph ("CLAUDE.local.md is skipped if you exclude local from --setting-sources"). We saw the same thing for the working directory's own local file. With the full layout and --setting-sources user,project, the first request was 21,674 tokens, which is 1,559 fewer than with local included. That is the local file's cost exactly. The instructions list held only the parent and project files.
To trigger the subdirectory files, we changed the prompt so the model would call the Read tool once on a named file and then list its codewords. We pre-approved Read with --allowedTools Read, told the model to use no other tool, and set --max-turns 2; every run made exactly one tool call. That gives two requests per run, and we compared the second request with the first.
| Read target | Request 1 | Request 2 | Growth | nested_memory records |
|---|---|---|---|---|
root.txt (control) |
23,247 | 23,401 | +154 | 0 |
sub/data.txt |
23,250 | 25,037 | +1,787 | 1 |
sub/deep/data.txt |
23,253 | 26,633 | +3,380 | 2 |
The control run shows what one Read costs by itself in this lab: 154 tokens for the tool call and its one-line result. Reading sub/data.txt added one attachment record of type nested_memory. Its displayPath was sub/CLAUDE.md, and its content was the whole file with the type Project. The second request grew by 1,787 tokens. Subtracting the 154 for the Read itself leaves about 1,630 tokens for a 4,627-byte file. That is about 40 tokens more than the parent file, which has the same byte count, cost at launch. We did not work out where those 40 tokens come from.
Reading the deeper file loaded two records in order: sub/CLAUDE.md, then sub/deep/CLAUDE.md. Reading one file three levels down pulled in every CLAUDE.md between the working directory and that file, not only the one next to it. The codeword answers matched: CW-SUBDIR-6621 appeared after the shallow read, and both subdirectory codewords appeared after the deep read.
The reverse was also true. Reading sub/data.txt did not load sub/deep/CLAUDE.md, because a file in a directory does not pull in memory from the directories below it.
The last question was whether the attachment repeats. We ran a three-turn version that read sub/data.txt and then sub/more.txt, which is in the same directory. The requests measured 23,266, then 25,053 (+1,787), then 25,211 (+158). The transcript had one nested_memory record. The second read cost about what a Read costs, so a subdirectory's CLAUDE.md was delivered once per session in our runs, not once per file read.
--add-dir: not at launch, not on Read, then all at once
Next we pointed --add-dir at the sibling extra/ directory, which has its own CLAUDE.md.
| Configuration | First-request input | CW-EXTRA seen |
|---|---|---|
Full layout, no --add-dir |
23,233 | — |
+ --add-dir extra/ |
23,290 | no |
+ CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1 |
24,883 | yes |
On its own, --add-dir added 57 tokens. The transcript's environment snapshot listed extra/ as an additional working directory, which is the likely source of those 57 tokens. The 4.6KB file was not loaded. That matches the docs.
The next result is not spelled out in the docs. We asked Claude to read extra/notes.txt with --add-dir and without the variable. The second request grew by 150 tokens, the model listed no CW-EXTRA codeword, and the transcript had zero nested_memory records. A CLAUDE.md below the working directory loads when Claude reads a file under it, but one in an added directory does not. Even with an explicit Read in that directory, its CLAUDE.md never reached the model.
The environment variables page describes CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD as: "Set to 1 to load memory files from directories specified with --add-dir. Loads CLAUDE.md, .claude/CLAUDE.md, .claude/rules/*.md, and CLAUDE.local.md." With the variable set, the extra file went into the launch-time instructions list, after the local file, with the type Project. It cost 1,593 tokens in the first request. It was not loaded lazily. It was loaded at launch like a parent directory file. Reading extra/notes.txt in that configuration added 150 tokens and no second attachment.
So each added directory is one of two things. Without the variable, it is a place Claude can read and edit files, and its CLAUDE.md is invisible. With the variable, its CLAUDE.md is paid for up front in every session, whether or not Claude ever opens a file there. We saw no in-between setting.
The machine that runs our shop has CLAUDE.md files at four levels: the home directory (5,176 bytes), a development folder (1,638 bytes), a projects folder (3,299 bytes), and the repository itself (35,465 bytes). The repository has a health check that warns when its CLAUDE.md passes a size limit. That check reads one file, the repository's own. Sessions started in the repository receive all four; the three files above it come to 10,113 bytes together, and the lab shows why they arrive: a file in a parent directory loads at launch under the same Project label as the repository's own. None of it is counted by the size check that was written to keep the per-session cost down.
Nothing is broken, since those files hold personal working rules that are supposed to apply everywhere. But the size budget we report is not the budget the model actually receives. The honest number is the sum of all four files. The fix we have in mind is to make the check add up every CLAUDE.md a session in the repository loads, not to move the files. We have not made that change yet.
The subdirectory result gives us a place for guidance that only matters inside one part of the tree. Such guidance can be a nested CLAUDE.md that costs nothing in sessions that never read there. It gets delivered once, the first time a file below it is read. That is the same trade-off as a path-scoped rule. The difference is that a nested CLAUDE.md is keyed to a directory instead of a glob, and it needs no frontmatter.
The --add-dir result is a warning for anyone who runs scheduled jobs with an extra directory attached so the agent can reach a shared folder. If that folder has a CLAUDE.md with rules the job depends on, the rules are not in the session. A Read in that folder does not fix it. Either set the variable and pay the full cost at launch, or copy the rules somewhere that loads.
The Read tool was the only trigger we tested for subdirectory files. We did not test Grep, Glob, Edit, Write, or a Bash cat of a file in sub/. If your agent reaches files mainly through the shell, check that path before counting on the lazy load.
The environment variable page lists four file names that load from added directories, and we tested only CLAUDE.md. We did not test .claude/CLAUDE.md in any location, a CLAUDE.local.md inside a subdirectory, or rules files inside an added directory. We did not test the claudeMdExcludes setting, a user-level ~/.claude/CLAUDE.md (this machine has none), or a managed policy file.
All sessions were headless and one to three turns long. We did not look at compaction, subagents, resumed sessions, or interactive mode. The docs say nested CLAUDE.md files "reload as Claude reads files they apply to" after compaction, and we did not check that.
The numbers are about delivery, not about whether the model follows the files. The codeword lists are the model's own report. We trusted them because they matched the transcript attachments in every run, not because the model said so.
The difference of about 40 tokens between lazy and launch delivery of files the same size was not investigated. We also did not repeat the runs on a second machine or a second version.
The 38 runs, including the 10 we discarded, had a combined list-price cost of $2.62 according to the total_cost_usd field.
LAB=$(mktemp -d) && cd "$LAB"
mkdir -p parent/proj/sub/deep extra
mk() { { printf '# %s\nCodeword: %s\n' "$2" "$3"; for i in $(seq 40); do echo "- filler $i for $2"; done; } > "$1"; }
mk parent/CLAUDE.md parent CW-PARENT; mk parent/proj/CLAUDE.md project CW-PROJECT
mk parent/proj/CLAUDE.local.md local CW-LOCAL
mk parent/proj/sub/CLAUDE.md sub CW-SUB; mk parent/proj/sub/deep/CLAUDE.md deep CW-DEEP
mk extra/CLAUDE.md extra CW-EXTRA
echo hello | tee parent/proj/sub/data.txt parent/proj/sub/deep/data.txt extra/notes.txt >/dev/null
cd parent/proj && git init -q && echo '*' >> .git/info/exclude
FLAGS=(--output-format json --settings '{"disableAllHooks": true}' --strict-mcp-config)
PROBE='Do not use any tools. List every string starting with CW- in your instructions or context, in order.'
claude -p "$PROBE" --max-turns 1 "${FLAGS[@]}" | jq '.usage, .result'
claude -p "$PROBE" --max-turns 1 "${FLAGS[@]}" --add-dir "$LAB/extra" | jq .result
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1 \
claude -p "$PROBE" --max-turns 1 "${FLAGS[@]}" --add-dir "$LAB/extra" | jq .result
claude -p "Read sub/deep/data.txt once, then list every CW- string you can see." \
--max-turns 2 --allowedTools Read "${FLAGS[@]}" | jq -r .session_id
grep -c nested_memory ~/.claude/projects/*/<session_id>.jsonl
Adding .git/info/exclude keeps the git status in the system prompt the same while you add files. Without it, every new CLAUDE.md shows up as an untracked file and shifts the count by a few tokens. Sum the three input fields of usage for each run, and read the instructions and nested_memory attachments in the transcript to see which files arrived and in what order. Your token counts will differ from ours. The zeros should not.
Rulestack builds rules files, skills, and hooks for Claude Code and the agents around it, at rulestack.gumroad.com. This lab started because our own repository's size check was counting one of the four CLAUDE.md files its sessions actually load.
Follow-up measurements, such as whether Grep or a shell read triggers a nested CLAUDE.md, go out on @ai-shop.bsky.social when we run them.