Agent RulesAgent Rules
Builder
Options
Browse all rules by language and framework
Templates
Pre-built rule sets ready to use
Popular Rules
Top community-ranked rules leaderboard
GuidesAnalyzePricingContact
Builder
OptionsTemplatesPopular Rules
GuidesAnalyzePricingContact

Product

  • Builder
  • Templates
  • Browse Rules
  • My Library

Learn

  • What are AI Agent Rules?
  • Guides
  • FAQ
  • About

Resources

  • Terms
  • Privacy Policy
  • Pricing
  • Contact
  • DMCA Policy

Support

Help keep this project free.

Agent RulesAgent Rules Builder
© 2026 Aurora Algorithm Inc.
Back to Guides
claude-code
hooks
automation
setup
tutorial

How to Set Up Claude Code Hooks: Tutorial With Ready-to-Use Scripts

Learn how to set up Claude Code hooks with step-by-step instructions. Includes 10 ready-to-use hook scripts for auto-formatting, file protection, test runners, notifications, and more.

Agent Rules Team3/7/202613 min read

Claude Code hooks are shell commands that execute automatically at specific points during a coding session. They give you deterministic control over Claude's behavior — actions that always happen, not actions you hope the LLM will choose to do.

This guide provides 10 production-ready hook implementations you can copy directly into your project. Each includes the shell script, the configuration JSON, and step-by-step setup instructions.


How Hooks Work — The 60-Second Version

Hooks live in your settings files and fire at lifecycle events:

EventWhen it firesCan block?
SessionStartSession begins or resumesNo
UserPromptSubmitYou submit a promptYes
PreToolUseBefore a tool call executesYes
PostToolUseAfter a tool call succeedsNo
StopClaude finishes respondingYes
NotificationClaude needs your attentionNo
SessionEndSession terminatesNo

Each hook receives JSON input on stdin with context about the event. Your script reads it, takes action, and communicates back via:

  • Exit 0 — allow the action (stdout becomes context for some events)
  • Exit 2 — block the action (stderr becomes Claude's feedback)
  • JSON on stdout — structured control (allow, deny, or escalate)

Where to put hooks

LocationScope
~/.claude/settings.jsonAll your projects (personal)
.claude/settings.jsonSingle project (shared with team via Git)
.claude/settings.local.jsonSingle project (personal, gitignored)

You can also use the /hooks command inside Claude Code to add hooks interactively.

Prerequisites

Most hook scripts use jq for JSON parsing. Install it:

bash
# macOS
brew install jq

# Debian/Ubuntu
sudo apt-get install jq

Hook 1: Auto-Format Code After Every Edit

Event: PostToolUse | Matcher: Edit|Write

Automatically run your formatter (Prettier, Biome, Black, gofmt) on every file Claude edits. No more format drift.

Script: .claude/hooks/auto-format.sh

bash
#!/bin/bash
# Auto-format files after Claude edits them
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then
  exit 0
fi

EXTENSION="${FILE_PATH##*.}"

case "$EXTENSION" in
  ts|tsx|js|jsx|json|css|md|html)
    npx prettier --write "$FILE_PATH" 2>/dev/null
    ;;
  py)
    black "$FILE_PATH" 2>/dev/null || ruff format "$FILE_PATH" 2>/dev/null
    ;;
  go)
    gofmt -w "$FILE_PATH" 2>/dev/null
    ;;
  rs)
    rustfmt "$FILE_PATH" 2>/dev/null
    ;;
esac

exit 0

Configuration

Add to .claude/settings.json in your project root:

json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/auto-format.sh"
          }
        ]
      }
    ]
  }
}

Setup

bash
mkdir -p .claude/hooks
# Save the script above to .claude/hooks/auto-format.sh
chmod +x .claude/hooks/auto-format.sh

Hook 2: Block Destructive Commands

Event: PreToolUse | Matcher: Bash

Prevent Claude from running dangerous shell commands: rm -rf, git push --force, DROP TABLE, docker system prune, etc. Claude receives feedback explaining why the command was blocked.

Script: .claude/hooks/block-dangerous.sh

bash
#!/bin/bash
# Block destructive commands before they execute
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

