Advanced Hooks: Automating Your Workflow with Deterministic Rules
- Explain the difference between advisory CLAUDE.md instructions and deterministic hooks
- Configure hooks in settings.json at project and personal scope
- Choose the correct exit code: 2 to block an action, 0 to allow it, 1 to warn only
- Write production-ready hooks for auto-formatting, safety gates, test running, lint enforcement, and task notifications
- Read tool input from stdin JSON in hook scripts using jq
Why CLAUDE.md Instructions Aren't Enough
CLAUDE.md instructions are advisory. Claude reads them and generally follows them — but when it's deep in a complex multi-file change, it sometimes forgets to format code, skips the lint step, or misses a project convention. Hooks are different: they are deterministic shell commands that fire automatically at specific points in Claude's lifecycle, regardless of what Claude is focused on.
Think of it like the difference between a sticky note that says "always run the formatter before committing" and an actual pre-commit hook that runs the formatter whether you remember or not. CLAUDE.md is the sticky note. Hooks are the enforcement mechanism. They don't ask Claude to remember anything — they just run.
How Hooks Are Configured
Hooks live in settings.json under a hooks key. There are two places to put them:
- Project hooks —
.claude/settings.jsonin your project root. Commit this file to git and every team member gets the same enforcement rules automatically. No onboarding documentation required. - Personal hooks —
~/.claude/settings.jsonin your home directory. These apply across every project you open.
A hook configuration has this shape:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/validate-bash.sh"
}
]
}
]
}
}
The matcher filters which tool triggers the hook. Use a tool name (Bash), a pipe-separated list (Edit|Write), a regex for MCP tools (mcp__memory__.*), or omit it to match everything. The type field supports command (shell script), http (POST to a URL), mcp_tool (call an MCP server tool), prompt (ask Claude to evaluate a condition), or agent (spawn a full subagent to decide). For most use cases, command is what you want.
The Hook Events That Matter Most
Claude Code fires hooks at over 20 lifecycle events. These five cover the vast majority of real-world use cases:
- PreToolUse — fires before a tool executes. This is the only event where you can stop an action before it happens. Your hook receives the full tool input as JSON via stdin — parse it to inspect the command, file path, or any other parameter before Claude acts on it.
- PostToolUse — fires after a tool succeeds. The hook receives both the input and the result. Use it to auto-format files, run tests, log changes, or trigger follow-on actions.
- Stop — fires when Claude finishes a response. Use it for desktop or Slack notifications so you know when a long autonomous task is complete without watching the terminal.
- SessionStart — fires when a session opens or resumes. Use it to inject dynamic context: the current git branch, open tickets, environment health, or any runtime state that static files can't capture.
- UserPromptSubmit — fires every time you submit a message. Use it to add automatic context to every prompt or block certain queries in production-connected environments.
Exit Codes: The Only Thing That Actually Enforces Rules
Your hook script communicates through exit codes. This is the most important concept to get right — getting it wrong means your "rules" don't actually enforce anything:
- Exit 0 — success. The action proceeds normally. Any JSON you write to stdout is processed by Claude Code (you can modify tool input, inject context, or rename the session). Writing nothing is fine.
- Exit 2 — blocking. The action is stopped. Whatever you write to stderr is shown to Claude as an error message. This is the enforcement exit code.
- Any other exit code (including 1) — non-blocking warning. The first line of stderr appears in the transcript, but execution continues. Use this for informational hooks that should never break the workflow.
The most common mistake: writing a hook that exits 1 thinking it's preventing something. It isn't. Only exit 2 stops an action. Exit 1 is a warning Claude Code logs and moves on from.
Five Production Hooks You Can Use Today
Each of these is a real shell script. Save them to .claude/hooks/, make them executable with chmod +x, and wire them into settings.json.
1. Auto-format every file after Claude writes it
This PostToolUse hook reads the file path from stdin JSON and runs the right formatter based on extension. Prettier for TypeScript/JavaScript, Black for Python:
#!/usr/bin/env bash
# .claude/hooks/format-on-write.sh
FILE=$(cat | jq -r ".tool_input.file_path")
if [[ "$FILE" == *.py ]]; then
black --quiet "$FILE" 2>/dev/null
elif [[ "$FILE" == *.ts ]] || [[ "$FILE" == *.js ]] || [[ "$FILE" == *.tsx ]]; then
npx prettier --write "$FILE" 2>/dev/null
fi
exit 0
# Wire it up in .claude/settings.json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format-on-write.sh" }]
}
]
}
}
2. Block destructive shell commands before they run
Exit 2 stops the command entirely. The message goes to Claude as context so it knows why it was blocked and can try a safer approach:
#!/usr/bin/env bash
# .claude/hooks/block-dangerous.sh
CMD=$(cat | jq -r ".tool_input.command")
PATTERNS="rm -rf /|git push.*(--force|-f).*(main|master)|DROP TABLE|git reset --hard HEAD"
if echo "$CMD" | grep -qE "$PATTERNS"; then
echo "Blocked: this command requires manual approval — run it yourself." >&2
exit 2
fi
exit 0
3. Run the matching test file immediately after a source edit
Every time Claude edits a Python source file, this hook finds the corresponding test file and runs it. Regressions are caught in the same turn, not three changes later:
#!/usr/bin/env bash
# .claude/hooks/test-on-edit.sh
FILE=$(cat | jq -r ".tool_input.file_path")
if [[ "$FILE" == *.py ]] && [[ "$FILE" != *test_* ]] && [[ "$FILE" != *_test.py ]]; then
TEST="tests/test_$(basename "$FILE")"
if [[ -f "$TEST" ]]; then
python -m pytest "$TEST" -x --tb=short 2>&1 | tail -20
fi
fi
exit 0
4. Block any git commit that fails linting
#!/usr/bin/env bash
# .claude/hooks/lint-before-commit.sh
CMD=$(cat | jq -r ".tool_input.command")
if echo "$CMD" | grep -qE "^gits+commit"; then
if ! ruff check . --select E,F,W 2>&1; then
echo "Commit blocked: fix lint errors first." >&2
exit 2
fi
fi
exit 0
5. Desktop notification when Claude finishes a long task
#!/usr/bin/env bash
# .claude/hooks/notify-done.sh
# macOS:
osascript -e "display notification "Claude finished" with title "Claude Code""
# Linux: notify-send "Claude Code" "Task complete"
{
"hooks": {
"Stop": [{ "hooks": [{ "type": "command",
"command": "~/.claude/hooks/notify-done.sh" }] }]
}
}
Building a Hook Practice That Compounds
Start with one hook that solves a frustration you actually have right now. The auto-formatter pays for itself the first session you run it. The security gate pays for itself the first time it would have caught a force-push to main.
Once a hook is working, commit .claude/settings.json and everyone who clones your repository gets your rules automatically. Hooks compound: each rule you add makes the entire environment more consistent, with zero ongoing maintenance cost. The best Claude Code workflow isn't one where Claude remembers your preferences — it's one where Claude literally cannot forget them.
- Hooks are deterministic; CLAUDE.md is advisory — hooks run regardless of what Claude is focused on
- Exit code 2 blocks the action; exit code 1 only logs a warning without stopping anything
- Project hooks in .claude/settings.json travel with the repository so every team member gets the same enforcement
- Every hook receives the full tool input as JSON via stdin — use jq -r to extract the command or file path
- Start with one hook that solves an existing frustration; commit it so the rules travel with your codebase