Skip to content

Claude Code Hooks: Block, Format, and Gate Tool Calls

Core Concept LearningAugust 18, 202613 min readUpdated October 17, 2026

Claude Code follows your CLAUDE.md most of the time. A hook makes something happen every time. Hooks are shell commands (or HTTP calls, prompts, or MCP tool calls) that Claude Code runs at fixed points in a session: before a tool call, after it, when Claude finishes a reply, when a session starts. Because they run outside the model, they give you deterministic control: the formatter always runs after an edit, and a push to main is always refused, whatever the model decides.

This guide follows the official hooks reference. It covers the event list, the three-level configuration shape, the JSON a hook receives on stdin, what exit codes and JSON output mean, and four working hooks: blocking pushes to main, protecting files, formatting after edits, and a Stop hook that sends Claude back when tests fail. It ends with the security model, which matters more than any example, because a hook runs with your full user permissions.

Hooks sit next to permission rules, which are covered in Claude Code Permissions, and permission modes, covered in Claude Code Auto Mode. For where hooks fit in the agent loop, see How Claude Code Works.

Hook events grouped by when they fire: before a tool runs, after it runs, and at session lifecycle points
Hook events grouped by when they fire: before a tool runs, after it runs, and at session lifecycle points

What hooks are and where they live

A hook is a handler attached to an event. Claude Code fires events at points in its lifecycle, and every handler registered for that event (and whose matcher fits) runs. The most used events are:

  • PreToolUse: before a tool call runs. It can block the call, ask the user, or rewrite the call's input.
  • PostToolUse / PostToolUseFailure: after a tool call succeeds or fails. The tool has already run.
  • UserPromptSubmit: when you submit a prompt, before Claude sees it. It can block the prompt or add context.
  • Stop: when Claude finishes a reply. It can send Claude back to keep working.
  • SessionStart: on startup, resume, /clear, or after compaction. Its plain stdout is added to Claude's context.
  • Notification: when Claude Code would notify you, for example when it needs a permission decision or has been idle.

The reference lists many more, including PermissionRequest, PreCompact, SubagentStop, ConfigChange, FileChanged, and SessionEnd.

Configuration nests three levels: the event name, a list of matcher groups, and for each group a list of handlers. For tool events the matcher is the tool name: Bash, Edit|Write, or a regular expression such as mcp__.*. Matchers are case-sensitive.

