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> --strict

The 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.