Skip to content

COMPARISONS

Claude Code Skills vs Hooks: 0 of 5 Against 5 of 5

Claude code skills vs hooks, settled by running one policy both ways. Fifteen runs, three arms: the hook recorded every edit, the skill recorded none of them.

The claude code skills vs hooks question has one answer that survives measurement: a hook fires and a skill is chosen. We wrote one policy — record every file edit in CHANGELOG.md — as a skill and as a PostToolUse hook, ran five identical prompts against each, and added a third arm with neither. The hook recorded 5 of 5. The skill recorded 0 of 5, which is exactly what the control recorded. Measured 2026-09-07 on Claude Code 2.1.263.

Key takeaways

  • Over fifteen runs on one policy: hook 5 of 5, skill 0 of 5, no-policy control 0 of 5.
  • The skill lost with its description fully present in the listing on every run, verified per run from the transcript — not because it was truncated away.
  • Turn it around and skills win: the same mechanism fired 4 of 5 when the user's own request named the job instead of leaving it as a standing rule.
  • Determinism was free here — skill $0.1253, hook $0.1184, control $0.1141 across five runs each.
  • The dividing line is not automation versus instruction. It is "must happen" versus "someone will ask".

The decision rule

Commit to it before the numbers: if the sentence contains "always", "every time" or "before you finish", it is a hook. If it contains "when I ask", "help me" or "the way we do X here", it is a skill. Everything else in this article is that rule being tested.

Terminal
npm run check:skill-vs-hook
# → All 8 skill-vs-hook guards passed.

npm run check:skill-vs-hook -- --arms

The experiment

One policy, written twice. As a skill, in the shape the authoring guide recommends — a trigger sentence in the description, a one-line procedure in the body:

.claude/skills/dv-changelog-policy/SKILL.md
---
name: dv-changelog-policy
description: Record a code change in CHANGELOG.md. Use after any edit to a source file in this project, every time, before reporting the work as done.
---

Append one line to CHANGELOG.md describing the edit you just made, in the form `- <summary>`.

As a hook, in the shape the hooks tutorial recommends — a matcher and a command:

.claude/settings.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [{ "type": "command", "command": "node .claude/changelog-hook.mjs", "timeout": 20 }]
      }
    ]
  }
}
.claude/changelog-hook.mjs
import { readFileSync, appendFileSync } from "node:fs";
import path from "node:path";

const p = JSON.parse(readFileSync(0, "utf8"));
const file = p?.tool_input?.file_path ?? p?.tool_response?.filePath;
if (!file) process.exit(0);
appendFileSync(path.join(p.cwd, "CHANGELOG.md"), `- ${p.tool_name} ${path.basename(file)}\n`);
process.exit(0);

Five prompts, none of which mentions a changelog: "Add a function called slugify to utils.js…", "In utils.js, change the value of VERSION from 1 to 2", and three more of the same shape. Every run starts from an identical utils.js, and every one of the fifteen runs made exactly one edit, so all fifteen had something to record.

The skill arm ran with the listing budget raised, so the skill's description was in front of the model in full. That is the skill's best case, and it was checked in each run's own transcript rather than assumed.

The result

ArmRecordedEdits madeTurnsCost
Hook5 of 553,3,4,4,4$0.1184
Skill0 of 553,3,4,4,3$0.1253
Control0 of 554,3,3,4,3$0.1141

The skill arm and the arm with no policy at all produced identical output. Five well-formed edits, five changelogs untouched, in both. The skill was never invoked — the Skill tool does not appear in any of the five transcripts.

The hook arm's five appends are in CHANGELOG.md because the hook ran, not because the model remembered:

lab/vs-hook/CHANGELOG.md after run 3
# CHANGELOG
- Edit utils.js

Why the skill lost

Not because it was invisible. The description was in the listing on all five runs, in full, and the model still picked Read and Edit and went home.

The mechanism is in the shape of the instruction rather than its placement. A description is matched against what the user is asking for, and nobody in these five prompts asked for a changelog entry. The description says "use after any edit … every time, before reporting the work as done" — a standing obligation — and there is no point in the turn at which a standing obligation looks like the user's request. The model has finished the edit, the task is done, and the listing offers a skill about changelogs to a session that was never asked about changelogs.

The direction skills win

The same mechanism, one variable changed. A skill described as "Use whenever the user asks to record, log or note a change in the project changelog", and five prompts that ask for exactly that — "Record in the changelog that we fixed the date parser."

Listing stateFiredRuns
Description present45
Name only05

Four of five when the request names the job. Zero of five when the description was truncated out of the listing — which is the ordinary state for a new skill on a busy machine and is measured in full in the companion article.

So the honest comparison is three-way, not two:

Job shapeSkillHook
"Do X when I ask"4 of 5cannot express it
"Do X after every edit"0 of 55 of 5
"Do X, and my skill is new"0 of 55 of 5

What determinism cost

Nothing measurable, on this task. Five runs each: skill $0.1253, hook $0.1184, control $0.1141 — the three arms are within a cent of one another, and the hook arm's mean wall clock is 14.8 seconds against the control's 13.3.

That is a property of this hook, and it is worth being precise about why. This one reads a file path out of the payload and appends a line. It does not touch the file the model is holding. A hook that rewrites that file is a different animal and costs about a turn every time, because it falsifies what the Write tool just told the model — the formatter tax is the same measurement run against that case.

Determinism is cheap when the hook stays out of the model's way. It gets expensive exactly when the hook edits what the model believes it already knows.

What only a hook can do

  • Fire on a tool call the model did not think to mention. The whole point: the hook's trigger is the tool call, not the model's judgement.
  • Refuse one. PreToolUse can deny a call before it runs, and it is the only real veto in the system — a skill runs after the model has already decided.
  • See a failed tool call. PostToolUseFailure and PostToolBatch carry the failures. A skill only ever sees what the model chose to tell it.

