Hooks: Zero-Compromise Automation
Jump to section
What hooks are and why you need them
Hooks are rules that fire automatically on certain Claude Code actions. When Claude Code wants to write a file, run a command, or completes a task, a hook can intervene — block the action, modify it, or run custom logic. Think of git hooks, but for an AI agent.
Without hooks, you manually run the linter after every change, check formatting, run tests. With hooks, it happens automatically. Claude Code writes a file, the hook runs ruff, if there are errors Claude Code sees them and fixes them. Zero manual intervention.
Hook types
Claude Code supports four hook types. Each fires at a different moment and serves a different purpose.
PreToolUse — before a tool is used
Fires BEFORE Claude Code uses a tool (Write, Bash, Edit, etc.). Can block the action, modify it, or just log it. Ideal for validation — 'never write to production config', 'never push to main'.
PostToolUse — after a tool is used
Fires AFTER Claude Code uses a tool. Hook output is fed back to Claude Code as context. Ideal for auto-linting, formatting, running tests after a file change.
Notification — on notification
Fires when Claude Code sends a notification (typically when waiting for input or upon completion). Ideal for custom notifications — Slack messages, sounds, desktop alerts.
Stop — on stop
Fires when Claude Code finishes its turn and stops generating. Useful for final validation or cleanup.
Configuring hooks
Hooks are configured in .claude/settings.json (project-level) or ~/.claude/settings.json (global). Each hook has a matcher (which tool it responds to) and a command (what it runs).
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "echo 'About to run a bash command'"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "cd $PROJECT_DIR && uv run ruff check --fix $CLAUDE_FILE_PATH 2>&1 || true"
}
]
}
]
}
}Hook environment variables
Hooks have access to environment variables that describe the action context. These are essential for writing useful hooks.
# Available variables in hooks:
$CLAUDE_FILE_PATH # Path to the file being worked on
$CLAUDE_TOOL_NAME # Tool name (Write, Edit, Bash...)
$CLAUDE_TOOL_INPUT # JSON tool input (via stdin)
$PROJECT_DIR # Project root directoryPractical example: auto-lint after every edit
The most common hook — runs the linter and formatter after every file change. The result feeds back to Claude Code, which automatically fixes any errors.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "if [[ \"$CLAUDE_FILE_PATH\" == *.py ]]; then cd $PROJECT_DIR && uv run ruff check --fix \"$CLAUDE_FILE_PATH\" && uv run ruff format \"$CLAUDE_FILE_PATH\"; fi"
}
]
}
]
}
}Hook commands should return exit code 0 on success. If a hook fails (non-zero exit code), Claude Code sees the error output and tries to fix the problem. This is actually desirable behavior for linting — you want Claude Code to see errors and fix them.
Practical example: blocking dangerous commands
A PreToolUse hook that blocks dangerous git commands. The hook returns a non-zero exit code and Claude Code won't execute the action.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "INPUT=$(cat); if echo \"$INPUT\" | jq -r '.command' | grep -qE 'git\\s+(push\\s+--force|reset\\s+--hard|clean\\s+-fd)'; then echo 'BLOCKED: Dangerous git command detected' >&2; exit 1; fi"
}
]
}
]
}
}Practical example: auto-test after changes
Runs relevant tests after every Python file change. The key is running only relevant tests, not the entire test suite — otherwise the hook takes too long.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "if [[ \"$CLAUDE_FILE_PATH\" == *.py && \"$CLAUDE_FILE_PATH\" != *test* ]]; then MODULE=$(echo \"$CLAUDE_FILE_PATH\" | sed 's|/|.|g' | sed 's|\\.py$||'); TEST_FILE=$(echo \"$CLAUDE_FILE_PATH\" | sed 's|\\([^/]*\\)\\.py$|tests/test_\\1.py|'); if [ -f \"$TEST_FILE\" ]; then cd $PROJECT_DIR && uv run pytest \"$TEST_FILE\" -x -q 2>&1 | tail -5; fi; fi"
}
]
}
]
}
}Practical example: custom notifications
A Notification hook that sends a macOS desktop notification when Claude Code needs your attention.
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "INPUT=$(cat); TITLE=$(echo \"$INPUT\" | jq -r '.title // \"Claude Code\"'); MSG=$(echo \"$INPUT\" | jq -r '.body // \"Needs attention\"'); osascript -e \"display notification \\\"$MSG\\\" with title \\\"$TITLE\\\"\""
}
]
}
]
}
}Combining hooks for a production workflow
In practice, you want a combination of hooks — linting, formatting, testing, and safety checks. Here's a complete configuration for a Python/Django project.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "INPUT=$(cat); CMD=$(echo \"$INPUT\" | jq -r '.command'); if echo \"$CMD\" | grep -qE 'rm\\s+-rf\\s+/|git\\s+push\\s+--force\\s+(origin\\s+)?main'; then echo 'BLOCKED: Dangerous command' >&2; exit 1; fi"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "if [[ \"$CLAUDE_FILE_PATH\" == *.py ]]; then cd $PROJECT_DIR && uv run ruff check --fix \"$CLAUDE_FILE_PATH\" && uv run ruff format \"$CLAUDE_FILE_PATH\"; fi"
}
]
}
],
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "INPUT=$(cat); MSG=$(echo \"$INPUT\" | jq -r '.body // \"Done\"'); osascript -e \"display notification \\\"$MSG\\\" with title \\\"Claude Code\\\"\""
}
]
}
]
}
}Hooks run in the order they're defined. If you have multiple hooks for the same matcher, they all run sequentially. If any PreToolUse hook fails, the action is blocked.
Create a .claude/settings.json file in your project and set up a PostToolUse hook that: 1. Responds to Write and Edit operations 2. Checks if the changed file has your language extension (.py, .ts, .js...) 3. Runs the appropriate linter (ruff, eslint, biome...) 4. Returns the result to Claude Code Then edit a file through Claude Code and verify the hook fires automatically.
Hint
Use a file extension check: if [[ "$CLAUDE_FILE_PATH" == *.py ]]. Don't forget cd $PROJECT_DIR, otherwise the linter runs from the wrong directory.
Write a PreToolUse hook that blocks: 1. Force push to main/master branch 2. rm -rf on root directories 3. Any command with --no-verify Test it by asking Claude Code to run one of these commands and verify the hook blocks it.
Hint
The hook reads stdin as JSON with a 'command' field. Use jq -r '.command' to extract the command and grep -qE for pattern matching.
- Hooks automate repetitive tasks — linting, formatting, testing, safety checks
- PreToolUse blocks dangerous actions BEFORE execution, PostToolUse reacts AFTER execution
- Hook output feeds back to Claude Code — it can auto-fix linter errors
- Notification hooks enable custom alerts (desktop, Slack, sounds)
- Hooks are configured in .claude/settings.json (project) or ~/.claude/settings.json (global)
- Combine hooks for a complete automated workflow — lint + format + test + safety
In the next lesson, we dive into Permission Modes: Safety vs. Speed — a technique that gives you a clear edge. Unlock the full course and continue now.
2/8 complete — keep going!