·11 min read

Claude Code Hooks: Practical Examples That Enforce Rules

Learn Claude Code hooks with copy-paste examples: block dangerous commands, format on save, gate stops on passing tests, plus exit codes and debugging.

Claude Code hooks are shell commands that run automatically at fixed points in a session — before a tool call, after a file edit, when the agent tries to stop. They are the enforcement layer that prompts can't be: a rule in CLAUDE.md is advice that Claude usually follows, but a PreToolUse hook that exits with code 2 blocks the tool call every single time, no matter how long the session has run or how full the context window is.

This guide walks through the hook events Claude Code supports, copy-paste examples for the three most useful patterns (format on save, blocking dangerous commands, and a test gate on the stop hook), how exit codes and JSON output work, and how to debug hooks that silently refuse to fire.

Why Rules in Prompts Aren't Enough

Instructions in CLAUDE.md are probabilistic. Claude reads them at session start, weighs them against everything else in context, and follows them most of the time. That's fine for style preferences. It's not fine for constraints where a single miss is expensive: force-pushing to main, editing a lockfile by hand, committing a .env file, or declaring work finished while the test suite is red.

Hooks are deterministic. The Claude Code harness executes them at defined lifecycle points and acts on their exit codes directly — the model doesn't get a vote. That difference is why hooks are the right tool for the "never do X, always do Y" tier of your standards, while prompts and skills handle the "prefer X, here's how we do Y" tier. If you're building out that layered approach, our guide to enforcing coding standards with AI covers where each layer fits.

The same mechanism also lets you automate Claude Code workflow chores. Instead of reminding the agent to run the formatter or the linter, a hook just does it after every edit — silently, with no context spent.

Hook Events Explained: PreToolUse, PostToolUse, Stop, and More

Hooks live under the "hooks" key in your settings files: ~/.claude/settings.json (all your projects), .claude/settings.json (checked into the repo), or .claude/settings.local.json (personal, gitignored). Each event maps to a list of matchers, and each matcher holds the commands to run:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-command.sh"
          }
        ]
      }
    ]
  }
}

The matcher is a regex against the tool name — Bash, Edit|Write, or mcp__.* for MCP tools. Events that aren't tied to a tool (like Stop) omit the matcher. These are the events you'll actually use:

EventFiresCan it block?
PreToolUseBefore a tool call executesYes — deny the call, or auto-allow it
PostToolUseAfter a tool call succeedsNo, but it can feed feedback to Claude
UserPromptSubmitWhen you submit a promptYes — and it can inject extra context
Stop / SubagentStopWhen the agent tries to finishYes — exit 2 forces it to keep working
SessionStartNew session beginsNo — but stdout is added to context
NotificationClaude Code sends a notificationNo
PreCompactBefore context compactionNo

Every hook receives a JSON payload on stdin describing the event: session_id, cwd, hook_event_name, and for tool events, tool_name and tool_input (plus tool_response on PostToolUse). Your script parses that payload, decides, and responds through its exit code or JSON on stdout.

Claude Code Hooks Examples You Can Copy-Paste

The setup is the same for every example:

  1. Create a scripts directory in your repo: mkdir -p .claude/hooks
  2. Save the script there and make it executable: chmod +x .claude/hooks/<name>.sh
  3. Register it in .claude/settings.json under the right event and matcher.
  4. Start a new session (or review changes in the /hooks menu) so Claude Code picks it up.

All of these scripts assume jq is installed for JSON parsing.

Example 1: Format files after every edit

A PostToolUse hook on Edit|Write runs Prettier on whatever file Claude just touched. No context is wasted telling Claude to format; it just happens.

#!/bin/bash
# .claude/hooks/format.sh
file_path=$(jq -r '.tool_input.file_path // empty')

case "$file_path" in
  *.ts|*.tsx|*.js|*.jsx|*.json|*.css|*.md)
    npx prettier --write "$file_path" > /dev/null 2>&1
    ;;
esac
exit 0
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format.sh" }
        ]
      }
    ]
  }
}

Exit 0 means success and stays out of the way. Swap in ruff format, gofmt, or cargo fmt as needed.

Example 2: Block dangerous commands with a PreToolUse hook

A PreToolUse hook on Bash inspects every shell command before it runs. Exit code 2 blocks the call, and whatever you print to stderr is fed back to Claude as the reason — so it course-corrects instead of retrying blindly.

#!/bin/bash
# .claude/hooks/check-command.sh
cmd=$(jq -r '.tool_input.command // empty')

if echo "$cmd" | grep -qiE 'rm -rf /|git push (-f|--force)|drop table|git reset --hard origin'; then
  echo "Blocked by policy: this command is on the deny list. Ask a human to run it manually." >&2
  exit 2
fi
exit 0

This is the pattern that makes hooks worth learning: the check is a plain grep, the deny message goes straight into Claude's context, and there is no way for a confused model to talk its way past it.

Example 3: Protect sensitive files from edits

The same idea applied to file paths. Point a PreToolUse hook at Edit|Write and refuse edits to anything you'd rather review by hand:

#!/bin/bash
# .claude/hooks/protect-files.sh
file_path=$(jq -r '.tool_input.file_path // empty')

