Chapter 8. Claude Code Agent Skills: The Format, the Loading Model, the Mechanics
A skill is a directory with a file named SKILL.md inside
it, opened by a YAML frontmatter block on line 1. The docs list
seventeen frontmatter fields, mark all of them optional, and recommend
only description. Automatic loading runs on the listing
alone: a skill’s name plus its description and
when_to_use text, truncated at 1,536 combined characters
per skill.
On 2026-07-27 this machine carries three populations of them: 31
personal skills under ~/.claude/skills, a 29-skill public
pack at ~/code/suede-creator-skills/skills that installs as
a plugin, and 46 project-scoped skills inside the suede-geo
repo. The format looks too small to deserve a chapter. Run thirty of
them at once and the mechanics start to matter: what loads when, what
the router sees, which copy wins when two exist, and how the installed
estate drifts away from its source.
Inside a SKILL.md file: identity and frontmatter
Identity comes from the directory, not the file. Claude Code derives
the command name from the skill’s directory name
(deploy-staging becomes /deploy-staging), and
in personal and project skills the frontmatter name sets
only the display label. My roots make the question moot:
name equals the directory name in all 31 personal skills
and all 29 pack skills, zero mismatches, lowercase hyphenated slugs
throughout. One naming authority, no aliases to reconcile.
Frontmatter is a YAML block opened by --- on line 1. The
docs list seventeen fields, mark all seventeen optional, and recommend
only description:
| Concern | Fields |
|---|---|
| Routing | name, description,
when_to_use |
| Arguments | argument-hint, arguments |
| Invocation control | disable-model-invocation, user-invocable,
paths |
| Tool policy | allowed-tools, disallowed-tools |
| Execution | model, effort, context,
agent, background, shell |
| Lifecycle | hooks |
Practice uses a fraction of the schema. All 29 public-pack skills
carry name and description and no other key.
Across the 31 personal skills, the optional fields that appear at all:
model: opus on five, a nested metadata: map on
two, and one skill (impeccable) carrying version,
license, user-invocable: true,
argument-hint, and allowed-tools as a cluster.
That cluster marks the command-shaped skill, the kind a person types
with subcommands and a target rather than something the model reaches
for on its own.
Even a two-field schema drifts. Of my 31 description
values, 22 are double-quoted scalars, 6 are bare, and 3 use the YAML
folded-block >. Nothing broke. But a schema this small
drifting in three directions told me the larger conventions would drift
worse, and the linter section below has those numbers.
One version note before the mechanics: boolean frontmatter fields
accept yes, no, on,
off, 1, and 0 in any letter case
from Claude Code v2.1.218; earlier versions recognize only
true and false. This machine’s CLI reports
2.1.211, so true/false here.
How a skill loads: listing, body, then bundle
Skills load in three tiers, each spending tokens at a different moment.
Tier one is the listing. At session start the model sees each skill’s
name plus its description and when_to_use
text, truncated at 1,536 combined characters per skill (the
skillListingMaxDescChars setting moves that cap). The
listing as a whole gets a budget defaulting to 1% of the model’s context
window, adjustable through skillListingBudgetFraction or a
fixed SLASH_COMMAND_TOOL_CHAR_BUDGET. On overflow,
descriptions drop starting with the least-invoked skills; names remain.
Note the eviction order: the description of a skill nobody invokes is
the first thing discarded, so a skill that fails to earn invocations
loses the words it needed to earn them.
Tier two is the body. Invocation renders SKILL.md,
applies substitutions, and inserts the result into the conversation as
one message that stays for the session. The file is not re-read on later
turns. Re-invoking with identical rendered content adds a short
already-loaded note instead of a second copy (v2.1.202 and later;
earlier versions appended a full copy each time). Auto-compaction keeps
skills alive across the summary by re-attaching the most recent
invocation of each one, first 5,000 tokens per skill, inside a combined
25,000-token budget filled from the most recent invocation backward. The
authoring rule that falls out is front-loading: content past the first
5,000 tokens of a body can vanish at the first compaction, so hard rules
go at the top and reference material goes in the bundle.
Tier three is the bundle: sibling directories the body points to by relative path, read when the work needs them instead of loaded up front. A rendering skill’s LUTs and frame presets have no business in the context window until a frame is being rendered.
How Claude Code decides to load a skill on its own
Automatic invocation runs on tier one alone; body sections do not participate in trigger matching. Whatever routing intelligence a skill has must fit in 1,536 characters. My convention packs three parts into the one field: a plain statement of the job the skill owns, a “Use when …” clause enumerating the literal phrasings and task nouns that should trigger it, and a routing clause for near misses.
The routing clause is the piece I would defend hardest. It is the
literal token NOT FOR: followed by semicolon-separated
pairs of near-miss task and correct destination:
NOT FOR: proving a finished feature works (use suede-verify); reviewing a diff for quality (use suede-code-review).
That line is from suede-debug. The failure mode of a thirty-skill
estate is not the missed match, it is the plausible wrong match:
debugging and verification look alike from a distance, and a model that
has read thirty descriptions will pick a defensible neighbor.
NOT FOR turns each description into a signpost that names
its neighbors and points away from itself. The router does not have to
infer the boundary, because the boundary is written at the exact place
the wrong turn happens.
Adoption is honest rather than flattering. The token appears in 14 of
31 personal descriptions and 5 of 29 pack descriptions, and two strays
read Not for and Don't use for. My authoring
standard, kept in suede-skill-forge, requires the three-part description
plus a ## Boundaries section and a terminal
## Routing section in the body. The standard is written;
compliance is partial; the gap is measurable because the standard is
written. And one confession the linter would force anyway: suede-debug’s
NOT FOR names suede-verify, which resolves to nothing on
this machine at the moment. The drift section at the end of this chapter
owns that.
Blocking a
skill from the model or from the / menu
Two frontmatter switches govern who may fire a skill, and they are
not mirror images. disable-model-invocation: true blocks
the model from loading the skill on its own, blocks preloading into
subagents, and from v2.1.196 stops the skill from running when a
scheduled task fires with it as the prompt.
user-invocable: false hides the skill from the
/ menu without blocking Skill-tool access. One switch
fences the model, the other fences the menu, and a skill closed in both
directions needs both lines.
Per-machine control sits in settings. skillOverrides
takes four states per skill: "on",
"name-only", "user-invocable-only", and
"off"; absent entries mean "on", plugin skills
are unaffected, and the /skills menu cycles states with
Space and writes to .claude/settings.local.json. From
v2.1.199, "off" also hides a skill from Remote Control
clients and Agent SDK callers. Below that sit permission rules:
Skill(name) matches one skill, Skill(name *)
prefix-matches with any arguments, and denying the bare
Skill tool turns skills off wholesale.
Tool policy inverts the intuition its name suggests.
allowed-tools restricts nothing; it pre-approves the listed
patterns for the turn that invokes the skill, then clears on the next
user message. disallowed-tools removes tools from the pool
while the skill is active and clears the same way. The one
allowed-tools in my personal root, on impeccable:
allowed-tools:
- Bash(npx impeccable *)
- Bash(node .claude/skills/impeccable/scripts/*)The shape is Tool(command-glob): a permission rule, not
a tool list. This is the mechanism that lets a skill ship a script and
run it without a prompt, and the portable form substitutes the install
path, as in
allowed-tools: Bash(${CLAUDE_SKILL_DIR}/scripts/render.sh *).
The ${CLAUDE_SKILL_DIR} substitution inside
allowed-tools needs v2.1.129;
${CLAUDE_PROJECT_DIR} there needs v2.1.196.
Execution pins finish the set. model accepts what
/model accepts plus inherit and applies for
the rest of the current turn; effort runs low
through max and defaults to the session level. I pin
model: opus on five skills (suede-think, suede-spec,
suede-plan, suede-product, suede-debug), the judgment-dense end of the
pack, where a cheaper model’s bad plan costs more than the tokens saved;
no other model value appears anywhere in my frontmatter.
paths limits automatic activation to glob patterns, for
skills that should wake only inside certain trees.
context: fork runs the body in a forked subagent,
agent picks the subagent type, and
background: false makes the invoking turn wait for the
fork’s result. That last field is gated at v2.1.218, which my 2.1.211
does not clear. A SKILL.md parses anywhere; its behavior is
version-gated, and the gap between those two facts stays invisible until
a field does nothing and no error says so.
$ARGUMENTS,
named arguments, and injected shell output
Bodies are templates: $ARGUMENTS takes the full argument
string; $ARGUMENTS[N] and the $N shorthand
take shell-quoted positional pieces
(/my-skill "hello world" second puts
hello world in $0); $name maps by
position from the arguments frontmatter list;
${CLAUDE_SESSION_ID}, ${CLAUDE_EFFORT},
${CLAUDE_SKILL_DIR}, and ${CLAUDE_PROJECT_DIR}
fill in environment facts. An indexed placeholder with no matching
argument stays literal, a named one expands to an empty string, and a
body with no $ARGUMENTS that gets invoked with arguments
has ARGUMENTS: <value> appended to its end.
argument-hint is display text for the human side of the
same contract; impeccable’s is a one-line subcommand menu,
[craft|shape · audit|critique · ... · init|document|extract|live] [target].
Skills can also pull live context in before the model reads a word. A
line-leading !`command` runs the command during rendering
and splices its output in; multi-line commands open a fenced block with
```!; substitution runs once, and command output is not
re-scanned for further placeholders. The off switch for locked-down
machines is the "disableSkillShellExecution": true setting,
which replaces each injected command with
[shell command execution disabled by policy] across user,
project, plugin, and added-directory skills.
What else ships in a skill directory
The census across my 31 personal skills: references/ in
15, agents/ in 14, scripts/ in 7,
tests/ and examples/ in 2 each, then a
twenty-name tail of one-offs (workflows, transitions, templates,
schemas, palettes, luts, frame-presets, audio, adapters, and more). The
format imposes no vocabulary here; a subdirectory is whatever the body
needs it to be. Drift shows anyway: impeccable ships
reference/ singular while 15 other skills use
references/ plural, and my linter accepts both rather than
fight an installed third-party layout.
The agents/ count hides the strangest observed fact on
the machine. All 14 of those directories contain one file,
openai.yaml, a Codex-shaped manifest
(interface with display_name,
short_description, and default_prompt, plus a
policy key in 2 of 14). No Claude subagent definition lives
inside any skill bundle here. The pack ships cross-host: one
SKILL.md body, with a per-host manifest riding along in the
bundle for the other harness.
Enforcement is bundled too, inside suede-skill-forge:
python3 suede-skill-forge/scripts/lint_skill_estate.py <skill-root> --external-root <path>
python3 suede-skill-forge/scripts/audit_skill_overlap.py <root> \
--exclusive-root <label>=<path> --compare <label>=<path> --strictThe linter exits nonzero on two classes of rot: a routing target
(use X, NOT FOR ... (use X), or the
$X sigil) that resolves to nothing on the target surface or
the supplied external roots, and a relative path under the accepted
bundle directories that does not exist. Public skills that reference
private companions get one escape hatch, a literal recognized phrase:
private Suede Labs companion, not in this pack: <skill-name>.
The overlap auditor enforces a different invariant: under
--exclusive-root, a skill name must not exist in two
loadable roots, because two loadable owners make routing depend on
loader order.
Both scripts would fail this machine as it stands on 2026-07-27. Four
names exist in both ~/.claude/skills and the public pack:
suede-codex-fleet, suede-design, suede-deslop, suede-full-send. All four
surface twice in a session listing, bare and
suede-skills:-prefixed. The installed plugin cache sits at
version 0.6.2 with 27 skills while the source repo declares 0.8.0 with
29, and the two skills missing from the cache are suede-deslop and
suede-full-send, which resolves half the duplication by accident: those
two load from the user root alone. The other two duplicates have two
loadable bodies each, and which body a bare invocation loads is untested
here; my own audit script calls that condition order-dependent, which is
the polite word for undefined. The marketplace description promises “29
MIT-licensed skills” while a machine on 0.6.2 receives 27: accurate
against source, wrong against what installs.
The four skill locations, and which copy wins
Discovery spans four locations: personal
~/.claude/skills/<name>/SKILL.md, project
.claude/skills/<name>/SKILL.md, plugin
<plugin>/skills/<name>/SKILL.md, and an
enterprise location under the managed settings directory. On a name
collision, enterprise overrides personal, personal overrides project,
any of the three overrides a bundled skill of the same name, plugin
skills cannot collide at all because they live behind the
plugin-name:skill-name namespace, and a skill beats a
.claude/commands/ file of the same name. The commands
directory survives as a merged legacy:
.claude/commands/deploy.md and
.claude/skills/deploy/SKILL.md both create
/deploy, and the docs’ old slash-commands URL now serves
the page titled “Extend Claude with skills” with a canonical link to the
skills page. I curled it to check.
Project discovery walks upward and gates on the working directory.
Skills load from .claude/skills/ in the starting directory
and each parent up to the repository root, plus on demand from nested
directories below the start; a nested name clash surfaces under a
directory-qualified name like /apps/web:deploy (v2.1.203 or
later). No nested skill root exists anywhere under ~/code,
so the qualified-name mechanic has no local example, but the gate itself
is easy to observe: suede-geo carries 46 project skills, and a session
started in my home directory sees none of them. --add-dir
is the documented exception that loads an added directory’s
.claude/skills/; the
permissions.additionalDirectories setting grants file
access without loading skills. The two look interchangeable and are not,
which matters the day a skill refuses to appear.
Project skills can pin by content hash. A suede-geo worktree carries
a skills-lock.json of this shape:
{"version": 1, "skills": {"<skill-name>": {"source": "<owner>/<repo>", "sourceType": "github", "skillPath": "skills/<name>/SKILL.md", "computedHash": "<sha256 hex>"}}}Change handling rounds out the loading model. Adding, editing, or
removing a skill under the loadable roots takes effect in the current
session; detection covers SKILL.md text only (plugin hooks,
.mcp.json, and agents need /reload-plugins),
and a top-level skills directory created after session start needs a
restart. A body already inserted into the conversation stays as
rendered, because the file is not re-read. A
<skill-name> entry may be a symlink; the loader
follows it and loads the skill once even when the same target is
reachable from more than one location.
Why the loaded estate and the source repo disagree
Installation is copy, not reference: zero of the 38 entries in my
skills root are symlinks. Copies drift, and this machine’s inventory
reads like a drift catalog. The personal source repo holds 74
SKILL.md files; 31 are installed.
~/.claude/commands/suedify.md symlinks to a
SKILL.md that no longer exists, so /suedify
resolves by filename with nothing behind it. The project CLAUDE.md on
this machine advertises ten slash commands that resolve to nothing
across the three loadable surfaces (suede-verify, suede-progress,
suede-roadmap, suede-arch, suede-docs, suede-ui, suede-map,
suede-growth, suede-visual-qa, suede-public-claim-check), and loaded
skills route to two more that are absent, suede-verification-law and
suede-fable-standard. Deactivation leaves dated residue on purpose:
~/.claude/skills.disabled/ holds one entry,
2026-07-19-canary-overlay-recovery.
None of that indicts the format. Flat files are what make it workable: a skill is authorable in an editor, diffable in git, and live-reloadable mid-session. The same properties guarantee drift the moment more than one surface holds a copy. The dangling routes and duplicate owners my two scripts check for are sitting on this disk right now, which is why they exist, and why they have to run against the surfaces a session loads rather than the source repo. The source repo is clean. The loaded estate is not, and the loaded estate is what the router sees.