Hooks
A hook is a shell command, HTTP endpoint, MCP tool call, or model prompt that Claude Code runs at a fixed point in the session lifecycle, with the power to block the action that triggered it.
What it is
Hooks are configured in JSON settings files with three levels of nesting: the event, a matcher group that filters when it fires, and one or more handlers that run when it matches. Claude Code sends the handler a JSON payload — on stdin for a command hook, as the POST body for an HTTP hook — and reads the result back from the exit code and stdout.
The events fall into three cadences. SessionStart and SessionEnd fire once per session. UserPromptSubmit, Stop, and StopFailure fire once per turn. PreToolUse and PostToolUse fire on every tool call inside the agentic loop, with EndConversation the one exception that skips both. Beyond those, Claude Code fires PermissionRequest, PermissionDenied, PostToolUseFailure, PostToolBatch, SubagentStart, SubagentStop, PreCompact, PostCompact, Notification, MessageDisplay, TaskCreated, TaskCompleted, TeammateIdle, InstructionsLoaded, ConfigChange, CwdChanged, DirectoryAdded, FileChanged, WorktreeCreate, WorktreeRemove, Elicitation, ElicitationResult, and Setup.
This is the enforcement layer. CLAUDE.md asks Claude to do something and Claude usually complies; a hook is code Claude Code executes whether or not Claude agrees. Hooks also run inside subagents, and the payload carries agent_id and agent_type so a handler can tell the difference.
What it does for you
- It makes a rule hold. "Always run the formatter after editing" is a coin flip as an instruction and a certainty as a
PostToolUsehook onEdit|Write. - It blocks actions before they happen. A
PreToolUsehook that exits 2 stops the tool call and hands your stderr text to Claude as the reason, which takes precedence even over an allow rule. - It gives you an audit trail. A hook on
Bashappends every command to a log file, independent of what the transcript shows or what Claude reports.
How it works
01An event fires and matchers are evaluated
The
matcherfilters on a different field per event: tool name for tool events, start reason forSessionStart, agent type forSubagentStart. A matcher of only letters, digits,_,-, spaces,,, and|is an exact string or a list; anything else is an unanchored JavaScript regular expression.02The optional if condition narrows further
On tool events,
ifuses permission rule syntax against the tool name and arguments together, so"Bash(git *)"or"Edit(*.ts)"decides whether the handler process is spawned at all. Eachifholds exactly one rule — no&&or||.03All matching handlers run in parallel
Command handlers get the event JSON on stdin and run in the current directory with Claude Code’s environment. Defaults are 600 seconds for command, HTTP, and MCP tool hooks, 30 for prompt hooks, and 60 for agent hooks.
04Exit 0 means success
Stdout is parsed for JSON output fields. For most events stdout goes only to the debug log;
UserPromptSubmit,UserPromptExpansion, andSessionStartare the exceptions, where stdout becomes context Claude can act on.05Exit 2 means block
Stdout is ignored and stderr is fed to Claude as the reason.
PreToolUseblocks the tool call,UserPromptSubmiterases the prompt,Stopprevents Claude from stopping,PreCompactblocks compaction.PostToolUsecannot block — the tool already ran — but Claude does see the stderr.06Any other exit code is a non-blocking error
Exit 1 does not block. The action proceeds and the transcript shows a hook error notice.
WorktreeCreateis the one exception, where any non-zero code aborts creation.
How to implement it
01Pick the event and the narrowest matcher
Decide whether you need to block something before it happens (
PreToolUse) or react to something that already did (PostToolUse), then setmatcherto the exact tool names involved.02Write the handler script and make it executable
Put it in
.claude/hooks/, read the JSON from stdin, andchmod +xit. Reference it as${CLAUDE_PROJECT_DIR}/.claude/hooks/<name>.shso it resolves regardless of the working directory.03Return the right exit code
Exit 0 for "no opinion", exit 2 with a message on stderr for "stop". Do not use exit 1 to enforce a policy — it is treated as a non-blocking error.
04Add an if condition to avoid spawning processes you do not need
A hook matched on
Bashruns on every shell command. Adding"if": "Bash(rm *)"means the script only spawns for the calls it cares about.05Register it and confirm it fires
Add the block to
.claude/settings.json, then run/hooksto see the configuration and trigger the event to confirm. Settings files reload without a restart.
Examples
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format.sh",
"statusMessage": "Formatting",
"timeout": 60
}
]
}
],
"PreToolUse": [
{
"matcher": "Edit|Write|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-paths.sh"
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -r '\"[\" + (now | todate) + \"] \" + .tool_input.command' >> \"$HOME/.claude/bash-audit.log\""
}
]
}
]
}
}#!/bin/bash
# Refuse edits to generated and vendored files.
# Registered on PreToolUse for Edit|Write|NotebookEdit.
set -euo pipefail
payload=$(cat)
path=$(jq -r '.tool_input.file_path // .tool_input.notebook_path // empty' <<<"$payload")
[[ -z "$path" ]] && exit 0
case "$path" in
*/db/schema.rb|*/package-lock.json|*/vendor/*|*.generated.ts)
echo "Refusing to edit $path: it is generated or vendored. Change the source and regenerate." >&2
exit 2
;;
esac
exit 0#!/bin/bash
# Format the file Claude just wrote. Registered on PostToolUse for Edit|Write.
set -euo pipefail
path=$(jq -r '.tool_input.file_path // empty')
[[ -z "$path" || ! -f "$path" ]] && exit 0
case "$path" in
*.ts|*.tsx|*.js|*.jsx|*.json|*.css|*.md)
if ! npx --no-install prettier --write "$path" 2>/dev/null; then
echo "prettier failed on $path; leaving it unformatted" >&2
exit 2 # the edit already landed, but Claude sees this message
fi
;;
*.py)
command -v ruff >/dev/null && ruff format "$path" >/dev/null
;;
esac
exit 0Use it when
- Running a formatter or linter after every file edit so the diff is always clean.
- Blocking edits to generated files, lockfiles, or migrations that have already shipped.
- Appending every Bash command to an audit log outside the transcript.
- Injecting environment context at
SessionStart, whose stdout Claude can read. - Failing a
Stophook when the test suite is red, so Claude keeps working instead of handing back a broken tree.
Avoid it when
- The check is advisory. A hook on every
Editadds a process spawn to every edit; if you only want a nudge, CLAUDE.md costs nothing at tool-call time. - The command is slow. Hook latency lands directly in your turn, and
/doctorwill flag the slow ones. Move anything expensive to CI. - You are trying to shape what Claude writes rather than gate what it does. Hooks see tool calls, not intent — an output style or CLAUDE.md is the right layer.
- You cannot express the condition mechanically. A hook that guesses gets in the way on every false positive, and Claude has no way to argue with it.
Common mistakes
SYMPTOMThe hook detects a violation, exits 1, and the tool call proceeds anyway.
FIXOnly exit code 2 blocks. Exit 1 is a non-blocking error for every event except
WorktreeCreate. Useexit 2and put the reason on stderr.SYMPTOMA
PostToolUsehook prints a warning and Claude never mentions it.FIXStderr from a hook that exits 0 goes to the debug log only. Exit 2 from
PostToolUseso Claude sees the message, even though the tool already ran.SYMPTOMA matcher written as
mcp__memorynever fires.FIXThat string contains only exact-match characters, so it is compared literally against tool names and matches nothing. Write
mcp__memory__.*to match every tool from the server.SYMPTOMA hook on
Bashspawns a process for every command and the session feels sluggish.FIXAdd an
ifcondition such as"Bash(rm *)". The handler only spawns when the rule matches, so ordinary commands cost nothing.SYMPTOMA hook tries to prompt you and hangs, or its output never appears.
FIXCommand hooks run without a controlling terminal and cannot open
/dev/tty. ReturnsystemMessagein JSON output to show text, orterminalSequencefor a desktop notification.
Best practices
- Reach for a hook when an instruction has failed twice; that is the signal the rule needs enforcement rather than persuasion.
- Use
matcherplusiftogether so handlers only spawn for the calls they care about. - Exit 2 to block and 0 to stay silent, and never rely on exit 1 meaning anything.
- Reference scripts as
${CLAUDE_PROJECT_DIR}/.claude/hooks/...and commit them next to the settings that register them. - Keep handlers fast and idempotent — they run on every matching tool call, including inside subagents.
- Treat a hook as code with your privileges: it runs arbitrary shell, so review any hook that arrives with a repository or a plugin.
Try it in five minutes
Write a PreToolUse hook that blocks a command, and prove exit 1 does not.
- 1.Create
.claude/hooks/no-force-push.shthat reads stdin, extracts.tool_input.commandwithjq, and exits 2 with a message on stderr when the command containspush --force. - 2.Run
chmod +x .claude/hooks/no-force-push.sh. - 3.Register it in
.claude/settings.jsonunderPreToolUsewith"matcher": "Bash"and"if": "Bash(git push *)". - 4.Start
claudeand ask it to force-push. The call is blocked and Claude repeats your stderr message. - 5.Change
exit 2toexit 1, ask again, and watch the push go through with only a hook-error notice — proof that 1 does not block.
Related concepts
Verified against code.claude.com/docs/en/hooks on 2026-08-09. See content/SOURCES.md for the full table.
← / → MOVE BETWEEN CONCEPTS