Hooks are deterministic: unlike CLAUDE.md advice they always run, so they enforce rules the model might forget.
CLAUDE.md says "please run the formatter"; Claude usually does. A hook guarantees it: it is a command Claude Code runs automatically at a specific event in the session, whatever the model decides.
Useful events include PreToolUse (before a tool call; can block it), PostToolUse (after a tool succeeds), UserPromptSubmit (when you send a prompt), Stop (when Claude is about to finish its turn), SessionStart, Notification and PreCompact. There are many more in the reference.
Hooks are configured in a settings file under hooks: event, then a matcher (for tool events, the tool name, such as Bash or Edit|Write), then the handlers. The common handler type is command; others send the event to a URL, call an MCP tool, or ask a model. This formats files after every edit:
{"hooks": {"PostToolUse": [{"matcher": "Edit|Write",
"hooks": [{"type": "command", "command": "npx prettier --write . --log-level warn"}]}]}}How a command hook talks to Claude Code:
- it receives the event as JSON on stdin (standard input), including
tool_nameandtool_input, - exit code 0 means success; stdout may contain JSON with a decision,
- exit code 2 means block: on events that can block, the action is stopped and your stderr message is shown to Claude as the reason,
- other exit codes are non-blocking errors.
Typical hooks: run the formatter after every edit, block edits to migrations/ or generated files, run the test suite in a Stop hook so Claude cannot finish while tests fail, send a desktop notification when Claude needs input, or filter huge test output down to failures.
/hooks lists configured hooks and where each comes from. Use ${CLAUDE_PROJECT_DIR} to reference scripts in the repo. Hooks run with your user's full permissions, so review hooks in any repository before trusting it.
Example
#!/usr/bin/env bash
# .claude/hooks/protect-migrations.sh (chmod +x it)
# Registered in .claude/settings.json:
# "PreToolUse": [{ "matcher": "Edit|Write",
# "hooks": [{ "type": "command",
# "command": "\"${CLAUDE_PROJECT_DIR}\"/.claude/hooks/protect-migrations.sh" }] }]
path=$(jq -r '.tool_input.file_path // empty') # event JSON arrives on stdin
if [[ "$path" == *"/migrations/"* ]]; then
echo "Migrations are generated; change the model and run make migrations." >&2
exit 2 # 2 = block, stderr goes to Claude
fi
exit 0Remember
- Hooks run your commands at events like PreToolUse, PostToolUse and Stop
- Event JSON arrives on stdin; exit 2 blocks (where the event allows), stderr explains why
- Use hooks for must-always rules; check /hooks and review repo hooks before trusting
Watch out for
- Writing a slow hook on every PostToolUse (such as a full test suite), which makes every edit painfully slow.