Chapter 5 of 9. A skill you have installed is not a skill that ran. Prerequisite: Chapter 4. You should already know what a
PreToolUsehook is, that exit code 2 blocks a tool call, and thatsettings.jsonis the file where a hook becomes real.
Twenty-nine skill directories sit in ~/.claude/skills/ on this machine. Nine are real directories. The other twenty are symlinks pointing into the repos that own them, so the global folder behaves as a mount table. Counted today, on Claude Code 2.1.209.
That number tells you nothing about how many of them ran this week.
A skill is installed by putting a SKILL.md on disk. A skill is executed because the model read a paragraph of your prose, weighed it against what you just asked for, and judged that the two matched. Those are two different events. The first is a filesystem operation you performed once. The second is a judgement the model makes fresh in every session, on the evidence of one paragraph you wrote weeks ago.
Why an installed Claude Code skill does not always run
Anthropic's skills documentation gives you the mechanism in one sentence, and the load-bearing word is when: "Claude uses skills when relevant, or you can invoke one directly with /skill-name."
Relevance is judged. There is no event, no matcher, no file glob, nothing in the harness that fires. The same page names the evidence the judgement runs on: "The directory name becomes the command you type, and the description helps Claude decide when to load the skill automatically." Your frontmatter description is the entire trigger surface. The body of SKILL.md, the CLI it points at, the reference tables: all of it loads only after the decision has already gone your way.
That design is deliberate and it is worth having. Anthropic makes the cost argument on the same skills page: "Unlike CLAUDE.md content, a skill's body loads only when it's used, so long reference material costs almost nothing until you need it." Context accumulates and is re-billed every turn, and Anthropic names the quality cost too, in the phrase context rot: recall degrades as the window fills. A skill that stays out of context until it is needed is the right default.
The price you pay for it is a silent failure mode. When the description does not win, nothing happens. No warning. No "skill considered, skipped" line. The session proceeds without the knowledge you wrote down, and it proceeds confidently, because a model with no skill loaded has no idea a skill was available.
Fix 1: rewrite the description until it matches how you actually ask
The first fix is the only lever the trigger mechanism gives you, and it is prose. Compare two real descriptions from this estate. Here is a skill invoked almost exclusively on purpose, by slash command:
---
name: dvlaw
description: DevLog Article Workflow — turn devlog entries into published articles via a six-step gate (triage → propose → clarify → draft → edit → ship), with SQLite-backed cross-reference across repos.
---
Terse. It never had to fight for a trigger, because nobody ever hopes it fires. Now here is one that got missed, and grew:
---
name: site-html-extract
description: Extract every route on a locally-runnable Next.js (App Router) site as a standalone HTML file in a date-stamped snapshot folder, with each page's components identified in a top-of-file comment block plus README/components-index/manifest indexes. Use this skill whenever the user asks to snapshot, extract, capture, dump, or save every page on the site as HTML; wants a structural reference before restyling, redesigning, or migrating; or says phrases like "extract pages as html", "snapshot site", "site reference for restyle", "extract each page", "capture every route", "html reference for redesign", "dump the site to html". Also use it when they want to repeat the workflow on a different project after seeing it work elsewhere — even if they don't name the skill explicitly.
---
Read what has been bolted on. A verb list. Seven literal phrases in quotation marks, all of them things a human actually types. Then a clause covering the case the author got burned by: someone who wants the workflow repeated on another project and never says its name. The image-harness skill on the same machine carries that same hedge mid-description, "even if they don't name the harness", and then runs on for another full sentence to cover image review and prompt tuning.
Description growth is the tuning loop. Every phrase in that list is a miss somebody paid for.
The mirror image is worth having too. False positives cost more than misses on some skills, so the trigger surface can carry a negative list. A brand-voice skill scoped to one commercial site closes its description like this, with the brand stripped:
Do NOT activate for /privacy, /terms, /cookie-policy, /disclaimer
(legal copy stays in regulatory register); internal documentation
(devlog, roadmap, ADRs, specs); code comments; commit messages; or
PR descriptions.
That is a real lever and it works. It also has a hard ceiling, because you are still writing an argument and hoping it wins. Every phrase you add wins back some of the misses. There is always one more phrasing you did not think of.
The site-html-extract description runs to 120 words. The dvlaw description, on the skill nobody ever hopes fires, runs to 30.
Fix 2: invoke the skill yourself with /name
If the model deciding is the problem, take the decision away from it.
As of Claude Code 2.1.209, commands and skills are the same mechanism. The docs are blunt: "Custom commands have been merged into skills. A file at .claude/commands/deploy.md and a skill at .claude/skills/deploy/SKILL.md both create /deploy and work the same way." Typing /name is a deterministic invocation of the same body of knowledge that description-matching was going to try to guess its way to.
The estate leans on that hard. Twenty-two of the twenty-three files in ~/.claude/commands/ are front doors onto a skill's CLI: twelve dvlaw_* commands and ten roadmap_*. The twenty-third shells out to a script that lives in a different repo altogether.
For a skill that should never fire on its own, the frontmatter has a switch:
---
name: deploy
description: Ship the current branch to production.
disable-model-invocation: true
---
The docs justify it with an example that needs no elaboration: "You don't want Claude deciding to deploy because your code looks ready." Set that flag and the skill is /deploy or it is nothing.
You can also check the guess after the fact. On a paid plan, /usage "attributes recent usage to skills, subagents, plugins, and individual MCP servers, with each shown as a percentage of the total." Run it after a session you expected a skill to handle, and look for the name.
Fix 3: move the deterministic half into a script the skill calls
Some of what a skill knows does not need to be judged at all. The roadmap skill documents the split in its own SKILL.md, under a heading called How invocations work:
Each slash command is a thin prompt template at
`~/.claude/commands/roadmap_*.md` that runs:
python3 ~/.claude/skills/roadmap/roadmap.py <subcommand> [args]
The Python prints JSON. Claude reads it and presents the result as
a readable table or status line.
Two things are being separated there. Anything that must come out the same every time lives in the Python. Anything requiring judgement lives in the markdown. The project-owned rewrite harness splits the same way. A regex layer does the detection. The model only rewrites.
The test is simple. Does the step have one correct output? One correct output means it belongs in the CLI. The prose around it then only has to say when to call the script and what to do with what comes back. That is a far easier argument to win than a nine-step procedure the model has to carry out from memory.
Fix 4: promote the rule to a hook
Anthropic's own memory documentation gives the escalation, and it is the sentence this entire course is organised around:
"If the instruction is something that must run at a specific point, such as before every commit or after each file edit, write it as a hook instead. Hooks execute as shell commands at fixed lifecycle events and apply regardless of what Claude decides to do."
The hooks guide puts the same claim positively: hooks "provide deterministic control over Claude Code's behavior, ensuring certain actions always happen rather than relying on the LLM to choose to run them."
Here is what that looks like when a skill and a hook are written for the same rule. The Final Cut skill on this machine opens with a section headed The five laws (each one shipped a broken render before it became a law). Two of the laws carry a parenthetical:
1. **A clean import proves NOTHING.** DTD-valid, FCP-accepted and visually correct are
three different things. An early cut imported with zero warnings and rendered *pure
black*. **A push is not done until a frame is rendered back OUT of FCP and compared to
the intended framing.** (A Stop hook enforces this.)
3. **NEVER guess an effect/transition `uid`.** A bogus uid imports with zero warnings and
renders BLACK. Get real ones: `python3 fx_catalogue.py --transitions <filter>` (85
transitions, 131 effects from FCP's own registry), or harvest a built-in by letting FCP
author it (`Edit → Add Cross Dissolve`) and pulling the XML back. (A PreToolUse hook
blocks unknown uids.)
The skill states the law. The parenthetical admits that stating it is not sufficient. Law 3's backstop is a PreToolUse hook on the Write tool, and its docstring opens with the incident that produced it:
"""PreToolUse(Write) — BLOCK an .fcpxml carrying an effect uid Final Cut can't resolve.
Why this is a hook: on 2026-07-12 a guessed uid ("FFTransition_CrossDissolve" — plausible,
wrong) was imported by FCP with **zero warnings** and then rendered the whole transition
BLACK. A uid typo is invisible at every stage until you look at a rendered frame. This is
the cheapest possible place to catch it.
...
Anything else is a guess. Fails OPEN on any internal error.
Bypass: BBBRAIN_UID_BYPASS=1 in the environment.
"""
Law 1's backstop runs on Stop. A marker file gets dropped whenever an .fcpxml is written, and the Stop hook then checks whether a render stamp is newer than that marker. If it is not, the session is blocked from ending:
stamp="$dir/last-render.stamp"
if [ -f "$stamp" ] && [ "$stamp" -nt "$m" ]; then
rm -f "$dir"/*.marker # rendered after the push — verified
exit 0
fi
The hook never reads the skill. It matches on the .fcpxml extension and nothing else, so it fires identically whether the skill loaded, was skipped, or was never installed on the machine. It triggers on an effect, a file of a given type having been written, not on an intent.
What a hook cannot do for a skill
The dual layer is two mechanisms with different jobs. Three limits sit on the hook half, and all three are visible in the estate above.
A hook cannot do the work. It cannot author an FCPXML or hold 131 effect uids in its head. Determinism buys you a floor, never a capability. The skill remains the only thing carrying the knowledge; the hook guarantees one sentence of it.
A hook is code you did not test. The two Final Cut guards were both written on 2026-07-12, the same day as the incidents recorded in their own docstrings, and they have zero tests between them. Three hooks in the same directory do have tests, sixty-two of them: the two PreToolUse blockers that veto git commit and archon workflow run, and the Stop-hook reviewer that files brainstorms at the end of a session. Those three are also the ones with the most power to ruin an evening when they misfire.
And every gate here fails open, on purpose. Fails OPEN on any internal error sits in the uid guard's docstring, and the same line runs through every blocking hook on the machine, each one alongside a named bypass environment variable. The design assumption is that you will occasionally miss a catch and survive it, and that a jammed gate at midnight on a git commit will cost you the whole evening.
There is a fourth limit, and it is the one that should make you check your own setup tonight. The roadmap skill carries an ## Enforcement hooks table naming its own backstop as ~/.git-hooks/pre-commit-roadmap-tick. No such file exists. On disk it is commit-msg-roadmap-tick, a commit-msg sub-hook. Both halves work anyway: the skill still runs, the hook still blocks, and only the sentence joining them is false. Nothing tests documentation. Type /hooks to see what is genuinely wired; the hooks guide is explicit that the browser is read-only: "To add, modify, or remove hooks, edit your settings JSON directly or ask Claude to make the change." The roadmap SKILL.md has named the wrong hook file for weeks and nothing anywhere noticed.
The ladder, in order
Work it in order, and know which rung you are standing on.
- Tune the description
Add the literal phrases you actually type. Add a negative list if false positives hurt more than misses. Each addition raises the hit rate a little.
- Invoke it yourself
/nameis deterministic.disable-model-invocation: trueremoves the guess entirely for anything you would never want auto-fired. - Move the deterministic half into a script
One correct output means it belongs in the CLI.
- Hook the sentence you cannot afford to have skipped
One rule, one event, one exit code. Everything else stays in the skill.
The site-html-extract description carries 120 words. A verb list, seven quoted phrases, and a clause for the case its author got burned by, every one of them added after a miss. That is what an argument costs to maintain. The uid guard sitting a directory away matches on one file extension and a table of real uids, and there is nothing in it to tune. Before you add a fourth emphatic clause to a SKILL.md because it keeps being ignored, check whether the thing you are describing has to be true every time. If it does, you are editing the wrong file.
Chapter 6 moves up a level, to subagents. Delegation runs on a description too. The difference is the bill: a missed subagent costs you a whole context window.
Walk one ignored rule up the ladder
Pick an instruction in your own estate that keeps being skipped, whether a CLAUDE.md rule or a skill that misses, and take it up all four rungs for real: tune the trigger prose, add a deterministic invocation path, extract the scripted half, and finish with a hook that backstops the one sentence you cannot afford to lose.
Expected behaviour
- A before-and-after diff of the description that adds at least two literal phrases you have actually typed, pulled from real session history rather than invented
- Every step with one correct output moved into a script the prose merely points at, runnable from a terminal with no model in the loop
- A hook that matches an observable effect, such as a file extension or a command pattern, rather than an intent, and that fails open with a named bypass variable
- The hook present in settings.json and showing a non-zero count next to its event in the /hooks browser
PROVE IT Violate the rule on purpose in a fresh session and paste the block message your hook printed. A gate you have never seen fire is decoration.
An installed skill fails to run and the session proceeds confidently without it. What decided that outcome?
Which frontmatter field, set to true, removes the model's ability to auto-fire a skill so it only ever runs when you type its slash command?
The render-a-frame law is enforced at Stop rather than PreToolUse. Using the chapter's rule about where evidence lives, explain why.
Show answer
At PreToolUse time the rule is unobservable: nothing about the push reveals whether a render will happen after it. By Stop the evidence sits on disk, a render stamp that is either newer than the push marker or older, so the hook can compare two timestamps and refuse to let the session end. The event is chosen by where the proof lives, and for an after-the-fact obligation the proof only exists at the end.
↺ re-read: “Fix 4: promote the rule to a hook”