BLOCKED_PATTERNS=(
  "rm -rf /"
  "rm -rf ~"
  "rm -rf \."
  "git push.*--force"
  "git push.*-f"
  "git reset --hard"
  "git clean -fd"
  "DROP TABLE"
  "DROP DATABASE"
  "TRUNCATE"
  "docker system prune"
  ":(){ :|:& };:"
  "mkfs\."
  "dd if="
  "> /dev/sda"
  "chmod -R 777 /"
  "curl.*| bash"
  "wget.*| bash"
)

for pattern in "${BLOCKED_PATTERNS[@]}"; do
  if echo "$COMMAND" | grep -qiE "$pattern"; then
    echo "Blocked: command matches dangerous pattern. Rephrase the command to be more targeted." >&2
    exit 2
  fi
done

exit 0

Configuration

Add to .claude/settings.json:

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

Hook 3: Desktop Notifications

Event: Notification | Matcher: *

Get a native desktop notification when Claude finishes working or needs your input. Essential for long-running tasks where you switch to another window.

Configuration (no script needed)

Add to ~/.claude/settings.json (user-level, applies to all projects):

macOS:

json
{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\" sound name \"Ping\"'"
          }
        ]
      }
    ]
  }
}

Linux:

json
{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "notify-send 'Claude Code' 'Claude Code needs your attention' --urgency=normal"
          }
        ]
      }
    ]
  }
}

Hook 4: Run Tests Before Claude Stops

Event: Stop

The most impactful hook: run your test suite every time Claude finishes a response. If tests fail, Claude receives the error output and continues working to fix the issue — without you having to say "the tests are failing."

Script: .claude/hooks/stop-verify.sh

bash
#!/bin/bash
# Run tests when Claude finishes — if they fail, Claude keeps working
INPUT=$(cat)

# Prevent infinite loops: if this is a Stop hook re-run, let Claude stop
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false')
if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
  exit 0
fi

echo "=== Running tests ===" >&2

# Run your test command — adjust for your project
TEST_OUTPUT=$(pnpm test 2>&1)
TEST_EXIT=$?

if [ $TEST_EXIT -ne 0 ]; then
  echo "Tests failed. Fix the failing tests before finishing." >&2
  echo "" >&2
  # Show only the last 50 lines to keep feedback focused
  echo "$TEST_OUTPUT" | tail -50 >&2
  exit 2  # Block Stop — Claude continues working
fi

echo "All tests passed." >&2
exit 0  # Allow Claude to stop

Configuration

Add to .claude/settings.json:

json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-verify.sh",
            "timeout": 120
          }
        ]
      }
    ]
  }
}

Hook 5: Protect Sensitive Files

Event: PreToolUse | Matcher: Edit|Write

Block Claude from modifying sensitive files: .env, lockfiles, migration files, generated code, or anything you want to protect.

Script: .claude/hooks/protect-files.sh

bash
#!/bin/bash
# Protect specific files and directories from AI modification
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

if [ -z "$FILE_PATH" ]; then
  exit 0
fi

# Add your protected patterns here
PROTECTED_PATTERNS=(
  ".env"
  ".env.local"
  ".env.production"
  "package-lock.json"
  "pnpm-lock.yaml"
  "yarn.lock"
  ".git/"
  "node_modules/"
  "dist/"
  "build/"
  ".next/"
)

for pattern in "${PROTECTED_PATTERNS[@]}"; do
  if [[ "$FILE_PATH" == *"$pattern"* ]]; then
    echo "Blocked: '$FILE_PATH' is a protected file (matches '$pattern'). Do not modify this file." >&2
    exit 2
  fi
done

exit 0

Configuration

Add to .claude/settings.json:

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/protect-files.sh"
          }
        ]
      }
    ]
  }
}

Hook 6: Inject Git Context on Session Start

Event: SessionStart | Matcher: startup

Automatically load recent commits, current branch, and uncommitted changes into Claude's context at the start of every session. Claude starts with awareness of your recent work.

Script: .claude/hooks/git-context.sh

bash
#!/bin/bash
# Inject git context at session start
if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then
  exit 0
fi

