Learn Claude Code Mastery Advanced Hooks: Automating Your Workflow with Deterministic Rules

Advanced Hooks: Automating Your Workflow with Deterministic Rules

Intermediate 🕐 30 min Lesson 11 of 11
What you'll learn
  • 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.json in 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.json in 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.

Key takeaways
  • 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