1{2  "hooks": {3    "PostToolUse": [4      {5        "matcher": "Edit|Write",6        "hooks": [7          { "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }8        ]9      }10    ]11  }12}

Hooks can live in ~/.claude/settings.json (all your projects), .claude/settings.json (committed, shared with the team), .claude/settings.local.json (yours, not committed), managed policy settings, a plugin's hooks/hooks.json, or the frontmatter of a skill or subagent. Entries from different files are merged, not replaced. Run /hooks to browse every configured hook and see which file it came from.

Hook configuration nests three levels: event, matcher group, and handler
Hook configuration nests three levels: event, matcher group, and handler

Quick reference

  • Handler type can be command (a shell command), http (POSTs the event JSON to a URL), mcp_tool, prompt (a single model evaluation), or agent (experimental; a subagent that can use tools).
  • All handlers that match an event run in parallel. If the same handler appears in several settings files, it runs once.
  • A handler can also take an if field in permission-rule syntax, such as Bash(git push *), to narrow a tool hook further than the matcher can.
  • Setting "disableAllHooks": true turns hooks off without deleting them. There is no per-hook switch; remove the entry instead.

Remember this

A hook is event + matcher + handler. /hooks shows what is actually configured and where it came from.

The contract: JSON in, exit code and JSON out

A command hook receives one JSON object on stdin. Every event includes common fields such as session_id, transcript_path, cwd, permission_mode, and hook_event_name. Tool events add tool_name, tool_input, and tool_use_id; PostToolUse also adds tool_response. For a Bash call, tool_input.command holds the command text. For Edit and Write, tool_input.file_path holds the target file.

The hook answers through its exit code and, optionally, JSON on stdout:

  • Exit 0: success. If stdout is a JSON object, Claude Code parses it for decisions. Plain-text stdout is added to Claude's context only for a few events, such as SessionStart and UserPromptSubmit.
  • Exit 2: a blocking error. On events that can block, the action stops and stderr becomes the reason. For PreToolUse, Claude reads that reason and can change course.
  • Any other code: a non-blocking error. The action continues and a hook-error notice appears in the transcript.

For finer control, exit 0 and print JSON. A PreToolUse hook sets hookSpecificOutput.permissionDecision to allow, deny, ask, or defer, with a permissionDecisionReason. Build that JSON with jq rather than string concatenation, so quotes in the reason do not break it:

1jq -n --arg reason "Edits to migrations/ need a human." '{2  hookSpecificOutput: {3    hookEventName: "PreToolUse",4    permissionDecision: "deny",5    permissionDecisionReason: $reason6  }7}'

Pick one style per hook. Exit 2 blocks even if the same hook also printed JSON that allows the action.

What a command hook's exit code means: 0 continues, 2 blocks with stderr as the reason, anything else is a non-blocking error
What a command hook's exit code means: 0 continues, 2 blocks with stderr as the reason, anything else is a non-blocking error

Quick reference

  • When several PreToolUse hooks disagree, the precedence is deny, then defer, then ask, then allow.
  • updatedInput rewrites a tool's arguments before it runs. It replaces the whole input object, so copy the unchanged fields too.
  • Default timeouts: 600 seconds for command, http, and mcp_tool handlers; 30 for prompt; 60 for agent. Set timeout in seconds to change it.
  • A crashing script exits non-zero but not 2, so the action goes ahead. If a check must fail closed, make the script exit 2 on its own errors.

Remember this

Exit 2 means block, stderr is the reason. Anything else non-zero is just an error, and the action goes ahead.

PreToolUse: block a push to main and protect files

PreToolUse is the event for policy. It fires before the permission-mode check, in every mode, including bypassPermissions and --dangerously-skip-permissions. A hook that denies or exits 2 blocks the call in all of them, which is why teams use hooks for rules that must not depend on which mode someone picked.

This script refuses pushes that target main, or that run while main is checked out:

1#!/bin/bash2# .claude/hooks/block-main-push.sh3CMD=$(jq -r '.tool_input.command // empty')4 5case "$CMD" in6  *git*push*)7    BRANCH=$(git -C "$CLAUDE_PROJECT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null)8    if [[ "$CMD" == *" main"* || "$CMD" == *":main"* || "$BRANCH" == "main" ]]; then9      echo "Pushing to main is blocked. Push a feature branch and open a pull request." >&210      exit 211    fi12    ;;13esac14exit 0

The second guard comes from the official guide: it blocks edits to .env, package-lock.json, and anything under .git/.

1#!/bin/bash2# .claude/hooks/protect-files.sh3FILE_PATH=$(jq -r '.tool_input.file_path // empty')4for pattern in ".env" "package-lock.json" ".git/"; do5  if [[ "$FILE_PATH" == *"$pattern"* ]]; then6    echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&27    exit 28  fi9done10exit 0

Register both, make them executable with chmod +x, and quote the project-dir variable:

1{2  "hooks": {3    "PreToolUse": [4      {5        "matcher": "Bash",6        "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-main-push.sh" }]7      },8      {9        "matcher": "Edit|Write",10        "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh" }]11      }12    ]13  }14}
A PreToolUse hook runs before the permission-mode check, so its deny holds even in bypassPermissions mode
A PreToolUse hook runs before the permission-mode check, so its deny holds even in bypassPermissions mode

Quick reference

  • Both scripts match text. git push through an alias, a script, or a Makefile target is not caught, and .envrc is blocked as a side effect of the .env pattern. Treat them as guardrails against mistakes, not as a security boundary.
  • The protect-files hook covers Edit and Write only. A Bash command such as echo x >> .env goes through the Bash matcher; add a Read/Edit deny rule or the sandbox if the file must stay untouched.
  • Hooks can tighten but not loosen: an allow from a hook does not override a deny rule in settings.
  • In plan mode, a hook's allow on a file edit gets the same prompt or block the edit would get with no hook.

Remember this

A PreToolUse deny holds in every permission mode, but a text-matching script only catches the commands it recognises.

PostToolUse: format, check, and report after the fact

PostToolUse fires after a tool succeeds. The tool has already run, so the hook cannot undo anything. It is the place for work that should follow every edit: formatting, a quick lint, a log line.

The formatter hook from the earlier example runs Prettier on each file Claude edits. With Prettier's defaults, ask Claude to add a line with single-quoted strings to a JavaScript file, and the file ends up with double quotes, without Claude being asked.

A PostToolUse hook can also feed information back. Exit 2 shows stderr to Claude, and the JSON field decision: "block" with a reason adds that reason next to the tool result. Both tell Claude something went wrong after the fact, for example that the file it just wrote fails type checking.

Slow work, such as a test suite, should not hold up every edit. Add "async": true to a command hook and it runs in the background. When it finishes, its additionalContext and systemMessage reach Claude on the next turn:

1{2  "hooks": {3    "PostToolUse": [4      {5        "matcher": "Write",6        "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/run-tests.sh", "async": true }]7      }8    ]9  }10}

Async hooks cannot block anything. decision, permissionDecision, and continue have no effect, because the action they would control has already finished.

