AI and Automation

Claude Code hooks: PreToolUse, PostToolUse, and patterns that scale

How to wire Claude Code hooks for security gates, format-on-save, and team-wide guardrails without slowing the agent down.

May 3, 20269 min read
Claude Code hooks: PreToolUse, PostToolUse, and patterns that scale

A Claude Code hook is a shell command that fires at a fixed point in the agent's lifecycle, receives a JSON event on stdin, and decides via exit code whether the next step proceeds. Hooks are the only mechanism Claude Code exposes for deterministic, code-level control over an agent: prompts can be ignored, system instructions can be overridden by context, but a PreToolUse hook that exits 2 always cancels the tool call.

Twelve lifecycle events are available in the current release, including PreToolUse, PostToolUse, UserPromptSubmit, Stop, SubagentStop, SessionStart, SessionEnd, and Notification. They live in settings.json, run as plain processes, and ship with the rest of the repo. This is how a team turns Claude Code from a clever individual tool into infrastructure other engineers can rely on.

What you will have at the end

Three production-grade hooks committed to .claude/settings.json in your repo:

  • A PreToolUse guard that blocks dangerous shell commands before they run.
  • A PostToolUse formatter that runs Prettier or Ruff after every file edit.
  • A Stop hook that pings the session log when Claude finishes a turn.

Plus the mental model to add the next nine without breaking the agent.

What you need before starting

  • Claude Code v2.0.10 or later (older versions cannot mutate tool input from a PreToolUse hook).
  • A repo with .claude/ at the root.
  • Bash or Python on PATH. We use Bash for short hooks and Python when the JSON parsing gets non-trivial.
  • A formatter installed locally (Prettier, Ruff, gofmt, anything that exits 0 on success).

Step 1 · Understand the three hook scopes

Settings load in this order, with the later ones overriding earlier ones for the same matcher:

  1. ~/.claude/settings.json: user-wide. Lives in your home dir, applies to every project. Good for personal preferences (notification sounds, log paths).
  2. .claude/settings.json in the repo root: project-wide. Committed to git. This is where team guardrails live.
  3. .claude/settings.local.json: per-developer override, gitignored. Use sparingly, mostly for local debug.

The official reference at code.claude.com/docs/en/hooks is the source of truth for schema. We refer to it whenever a hook misbehaves; the docs version moves faster than third-party guides.

Step 2 · Wire a PreToolUse guard for Bash

The single highest-leverage hook in any team setup. PreToolUse fires before a tool runs, sees the tool name and the JSON arguments on stdin, and can exit 2 to block. Anthropic ships a reference implementation at examples/hooks/bash_command_validator_example.py in the public Claude Code repo. We copied it, trimmed it, and dropped this version in .claude/hooks/bash-guard.sh:

#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // ""')

if echo "$CMD" | grep -qE '\brm\s+-rf\s+/'; then
  echo "Blocked: rm -rf / refused by PreToolUse guard." >&2
  exit 2
fi

if echo "$CMD" | grep -qE '\b(curl|wget)\b.*\|.*\bsh\b'; then
  echo "Blocked: piping remote content to a shell is refused." >&2
  exit 2
fi

if echo "$CMD" | grep -qE '\.env(\s|$|:)'; then
  echo "Blocked: .env access. Add the var to settings.local.json instead." >&2
  exit 2
fi

exit 0

The matching block in .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/bash-guard.sh" }
        ]
      }
    ]
  }
}

Three things to internalize about exit codes:

  • Exit 0: the tool proceeds. Anything you wrote to stdout is shown in the transcript and may be parsed as JSON for advanced control.
  • Exit 2: the tool is cancelled. Stderr text is fed back to Claude as a tool-result error, so the model can self-correct on the next turn.
  • Exit 1 (or any other non-zero): a non-blocking hook error. The tool still proceeds. Reserve this for hook-internal failures you want logged but not enforced.

This distinction is the most common source of broken hooks we see: a team intends to block, exits 1, and the dangerous command runs anyway. Every security hook must use exit 2.

Step 3 · Wire a PostToolUse formatter

PostToolUse fires after a tool succeeds. It cannot undo the action, but it can run a follow-up command and feed its output back to Claude. The canonical use is auto-format after every Write or Edit, so the agent never commits unformatted code:

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

The script extracts the file path from the event, branches on extension, and runs the right formatter:

#!/usr/bin/env bash
set -euo pipefail
FILE=$(jq -r '.tool_input.file_path // .tool_input.path // ""')
[ -z "$FILE" ] && exit 0

case "$FILE" in
  *.ts|*.tsx|*.js|*.jsx|*.json|*.md) npx --no -y prettier --write "$FILE" ;;
  *.py) ruff format "$FILE" ;;
  *.go)  gofmt -w "$FILE" ;;
esac

exit 0

Two field-tested rules: keep the formatter idempotent and silent on success, and never let it exit 2. A non-zero exit on PostToolUse pushes a noisy error back to Claude that often triggers a redundant retry.

Step 4 · Wire a Stop hook for session telemetry

Stop fires when the main agent finishes a reply (not on user interrupt). We use it to stamp a one-line entry in a daily session log, so the team can audit which features Claude touched without trawling the transcript:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": ".claude/hooks/log-stop.sh" }
        ]
      }
    ]
  }
}

