Guide

Hooks vs Skills: When Instructions Are Not Enough

Decide when a rule needs a hook instead of a skill, then write and verify the hook that enforces it.

~9 min read

A terminal showing a request to edit the .env file being refused by a PreToolUse hook before the write reaches the file.

You wrote a skill. Somewhere in the body, in plain language, sits the line: never edit .env, the file that holds your local secrets and configuration. It held for weeks. Then one afternoon you asked Claude to wire up a new database connection, and it edited .env.

You type: wire up the new Postgres connection for local dev
Claude announces: Using the project-conventions skill
Output: Reading src/db/client.ts
Output: Updated .env (added DATABASE_URL)
Note: The skill body says never edit .env. Claude read it and edited anyway.
The skill loaded. The rule was in it. The file changed anyway.

The skill was not broken. The description matched, the body loaded, the rule was sitting right there in the context window. What failed was the assumption underneath it: that writing a rule into a skill makes the rule hold. A skill is an instruction, and instructions get weighed against everything else in the conversation. Most of the time yours wins. Sometimes it loses.

If your skill genuinely never loads at all, that is a different failure with its own guide, linked at the end. This one is about the skill that loaded, was read, and got overruled anyway. And if you are not yet sure which of Claude Code’s mechanisms a problem belongs to, the wider decision guide covering CLAUDE.md, skills, subagents, and hooks is also linked at the end. Here we stay on the one pairing people actually get wrong.

Instructions versus enforcement

This is the whole distinction, and it is worth stating once, cleanly. A skill is content the model reads and tries to follow. A hook is a command Claude Code runs at a fixed point in the session, whatever the model decided. The documentation states this outright about CLAUDE.md, the plain-text instruction file a project keeps for Claude: Claude treats it as context, not enforced configuration, and to block an action regardless of what Claude decides, you use a PreToolUse hook instead. That sentence is written about CLAUDE.md, not about skills. Skills land on the same side of the line for the same reason: the official comparison of these mechanisms sorts them by who decides a thing runs, and for a skill the answer is you or Claude. For a hook, the answer is neither.

So the useful test is not whether the rule is important. Plenty of important things belong in a skill. The test is whether it has to hold every single time. Words like always, never, must, and under no circumstances are the tell. The moment you write one of those and mean it literally, you have left skill territory. A skill buys you a high pass rate. A hook buys you a guarantee across the tool calls it matches, because the model never gets a vote.

What a hook actually is

A hook is a shell command that Claude Code runs when a specific thing happens. You do not call it. Claude does not call it. The harness, meaning Claude Code itself rather than the model inside it, calls it every time that event fires, whether or not the model would have chosen to. That single fact is the entire source of its power and the entire source of its cost.

You attach a hook to a lifecycle event rather than inventing a moment for it. There are roughly thirty events, and the full list lives in the hooks reference. These are the ones you will reach for first:

  • PreToolUse: before a tool call executes. It can block the call. This is the one you want.
  • PostToolUse: after a tool call succeeds. Good for format-on-save. It cannot undo anything, because the tool has already run.
  • UserPromptSubmit: when you submit a prompt, before Claude processes it.
  • SessionStart and SessionEnd: when a session begins or resumes, and when it terminates.
  • Stop: when Claude finishes responding.
  • Notification: when Claude Code sends a notification, which is how people get a desktop alert instead of watching the terminal.

PreToolUse is the one nearly every beginner is actually hunting for, because it is the one in that list that runs before the action and can stop it. The reference describes it in a single line: before a tool call executes, can block it. It also fires before any permission-mode check, so a hook that says no still says no in a permission mode that would otherwise wave the edit through. Everything else in the list observes, reacts, or cleans up afterward. If your sentence is “this must never happen,” PreToolUse is where it goes.

A hook that protects one file

Build the smallest useful version: a script that refuses any edit to .env. The contract is small. Claude Code hands your script the pending tool call as JSON on standard input. Your script answers with an exit code: 0 means no objection, 2 means block. When it blocks, whatever the script wrote to standard error becomes the reason, and Claude receives that reason as feedback rather than as a mystery. Save this as .claude/hooks/protect-env.sh.

#!/bin/bash
# .claude/hooks/protect-env.sh
# A PreToolUse hook. Claude Code sends the pending tool call as
# JSON on stdin. Exit 0 to allow it, exit 2 to block it.

INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

# Windows paths arrive with backslashes. Normalize before matching.
FILE_PATH="${FILE_PATH//\\//}"

if [[ "$FILE_PATH" == *".env"* ]]; then
  echo "Blocked: .env is protected. Add that line by hand." >&2
  exit 2
fi

exit 0

Two things about that script are worth reading closely. It uses jq to pull one field out of the JSON, which is what the official hook examples do, so if jq is not installed on your machine the hook cannot work. And the match is deliberately blunt: any path containing .env is refused, which also catches .env.example. That is the tradeoff you are making, in the open. A narrower test lets more through. A wider one starts refusing edits you wanted. Decide which mistake you would rather live with, then write the test you meant.

