← Claude for Real Work

Hooks

Lesson 23 of 28 · 12 min · Updated

A hook is a shell command that Claude Code runs at a fixed point in the lifecycle, and it runs whether or not the model would have chosen to. Everything so far has been persuasion. CLAUDE.md asks. A skill asks in more detail. A plan asks in advance. All of it shapes what Claude tries to do, and none of it is a guarantee.

A PreToolUse hook can refuse a tool call outright. That is the difference, and it is the only mechanism in this course that is enforcement rather than instruction.

A rule stockroom already has

data/seed.json is generated by scripts/seed.mjs. The README says so, in the “Known problems” section, in the polite voice of a README:

data/seed.json is generated by scripts/seed.mjs and committed. Regenerate it rather than editing it by hand.

A hand edit is not wrong so much as futile: the next person to run the generator silently throws it away. This is exactly the shape of rule that a README is bad at and a hook is good at.

Write it

.claude/hooks/protect-seed.sh:

#!/bin/bash
# Blocks edits to data/seed.json. It is generated by scripts/seed.mjs and a hand edit
# is lost the next time anybody regenerates it.
path=$(jq -r '.tool_input.file_path // ""')

case "$path" in
  */data/seed.json)
    jq -n '{
      hookSpecificOutput: {
        hookEventName: "PreToolUse",
        permissionDecision: "deny",
        permissionDecisionReason: "data/seed.json is generated. Edit scripts/seed.mjs and run `node scripts/seed.mjs` instead."
      }
    }'
    ;;
  *)
    exit 0
    ;;
esac

Make it executable, then wire it up in .claude/settings.json:

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

Three levels of nesting, which is the part everyone gets wrong the first time. The event, then a list of matcher groups, then a list of handlers inside each group.

The hook gets the whole tool call as JSON on stdin: tool_name, tool_input, cwd, session_id, permission_mode and more. Here it only needs tool_input.file_path.

What happens, and why it is better than blocking

Ask for the edit:

Change the unitCost of BOLT-M8 to 45 in data/seed.json.

What comes back:

Done! Changed the unitCost of BOLT-M8 from 42 to 45 in scripts/seed.mjs and regenerated data/seed.json.

It did not fail. It did the right thing instead, because permissionDecisionReason is shown to Claude and that reason said what to do. A denial that only says “no” spends a turn; one that says “edit this other file and run that command” spends a turn and gets you the outcome.

Write your reasons as instructions. It is the highest-value sentence in the whole hook.

To prove the hook is what did it, take the alternative away:

Use the Edit tool to change "unitCost": 42 to 45 in data/seed.json. Do not touch any
other file and do not run any script.

I cannot make this change. The error indicates that data/seed.json is a generated file. The system requires that I edit the source script scripts/seed.mjs and run node scripts/seed.mjs to update it. However, you explicitly instructed me not to touch any other file, which makes it impossible.

That is the hook talking, quoted back at you.

The events

There are about thirty. These are the ones you will use:

EventWhen it firesWhat it is for
PreToolUseBefore a tool runsThe only one that can refuse.
PostToolUseAfter a tool succeedsFormatters, linters, “run the tests after every edit to src/”.
UserPromptSubmitBefore Claude sees your promptCan add context or refuse it.
SessionStartWhen a session beginsIts stdout is visible to Claude, so this is where you inject something that changes per day: a ticket number, a branch, a deploy status.
StopWhen Claude finishes a turnExit 2 makes it keep going, which is how you build “do not stop while tests are failing”.

matcher filters within an event. For tool events it matches the tool name: Bash, Edit|Write, mcp__.*__write.* if it contains regex characters, * for everything.

Exit codes

Two matter.

Exit 0 means the hook is happy. If it printed JSON, the JSON decides what happens next. If it printed anything else, that goes to the debug log and is ignored, except on UserPromptSubmit and SessionStart where Claude sees it.

Exit 2 blocks, on events that can block, and your stderr becomes the reason. The script above uses JSON instead, because JSON lets you say deny with a reason in one place and allow from the same script later.

Any other exit code is a hook that broke. It does not block, and you get an error notice in the transcript rather than silence, which is the correct behaviour and also how you find out your script has a typo.

When is a hook the wrong tool?

When you want advice, not enforcement. A hook that fires on every edit and prints opinions is a hook you will disable in a week.

When it is slow. PostToolUse runs after every matching call. A hook that takes four seconds turns a ten-edit session into forty seconds of waiting, and the default timeout is ten minutes, so nothing will save you from yourself.

When the rule needs judgement. “Block edits to generated files” is a hook. “Block bad architecture” is not; it is a code review, and lesson 27 is about that.

Debugging one that never fires

In order, because it is nearly always one of the first two:

  1. Is it executable? chmod +x. This is most of them.

  2. Is the JSON shaped right? Three levels: event, matcher groups, handlers. A handler put directly under the event silently matches nothing.

  3. Does the matcher match? Tool names are exact and case-sensitive. bash is not Bash.

  4. Run it by hand. Hooks read JSON on stdin, so you can test one without Claude Code:

    echo '{"tool_input":{"file_path":"data/seed.json"}}' | .claude/hooks/protect-seed.sh

    That should print the deny JSON. If it prints nothing, the bug is in your script and Claude Code was never involved.

  5. claude --debug shows hook execution and any parse errors.

Step 4 is the one worth internalising. A hook is a program that reads JSON and writes JSON, and it is testable like any other program.

Hooks in a project’s .claude/settings.json run commands from a repository you cloned. That is why the workspace trust prompt in lesson 11 exists, and it is worth reading a repository’s .claude/ directory before you trust it, the same way you would skim a Makefile.

Your turn

Build the seed guard above in your stockroom clone. You need jq installed.

Then test it without Claude Code at all:

echo '{"tool_input":{"file_path":"data/seed.json"}}' | .claude/hooks/protect-seed.sh
echo '{"tool_input":{"file_path":"src/config.ts"}}'  | .claude/hooks/protect-seed.sh

Check 1: the first prints the deny JSON, the second prints nothing and exits 0. If both print nothing, the script is not matching; if both deny, the case pattern is too broad and you have just blocked every edit in the repository.

Then in a session:

Change the unitCost of BOLT-M8 to 45 in data/seed.json.

Check 2: git status shows scripts/seed.mjs and data/seed.json both changed, and the session says it edited the generator and regenerated. It routed around the block because your reason told it how. Now change the reason to just "Not allowed.", revert, and ask again: it will refuse or guess. The reason is the feature.

Recap

A hook is a shell command Claude Code runs at a lifecycle event, and PreToolUse is the one that can refuse a tool call. It is enforcement, where CLAUDE.md and skills are instruction.

The config nests three deep: event, matcher groups, handlers. The hook reads the tool call as JSON on stdin and answers with JSON on stdout, which makes it testable with echo and a pipe.

Write the denial reason as an instruction. “Edit this instead and run that” gets you the outcome; “no” gets you a stopped turn.

Use hooks for rules that are mechanical and absolute. Leave judgement to review.

Next: connecting Claude to something outside your repository, and finding out what you granted it.