The Stop event also accepts a {"decision": "block", "reason": "..."} JSON response on stdout (with exit 0) to refuse the stop and force Claude to keep working. We have used it twice in production: once to enforce a test run before turn end, once to require a commit message draft. Useful, but easy to abuse, since a Stop block burns context. Default to logging, escalate to blocking only when the cost of stopping early is real.

Step 5 · Verifying it works

Hooks fail silently more often than they fail loudly. Three checks before you trust them in production:

  1. Run claude --debug in the project. Every hook invocation prints to stderr with timing and exit code. If your hook is missing, the matcher is wrong.
  2. Trigger the matcher manually. Ask Claude to run rm -rf /tmp/test-dir and confirm it blocks. Ask it to write a TS file and confirm the formatter ran (check git diff).
  3. Time the hooks. Anything over 200ms on PreToolUse compounds across a session. We profile with time and put anything heavier than that on PostToolUse where the latency is hidden.

Common failures and fixes

The hook runs but does not block

You exited 1 instead of 2. Or you wrote JSON with "decision": "block" but exited 2; in that case the JSON is ignored and only stderr is read. Pick one path: exit 2 with a stderr message, or exit 0 with a JSON decision block on stdout.

The hook never runs

Matcher mismatch. Built-in tools are Bash, Edit, Write, MultiEdit, Read, Grep, Glob, WebSearch, WebFetch, Task. MCP tools follow mcp__<server>__<tool>. To match all MCP write tools across servers, use mcp__.*__write.*.

The hook hangs

You forgot to read stdin. Even if you do not need the JSON, consume it: cat > /dev/null at the top of a Bash hook is enough. Otherwise the agent waits on the pipe.

The formatter runs on files outside the repo

Always check the path before formatting. Claude can edit files anywhere it has filesystem access, including ~/Library on macOS, and Prettier on a system file is a bad day. Guard with a prefix check: [[ "$FILE" == "$PWD"* ]] || exit 0.

Hook output appears as a Claude message

For most events, stdout is debug-only. For UserPromptSubmit, UserPromptExpansion, and SessionStart, stdout is added as context that Claude sees. If you want a hook to inject context (like recent git log on session start), pick those events. If you want silent enforcement, every other event is silent on stdout by design.

The patterns that scale to a team

The three hooks above are starting points. The pattern that distinguishes a personal setup from a team setup is where the hooks live and how they evolve.

Commit hooks to the repo, not the home dir

Personal hooks in ~/.claude/settings.json work for one person. Team hooks belong in .claude/settings.json at the repo root, committed and reviewed in PRs like any other code. New hires inherit the guardrails on day one.

Keep hooks short and shell out for logic

The command field should call a script, not contain logic. Inline commands are unreadable in JSON, hard to test, and impossible to grep. Every hook in our setup is a one-line call to a script in .claude/hooks/.

Use prompt-type hooks sparingly

Claude Code now supports a "type": "prompt" handler that sends the event to a Claude model for evaluation instead of a script. It works, but it adds latency and cost to every tool call. Reserve for cases where rule-based matching cannot capture intent (for example, classifying whether a Bash command is destructive based on context). For everything else, exit codes from a 20-line shell script are faster, cheaper, and auditable.

Version your hooks like dependencies

When the Claude Code release notes change hook semantics (and they have, three times since the v1.0 launch), your hooks need a regression pass. We keep a .claude/hooks/test.sh that fires fixture events at every script and asserts exit codes. Five minutes to write, saves hours when v2.x ships a breaking change.

Audit what hooks see

Hooks receive the full tool input, including secrets that may pass through Bash commands. Treat hook scripts with the same scrutiny as any other code that touches credentials. The model's output may be sandboxed; your hook is not.

Done well, hooks are the layer where Claude Code stops being a productivity tool and starts being part of the build system. The first three above pay for themselves in a week. The next nine are how you turn an agent into infrastructure.

Sources

Photo by Hans Westbeek on Unsplash

Frequently asked questions

How do Claude Code hooks differ from MCP servers?
Hooks fire deterministically on lifecycle events (before a tool runs, after a tool runs, when a session starts) and decide via exit code whether the next step proceeds. MCP servers expose new tools and resources to the agent. They solve different problems: MCP extends what Claude can do, hooks constrain how Claude does it. Most production setups use both.
What is the latency cost of a PreToolUse hook?
The hook runs synchronously before the tool, so its wall time is added to every matched call. A 50ms shell script is invisible; a 500ms script is felt across a multi-tool session. We profile every PreToolUse hook with the time command and aim for under 200ms. Anything heavier moves to PostToolUse, where the latency is hidden behind the tool result.
Can a hook see the model's reasoning or only the tool call?
Only the tool call. Hooks receive the tool name and the JSON arguments Claude is about to pass. They do not see the model's chain of thought, the system prompt, or earlier turns. If you need semantic understanding of intent, that is what the prompt-type hook handler is for, but it adds API cost on every invocation.

Studio

Start a project.

One partner for companies, public sector, startups and SaaS. Faster delivery, modern tech, lower costs. One team, one invoice.