Skip to main content
PraktickAI
CoursesFor TeamsServicesDocsBlog
CoursesFor TeamsServicesDocsBlog
HomeCoursesClaude Code MasteryHooks: Zero-Compromise Automation
Previous lessonNext lesson
Lesson 2 of 830 min

Hooks: Zero-Compromise Automation

Jump to section
  • What hooks are and why you need them
  • Hook types
  • PreToolUse — before a tool is used
  • PostToolUse — after a tool is used
  • Notification — on notification
  • Stop — on stop
  • Configuring hooks
  • Hook environment variables
  • Practical example: auto-lint after every edit
  • Practical example: blocking dangerous commands
  • Practical example: auto-test after changes
  • Practical example: custom notifications
  • Combining hooks for a production workflow

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 directory

Practical 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.

Set up an auto-lint hook

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.

Create a safety PreToolUse hook

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.

Key Takeaways
  • 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.

Previous lesson
LinkedInX / Twitter

2/8 complete — keep going!

  • What hooks are and why you need them
  • Hook types
  • PreToolUse — before a tool is used
  • PostToolUse — after a tool is used
  • Notification — on notification
  • Stop — on stop
  • Configuring hooks
  • Hook environment variables
  • Practical example: auto-lint after every edit
  • Practical example: blocking dangerous commands
  • Practical example: auto-test after changes
  • Practical example: custom notifications
  • Combining hooks for a production workflow

PraktickAI

AI training & consulting for technical teams

CoursesFor TeamsServicesDocsBlog
karel@praktickai.appLinkedIn

© 2026 PraktickAI — SkyVisual s.r.o. All rights reserved. Privacy Policy · Terms & Conditions · Complaints Procedure