Claude Code hooks — the complete guide to guardrails that actually work

Claude Code hooks — the complete guide to guardrails that actually work

PreToolUse, PostToolUse, Notification. What Claude Code hooks are, how to configure them in settings.json, and the three patterns I use in every production repo.

Jakub Kontra
Jakub Kontra
Developer

TL;DR

Claude Code hooks are shell commands that run automatically around tool calls — before a command executes, after a file is written, when the agent waits for input. They live in settings.json, they can block tool calls outright, and they're the one mechanism in the whole stack that the agent cannot bypass. If you're running Claude Code on anything production-adjacent and you haven't configured hooks yet, that's the single biggest reliability upgrade available to you.

This guide walks through the three hook types, the settings.json schema, the exit-code contract, and the three patterns I ship on every repo I touch.


Why hooks exist

Prompts are suggestions. Hooks are contracts.

You can put "always run pnpm format after editing code" in CLAUDE.md and the agent will comply most of the time. Most of the time isn't good enough when the cost of a slip is a broken main branch, a leaked secret, or a PR that silently bypasses your lint rules. Hooks close that gap — they shift behavior from model compliance to system enforcement.

I've watched teams spend weeks debating CLAUDE.md wording to nudge the model toward their coding conventions. A ten-line hook does the same job deterministically.

The three hook types

PreToolUse — runs before a tool executes

Gets the tool name and arguments. Can approve, deny, or let the call through with a warning. The single most important hook in your setup, because this is where you block the actions you actually care about.

Typical uses:

  • Block rm -rf on anything outside a sandbox dir
  • Block git push --force to main/master
  • Reject Bash calls that contain obvious secrets or credentials
  • Enforce that Write never touches migrations/ or src/auth/ without an explicit approval flag

PostToolUse — runs after a tool executes

Gets the tool name, arguments, and the result. Can't undo the call, but can format, validate, or log. Where "agent can't forget to format" actually lives.

Typical uses:

  • Auto-run prettier --write (or black, gofmt, rustfmt) on every Write
  • Bump a changelog entry after a package.json edit
  • Emit a structured log line for auditing
  • Trigger a fast lint check and report errors back to the agent

Notification — runs when the agent needs input

Gets the prompt the agent is waiting on. Ideal for "desktop notification when Claude is idle" and similar low-stakes automation.

Typical uses:

  • Ping macOS notification center so you come back to an agent that's been waiting two minutes
  • Post to a Slack channel if the agent asks for human review
  • Beep — yes, an actual terminal bell is still the fastest way to get your attention

settings.json schema

Hooks are configured in .claude/settings.json (project-scoped) or ~/.claude/settings.json (user-scoped). Project settings win.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/block-dangerous-bash.sh"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/format.sh"
          }
        ]
      }
    ]
  }
}

Two things worth knowing:

  • matcher is a regex on tool name. "Bash" matches only Bash. "Write|Edit" matches either. ".*" matches everything — useful, occasionally dangerous.
  • command receives the tool input as JSON on stdin. Your script parses it, decides what to do, exits.

The exit-code contract

This is the part that trips people up most often.

  • Exit 0 — approve (for PreToolUse) or no-op (for PostToolUse). The tool call proceeds.
  • Exit 2 — block. The tool call is denied. Your script's stderr is shown to the agent as the block reason. This is how you turn "please don't" into "system won't let you."
  • Any other non-zero — error. The tool call fails, but as an error rather than a deliberate block. Reserve this for actual hook failures.

Write clear stderr messages on exit 2. The agent reads them and adapts. "Blocked: force-push to main is not allowed; open a PR instead" teaches the agent what to do next. "Error" teaches it nothing.

The three patterns I ship on every repo

Pattern 1: block destructive Bash

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

input=$(cat)
command=$(echo "$input" | jq -r '.tool_input.command // ""')

if echo "$command" | grep -qE '(rm -rf /|git push.*--force.*main|git push.*-f.*main)'; then
  echo "Blocked: destructive command detected" >&2
  exit 2
fi

exit 0

Covers 90% of the "agent wrecks the repo" failure mode with ten lines. Expand the regex over time as new footguns appear.

Pattern 2: auto-format after writes

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

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

case "$file_path" in
  *.ts|*.tsx|*.js|*.jsx|*.json|*.md|*.mdx)
    pnpm prettier --write "$file_path" >/dev/null 2>&1 || true
    ;;
  *.py)
    ruff format "$file_path" >/dev/null 2>&1 || true
    ;;
  *.go)
    gofmt -w "$file_path" >/dev/null 2>&1 || true
    ;;
esac

exit 0

Notice the || true — a formatter failure shouldn't block the agent, it should just skip. The next lint run will surface real issues.

Pattern 3: require explicit approval for sensitive paths

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

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

case "$file_path" in
  *migrations/*|*src/auth/*|*.env*)
    if [ ! -f ".claude/allow-sensitive.flag" ]; then
      echo "Blocked: $file_path is sensitive. Create .claude/allow-sensitive.flag for this session if you really want to edit it." >&2
      exit 2
    fi
    ;;
esac

exit 0

The flag-file pattern gives you an explicit "I know what I'm doing" gate. The agent can't create the flag on its own — you do. After the session, you delete it. Paranoid, cheap, works.

Debugging hooks

Three things to check when a hook "doesn't work":

  1. Is settings.json valid JSON? Claude Code silently ignores broken config. Run jq . .claude/settings.json as a sanity check.
  2. Is the script executable? chmod +x .claude/hooks/*.sh. Missed this one more times than I want to admit.
  3. Is stdin being read? If your script hangs, it's probably waiting on stdin. Always input=$(cat) at the top.

Log verbosely while you develop:

echo "hook invoked: $(date) $0" >> .claude/hooks.log
echo "$input" >> .claude/hooks.log

Then delete the logging once the hook works.

Where hooks fit in the bigger picture

Hooks are one of six building blocks I cover in my extended post on AI adoption. CLAUDE.md gives the agent context, skills give it reusable recipes, hooks enforce invariants. Together they're the difference between an agent that occasionally surprises you and one that behaves like a reliable teammate.

If your team is rolling out Claude Code and this is the part that feels fuzzy, drop me a line — I run on-site trainings that walk through exactly this. me@jakubkontra.com