Overview
Hooks run at fixed points in the Claude Code lifecycle, so they fire whatever the model decides; CLAUDE.md instructions are advisory, hooks are deterministic. A handler is one of five types: command (shell), http, mcp_tool, prompt (single-turn LLM check), or agent (verifier subagent; experimental, so prefer command in production). Events include SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStop, PreCompact, Notification, FileChanged, and WorktreeCreate; the hooks reference lists the full set.
Configure hooks under the hooks key of a settings file
Hooks live in ~/.claude/settings.json, .claude/settings.json (commit it), .claude/settings.local.json, managed settings, plugins, and skill or subagent frontmatter. Entries merge across levels instead of overriding; all matching hooks run in parallel, and an identical handler defined twice runs once. /hooks is a read-only browser of what is active.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
]
}
]
}
}The matcher filters by tool name and is case-sensitive. An empty, omitted, or * matcher matches everything; a value of only letters, digits, _, -, ,, and | is an exact name or list (Edit|Write); any other character makes it a JavaScript regex. A handler-level if narrows by arguments using permission-rule syntax, such as "if": "Bash(git *)". Command hooks receive the event as JSON on stdin (.tool_name, .tool_input), not as environment variables. Reference scripts with "$CLAUDE_PROJECT_DIR"/.claude/hooks/x.sh so the path survives a changed working directory.
Block with PreToolUse exit 2 or a deny decision
PreToolUse runs before the permission-mode check in every mode. Exit code 2 blocks the call and sends stderr to Claude as the reason; any other nonzero code is a non-blocking error. A JSON permissionDecision: "deny" inside hookSpecificOutput also blocks, even under bypassPermissions. A hook can tighten permissions but not loosen them: an allow does not override a deny rule.
#!/bin/bash
# .claude/hooks/protect-files.sh; register on PreToolUse with matcher "Edit|Write"
FILE=$(jq -r '.tool_input.file_path // empty')
case "$FILE" in
*.env*|*package-lock.json|*/.git/*) echo "Blocked: $FILE is protected" >&2; exit 2 ;;
esacMake the script executable with chmod +x. Prefer this over a prose rule when a file must never change.
Use PostToolUse for side effects, not prevention
PostToolUse fires after the tool succeeded and cannot undo it. Use it to format, log, or update an index. A hook matching Edit|Write does not fire when a Bash command or another process rewrites the file; to react to a named file however it changes, use a FileChanged hook, whose matcher lists the literal filenames to watch.
Gate the end of a turn with Stop, and guard against loops
Stop fires whenever Claude finishes responding, not only at task completion, and not on a user interrupt. Exit 2 (or {"decision": "block", "reason": "..."}) keeps Claude working on the reason you return, which turns a lint or test run into a gate.
#!/bin/bash
INPUT=$(cat)
[ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ] && exit 0
npm run lint --silent >&2 || { echo "Lint failed; fix before stopping." >&2; exit 2; }Check stop_hook_active and exit 0 when it is true, or the hook re-triggers itself. After eight consecutive continuations Claude Code overrides the next block and ends the turn; raise the cap with CLAUDE_CODE_STOP_HOOK_BLOCK_CAP only if the hook needs more iterations to converge.
Inject context with UserPromptSubmit and SessionStart
On exit 0, plain stdout from UserPromptSubmit, UserPromptExpansion, and SessionStart is added to Claude’s context; for every other event stdout goes to the debug log only. Use this for the current branch or environment name. Output that is JSON is parsed for decision fields instead of being added as text.
{ "type": "command", "command": "echo \"Branch: $(git rev-parse --abbrev-ref HEAD)\"" }Debug a hook that does not fire or has no effect
- Confirm it appears under the right event in
/hooks, and that the matcher case matches the tool name. - Pipe sample JSON into the script and check
echo $?:echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./hook.sh. - An unconditional
echoin a shell profile prepends text to your JSON and silently breaks parsing. - Defaults: command hooks time out at 600 seconds,
UserPromptSubmitat 30. Set"async": truefor slow work that should not block.