A script in a folder does nothing on its own. You register it in a settings file, and which file you pick is the scope decision: .claude/settings.json inside the project, which you commit so the whole team inherits the rule, .claude/settings.local.json for the same project but only you, or ~/.claude/settings.json for every project on your machine. The entry names the event, and its matcher narrows that event to the tools you care about. Matchers match the tool name and are case-sensitive, so Edit|Write means those two tools exactly and nothing else. Point the command at your script through $CLAUDE_PROJECT_DIR rather than a bare relative path, because a path that does not resolve leaves the gate silently disabled rather than announcing itself. On Windows PowerShell write that variable as $env:CLAUDE_PROJECT_DIR, since PowerShell resolves the bare spelling to $null and the hook quietly never runs.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-env.sh"
          }
        ]
      }
    ]
  }
}

One limit is worth stating plainly before you trust this. That matcher covers the Edit and Write tools. A shell command that appends to .env through the Bash tool is a different tool name, and this hook never sees it. Covering that case means matching Bash as well and inspecting the command instead of the file path, which is a second script. Know which half you have protected.

You type: wire up the new Postgres connection for local dev
Claude announces: Using the project-conventions skill
Output: Reading src/db/client.ts
Output: Blocked: .env is protected. Add that line by hand.
Note: That is your script’s stderr. The write never reached the file.
Output: I cannot write to .env. Add this line yourself:
Output: DATABASE_URL=postgres://localhost:5432/app_dev
Note: Claude gets the reason as feedback, so it routes around the block.
Same request, same skill, different outcome.

Verify it is actually registered

Do three checks, in this order, and do all three. Run the script by hand to prove it behaves. Confirm Claude Code can see it. Then attempt the forbidden thing in a real session. Jumping straight to the third is tempting, and it is how people end up debugging the wrong half.

Command: chmod +x .claude/hooks/protect-env.sh
Note: chmod +x makes the file runnable. Skip it on macOS or Linux and Claude Code cannot run the hook at all.
Command: echo '{"tool_input":{"file_path":".env"}}' | .claude/hooks/protect-env.sh
Output: Blocked: .env is protected. Add that line by hand.
Command: echo $?
Output: 2
Note: Feed it the same JSON shape Claude Code sends. Exit 2 is the block.
Prove the script alone before you trust the whole chain.
Command: claude
You type: /hooks
Output: a browser of every hook event, with a count beside each one
Note: Your hook should appear under PreToolUse. The menu is read-only: edit the settings file to change anything.
You type: add a DATABASE_URL line to .env
Output: Blocked: .env is protected. Add that line by hand.
Note: Only this proves the whole chain. Do it once, in a fresh session.
Then check registration, and finish with a live attempt.

One warning matters more than it looks. Exit code 2 is what blocks. Any other nonzero exit, a typo in the script, a missing jq, a path that does not resolve, is reported as a hook error and the tool call proceeds anyway. A broken hook fails open, not closed. That is the whole argument for running the script by hand before you trust it with something you actually care about.

Which one does this call for

  • Format every file you edit with the project formatter: hook. There is no judgment in it, and a formatter that runs usually is worse than one that never runs, because you stop checking its work.
  • Explain how this repo structures its API routes so new code matches: skill. That is judgment, and judgment is exactly what a skill is for.
  • Never commit directly to the main branch: hook, on PreToolUse. You mean never, and you will find out you meant it on the day it slips.
  • Follow our pull request checklist before opening a PR: skill. It is a procedure a person would also read, and steps get skipped for good reasons sometimes.
  • Our release process, including never tagging from a dirty working tree: both, and this is the common case.

That last answer is the realistic one for most team conventions, and it is worth sitting with. The skill teaches the process: the order of the steps, what a good changelog entry looks like, what to do when the tests are flaky. That part needs a reader who can think. Then you take the one clause that must never slip, tagging from a dirty tree, and you put that clause in a hook. The skill carries the convention. The hook carries the guarantee. Neither is a fallback for the other, and most conventions need both, because most conventions are mostly judgment with one hard edge.

What hooks cost you

  • They run whether you want them to or not. A PreToolUse hook fires on every matching tool call, in every session, including the one where you knew exactly what you were doing. There is no just this once.
  • A slow hook slows everything. By default every matching hook runs to completion before Claude Code decides what happens next, so a hook that reaches out to the network adds that wait to every edit for the rest of the day. There is a timeout field, which caps the damage rather than removing it.
  • A buggy hook is confusing rather than loud. A matcher that is too broad, or a script that exits 2 on a case you did not consider, shows up as Claude refusing ordinary requests for no visible reason. Keep the matcher as narrow as the rule actually needs.
  • They are machine configuration, not portable content. A hook lives in a settings file and points at a script on disk. Hand someone your skill folder and they get the instructions. They do not get the enforcement.

That last cost catches people who share skills. Publish a skill that says never touch production credentials and whoever installs it receives the sentence, not the guardrail. There are two partial answers. A SKILL.md can declare hooks in its frontmatter, the block of settings at the very top of the file between two lines of three dashes, and those hooks apply for the rest of the session once the skill is invoked, so some enforcement travels with the file. Frontmatter can also list allowed-tools or disallowed-tools, which pre-approve or block tools for the turn that invokes the skill, a grant that clears on your next message. Both are narrower than a settings-level hook, which is simply always on. Read the field documentation before you lean on either.

Stay updated

Get new guides in your inbox

One task, one guide, done fast. Practical Claude Code skills, zero noise.