Most of the friction people hit with Claude Code skills — the SKILL.md format Claude Code uses to learn task-specific behaviour — comes from a foggy mental model of what the loader actually does. Why did my skill not fire? Why did two skills fire at once? Why didn't my edit take effect? The answers all live in one place: the discovery and loading pipeline that runs the moment you launch a session.
This page is a technical walkthrough of that pipeline, written from the perspective of someone who has spent a lot of time debugging it. We will cover the directory scan, what gets read at scan time versus at invocation time, precedence rules between user and project scope, the frontmatter contract, how the matching layer actually selects a skill, multi-skill activation, body-load behaviour, slash-command bypass, and the hot-reload gap that surprises almost everyone the first time.
When Claude Code launches a session, it walks two directories in a fixed order. The user-scoped directory lives at ~/.claude/skills/ — global to your machine, available in every project. The project-scoped directory lives at ./.claude/skills/ relative to your current working directory — local to the repo you launched Claude Code from.
Inside each directory, the loader looks for one specific pattern: subdirectories containing a SKILL.md file. The subdirectory name becomes the skill slug. So if you have ~/.claude/skills/git-flow/SKILL.md, the slug is git-flow. Files at the root of skills/ are ignored. Subdirectories without a SKILL.md are ignored. Hidden directories starting with . are ignored.
Here is what a healthy layout looks like:
~/.claude/skills/
├── git-flow/
│ └── SKILL.md
├── pdf-extractor/
│ ├── SKILL.md
│ └── scripts/
│ └── extract.py
└── secrets-audit/
└── SKILL.md
./.claude/skills/
└── deploy-runbook/
└── SKILL.mdThe crucial detail: at scan time, only the frontmatter of each SKILL.md is parsed. The body — the actual Markdown content below the frontmatter delimiter — is read into memory but not pushed into Claude's context window. The loader is building an index, not loading the world. This matters because it means you can have a hundred skills installed without paying the token cost for any of them until one is actually selected.
The scan is synchronous and happens once per session. If you have a slow filesystem (network mount, encrypted volume with a large skill collection), startup will reflect that. In practice it is fast — reading a few hundred frontmatter blocks is on the order of milliseconds. Anything noticeable is usually a sign you have an enormous attached script directory inside one of your skills, and the loader is traversing it unnecessarily.
One subtlety: the project scan happens relative to the directory where Claude Code was launched, not the workspace root. If you cd into a subdirectory of a repo and launch from there, project-scoped skills under the parent will not load. Always launch from the repo root if you want the project skills to come along.
When the same skill slug exists in both ~/.claude/skills/ and ./.claude/skills/, the project copy wins. The user copy is shadowed for the duration of that session. This is a deliberate design choice that pays off enormously for teams.
Here is the practical workflow. You have deploy as a personal skill in your user directory — it knows your habits, your preferred verbosity, the way you like commit summaries. You join a team that has its own deploy skill committed to the repo at ./.claude/skills/deploy/SKILL.md. The moment you launch Claude Code in that repo, the team's version takes over. Your personal version is invisible. When you switch to a different project, your personal version reappears.
This is the right default for almost every situation. Project skills encode team conventions, runbook steps, deploy commands, the specific way your codebase wants you to write tests. A team member should not be running a personal variant of deploy that omits the safety checks the rest of the team relies on. Conversely, personal habits — your preferred git commit format, your scratch notes skill — should never leak across repos.
Some implications worth internalising:
./.claude/skills/ is gitignored, the team workflow falls apart.summarise skill leaks into the repo, every teammate is now stuck with your phrasing.If you want both versions available, give them distinct slugs. A common pattern is to prefix personal skills with your initials or with my-: my-deploy for the personal version, deploy for the team version. They will both load, both be matchable, and you can invoke either explicitly via slash command if you need to.
The frontmatter is the part of SKILL.md the loader actually parses for indexing. It sits at the top of the file between two --- delimiters and is standard YAML:
---
name: pdf-extractor
description: Extract structured text and tables from PDF files. Use when the user uploads a PDF and asks for the contents, a summary, or specific fields.
allowed-tools:
- Bash
- Read
- Write
user-invokable: true
---
# PDF Extractor
(skill body follows here)Four fields drive loader behaviour and are worth knowing exactly. name is the canonical skill name. It should match the directory slug; if it does not, the directory slug wins for invocation but the name field gets surfaced in some Claude-facing strings. Keep them in sync to avoid confusion.
description is the single most important field in the entire system. It is what the matching layer reads when deciding whether to fire your skill. A vague description gets ignored. A specific one with concrete triggers ("Use when the user uploads a PDF and asks for the contents") gets selected. We cover this in detail in the matching layer section, but the short version: write it like a job description for an autonomous agent, not like a tagline.
allowed-tools is a whitelist. When the skill is active, Claude is restricted to the tools listed here. If you omit the field, the skill inherits whatever tool permissions the session has. If you include it, it constrains. This is a safety mechanism — a skill that only needs to read files should not need Bash.
user-invokable is a boolean gate. When true, the skill can be triggered explicitly via /skill-name. When false or omitted, the slash-command shortcut is unavailable and the skill can only fire via the matching layer. Use user-invokable: true for skills that you actively want to call by hand — runbooks, generators, anything where the user knows the exact step they need.
Other fields you might encounter in the wild — model, tags, version, license, author — are not loader-parsed. The loader reads them into the frontmatter dict but does nothing with them. They exist for catalog and tooling consumers (skill catalogs, IDE integrations, documentation generators) to surface metadata. You can add them freely without affecting runtime behaviour.
If your frontmatter has a YAML syntax error, the loader silently drops the skill. There is no error message, no warning. The skill simply does not appear. This is one of the most common reasons "my skill isn't loading" — almost always a missing colon, a stray tab, or a multi-line description that wasn't quoted. When in doubt, paste your frontmatter into a YAML linter before launching the session.
This is the part most people get wrong. The matching layer is not a string match. It is not a keyword index. It is not a regex check on the user's prompt. It is the same probabilistic tool-selection mechanism Claude uses for any other tool call — applied to a list of skill descriptions instead of a list of tools.
Here is the mechanical picture. When you send a prompt, Claude sees something like this internally:
You have access to the following skills, each invokable by selecting it:
- pdf-extractor: Extract structured text and tables from PDF files. Use when the user uploads a PDF...
- git-flow: Manage feature branches, rebases, and squash merges. Use when the user wants to start a new feature...
- secrets-audit: Scan recent commits for accidentally committed credentials...
User prompt: "can you pull the line items out of this invoice?"Claude evaluates the prompt against each skill description and decides whether to invoke one. The decision is probabilistic. It is the same selection mechanism that picks between, say, Read and Glob for a file-search task. It uses semantic similarity, not lexical matching. "pull the line items out of this invoice" will reliably trigger a skill described as "extract structured text from PDFs" even with zero shared keywords, because the model understands invoices are PDFs and line items are structured text.
Two consequences follow. First, your description is the entire interface. Claude only sees the description at selection time. It does not see the body, the filename, or the directory layout. If your description is "My skill" or "PDF helper", you have given the matching layer nothing to work with. Write descriptions that include the trigger conditions explicitly. "Use when X" and "Use when the user asks Y" are the patterns that work.
Second, the selection is non-deterministic in the same way any model output is. The same prompt can fire the skill one time and not the next. This is normal. You can reduce variance by writing tighter descriptions, but you cannot eliminate it. If you need deterministic invocation, use the slash-command path covered below.
A useful mental model: descriptions are not for humans, they are for Claude. Forget what looks good in a list view. Optimise for what helps Claude decide. A 30-word description with concrete triggers will outperform a 5-word description with a clever tagline every single time. The catalogs that display skills usually surface only the first sentence, so put the human-readable framing first and the trigger conditions in the rest of the description.
Two skills can fire on a single prompt. The matching layer does not enforce single selection. If a prompt plausibly invokes two skills — say, a request to "audit the recent commits for secrets and open a PR with the fixes" — Claude can select both secrets-audit and git-flow in sequence within the same turn.
This is sometimes exactly what you want. A composable skill ecosystem is one where small, focused skills compose into larger workflows. secrets-audit finds the issues; git-flow handles the PR mechanics. Each skill is simple, but together they cover the full task.
It is also sometimes a problem. If two skills overlap in scope — say, you have both a deploy skill and a release skill that both handle production pushes — Claude can fire the wrong one, or fire both and produce confusing interleaved output. The matching layer has no notion of "these two are alternatives, pick one." It treats each description independently.
The design rule that emerges: make your skill descriptions mutually exclusive where you intend them to be alternatives, and complementary where you intend them to compose. Concretely:
release skill description should say "Use when the user wants to cut a versioned release. Do not use for routine production deploys; use the deploy skill for those." The matching layer reads anti-triggers and respects them.user-invokable: true and skip the matching layer. If a skill produces irreversible side effects (database migrations, force-push, mass deletes), do not rely on the probabilistic selection to gate it. Require the user to invoke it explicitly.When you are debugging unexpected multi-skill activation, the move is to look at descriptions. Almost always the issue is two descriptions that, from Claude's perspective, are semantically too similar. Tightening one or both — adding specific anti-triggers, narrowing the scope — fixes it without code changes.
Once a skill is selected, its body — everything below the closing --- delimiter — gets pulled into Claude's working context for that turn. This is the moment the actual instructions become visible to the model. Until selection, the body might as well not exist.
There is a soft budget on body length. Skills with very long bodies get truncated when loaded. The exact threshold moves around with model and session configuration, but as a working rule of thumb: keep the body under a few thousand tokens. Long-form documentation, exhaustive examples, sample data — these belong in attached files referenced from the body, not inline in the SKILL.md.
A common pattern is to keep the SKILL.md tight (the trigger conditions, the workflow steps, the anti-trigger warnings) and put heavier resources alongside it:
~/.claude/skills/pdf-extractor/
├── SKILL.md # tight, ~50-200 lines
├── scripts/
│ ├── extract.py # the actual extraction logic
│ └── parse_table.py
└── examples/
├── invoice.pdf
└── invoice-expected.jsonThe SKILL.md body then references the scripts and example files by relative path. Claude reads them on demand using Read or invokes them via Bash. The body itself stays small. This pattern matters because the body load happens every time the skill fires, not once per session. A bloated body costs you tokens on every invocation.
What goes in the body, then? The shape that has held up best over a lot of skills:
scripts/extract.py for the parsing logic."If you find yourself writing a body longer than 200 lines, almost always there is a refactor: move the prose into a reference doc, point the body at it, and the loader (and Claude) will thank you on every fire.
When a skill has user-invokable: true in its frontmatter, it becomes callable via the slash-command shortcut: /skill-name in the prompt. The slug is the slug — if your directory is git-flow/, the command is /git-flow. Hyphenation matters; underscores in directory names become hyphens in slash commands.
This is the escape hatch from the matching layer. When you type /git-flow, the matching layer is bypassed entirely. The skill body loads, the skill becomes active, and Claude proceeds with that skill's instructions in scope. No probabilistic selection, no risk of the wrong skill firing, no risk of two skills colliding.
The right times to use slash-command invocation:
The trade-off: slash commands lock you into knowing the skill exists. The matching layer is good precisely because it surfaces relevant skills without you having to remember they are installed. For a skill collection in the dozens, the matching layer is what makes the system feel intelligent. For the handful of skills you use every day, slash commands are faster and more predictable.
A subtle behaviour: skills with user-invokable: true are still matchable through the natural-language path. The flag adds the slash command without removing the implicit invocation. If you want a skill to only be slash-invokable — never automatically fire — you need to do that through the description. Write the description as a label rather than a trigger: "Manually invoke via /skill-name to do X." The matching layer will see no trigger conditions and almost never select it implicitly, while the slash command stays available.
The single most common surprise for new skill authors: edits to SKILL.md during a running session do not take effect. The loader scans once at session start. After that, the in-memory index of skills is fixed for the duration of the session. You can edit a SKILL.md, save it, watch your editor confirm the file is on disk — and the skill that fires will still be the old version.
This is a deliberate design choice. Hot-reload would mean re-scanning the filesystem on every prompt, re-parsing frontmatter, potentially shifting the matching surface mid-conversation. The behavioural complexity that introduces (a skill that was about to fire suddenly disappears because you edited its description) is worse than the workflow friction of restarting a session.
The workflow this forces:
For active skill development, set up two terminals. One runs Claude Code; the other is your editor. Iterate in cycles: edit, save, exit, relaunch, test. It feels slow compared to a typical edit-save-refresh loop. After a dozen skills you stop noticing.
A few non-obvious cases where the lack of hot-reload bites:
SKILL.md into ~/.claude/skills/ does not register it until next session. New skills don't "appear."name field is a session-bounded change. Old name still works mid-session if it was loaded; new name does not work until next session.rm -rf a skill directory, that skill stays loaded and matchable for the rest of the session. It cannot execute its body if the file is gone (the body load will fail), but it stays in the matching surface.scripts/extract.py. Changes to that script do take effect immediately, because the script is read at the time the skill executes, not at session start. Only frontmatter and body of SKILL.md itself are cached.That last point is the workaround for the impatient. If you want a tighter iteration loop, move the meaty logic out of the SKILL.md body and into an attached script. Edits to the script are live. The SKILL.md stays a stable contract.
When things go sideways — a skill won't load, the wrong skill fires, edits don't seem to land — there is a short debugging checklist that catches almost every case.
Skill not appearing at all. Almost always one of three causes. Check that the file is named exactly SKILL.md (case-sensitive on Linux, case-insensitive but case-preserving on macOS — but be careful if you sync across systems). Check that the frontmatter is valid YAML; paste it into a linter. Check that you are launching Claude Code from the right directory (project skills live relative to launch directory).
Skill loads but never fires. Almost always the description. Open the SKILL.md, read the description out loud, and ask: if I were Claude looking at this in a list of 50 skills, would I know what user prompts should trigger it? If the description is a label ("PDF Helper") rather than a trigger ("Use when the user uploads a PDF and asks to extract data"), rewrite it. Try invoking explicitly via slash command — if that works, the body is fine and the issue is the description.
Wrong skill fires consistently. Look for description overlap with the skill you wish had fired. Two skills with semantically-similar trigger language compete for selection, and the matching layer's pick is not always the one your gut expects. Tightening the loser's description with anti-triggers ("Do not use for X; use the Y skill instead") usually resolves it.
Two skills fire when you wanted one. Same fix in reverse: explicit anti-trigger language in the description of whichever skill you want to suppress.
Edits not taking effect. You forgot to restart the session. Exit Claude Code and relaunch.
Project skill not loading despite being committed. Check the launch directory. Project skills are relative to where you ran claude-code from, not the repo root necessarily. pwd before launch; make sure you are at the level where ./.claude/skills/ resolves.
User skill shadowed unexpectedly. Check for a same-slug skill in ./.claude/skills/. Project always wins.
A useful habit when authoring new skills: invoke them via slash command first, before relying on the matching layer. Slash invocation isolates the body. If the slash command works but the natural-language trigger doesn't, you know the issue is the description, not the body. If neither works, the body has a problem. This bisection saves a lot of guessing.
And one final piece of practical advice: keep a known-good test prompt for each of your important skills, written down somewhere. When you make changes, you have a ready way to verify the matching layer still picks the skill. Skills that worked yesterday can stop working tomorrow after a seemingly-innocent description tweak. The test prompt catches it.
Found a bug or want a topic covered? Email [email protected] or open an issue via GitHub.
SKILL.md files, not affiliated with, endorsed by, or sponsored by Anthropic.