case "$file_path" in
  *.env*|*pnpm-lock.yaml|*package-lock.json|*/migrations/*)
    echo "Blocked: $file_path is protected. Edit it manually or update the allowlist in .claude/hooks/protect-files.sh." >&2
    exit 2
    ;;
esac
exit 0

Example 4: A test gate with the Stop hook

The stop hook fires when Claude believes it's done. Exit 2 sends it back to work with your stderr as instructions. This turns "please run the tests before finishing" into a hard gate:

#!/bin/bash
# .claude/hooks/test-gate.sh
input=$(cat)

# Guard against an infinite loop: if the agent is already continuing
# because of this hook, let it stop.
if [ "$(echo "$input" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0
fi

cd "$CLAUDE_PROJECT_DIR" || exit 0
if ! npm test --silent > /tmp/stop-gate.log 2>&1; then
  echo "The test suite is failing. Fix the failures before finishing. Last 20 lines:" >&2
  tail -20 /tmp/stop-gate.log >&2
  exit 2
fi
exit 0

The stop_hook_active check matters. Without it, a stubbornly broken test suite would bounce Claude between "try to stop" and "keep working" forever. With it, the gate fires once per natural stopping point.

Writing Hook Scripts: Exit Codes and JSON Output

Three exit codes cover almost everything:

Exit codeMeaningWhat happens
0SuccessExecution continues. stdout is shown in transcript mode; for UserPromptSubmit and SessionStart it's injected into context
2Blocking errorstderr is fed back to Claude. PreToolUse blocks the tool call; Stop forces the agent to continue
Anything elseNon-blocking errorstderr is shown to you, execution continues

When exit codes aren't expressive enough, print JSON to stdout instead. A PreToolUse hook can return a structured permission decision — including "allow", which skips the permission prompt entirely (useful for auto-approving safe, repetitive commands):

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Force pushes are not allowed on this repository."
  }
}

Stop and PostToolUse hooks use a simpler shape — {"decision": "block", "reason": "..."} — where the reason is handed to Claude as its next instruction. Two practical rules keep scripts robust: read stdin exactly once (capture it into a variable if you need multiple fields), and stay under the 60-second default timeout, which you can raise per-hook with a "timeout" field.

Hooks vs Skills vs Rules for Enforcement

These three layers get conflated constantly, but they solve different problems:

LayerEnforcementBest for
Rules (CLAUDE.md)Advisory — always in contextConventions, architecture context, style preferences
SkillsAdvisory — loaded on demandWorkflows, domain expertise, multi-step procedures
HooksDeterministic — harness-enforcedHard constraints and automatic chores

A useful heuristic: if violating the rule once is acceptable and recoverable, put it in a rule or a skill, where it costs nothing to maintain and Claude can apply judgment. If violating it once means an incident, a broken lockfile, or a leaked secret, make it a hook. And if it's not a rule at all but a repeatable action you trigger deliberately, that's a custom slash command.

The layers compose. A team we'd consider well set up typically has rules for context, a handful of skills for workflows, hooks for the deny list and the test gate, and shares all of it through version control rather than tribal knowledge. Skills on localskills.sh can be multi-file packages — docs, scripts, templates — so a published skill can carry your hook scripts and the settings snippet alongside the instructions, and one localskills install gives a teammate the whole setup.

Debugging Claude Code Hooks When They Don't Fire

When a hook doesn't run, it fails silently by design — Claude Code won't interrupt your session to complain. Work through this list:

  1. Hooks are snapshotted at session start. Editing settings mid-session does nothing until you review the change in the /hooks menu or start a new session. This is the single most common cause of "my hook doesn't fire."
  2. Run claude --debug. Debug output shows which hooks matched, what they were sent, and what they returned. It's the fastest way to distinguish "never fired" from "fired and exited 0."
  3. Check the matcher. Matchers are regexes against exact tool names: Edit|Write, not edit. MCP tools use the mcp__server__tool naming, so match them with patterns like mcp__github__.*.
  4. Test the script standalone. Hooks read JSON from stdin, so simulate the payload: echo '{"tool_input":{"command":"git push --force"}}' | .claude/hooks/check-command.sh; echo $? should print your stderr message and 2.
  5. Use absolute paths. Hooks don't necessarily run from your project root. Reference scripts via $CLAUDE_PROJECT_DIR and make sure they're executable.
  6. Check which settings file won. Hooks merge across user, project, and local settings; a local file is a common place for a stale copy to hide.

Frequently Asked Questions

Do Claude Code hooks work with MCP tools?

Yes. MCP tools show up with mcp__<server>__<tool> names, and PreToolUse/PostToolUse matchers treat them like any other tool. A matcher of mcp__.* covers every MCP tool, which is a reasonable default for logging or auditing what external servers are being asked to do.

What's the difference between a hook and a slash command?

Direction of control. A slash command is something you invoke deliberately; a hook is something the harness invokes automatically at lifecycle events. Use commands for repeatable actions, hooks for guarantees that hold whether or not anyone remembers them.

Can a Stop hook loop forever?

It can if you let it. The input payload includes stop_hook_active: true when Claude is already continuing because of a stop hook — check it and exit 0, as in the test-gate example above, so the gate can't trap the agent against an unfixable failure.

Are hooks safe to run?

Hooks execute arbitrary shell commands with your credentials, automatically. Treat a repo's .claude/settings.json like you'd treat its CI config: read the hook scripts before starting a session in a repository you don't trust, and keep your own scripts in version control where they get reviewed.

How do I share hooks across a team?

Commit .claude/settings.json and the .claude/hooks/ scripts to the repo — that covers one project. For enforcement that should follow your team across many repos and tools, package the scripts and settings snippet as a skill and distribute it through a registry, so updates ship as new versions instead of copy-paste.


If your team's rules live in prompts and hope, move the critical ones into hooks — and share the whole setup instead of re-building it per repo. Sign up for localskills.sh to publish your hook scripts, rules, and skills as versioned packages your whole team can install.

npm install -g @localskills/cli
localskills login
localskills install your-org/claude-code-hooks