Claude Code8 min read

Claude Code Hooks: Complete Tutorial for Automating Your Dev Workflow

Learn how to use Claude Code hooks to auto-lint files, enforce policies, run git workflows, and build deterministic guardrails — with working examples.

Claude Code Hooks: Automate Your Dev Workflow with Deterministic Guardrails

Most Claude Code users discover hooks by accident — or never at all. That's a mistake. Hooks are the feature that separates casual Claude Code users from power users who have it deeply wired into their development process.

If you've ever wished Claude would automatically run your linter after editing a file, refuse to touch certain directories, or log every tool call for audit purposes — hooks are exactly what you need.

This tutorial walks you through Claude Code hooks from zero to a working multi-hook setup, with real configuration examples you can drop into your project today.


What Are Claude Code Hooks?

Hooks are user-defined commands, scripts, or HTTP endpoints that Claude Code calls automatically at specific points in its execution lifecycle.

Think of hooks like Git hooks (pre-commit, post-merge) — except instead of reacting to Git events, you're reacting to Claude's actions: before it runs a tool, after it edits a file, when a session starts, when a task completes.

Here's why this matters: prompt instructions have a non-zero failure rate. You can write "never delete files without asking" in your CLAUDE.md, and Claude will mostly respect it — but "mostly" isn't good enough when you're working with production code, financial data, or anything where one wrong action has real consequences.

Hooks give you 100% enforcement. The shell script either blocks the action or it doesn't. No hallucination, no misinterpretation, no exceptions.


How Hooks Work: The Core Model