What only a skill can do

  • Carry a procedure rather than a command. The body is instructions the model reads and adapts — "compare check:rules against the number in roadmap.md" is not something a shell script can do.
  • Be asked for by name. /article-preflight runs it on demand. No hook has an on-demand form.
  • Decline. A skill that decides the situation does not apply is behaving correctly. That is a feature for conditional work and precisely the defect measured above for unconditional work.

Using both

The two are not alternatives for the same job; they are alternatives for the same sentence, which is where the confusion starts. Split the sentence instead:

  • The gate is a hook. PreToolUse denying rm -rf, or a PostToolUse lint that reports. It does not depend on anyone's judgement and it does not stop being true when the model is busy.
  • The procedure is a skill. How this repository preflights an article, what its check numbers mean, what to do when one is non-zero for a known reason.
  • Wire them together. A hook's additionalContext can tell the model that a check failed; the skill is what the user then runs to deal with it. The hook supplies the fact, the skill supplies the response.

The reason to keep them separate is maintenance. A hook that grows judgement becomes a script nobody can debug; a skill that grows obligations becomes a rule nobody enforces. The permission rules are a third layer with the same discipline — they say what is allowed, not how to do it.

What did not work

The first prompt set measured our own harness. It was five steps of one story — add slugify, then rename it, then document it — and the harness resets utils.js before every run, so three of the five prompts referred to symbols that no longer existed. The model searched, found nothing and made no edit at all in runs 2, 4 and 5. An arm with no edits in it cannot record an edit, and "the skill recorded nothing" would have been true and meaningless. The prompts were rewritten so each is a complete edit against the same starting file; all fifteen published runs have exactly one.

The skill arm was nearly run at the default listing budget, where the skill is a bare name and fires 0 of 5 for a completely different reason. That would have produced the same headline number by the wrong mechanism. It is published with the budget raised and the per-run listing state read out of each transcript, so the 0 of 5 is against a skill the model could see in full.

⚠️ Five runs per arm, one model, one task shape — a single-file edit in an empty project on Haiku. 0 of 5 is a direction, not a rate, and it is not a claim that a skill never fires as a policy.

⚠️ The hook is doing work it is well suited to. A mechanical append keyed off a file path is the friendliest possible case for a hook. A policy that needs judgement about what to write is not this experiment, and a hook would do it badly.

Best practices

  • Write the sentence first and read it back. "Every time" is a hook. "When I ask" is a skill. The word decides the mechanism.
  • Never encode a compliance requirement as a skill. If it has to be true of every run, it needs a trigger that is not a judgement call.
  • Use a hook for the fact and a skill for the response. additionalContext reports; the skill is what someone runs about it.
  • Give a skill a description that matches a request, not an obligation. "Use when the user asks to preflight an article" fires; "use after every edit" does not.
  • Invoke a new skill once by hand. Otherwise its description is not in the listing and the comparison you think you are making is not the one happening.
  • Keep judgement out of hooks. A hook that needs to decide what to write is a skill wearing the wrong hat.

Common mistakes

Writing a policy as a skill. Symptom: it works every time you test it by name and never happens in real work. Fix: a PostToolUse hook, or Stop if once per turn is enough.

Writing a procedure as a hook. Symptom: a shell script full of conditionals nobody will maintain, run on every Edit. Fix: a skill, invoked when it applies.

Concluding a skill "does not work" from a policy test. The same skill fires 4 of 5 when the request names the job. Fix: test it against the request you expect, not the rule you wish it enforced.

Assuming a skill can stop something. It cannot — by the time it runs, the model has already chosen it. Fix: PreToolUse.

Comparing the two on cost. They cost the same here, so cost is not the axis. The axis is whether the thing happens.

Conclusion

On claude code skills vs hooks, the measurement is blunt: written as a standing rule, the skill arm and the arm with no rule at all produced identical output over five runs, while the hook produced five for five at no measurable premium. Write hooks for the things that must be true and skills for the things someone will ask for, and when you catch yourself writing "every time" into a description, move it into settings.json instead.

Frequently asked questions

Should I use a Claude Code skill or a hook?
Use a hook when the thing must happen every time regardless of what the model decides, and a skill when the thing is a procedure someone asks for. We ran one policy both ways over five identical prompts: the PostToolUse hook recorded 5 of 5 edits, the skill recorded 0 of 5, and the skill arm was indistinguishable from having no policy at all.
Why does my Claude Code skill not run automatically?
Because a skill is chosen, not triggered. Two things have to happen: its description has to reach the model, which the 8,000-character listing budget often prevents, and the model has to decide the task matches. In our runs a skill whose description was fully in the listing still fired 0 of 5 times when it was written as a background policy rather than as a job someone asked for.
Can a hook replace a skill?
Only for mechanical work. A hook is a command with a JSON payload on stdin — it cannot read a repository, weigh a judgement, or adapt its output. Our changelog hook wrote a line keyed off a file path, which is exactly what a hook is for. A hook cannot decide what a change was for; a skill can, when it runs.
Can a skill block a tool call?
No. A skill runs after the model has already chosen to invoke it, so there is nothing left to block. allowed-tools narrows what the model may call for the rest of that turn, which is a restriction rather than a veto. Only a PreToolUse hook can refuse a specific tool call before it happens.
Do hooks cost more than skills?
Not measurably, on this task. Across five runs each, the skill arm cost $0.1253, the hook arm $0.1184 and the no-policy control $0.1141 — within a cent of each other. A hook that rewrites a file the model is holding is a different story and costs about a turn per pair, which we measured separately.

Muhammad Kashif

Founder and editor of Devventa, covering AI coding assistants, Next.js and the modern AI development stack.