All sections

10Extending

Hooks

Everything else is a request the model generally complies with. Hooks are deterministic, shell commands that fire on defined events, outside the model, and can block an action outright.

Why they exist

Memory, rules, skills and output styles are all requests. The model reads them and generally complies, but compliance is probabilistic. Hooks are deterministic: shell commands that fire on defined events, outside the model, and can block an action outright.

If a rule must hold 100% of the time — never commit to main, never edit generated files, always run the formatter — it belongs in a hook.

flowchart LR
    A["Claude decides<br/>to run a tool"] --> B["PreToolUse hook fires"]
    B --> C{"Exit code"}
    C -->|"0"| D["Tool executes"]
    C -->|"non-zero"| E["Tool <b>blocked</b><br/>stderr fed back to Claude"]
    D --> F["PostToolUse hook fires"]
    F --> G["Result returns to Claude"]
    E --> G

Exit 0 lets the call through. A non-zero exit blocks it; exit code 2 is the one that feeds your stderr back to Claude as an explanation, which is almost always what you want, because a blocked call with no message just looks broken.

Common events

Event Fires Typical use
PreToolUse Before a tool call; can block Reject writes to protected paths, forbid git push --force
PostToolUse After a tool call Run the formatter, run the linter, regenerate mocks
SessionStart New session Inject dynamic context, warm caches
UserPromptSubmit Each prompt Attach ticket context, enforce prompt policy
Stop Claude finishes a turn Run tests, report status

Where they are defined

  • Inline in settings.json under a hooks key
  • In a plugin’s hooks/hooks.json

The shape is the same in both places: an event name, a matcher regex against the tool name, and a list of commands.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/format.sh"
          }
        ]
      }
    ]
  }
}

The hook receives a JSON payload on stdin describing the tool call, so it can decide based on the actual file path rather than firing blindly.

Worked examples

Format whatever was just written

The single highest-value hook. It removes an entire category of “please run gofmt” from your prompts, and it is more reliable than asking.

#!/usr/bin/env bash
# .claude/hooks/format.sh
set -euo pipefail

path=$(jq -r '.tool_input.file_path // empty')
[[ -z "$path" || ! -f "$path" ]] && exit 0

case "$path" in
  *.go)          gofmt -w "$path" ;;
  *.ts|*.tsx)    npx --no-install prettier --write "$path" ;;
  *.py)          ruff format "$path" ;;
  *.tf)          terraform fmt "$path" ;;
  *.java)        ./gradlew -q spotlessApply -PspotlessFiles="$path" ;;
esac
exit 0

Note the exit 0 at the end and the early exit on a missing path. A formatter hook that fails loudly on an unrelated file type turns every edit into a blocked call.

Protect generated code and migrations

#!/usr/bin/env bash
# .claude/hooks/guard-paths.sh
set -euo pipefail

path=$(jq -r '.tool_input.file_path // empty')

case "$path" in
  */generated/*|*.pb.go|*_gen.go)
    echo "Refusing: $path is generated. Edit the source schema in proto/ and run 'make generate'." >&2
    exit 2
    ;;
  */migrations/*)
    if [[ -f "$path" ]]; then
      echo "Refusing: $path is an applied migration. Migrations are forward-only; add a new one." >&2
      exit 2
    fi
    ;;
esac
exit 0

The migration guard is the interesting one: it blocks edits to existing migrations but allows new ones, which is a rule prose cannot express reliably because it depends on the filesystem.

Wire both up:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [{ "type": "command", "command": ".claude/hooks/guard-paths.sh" }]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [{ "type": "command", "command": ".claude/hooks/format.sh" }]
      }
    ]
  }
}

Refuse to work on the default branch

#!/usr/bin/env bash
# .claude/hooks/branch-guard.sh
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo detached)
if [[ "$branch" == "main" || "$branch" == "master" ]]; then
  echo "On $branch. Create a feature branch before editing." >&2
  exit 2
fi
exit 0

permissions.deny can stop git commit, but it cannot express “only when the branch is main”. Anything conditional on runtime state is a hook.

Inject the current ticket at session start

#!/usr/bin/env bash
# .claude/hooks/session-context.sh
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)
echo "Branch: $branch"
if [[ "$branch" =~ ([A-Z]+-[0-9]+) ]]; then
  gh issue view "${BASH_REMATCH[1]}" --json title,body -q '"Ticket: \(.title)\n\n\(.body)"' 2>/dev/null || true
fi

SessionStart output is added to the context, so the session opens already knowing which ticket the branch belongs to.

Run the fast tests when Claude stops

{
  "hooks": {
    "Stop": [
      {
        "hooks": [{ "type": "command", "command": "make test-unit 2>&1 | tail -30" }]
      }
    ]
  }
}

Keep this to the fast suite. A Stop hook that takes four minutes makes every turn feel broken, and you will disable it within a day.

Everybody runs

Hooks are additive: all matching hooks from all sources run. There is no “most specific wins”. If two PostToolUse hooks both format Go files, both run.

Consequences:

  • Order is not guaranteed. Never write two hooks that depend on running in a particular sequence.
  • Any one of them can block. A single non-zero exit is enough. If Claude reports a blocked tool call you didn’t expect, check plugin hooks too.
  • They compose badly if they mutate the same file. Two PostToolUse formatters on the same path will fight.

Keeping hooks from being a nuisance

Practice Why
exit 0 on anything you do not explicitly handle A hook that fails by default blocks unrelated work
Write the reason to stderr before a blocking exit Claude reads it and can route around the block; a silent block just looks like a bug
Keep PreToolUse hooks under ~200ms They run on every matching call
Never let a hook rewrite a file it did not just receive Two hooks fighting over one path is very hard to diagnose
Check the hook in to .claude/hooks/ and commit it A hook referenced from committed settings but absent from the repo fails for everyone else

The prose-vs-hook decision

flowchart TD
    Q["I want Claude to always do X"] --> A{"Does a violation<br/>cause real damage?"}
    A -->|"No — it is a style preference"| P["CLAUDE.md or a rule"]
    A -->|"Yes"| B{"Can it be checked<br/>by a shell command?"}
    B -->|"Yes"| H["<b>Hook</b> — deterministic"]
    B -->|"No — needs judgement"| C{"Should it fail loudly?"}
    C -->|"Yes"| SA["Subagent with a<br/>restricted tool list"]
    C -->|"No"| P2["CLAUDE.md, and accept<br/>occasional misses"]

There is one more branch worth adding in practice: if the rule is simply “never touch these paths” with no conditional logic, permissions.deny in settings is simpler than a hook and needs no script to maintain.