Every hook has three parts:

  • Event — when it fires (e.g., PreToolUse, PostToolUse, SessionStart)
  • Matcher — which tools or conditions trigger it (e.g., "Bash", "Write", "*")
  • Handler — what runs when triggered (a shell command, HTTP POST, or prompt)
  • Claude Code passes a JSON payload to your hook via stdin. Your hook reads it, does its thing, and exits. The exit code tells Claude what to do next:

    Exit CodeBehavior
    0Success — continue normally
    2Blocking — abort the action
    Any otherNon-blocking warning — log but continue

    If your hook exits with 2, Claude stops the tool call completely. This is how you build hard guardrails.


    Setting Up Your First Hook

    Hooks live in settings.json. There are three locations depending on scope:

    FileScope
    ~/.claude/settings.jsonApplies to all your Claude Code sessions
    .claude/settings.jsonProject-wide (commit to git, shared with team)
    .claude/settings.local.jsonLocal overrides (gitignore this)

    Here's the base structure:

    json{
      "hooks": {
        "EventName": [
          {
            "matcher": "ToolName",
            "hooks": [
              {
                "type": "command",
                "command": "path/to/your/script.sh"
              }
            ]
          }
        ]
      }
    }

    Let's build three practical hooks from scratch.


    Hook #1: Auto-Lint After File Edits

    This is the most common hook. Every time Claude edits a JavaScript or TypeScript file, run ESLint and Prettier automatically.

    Create .claude/hooks/post-edit-lint.sh:

    bash#!/bin/bash
    # Read the JSON payload from stdin
    INPUT=$(cat)
    
    # Extract the file path that was edited
    FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
    
    if [ -z "$FILE" ]; then
      exit 0
    fi
    
    # Only lint JS/TS files
    if [[ "$FILE" =~ \.(js|jsx|ts|tsx)$ ]]; then
      echo "Running ESLint on $FILE..."
      npx eslint --fix "$FILE" 2>&1
      npx prettier --write "$FILE" 2>&1
      echo "Lint complete."
    fi
    
    exit 0

    Make it executable:

    bashchmod +x .claude/hooks/post-edit-lint.sh

    Add to .claude/settings.json:

    json{
      "hooks": {
        "PostToolUse": [
          {
            "matcher": "Write",
            "hooks": [
              {
                "type": "command",
                "command": ".claude/hooks/post-edit-lint.sh"
              }
            ]
          },
          {
            "matcher": "Edit",
            "hooks": [
              {
                "type": "command",
                "command": ".claude/hooks/post-edit-lint.sh"
              }
            ]
          }
        ]
      }
    }

    Now every time Claude writes or edits a file, it gets linted automatically. You stop thinking about it.


    Hook #2: Block Destructive Bash Commands

    This hook fires before Claude runs any Bash command and blocks ones that look dangerous.

    Create .claude/hooks/block-dangerous-commands.sh:

    bash#!/bin/bash
    INPUT=$(cat)
    
    # Extract the command Claude wants to run
    COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
    
    if [ -z "$COMMAND" ]; then
      exit 0
    fi
    
    # Block patterns
    DANGEROUS_PATTERNS=(
      "rm -rf /"
      "rm -rf ~"
      "DROP TABLE"
      "DROP DATABASE"
      "> /dev/sda"
      "dd if=/dev/zero"
      "chmod -R 777 /"
    )
    
    for PATTERN in "${DANGEROUS_PATTERNS[@]}"; do
      if echo "$COMMAND" | grep -qi "$PATTERN"; then
        echo "BLOCKED: Detected dangerous command pattern: '$PATTERN'" >&2
        echo "Command was: $COMMAND" >&2
        exit 2  # Exit code 2 = blocking
      fi
    done
    
    exit 0

    Add to settings:

    json{
      "hooks": {
        "PreToolUse": [
          {
            "matcher": "Bash",
            "hooks": [
              {
                "type": "command",
                "command": ".claude/hooks/block-dangerous-commands.sh"
              }
            ]
          }
        ]
      }
    }

    Exit code 2 causes Claude to abort the tool call entirely and tell you what happened. This is your last line of defense.


    Hook #3: Git Auto-Commit Log After Session Ends

    Track what Claude changed each session by automatically writing a summary to a log file.

    Create .claude/hooks/session-end-log.sh:

    bash#!/bin/bash
    INPUT=$(cat)
    
    LOG_FILE=".claude/session-log.md"
    TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
    
    # Get git diff summary
    DIFF_SUMMARY=$(git diff --stat HEAD 2>/dev/null || echo "No git changes detected")
    
    cat >> "$LOG_FILE" << EOF
    
    ## Session ended: $TIMESTAMP
    
    ### Git changes:
    \`\`\`
    $DIFF_SUMMARY
    \`\`\`
    
    ---
    EOF
    
    echo "Session logged to $LOG_FILE"
    exit 0

    Add to settings:

    json{
      "hooks": {
        "SessionEnd": [
          {
            "matcher": "*",
            "hooks": [
              {
                "type": "command",
                "command": ".claude/hooks/session-end-log.sh"
              }
            ]
          }
        ]
      }
    }

    Now every time a Claude Code session ends, a structured diff summary is appended to .claude/session-log.md. Useful for auditing what changed across sessions.


    Complete Multi-Hook Settings File

    Here's a full .claude/settings.json combining all three hooks:

    json{
      "hooks": {
        "PreToolUse": [
          {
            "matcher": "Bash",
            "hooks": [
              {
                "type": "command",
                "command": ".claude/hooks/block-dangerous-commands.sh"
              }
            ]
          }
        ],
        "PostToolUse": [
          {
            "matcher": "Write",
            "hooks": [
              {
                "type": "command",
                "command": ".claude/hooks/post-edit-lint.sh"
              }
            ]
          },
          {
            "matcher": "Edit",
            "hooks": [
              {
                "type": "command",
                "command": ".claude/hooks/post-edit-lint.sh"
              }
            ]
          }
        ],
        "SessionEnd": [
          {
            "matcher": "*",
            "hooks": [
              {
                "type": "command",
                "command": ".claude/hooks/session-end-log.sh"
              }
            ]
          }
        ]
      }
    }


    Advanced: HTTP Hooks for Team Logging

    Instead of writing to a local file, send events to a central endpoint. Useful when your team wants a shared audit trail.

    json{
      "hooks": {
        "PostToolUse": [
          {
            "matcher": "*",
            "hooks": [
              {
                "type": "http",
                "url": "https://your-internal-api.com/claude-audit",
                "method": "POST"
              }
            ]
          }
        ]
      }
    }

    Claude Code will POST the full event JSON (tool name, inputs, outputs, session ID) to your endpoint. Your API can store it, alert on anomalies, or feed a dashboard.


    All Claude Code Hook Events

    Here's a reference of the key events available:

    EventWhen It Fires
    PreToolUseBefore any tool runs
    PostToolUseAfter a tool succeeds
    PostToolUseFailureAfter a tool fails
    SessionStartWhen a Claude Code session begins
    SessionEndWhen a session ends
    UserPromptSubmitBefore user input is processed
    StopWhen Claude decides to stop
    TaskCompletedWhen a task is marked done
    SubagentStartWhen a subagent is spawned
    SubagentStopWhen a subagent finishes
    WorktreeCreateWhen a git worktree is created
    PreCompactBefore context compaction
    PostCompactAfter context compaction

    Use "*" as the matcher to catch all tools for a given event.


    Common Mistakes to Avoid

    1. Forgetting to make scripts executable. Every hook script needs chmod +x. If Claude reports a hook error, this is usually why. 2. Using exit code 1 instead of 2 for blocking. Exit code 1 is a non-blocking warning — Claude logs it but continues. Only exit code 2 blocks the action. 3. Heavy hooks on every tool call. If your PostToolUse hook runs a full test suite on every file write, sessions become sluggish. Be selective with your matchers. 4. Not testing hooks before relying on them. Run your hook scripts manually with a sample JSON payload before trusting them:

    bashecho '{"tool_input": {"command": "rm -rf /"}}' | bash .claude/hooks/block-dangerous-commands.sh
    echo "Exit: $?"

    5. Committing secrets in hook scripts. If your HTTP hook needs an API key, load it from an environment variable — never hardcode it in .claude/settings.json.

    Key Takeaways

    • Hooks fire at lifecycle events (PreToolUse, PostToolUse, SessionEnd, etc.) and run shell commands, HTTP calls, or subagents
    • Exit code 2 blocks the action; exit code 0 continues; anything else is a logged warning
    • Store project hooks in .claude/settings.json (versioned) and personal hooks in ~/.claude/settings.json
    • Use PreToolUse + Bash matcher for safety guardrails; PostToolUse + Write/Edit for post-processing
    • Hooks give you deterministic enforcement — unlike CLAUDE.md instructions, they cannot be ignored or misinterpreted


    Next Steps

    If you're using Claude Code seriously, hooks are non-negotiable once your projects grow past solo experiments. They're the difference between "Claude Code does what I hope" and "Claude Code does what I know."

    Want to go deeper on Claude Code configuration? Read our guide on writing effective CLAUDE.md files — the companion to hooks for encoding project context and rules.

    Preparing for the Claude Certified Architect (CCA) exam? Hooks, tool orchestration, and lifecycle management are core topics. Our CCA exam guide covers the full syllabus and our CCA practice test bank has 200+ questions with detailed explanations.

    Ready to Start Practicing?

    300+ scenario-based practice questions covering all 5 CCA domains. Detailed explanations for every answer.

    Free CCA Study Kit

    Get domain cheat sheets, anti-pattern flashcards, and weekly exam tips. No spam, unsubscribe anytime.