Quick reference

  • If two hooks rewrite the same file after an edit (say Prettier and ESLint --fix), they run in parallel and can conflict. Chain them in one script instead.
  • In claude -p runs, async hooks still running at teardown are killed. Work that must outlive the session needs a detached process.
  • The formatter hook passes every edited path to Prettier, including files it cannot parse. Prettier then exits non-zero, which is a non-blocking error: the edit stands and a hook-error notice appears. Filter by file extension in the script.

Remember this

PostToolUse is for follow-up work. Use async for anything slow, and accept that it can report problems but never prevent them.

Stop and SessionStart: quality gates and lost context

A Stop hook runs whenever Claude finishes a reply. If it exits 2, Claude keeps working and reads stderr as the reason. That turns "make sure tests pass" from a request into a gate:

1#!/bin/bash2# .claude/hooks/tests-must-pass.sh3INPUT=$(cat)4if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then5  exit 0   # already sent back once; let Claude stop6fi7 8cd "$CLAUDE_PROJECT_DIR" || exit 09LOG=$(mktemp)10if ! npm test --silent >"$LOG" 2>&1; then11  echo "Tests are failing. Fix them before finishing:" >&212  tail -n 30 "$LOG" >&213  exit 214fi15exit 0

The stop_hook_active check matters. It is true when Claude is already continuing because of a Stop hook, and without it a test that can never pass sends Claude around in circles. Claude Code also has its own limit: after eight consecutive blocks with no tool call in between, it overrides the hook and ends the turn. You can raise the cap with CLAUDE_CODE_STOP_HOOK_BLOCK_CAP.

SessionStart with the compact matcher solves a different problem. Compaction summarises the conversation and can drop details. Plain stdout from a SessionStart hook is added to Claude's context, so you can restore the essentials after every compaction:

1{2  "hooks": {3    "SessionStart": [4      {5        "matcher": "compact",6        "hooks": [{ "type": "command", "command": "echo 'Use pnpm, not npm. Current task: auth refactor.'; git log --oneline -5" }]7      }8    ]9  }10}

For instructions you want in every session, CLAUDE.md is simpler; this hook is for re-adding what compaction loses. See Claude Code Context Window for how compaction works.

A Stop hook that runs tests can send Claude back to work; stop_hook_active prevents an endless loop
A Stop hook that runs tests can send Claude back to work; stop_hook_active prevents an endless loop

Quick reference

  • Stop fires at the end of every reply, including a one-line answer to a question. A full test suite on each of those wastes minutes; check git status first and skip when nothing changed.
  • Stop does not fire when you interrupt Claude, and API errors fire StopFailure instead.
  • Notification hooks with the permission_prompt or idle_prompt matcher are a simple way to get a desktop or chat alert when Claude is waiting for you. They cannot block anything.

Remember this

A Stop hook turns 'tests must pass' into a gate. Always check stop_hook_active, or a failing test becomes a loop.

Security model and debugging

Command hooks run with your full user permissions. They can read, change, or delete anything your account can. That cuts both ways: it is what makes them useful, and it is why a hook from someone else's repository deserves the same review as any script you are about to run.

The workspace-trust rules decide when repository hooks run:

  • Interactive sessions hold back hooks from every settings file, including your own, until you accept the workspace trust dialog for the folder.
  • claude -p and SDK sessions never show the dialog and treat the folder as trusted. Hooks committed in a repository's .claude/settings.json run even if you have never opened the folder before.

Before scripting claude -p over a repository you did not write, read its .claude/ settings, or run with --settings '{"disableAllHooks": true}'. Organisations can set allowManagedHooksOnly in managed settings so that only admin-approved hooks run.

When a hook misbehaves, test it outside Claude Code first by piping sample input into it:

1echo '{"tool_name":"Bash","tool_input":{"command":"git push origin main"}}' \2  | CLAUDE_PROJECT_DIR=$PWD .claude/hooks/block-main-push.sh3echo "exit: $?"

Then check the debug log. Start Claude Code with claude --debug and read ~/.claude/debug/<session-id>.txt, or choose the location with --debug-file <path>. Each command hook leaves a line with its event, command, exit status, and duration.

Quick reference

  • Quote every shell variable ("$FILE_PATH"), use absolute paths or $CLAUDE_PROJECT_DIR for scripts, and reject .. in paths you act on.
  • If a hook's JSON is ignored, check whether your shell profile prints text on startup. Output that does not start with { is treated as plain text.
  • /hooks showing nothing usually means invalid JSON (trailing commas, comments) or a settings file in the wrong place.
  • A missing jq makes every example here fail with a non-zero exit that is not 2, so the action goes ahead. Install it, or write hooks in Python or Node.

Remember this

Treat a repository's hooks like its build scripts: review them before you let them run, especially under claude -p.

Hook, permission rule, or CLAUDE.md?