BRANCH=$(git branch --show-current 2>/dev/null)
RECENT_COMMITS=$(git log --oneline -10 2>/dev/null)
UNCOMMITTED=$(git diff --stat 2>/dev/null)
STAGED=$(git diff --cached --stat 2>/dev/null)
STASH_COUNT=$(git stash list 2>/dev/null | wc -l | tr -d ' ')

echo "## Git Context"
echo "**Branch:** $BRANCH"
echo "**Stashes:** $STASH_COUNT"
echo ""
echo "### Recent commits (last 10):"
echo "$RECENT_COMMITS"
echo ""

if [ -n "$UNCOMMITTED" ]; then
  echo "### Uncommitted changes:"
  echo "$UNCOMMITTED"
  echo ""
fi

if [ -n "$STAGED" ]; then
  echo "### Staged changes:"
  echo "$STAGED"
  echo ""
fi

exit 0

Configuration

Add to .claude/settings.json:

json
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/git-context.sh"
          }
        ]
      }
    ]
  }
}

Hook 7: Log Every Bash Command

Event: PostToolUse | Matcher: Bash

Maintain an audit trail of every shell command Claude executes. Useful for security reviews, debugging, and understanding what Claude did during a long session.

Script: .claude/hooks/log-commands.sh

bash
#!/bin/bash
# Log every Bash command Claude runs
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
CWD=$(echo "$INPUT" | jq -r '.cwd // "unknown"')

LOG_DIR="$HOME/.claude/logs"
mkdir -p "$LOG_DIR"

echo "[$TIMESTAMP] session=$SESSION_ID cwd=$CWD cmd=$COMMAND" >> "$LOG_DIR/commands.log"

exit 0

Configuration

Add to ~/.claude/settings.json (user-level — logs commands across all projects):

json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/log-commands.sh"
          }
        ]
      }
    ]
  }
}

Hook 8: Re-Inject Context After Compaction

Event: SessionStart | Matcher: compact

When Claude's context window fills up, compaction summarizes the conversation to free space. This can lose important details. This hook re-injects critical context after every compaction.

Script: .claude/hooks/post-compact.sh

bash
#!/bin/bash
# Re-inject critical context after compaction

# Start with your most important reminders
echo "## Post-Compaction Reminders"
echo "- Always run tests before committing"
echo "- Use pnpm, not npm or yarn"
echo "- Never modify files in /migrations without explicit instruction"
echo "- Follow the existing code patterns — do not introduce new abstractions"

# Inject recent git context so Claude remembers what you were working on
if git rev-parse --is-inside-work-tree > /dev/null 2>&1; then
  echo ""
  echo "### Current git state:"
  echo "Branch: $(git branch --show-current)"
  echo ""
  echo "Recent commits:"
  git log --oneline -5
  echo ""
  DIFF=$(git diff --stat)
  if [ -n "$DIFF" ]; then
    echo "Uncommitted changes:"
    echo "$DIFF"
  fi
fi

exit 0

Configuration

Add to .claude/settings.json:

json
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "compact",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/post-compact.sh"
          }
        ]
      }
    ]
  }
}

Hook 9: Lint on Every File Edit

Event: PostToolUse | Matcher: Edit|Write

Run your linter on every file Claude touches and feed the results back. Unlike formatting (Hook 1), this hook reports lint warnings to Claude so it can fix issues immediately.

Script: .claude/hooks/lint-check.sh

bash
#!/bin/bash
# Lint files after edits and report issues to Claude
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then
  exit 0
fi

EXTENSION="${FILE_PATH##*.}"
LINT_OUTPUT=""
LINT_EXIT=0

case "$EXTENSION" in
  ts|tsx|js|jsx)
    # Try Biome first, then ESLint
    LINT_OUTPUT=$(npx biome check "$FILE_PATH" 2>&1)
    LINT_EXIT=$?
    if [ $LINT_EXIT -ne 0 ] && ! command -v biome &>/dev/null; then
      LINT_OUTPUT=$(npx eslint "$FILE_PATH" --no-error-on-unmatched-pattern 2>&1)
      LINT_EXIT=$?
    fi
    ;;
  py)
    LINT_OUTPUT=$(ruff check "$FILE_PATH" 2>&1)
    LINT_EXIT=$?
    ;;
  go)
    LINT_OUTPUT=$(golangci-lint run "$FILE_PATH" 2>&1)
    LINT_EXIT=$?
    ;;
