Skip to content

Hooks System Mastery

Your junior developer just asked Claude Code to “clean up the database migration files.” Claude interpreted that as deleting the migration history. The production deploy script ran the next morning and recreated every table from scratch. Your Friday night is now a recovery operation.

Hooks prevent this. They are deterministic shell commands, LLM prompts, or agent invocations that fire at specific points in Claude Code’s lifecycle. They do not depend on the model following instructions — they execute whether the model cooperates or not.

  • A working mental model of the 14 hook events and when each fires
  • Production-ready hook scripts for file protection, auto-formatting, and security enforcement
  • Patterns for prompt-based and agent-based hooks that use AI judgment deterministically
  • Debugging techniques when hooks behave unexpectedly

Every Claude Code session follows a predictable lifecycle. Hooks intercept this lifecycle at 14 distinct points:

EventWhen It FiresCan Block?
SessionStartSession begins or resumesYes
UserPromptSubmitAfter you press Enter, before Claude processesYes
PreToolUseBefore any tool call executesYes
PermissionRequestWhen a permission dialog would appearYes
PostToolUseAfter a tool call succeedsNo
PostToolUseFailureAfter a tool call failsNo
NotificationWhen Claude sends a notificationNo
SubagentStartWhen a subagent spawnsNo
SubagentStopWhen a subagent finishesNo
StopWhen Claude finishes respondingYes
TeammateIdleWhen an agent team member goes idleYes
TaskCompletedWhen a task is marked completeYes
PreCompactBefore context compactionNo
SessionEndWhen the session terminatesNo

The “Can Block?” column is key. PreToolUse hooks can prevent a dangerous command from executing. Stop hooks can force Claude to keep working when it tries to finish prematurely.

Hooks live in your settings JSON files. The configuration has three levels:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/block-dangerous-commands.sh"
}
]
}
]
}
}

Where to put hooks:

LocationScopeShareable
~/.claude/settings.jsonAll your projectsNo
.claude/settings.jsonCurrent project, all team membersYes (commit it)
.claude/settings.local.jsonCurrent project, just youNo (gitignored)

The matcher field is a regex that filters when the hook fires:

  • "Bash" — only Bash tool calls
  • "Edit|Write" — file modification tools
  • "mcp__.*" — any MCP tool
  • Omit matcher or use "*" to match everything

This PostToolUse hook runs Prettier on any file that Claude edits or writes:

.claude/hooks/auto-format.sh
#!/bin/bash
# Runs after Edit or Write tool calls
FILE_PATH=$(jq -r '.tool_input.file_path // .tool_input.path // empty')
if [ -n "$FILE_PATH" ] && [ -f "$FILE_PATH" ]; then
case "$FILE_PATH" in
*.ts|*.tsx|*.js|*.jsx|*.json|*.css|*.md)
npx prettier --write "$FILE_PATH" 2>/dev/null
;;
esac
fi
exit 0

Hook configuration:

{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/auto-format.sh"
}
]
}
]
}
}
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "afplay /System/Library/Sounds/Glass.aiff"
}
]
}
],
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "afplay /System/Library/Sounds/Ping.aiff"
}
]
}
]
}
}

Block Claude from modifying files that should only be changed through specific processes:

.claude/hooks/protect-files.sh
#!/bin/bash
FILE_PATH=$(jq -r '.tool_input.file_path // .tool_input.path // empty')
PROTECTED_PATTERNS=(
"*/migrations/*"
".env*"
"*/secrets/*"
"package-lock.json"
"yarn.lock"
)
for pattern in "${PROTECTED_PATTERNS[@]}"; do
if [[ "$FILE_PATH" == $pattern ]]; then
jq -n "{hookSpecificOutput:{hookEventName:\"PreToolUse\",permissionDecision:\"deny\",permissionDecisionReason:\"Protected file: $FILE_PATH cannot be modified by Claude\"}}"
exit 0
fi
done
exit 0

When you need AI judgment rather than pattern matching, use prompt-based hooks. These send the event context to an LLM and act on the response:

{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "prompt",
"prompt": "Review the assistant's final response. Did it actually complete the requested task, or did it stop early with a vague summary? If the task is incomplete, respond with {\"decision\": \"block\", \"reason\": \"Task not complete: [specific missing items]\"}. If complete, respond with {\"decision\": \"allow\"}."
}
]
}
]
}
}

This pattern catches Claude’s tendency to declare victory before finishing the actual work.

Some hooks should not block the main flow. Background test execution is the classic example:

{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/run-tests-async.sh",
"async": true
}
]
}
]
}
}

Async hooks start immediately but do not block Claude from continuing. The result is delivered when available.

When hooks misbehave, use the debug flag:

Terminal window
claude --debug "hooks"

This shows every hook event, matcher evaluation, and handler execution. Common issues:

  • Hook not firing: Check that your matcher regex actually matches the tool name. "Bash" matches the Bash tool, not "bash" (case-sensitive).
  • Hook fires but has no effect: Check the exit code and JSON output. Exit code 0 means “allow.” Exit code 2 means “block.” Anything else is an error.
  • Hook script permissions: Make sure .claude/hooks/*.sh files are executable (chmod +x).

Hooks slow down every operation: Each synchronous hook adds latency to the tool call it intercepts. If your PostToolUse hook runs a full test suite on every edit, Claude will feel sluggish. Move heavy operations to async hooks.

Hooks conflict with each other: When multiple hooks fire on the same event, they all run. If one allows and another denies, the deny wins. Use /hooks to see all configured hooks and their sources.

Prompt hooks consume extra tokens: Prompt-based hooks make additional API calls. Each one adds cost. Use them sparingly and prefer command hooks for pattern-matching tasks.

Hook cannot read stdin: Command hooks receive JSON on stdin. If your script reads from a file or does not process stdin, it will not receive the event context. Always use jq to parse the input.