Use the simplest mechanism that holds. Permission rules are declarative and need no code; use them for fixed lists of allowed or forbidden tools, paths, and commands. Hooks are for checks that need logic, such as reading the current branch, parsing a file, or calling a service, and for actions that must always follow an event. CLAUDE.md is for conventions where judgment is fine and an occasional miss costs little.

They also combine. A deny rule on Read(./.env) plus a PreToolUse hook on Edit|Write plus the sandbox covers far more paths to a secret than any one of them alone.

Three ways to shape what Claude Code does, and where each one breaks
NeedBest toolWhyWhere it breaks
Never read .envDeny ruleChecked before every Read; no code to maintainBash commands such as cat .env need their own rule or the sandbox
Block pushes to mainPreToolUse hookCan inspect the branch, not just the textCommands that push indirectly (scripts, aliases)
Format every edited filePostToolUse hookRuns every time, no reminder neededTwo formatters on one file can conflict
Tests must pass before stoppingStop hookSends Claude back with the failure outputSlow suites on every reply; loops without stop_hook_active
Prefer pnpm, name branches feat/*CLAUDE.mdGuidance the model applies with judgmentNot enforced; can be ignored or lost in long sessions

Quick reference

  • If a rule can express it, prefer the rule: it has no script to break and shows up in /permissions.
  • If it must happen every time, it belongs in a hook, not in CLAUDE.md.
  • If it needs judgment ("is this migration safe?"), a prompt or agent hook can ask a model, but it is slower and not deterministic.

Remember this

Rules for fixed lists, hooks for logic and guaranteed side effects, CLAUDE.md for preferences.

Hands-on: add two hooks and prove they work

In a scratch Git repository with a package.json and a test script:

1. Add block-main-push.sh and protect-files.sh from the PreToolUse section, run chmod +x on both, and register them in .claude/settings.json. 2. Pipe sample JSON into each script, as in the debugging section, and confirm exit: 2 for a push to main and for an edit to .env, and exit: 0 for git status. 3. Start Claude Code, run /hooks, and confirm both appear under PreToolUse from the project settings file. 4. Ask Claude to add a comment to .env, then to commit and push to main. 5. Add the Stop hook, break one test on purpose, and ask Claude to "rename a variable and finish".

Pass criterion: the .env edit and the push are both blocked with your messages shown; Claude offers a feature branch instead of pushing; the Stop hook sends Claude back once with the failing test output, and the turn ends after the fix or after the second stop.

Quick reference

  • If a hook never fires, check the matcher's case (Bash, not bash) and that the script is executable.
  • If the push goes through, run claude --debug and look for the hook's line in the debug log to see its exit status.
  • Switch to bypassPermissions mode and repeat step 4: the push should still be blocked, because PreToolUse runs before the mode check.

Remember this

Test every hook with an input you expect it to block, both from the shell and inside a session.

Key takeaway

Hooks give Claude Code deterministic behaviour at fixed points: before a tool runs, after it runs, when Claude stops, and when a session starts. A command hook reads JSON on stdin and answers with an exit code (2 blocks, with stderr as the reason) or with JSON that can deny, ask, rewrite input, or add context. PreToolUse denials hold in every permission mode, PostToolUse is for follow-up work that cannot undo anything, and Stop hooks can turn passing tests into a gate as long as they check stop_hook_active.

The limits matter as much as the features. Text-matching scripts catch only what they recognise, a crashing hook does not block, and hooks run with your full permissions, including hooks committed to repositories you run claude -p over. For the rule system that sits beside hooks, see Claude Code Permissions; for keeping settings and hooks organised across a team, see Claude Code Workflow.

Practice (30 min): complete the five steps above, then write one PreToolUse hook for a rule your team keeps forgetting, and test it with an input that should be blocked.

Share:

References

  1. Claude Code docs: Hooks reference — checked Oct 17, 2026
  2. Claude Code docs: Automate actions with hooks — checked Oct 17, 2026
  3. Claude Code docs: Configure permissions — checked Oct 17, 2026
SH

Shoaib Hossain

Lead Author & Systems Architect

Software engineer and distributed systems architect specializing in backend scalability, cloud-native infrastructure, databases, and AI engineering workflows. Author and maintainer of Core Concept Learning.

Human-Engineered & Fact-CheckedOriginal Visual DiagramsEditorial Standards →Send Feedback

Related Articles

Mention @Claude with a coding task in a Slack channel and Claude can start a Claude Code cloud session, work on one of y…

Read

Claude Code reads configuration from a small set of files in your project and in ~/.claude, and writes session data to ~…

Read

Claude Code has a dense vocabulary because it is more than a chat box beside your editor. The important terms describe w…

Read

Keep learning

Follow a structured path or browse all courses to go deeper.