esac

if [ $LINT_EXIT -ne 0 ] && [ -n "$LINT_OUTPUT" ]; then
  echo "Lint issues found in $FILE_PATH:" >&2
  echo "$LINT_OUTPUT" | head -30 >&2
  echo "" >&2
  echo "Please fix these lint issues." >&2
fi

# Always exit 0 — the edit already happened, we are just providing feedback
exit 0

Configuration

Add to .claude/settings.json:

json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/lint-check.sh"
          }
        ]
      }
    ]
  }
}

Hook 10: Auto-Verify With an Agent Hook

Event: Stop | Type: agent

Instead of a shell script, use an agent hook that spawns a subagent with tool access to verify your code. The agent can read files, run commands, and make informed decisions about whether Claude should keep working.

Configuration

Add to .claude/settings.json:

json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "agent",
            "prompt": "Check if the changes Claude just made are complete and correct. Run the test suite with 'pnpm test'. If any tests fail, respond with {\"ok\": false, \"reason\": \"description of failures\"}. If all tests pass, respond with {\"ok\": true}. Also check if there are any lint errors with 'pnpm lint'. $ARGUMENTS",
            "timeout": 120
          }
        ]
      }
    ]
  }
}

Agent hooks are more powerful than shell script hooks because the agent can:

  • Run multiple commands and check results sequentially
  • Read files to verify changes look correct
  • Search the codebase for related issues
  • Make judgment calls about whether something is done

The tradeoff is they are slower (30-60 seconds vs. instant) and cost API credits. Use them for critical verification steps, not routine formatting.


Combining Multiple Hooks

You can use multiple hooks for the same event. They run in parallel. Here is a complete configuration that combines several hooks from this guide:

json
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/git-context.sh"
          }
        ]
      },
      {
        "matcher": "compact",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/post-compact.sh"
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous.sh"
          }
        ]
      },
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/protect-files.sh"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/auto-format.sh"
          }
        ]
      },
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/log-commands.sh"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-verify.sh",
            "timeout": 120
          }
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\" sound name \"Ping\"'"
          }
        ]
      }
    ]
  }
}

Troubleshooting

Hook not firing

  • Run /hooks in Claude Code to verify the hook appears under the correct event
  • Check that the matcher pattern matches exactly (matchers are case-sensitive regex)
  • Verify the script is executable: chmod +x .claude/hooks/my-script.sh

JSON validation failed

If your shell profile (~/.zshrc) prints text on startup, it can corrupt hook JSON output. Wrap echo statements in an interactive check:

bash
# In ~/.zshrc
if [[ $- == *i* ]]; then
  echo "Shell ready"  # Only prints in interactive shells
fi

Stop hook runs forever

Always check stop_hook_active at the top of your Stop hook scripts to prevent infinite loops:

bash
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0  # Let Claude stop
fi

Debugging

  • Toggle verbose mode with Ctrl+O to see hook output in the transcript
  • Run claude --debug for full execution details
  • Test scripts manually: echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh

Quick Reference

HookEventMatcherPurpose
Auto-formatPostToolUse`EditWrite`
Block dangerousPreToolUseBashPrevent destructive commands
NotificationsNotification*Desktop alerts when Claude needs input
Test on StopStop—Run tests before Claude finishes
Protect filesPreToolUse`EditWrite`
Git contextSessionStartstartupLoad recent git state into context
Log commandsPostToolUseBashAudit trail of all commands
Post-compactSessionStartcompactRe-inject context after compaction
Lint checkPostToolUse`EditWrite`
Agent verifyStop—AI-powered test + lint verification

All scripts are available as a starter kit. Create the .claude/hooks/ directory in your project and add the scripts you need. Start with hooks 2 (block dangerous), 3 (notifications), and 4 (test on stop) — they provide the highest immediate value.

Previous
How to Code Faster With AI Assistants: A Developer's Guide
Next
How to Protect Sensitive Files From AI Coding Agents

Explore Rules by Language

TypeScript Agent RulesPython Agent RulesGo Agent RulesRust Agent RulesJavaScript Agent RulesJava Agent Rules→ All Languages