Claude Code Hooks: Block, Format, and Gate Tool Calls
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.
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.
Quick reference
- Handler
typecan becommand(a shell command),http(POSTs the event JSON to a URL),mcp_tool,prompt(a single model evaluation), oragent(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
iffield in permission-rule syntax, such asBash(git push *), to narrow a tool hook further than the matcher can. - Setting
"disableAllHooks": trueturns 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
SessionStartandUserPromptSubmit. - 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.
Quick reference
- When several PreToolUse hooks disagree, the precedence is deny, then defer, then ask, then allow.
updatedInputrewrites 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
timeoutin 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 0The 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 0Register 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}Quick reference
- Both scripts match text.
git pushthrough an alias, a script, or a Makefile target is not caught, and.envrcis blocked as a side effect of the.envpattern. 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 >> .envgoes through the Bash matcher; add aRead/Editdeny rule or the sandbox if the file must stay untouched. - Hooks can tighten but not loosen: an
allowfrom a hook does not override a deny rule in settings. - In plan mode, a hook's
allowon 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 -pruns, 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 0The 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.
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 statusfirst and skip when nothing changed. - Stop does not fire when you interrupt Claude, and API errors fire
StopFailureinstead. - Notification hooks with the
permission_promptoridle_promptmatcher 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 -pand SDK sessions never show the dialog and treat the folder as trusted. Hooks committed in a repository's.claude/settings.jsonrun 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_DIRfor 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. /hooksshowing nothing usually means invalid JSON (trailing commas, comments) or a settings file in the wrong place.- A missing
jqmakes 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.
| Need | Best tool | Why | Where it breaks |
|---|---|---|---|
Never read .env | Deny rule | Checked before every Read; no code to maintain | Bash commands such as cat .env need their own rule or the sandbox |
| Block pushes to main | PreToolUse hook | Can inspect the branch, not just the text | Commands that push indirectly (scripts, aliases) |
| Format every edited file | PostToolUse hook | Runs every time, no reminder needed | Two formatters on one file can conflict |
| Tests must pass before stopping | Stop hook | Sends Claude back with the failure output | Slow suites on every reply; loops without stop_hook_active |
| Prefer pnpm, name branches feat/* | CLAUDE.md | Guidance the model applies with judgment | Not 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
promptoragenthook 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, notbash) and that the script is executable. - If the push goes through, run
claude --debugand 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.
References
- Claude Code docs: Hooks reference — checked Oct 17, 2026
- Claude Code docs: Automate actions with hooks — checked Oct 17, 2026
- Claude Code docs: Configure permissions — checked Oct 17, 2026
Shoaib Hossain
Lead Author & Systems ArchitectSoftware engineer and distributed systems architect specializing in backend scalability, cloud-native infrastructure, databases, and AI engineering workflows. Author and maintainer of Core Concept Learning.
Related Articles
Explore this topic