0 of 2 runs that read
packages/api/src/index.tswithcatthrough Bash loaded the skill inpackages/api/.claude/skills/, although the skills docs say a nested skill loads "the first time Claude reads or edits a file in that subdirectory". The Read tool on the same file loaded it in 6 of 6 runs, the Skill tool had answeredUnknown skillin 10 of 10 calls before any read, and on arrival the listing grew by about 140 tokens, against 60 for the same skill at the root (Claude Code 2.1.285, 20 headless runs on Opus 5.5). Rerunning five of the configurations on Sonnet 5.5, released September 28, gave the same loads and failures and the same listing costs, for about half the price.
Monorepos are where per-package skills make the most sense: a deploy skill that knows how the API package ships, a migrate skill that only means something in the database package. Claude Code supports this. A .claude/skills/ directory can sit in any subdirectory, and the skills documentation describes when a skill kept there becomes available. I wanted to watch that moment happen in a transcript and put numbers on four questions the page leaves open. Is a nested skill in the skill listing of the first request? Does it show up after Claude reads a file in its directory, and does every way of reading count? How much does the listing grow when it shows up? And can the model call it, before and after?
In an earlier lab, Six CLAUDE.md files, six codewords, we ran the same kind of test for CLAUDE.md files and found that a CLAUDE.md in a subdirectory costs nothing until Claude reads a file below it. Skills deserved their own lab, because three things about them are different. What loads is an entry in a skill listing, not a file body. The skill is used through a tool call, and a tool call can fail. And two skills in different directories can share a name.
Everything here ran on 2026-09-30 with Claude Code 2.1.285 (claude --version), using the default model, which the transcripts record as claude-opus-5-5. There were 20 claude -p runs, and their total_cost_usd fields add up to $0.29. Ten more runs repeated part of the lab on Sonnet 5.5, and they have their own section near the end.
What the skills page says
I fetched https://code.claude.com/docs/en/skills.md with trafilatura on the same day. The text extractor drops angle-bracket placeholders such as <subdir>, so for the one table row quoted below I copied the wording from the raw markdown of the same URL. The section on monorepos and subdirectories says four things that matter here. About parent directories:
Claude Code loads project skills from
.claude/skills/in the directory where you start it and in every parent directory up to the repository root, so starting inpackages/frontend/still picks up skills defined at the root.
About directories below the start directory:
Skills in a
.claude/skills/directory below where you started don't load at startup. They load the first time Claude reads or edits a file in that subdirectory and stay available for the rest of the session. Until then they don't appear in the/menu and you can't invoke them by name. To load them sooner, run/add-dirwith the subdirectory's path, which requires Claude Code v2.1.257 or later.
About a nested skill whose directory name matches another skill's, with deploy both at the root and in apps/web/:
/deployruns the root skill. Claude Code also lists the directory-qualified variants for Claude, with an instruction to invoke the one whose directory holds the files it's working on, so the nested skill still applies to work inapps/web/.
The next bullet adds: "/apps/web:deploy runs the nested skill on its own. Its description names the directory it applies to." And the table of skill locations gives the nested row as <subdir>/.claude/skills/<skill-name>/SKILL.md, loading in "Sessions started in or below <subdir>. A session started above it loads the skill once Claude works on files there."
"Reads or edits a file" and "works on files there" are the phrases I most wanted to test, because an agent can read a file in more than one way.
The lab
One repository with three packages, and a probe skill at the root and in two of the packages:
mono/ git init; the working directory
.claude/settings.json { "disableBundledSkills": true }
.claude/skills/probe-root/SKILL.md
packages/api/.claude/skills/probe-api/SKILL.md
packages/api/src/index.ts
packages/web/.claude/skills/probe-web/SKILL.md
packages/web/src/index.ts
packages/lib/src/index.ts no skill directory of its own
Each skill has a description of 179 to 185 characters and a two-sentence body with a marker the model would not produce on its own. The three descriptions have the same shape and differ only in the package they name. This is the one in packages/api/:
---
description: Reports the probe marker for the packages/api package. Use when the user asks for the api probe marker, or asks which probe skill applies to files under packages/api in this repository.
---
The probe marker for this skill is API-MARK-2864. When this skill is invoked, put API-MARK-2864 in your reply and continue with the user's remaining steps.
The three index.ts files hold the same single line, export const value = 1;, and api, web, and lib all have three letters. A Read in one package and a Read in another therefore produce tool calls and results of the same size, which is what makes the token arithmetic below work.
The disableBundledSkills setting in the project settings removes the skills that ship with Claude Code, so the listing held only the probes. The command line isolated the rest:
CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 claude -p "$PROMPT" --output-format json \
--settings '{"disableAllHooks": true}' --strict-mcp-config \
--setting-sources project --tools "Read,Skill" --allowedTools "Read,Skill" --max-turns 4
--setting-sources project kept my user settings, personal skills, and plugins out of the session. --strict-mcp-config kept MCP servers out. --tools "Read,Skill" removed the built-in tools the runs did not need, which also kept the first request of the base layout under 4,000 tokens, so a difference of a few dozen tokens is easy to see. The hooks override is a second guard against the notification hook in my user settings, and the environment variable stops the runs from writing auto memory. The Bash runs swapped Read for Bash, and the runs that only list skills used --max-turns 1. Every configuration ran twice, except the control, which ran four times for a reason explained below.
One detail of this machine: its global git excludes file lists .claude, so every .claude directory in the lab was git-ignored. I added a one-line .gitignore containing !.claude to the lab repository, and ran one configuration without it to see whether ignoring made a difference. It did not.
The evidence is the transcript that every claude -p session writes under ~/.claude/projects/. It records the skill listing as an attachment of type skill_listing, with the fields isInitial, skillCount, names, and the exact listing text in content, and it records usage on every assistant message. I also asked the model for a fixed-format answer in each run. Its list of skills matched the attachments in all 20 runs, but every claim below rests on the transcripts.
Fail, read, try again
The main prompt asked for three tool calls in order, one per message: call the Skill tool with probe-api even if it is not listed, do something with one file, then call the Skill tool with probe-api again. After that, report what both calls returned and which skills are listed now. Only step 2 changed between configurations.
| Step 2 | Runs | First Skill call |
dynamic_skill record |
Second Skill call |
|---|---|---|---|---|
Read packages/lib/src/index.ts (control) |
4 | Unknown skill | none | Unknown skill |
Read packages/api/src/index.ts
|
2 | Unknown skill | yes | Launching skill |
Same, with .claude git-ignored |
2 | Unknown skill | yes | Launching skill |
Bash cat packages/api/src/index.ts
|
2 | Unknown skill | none | Unknown skill |
The first call failed in all ten runs with the same tool error, <tool_use_error>Unknown skill: probe-api</tool_use_error>. The docs say a person cannot invoke a nested skill by name before it loads, and the same held for the model. The Skill tool refused a name it had not discovered, even though the model passed it exactly.
After the Read of packages/api/src/index.ts, two attachment records follow the tool result. The first has type dynamic_skill, a skillDir ending in packages/api/.claude/skills, and skillNames: ["probe-api"]. The second is a skill_listing with isInitial: false and skillCount: 1. It is a delta that holds only the new skill, not a rebuilt list. Its content was this line:
- probe-api: Reports the probe marker for the packages/api package. Use when the user asks for the api probe marker, or asks which probe skill applies to files under packages/api in this repository. (from packages/api/.claude/skills — applies when working on files under packages/api/)
Everything before the parenthesis is the skill's own description. Claude Code adds the parenthesis, which names the directory the skill came from and the files it applies to. The docs mention that a nested skill's description "names the directory it applies to" only in the bullet about name clashes. There was no clash here, and the suffix was added anyway. probe-web never appeared in any run, so reading a file in packages/api/ loaded that package's skills and not its sibling's.
The second Skill call then returned Launching skill: probe-api, followed by the skill body with a Base directory for this skill: line pointing at the nested path. All four answers from the runs that loaded the skill carried API-MARK-2864.
One run showed how early the skill becomes callable. In the second git-ignored run, the model ignored the one-call-per-message instruction and sent the Read and the second Skill call in the same message. The Skill call still succeeded, although the listing delta was recorded after both tool results. So the skill was registered by the time the Skill call ran, before the model had seen the new listing entry. The same batching happened in two of the control runs, where it changed nothing because packages/lib/ has no skills, but it made those two runs useless for the token arithmetic. That is why the control ran four times.
What the arrival cost
Because the api and lib Reads are the same size, the arrival's cost is the difference in how much the Read step grew the next request. Every time the skill loaded in a run that kept one call per message, the request after the Read was 338 tokens larger than the one before it (three runs). In the two control runs that kept one call per message, it was 199 tokens larger. The Read call itself was one output token shorter in the loading runs (157 against 158), so the arrival cost 140 tokens.
To price the same skill at launch, I ran a prompt that uses no tools and only asks for the skill list, in two layouts: the base layout, and the base layout plus a copy of the probe-api folder in the root .claude/skills/. The first request was 3,799 tokens with one root skill and 3,859 with two, identical across the two runs of each. The same SKILL.md, listed at launch, cost 60 tokens.
So the mid-session arrival cost about 2.3 times the launch price for the same description. Part of the difference is the suffix, 86 characters of added text. The rest is whatever Claude Code wraps around a second listing block, and possibly the dynamic_skill record. The transcript shows the attachment objects, not the exact text they become in the request, so I cannot split the extra 80 tokens between those parts from usage numbers alone.
The name-clash runs further down add one data point. There, two nested skills arrived together after one Read, and the Read step grew by 421 tokens. Taking off about 198 for a Read of that size leaves about 223 for the pair, or roughly 111 each. That suggests part of the overhead is paid once per arrival rather than once per skill, though two runs of one layout are not enough to call it a rule.
In the requests that followed, the delta stayed in the conversation as cached input and was not attached a second time.
Reading with cat did not count
The two Bash runs replaced the Read tool with Bash (--tools "Bash,Skill" and --allowedTools "Bash(cat:*),Skill"), and the model ran exactly cat packages/api/src/index.ts. The command printed the file. No dynamic_skill record followed, the listing never changed, and the second Skill call failed with the same Unknown skill error as the first, in both runs. That step grew the next request by 122 tokens: the Bash call and its output, nothing more.
In these runs, then, "reads or edits a file" meant the Read tool, not any command that opens the file. That is narrower than Claude Code's own idea of reading elsewhere. The tools reference, fetched the same day, says about the Edit tool's read-before-edit check: "Viewing a file with Bash also satisfies the read-before-edit requirement when the command is cat, nl, bat, batcat, head, tail, sed -n 'X,Yp', grep, egrep, fgrep, or rg on a single file with no pipes or redirects." My cat was exactly that kind of command. By that sentence, it would have counted as reading the file for an edit. It did not count as reading it for skill discovery.
This matters more than it first appears because of what the default tool set contains. The same page says: "On macOS, Linux, and WSL, Claude Code leaves Glob and Grep out of the default tool set, and Claude searches with find and grep through the Bash tool instead." A session on those platforms that explores a package with find and grep and never calls Read on a file inside it may never load the package's skills. I tested cat only, so treat that as the question to check on your own setup, not as a result.
Two ways to have the skill at launch
Starting the session in packages/api/ put both probes in the first request: probe-api from the working directory's own .claude/skills/, and probe-root from the repository root, as the parent-directory sentence says. Neither entry had a suffix. But the listing held 15 skills instead of 2, and the first request was 6,081 tokens. The other 13 were bundled skills. The disableBundledSkills setting lives in the root's .claude/settings.json, and a session started in packages/api/ did not read that file. The settings page says so directly: "Claude Code reads the shared .claude/settings.json from the session's primary working directory, so to use a file committed at the repository root, start Claude Code there." Skills are collected from every directory up to the repository root, but the shared settings file is read from the start directory only. Start in a package and you get the root's skills without the root's shared settings.
The other route was --add-dir with the absolute path of packages/api, from the root. That is the command-line flag, not the /add-dir command the docs name, which I did not test. probe-api was in the first request without a suffix, and the first request was 3,859 tokens, the same as the layout with a copy of the skill at the root. The transcript's environment snapshot listed no additional working directories for these runs. For a headless job that works in one package, adding that package at launch made its skill look exactly like a root skill in the listing, at the root price.
When a nested name collides with a root skill
The last layout put a deploy skill both at the root and in packages/api/.claude/skills/, each body with its own marker. The prompt asked Claude to read packages/api/src/index.ts and then "invoke the deploy skill that applies to the file you just read". At launch the listing held deploy and probe-root. After the Read, the delta held two entries:
- packages/api:deploy: Deploys the project. Use when the user asks to deploy. (scoped to packages/api/ — use this instead of the unscoped "deploy" skill when the files being changed are under packages/api/)
- probe-api: Reports the probe marker for the packages/api package. Use when the user asks for the api probe marker, or asks which probe skill applies to files under packages/api in this repository. (from packages/api/.claude/skills — applies when working on files under packages/api/)
This matches the docs, and the transcript shows the exact wording. The nested skill is renamed to the directory-qualified packages/api:deploy. The "instruction to invoke the one whose directory holds the files" is a suffix on that line, and it names the unscoped skill it stands in for. The model called the Skill tool with packages/api:deploy in both runs, and both answers carried the nested marker, DEPLOY-API-9127. My prompt pointed at the file, so this shows the model can follow the instruction, not that it always will. I did not try calling the bare deploy after the Read, which according to the docs runs the root skill.
What this means for where a skill goes
A nested skill is one that Claude cannot see or call until it has used the Read tool on a file in that package (the docs also name edits, which I did not test). That suits skills that matter once the work is already there: a skill about changing the API's handlers is not needed before Claude has opened a handler. It does not suit a skill meant to shape how Claude approaches the package in the first place, such as how the package is laid out or which test command to run before touching anything. The model cannot find that skill by name, or see its description, until after its first Read in the package.
For headless jobs, the cat result is the one to act on. A scripted run whose prompt makes Claude work mainly through Bash can finish without loading the package's skills, and the output does not say so. The only sign in my runs was an Unknown skill error, and only because the prompt made the model try the name. Passing the package with --add-dir put its skill in the first request at the same 60 tokens as a root skill. Starting the job in the package also put it there, along with every bundled skill that the root's settings file had been turning off.
On tokens, nesting saves the listing cost in sessions that never read in the package and charges about 2.3 times the root price in sessions that do. When names collide, the directory-qualified name and its instruction worked in both runs. But before the first Read in the package, the listing held only the root deploy, so that is the only deploy an earlier step could have picked.
The same lab on Sonnet 5.5
Every run above used Opus 5.5, the default model. Sonnet 5.5 came out on September 28, two days before these runs, at half of Opus 5.5's list price per token ($2 and $10 per million input and output tokens, against $4 and $20). Finding a nested skill is Claude Code's job, not the model's, but the model decides how it carries out each step, and the bill depends on it. So I reran five configurations with --model sonnet, which resolved to claude-sonnet-5-5 in every request of all ten runs, in the same lab on the same Claude Code 2.1.285.
| Configuration | Opus 5.5 | Sonnet 5.5 |
|---|---|---|
Read packages/api/src/index.ts: second Skill call launched the skill |
2 of 2 | 2 of 2 |
cat packages/api/src/index.ts through Bash: second Skill call launched the skill |
0 of 2 | 0 of 2 |
Read packages/lib/src/index.ts (control): second Skill call launched the skill |
0 of 4 | 0 of 2 |
| First request, one root skill, listing only | 3,799 tokens | 3,710 tokens |
First request, probe-api copied to the root, listing only |
3,859 tokens | 3,770 tokens |
Discovery worked the same way. The first Skill call answered Unknown skill in all six Sonnet runs that made one. A Read in packages/api/ added the same dynamic_skill record and the same one-line listing delta, word for word, and cat added nothing. A skill listed at launch cost 60 tokens on both models, and the arrival matched too, measured as above. The two Sonnet runs that loaded the skill gave the Read tool a relative path, as did one of the two control runs, so those three are the fair comparison: the Read step grew the next request by 239 tokens when the skill arrived and by 100 when it did not, and the Read call itself was again one output token shorter in the loading runs (58 against 59). That puts the arrival at 140 tokens, the same as on Opus 5.5. The other control run passed an absolute path, and its Read step grew the next request by 199 tokens, as in the Opus 5.5 control runs.
Two things did differ. Every first request was 87 to 89 tokens smaller on Sonnet 5.5, in every layout. The prompt snapshots in the transcripts show the same system prompt and the same tool definitions for both models and differ only in two settings, toolChangeHeader and inlineTools, present in the Opus 5.5 runs and absent in the Sonnet 5.5 runs, so I cannot tell from these numbers what the 87 to 89 tokens are. And Sonnet 5.5 kept to one tool call per message in all six three-step runs, where Opus 5.5 had put the Read and the second Skill call in one message in three of the lab's ten three-step runs. The ten Sonnet runs cost $0.079 in total_cost_usd. Ten Opus 5.5 runs of the same five configurations cost $0.154 (for the control, the two runs that kept one call per message).
So the model did not change where a nested skill shows up or what it costs in tokens. It changed the bill, and in this small sample Sonnet 5.5 followed the one-call-per-message instruction more literally.
What I did not measure
Only two triggers were tested: the Read tool, which loaded the nested skill every time, and cat through Bash, which never did. Edit and Write, which the docs name, were not tested, and neither were Grep, Glob, or find, grep, rg, and sed -n run through Bash. Every run was headless. I did not test the interactive / menu, typing /probe-api before and after discovery, or the /add-dir command; the --add-dir flag at launch is what I measured.
Sessions were one to four requests long. The docs say a nested skill stays available "for the rest of the session", and I saw that for at most two requests after the arrival. Compaction, --resume, and subagents were not tested. The docs list the watched skill directories as those "under ~/.claude/skills/, the project .claude/skills/, or a .claude/skills/ inside an --add-dir directory", and I did not test editing a nested skill in the middle of a session.
One case I would like to see and did not run involves worktrees. The worktrees page says a worktree is "created under .claude/worktrees/<name>/ at your repository root", and that Claude Code "checks out only tracked files", so a committed .claude/skills/ comes along into it. Whether a main session that reads a file inside such a worktree picks up its copies as nested skills, each renamed because every name clashes with the root, is untested here.
The token figures are differences between totals, not measurements of rendered text, so the split of the extra 80 tokens is unknown. All runs used one machine and one version, with descriptions under 200 characters, and the Sonnet 5.5 rerun covered five configurations: not the git-ignored variant, the name clash, the start in a package, or --add-dir. Longer descriptions will move both the 60 and the 140.
Reproduce it
This is a condensed version of the lab's scripts. It keeps the layout and the two triggers but shortens the descriptions and the prompt, and I did not rerun it in exactly this form.
LAB=$(mktemp -d) && cd "$LAB" && git init -q
mkdir -p .claude/skills/probe-root packages/api/.claude/skills/probe-api packages/api/src packages/lib/src
echo '{ "disableBundledSkills": true }' > .claude/settings.json
mk() { printf -- '---\ndescription: Reports the probe marker for %s.\n---\nThe probe marker for this skill is %s.\n' "$2" "$3" > "$1/SKILL.md"; }
mk .claude/skills/probe-root "the repository root" ROOT-MARK-7351
mk packages/api/.claude/skills/probe-api "the packages/api package" API-MARK-2864
echo 'export const value = 1;' | tee packages/api/src/index.ts packages/lib/src/index.ts >/dev/null
run() { # $1 = step 2, $2 = --tools, $3 = --allowedTools
CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 claude -p "One tool call per message. Step 1: call the Skill tool with skill \"probe-api\", even if it is not listed. Step 2: $1 Step 3: call the Skill tool with skill \"probe-api\" again. Step 4: report what steps 1 and 3 returned." \
--output-format json --max-turns 4 --settings '{"disableAllHooks": true}' \
--strict-mcp-config --setting-sources project --tools "$2" --allowedTools "$3" | jq -r .session_id
}
READ=$(run 'use the Read tool on packages/api/src/index.ts.' 'Read,Skill' 'Read,Skill')
CAT=$(run 'use the Bash tool to run exactly: cat packages/api/src/index.ts' 'Bash,Skill' 'Bash(cat:*),Skill')
for S in "$READ" "$CAT"; do grep -c '"type":"dynamic_skill"' ~/.claude/projects/*/"$S".jsonl; done
The last line prints one count per session. In the lab, a Read session had one dynamic_skill record and a cat session had none. For token numbers, sum input_tokens, cache_read_input_tokens, and cache_creation_input_tokens on each assistant message in the transcript. Your totals will differ from mine because the descriptions are shorter; the one and the zero should not. Swap step 2 for a Read of packages/lib/src/index.ts to get the control.
Rulestack builds skills, hooks, and rules files for Claude Code and sells them at rulestack.gumroad.com. The probes in this lab are one description and one marker each, so the whole lab can be rebuilt in a few minutes when a release changes skill discovery.
If an Edit, or grep or sed -n through Bash, loads a nested skill in your runs, or fails to, reply on @ai-shop.bsky.social; those are the triggers this lab did not reach.


Top comments (1)
The most useful finding here is that “reads a file” is not equivalent to “opens a file.” The fact that Claude Code's Read tool triggers nested skill discovery while
catthrough Bash does not creates a surprisingly important dependency on the agent's tool path.That matters beyond the specific skill mechanism. In production coding-agent workflows, capability discovery should ideally be tied to the semantic operation being performed, not just one particular tool implementation. Otherwise two agents can inspect the same source tree and silently operate under different instruction sets depending on whether they used Read, Bash, or another search path.
This is the kind of edge case we pay close attention to at IT Path Solutions when working with AI-assisted development workflows: reproducibility includes the agent's tool choices and loaded context, not just the repository state.
The
--add-dirresult is also interesting because it effectively turns a nested skill into a launch-time dependency at the same listing cost as a root skill. For headless jobs, that seems like a much stronger contract than relying on the first Read to discover the package context.I'd treat the skill-loading mechanism as part of the agent's execution environment and test it alongside the code itself. Otherwise a seemingly harmless change in tool selection can change which instructions the agent is actually operating under.