Skip to main content
Featured hooks claude code automation tutorial

Claude Code Hooks 2026: Automate Your Dev Workflow

Learn Claude Code Hooks with 10+ practical examples. Master PreToolUse, PostToolUse, and automation patterns. Includes security guards, debugging tips, and best practices.

January 17, 2026 20 min read By Claude World

Hooks are a powerful extension mechanism in Claude Code that lets you inject custom logic before and after tool execution. This guide compiles practical experience to help you avoid common pitfalls and quickly master Hooks development.


Hook Types Overview

Hook TypeTrigger TimingUse Case
PreToolUseBefore tool executionBlock dangerous operations, add context
PostToolUseAfter tool executionLog operations, post-processing
SessionStartWhen session startsLoad environment, display information
StopWhen automation loop endsDecide whether to continue looping

Input/Output Specification

Input Format (stdin JSON)

All Hooks receive JSON-formatted input:

{
  "tool_name": "Bash",
  "tool_input": {
    "command": "ls -la",
    "file_path": "/path/to/file",
    "content": "file content..."
  },
  "tool_response": "command output..."
}

Common fields:

  • tool_name - Tool name (Bash, Write, Edit, Read, etc.)
  • tool_input.command - Bash command
  • tool_input.file_path - File path
  • tool_input.content - File content for Write
  • tool_input.new_string - New content for Edit
  • tool_response - Tool execution result (PostToolUse)

Output Format

1. Allow Operation (no output)

exit 0

2. Block Operation (exit 2 + stderr)

echo "🛡️ BLOCKED: Reason explanation" >&2
exit 2

3. Add Context (PreToolUse)

jq -n --arg ctx "Context message" '{
    "hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "additionalContext": $ctx
    }
}'
exit 0

4. Stop Hook Prevent Stop

jq -n --arg reason "Continue reason" '{
    "decision": "block",
    "reason": $reason
}'
exit 0

⚠️ Important: Old Format No Longer Supported

# ❌ Wrong (causes hook error)
echo '{"decision": "allow"}'

# ✅ Correct
exit 0

Script Templates

PreToolUse - Blocking Type (Security Guard)

#!/bin/bash
# Security Guard - Block dangerous commands
# PreToolUse hook for Bash tool

# Read input
HOOK_INPUT=$(cat)
COMMAND=$(echo "$HOOK_INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || echo "")

# Skip if no command
[[ -z "$COMMAND" ]] && exit 0

# Check dangerous patterns
# 1. Delete root directory
if echo "$COMMAND" | grep -qE 'rm\s+-[rf]+\s+/($|[^a-zA-Z])' 2>/dev/null; then
    echo "🛡️ BLOCKED: Delete root filesystem (rm -rf /)" >&2
    exit 2
fi

# 2. Pipe remote script to shell
if echo "$COMMAND" | grep -qE '(curl|wget).*\|\s*(sh|bash)' 2>/dev/null; then
    echo "🛡️ BLOCKED: Pipe remote script to shell" >&2
    exit 2
fi

# 3. Force push to main/master
if echo "$COMMAND" | grep -qE 'git\s+push.*--force.*\s+(main|master)' 2>/dev/null; then
    echo "🛡️ BLOCKED: Force push to main/master" >&2
    exit 2
fi

# Allow execution
exit 0

PreToolUse - Context Type (Git Context)

#!/bin/bash
# Git Context - Add Git status context
# PreToolUse hook for Bash tool

# Consume stdin (important! consume even if not needed)
cat > /dev/null

# Check if in git repo
if ! git rev-parse --git-dir &>/dev/null; then
    exit 0
fi

# Collect Git info
BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
UNCOMMITTED=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')

# Output context
jq -n --arg ctx "[Git] Branch: $BRANCH, Uncommitted: $UNCOMMITTED files" '{
    "hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "additionalContext": $ctx
    }
}'
exit 0

PreToolUse - Warning Type (File Guard)

#!/bin/bash
# File Guard - Warn about sensitive files
# PreToolUse hook for Write/Edit tools

HOOK_INPUT=$(cat)
FILE_PATH=$(echo "$HOOK_INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null || echo "")

[[ -z "$FILE_PATH" ]] && exit 0

FILENAME=$(basename "$FILE_PATH")
WARNINGS=()

# Check sensitive patterns
if echo "$FILENAME" | grep -qiE '^\.env$|^\.env\.' 2>/dev/null; then
    WARNINGS+=("Environment file may contain secrets")
fi

if echo "$FILENAME" | grep -qiE '\.pem$|\.key$|^id_rsa' 2>/dev/null; then
    WARNINGS+=("File may be a private key")
fi

# Output warnings
if [[ ${#WARNINGS[@]} -gt 0 ]]; then
    WARNING_TEXT=$(printf '%s; ' "${WARNINGS[@]}")
    jq -n --arg warn "⚠️ Sensitive file ($FILENAME): $WARNING_TEXT" '{
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "additionalContext": $warn
        }
    }'
fi

exit 0

PostToolUse - Logging Type

#!/bin/bash
# Log File Change - Record file changes
# PostToolUse hook for Write/Edit tools

HOOK_INPUT=$(cat)
TOOL_NAME=$(echo "$HOOK_INPUT" | jq -r '.tool_name // empty' 2>/dev/null || echo "")
FILE_PATH=$(echo "$HOOK_INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null || echo "")

[[ -z "$TOOL_NAME" ]] && exit 0
[[ -z "$FILE_PATH" ]] && exit 0

# Ensure log directory exists
mkdir -p .claude/logs 2>/dev/null

# Record changes
case "$TOOL_NAME" in
    Write)
        echo "[$(date -Iseconds)] WRITE: $FILE_PATH" >> .claude/logs/file-changes.log
        ;;
    Edit)
        echo "[$(date -Iseconds)] EDIT: $FILE_PATH" >> .claude/logs/file-changes.log
        ;;
esac

exit 0

Stop Hook - Loop Control

#!/bin/bash
# Post Auto Check - Control automation loop
# Stop hook

AUTO_DIR=".auto"
AC_FILE="$AUTO_DIR/acceptance-criteria.json"
STOP_FILE="$AUTO_DIR/stop"

# No state directory, allow stop
if [ ! -d "$AUTO_DIR" ]; then
    exit 0
fi

# Check manual stop signal
if [ -f "$STOP_FILE" ]; then
    rm -rf "$AUTO_DIR"
    exit 0
fi

# Check AC completion rate
if [ -f "$AC_FILE" ] && command -v jq &> /dev/null; then
    PASSED=$(jq '[.criteria[] | select(.status=="passed")] | length' "$AC_FILE" 2>/dev/null || echo "0")
    TOTAL=$(jq '.criteria | length' "$AC_FILE" 2>/dev/null || echo "0")

    if [ "$TOTAL" -gt 0 ] && [ "$PASSED" -lt "$TOTAL" ]; then
        # Block stop, continue loop
        jq -n --arg reason "AC completion: $PASSED/$TOTAL, continuing" '{
            "decision": "block",
            "reason": $reason
        }'
        exit 0
    fi
fi

# Allow stop
exit 0

Design Patterns

Global Shared + Namespace

Install hooks at user level, shared across all projects:

~/.claude/hooks/bootstrap-kit/
├── pre-tool-use/
│   ├── security-guard.sh
│   ├── git-context.sh
│   ├── file-guard.sh
│   └── architecture-guard.sh
├── log-bash-event.sh
├── log-file-change.sh
├── session-start/
│   └── session-start.sh
└── auto/
    └── post_auto_check.sh

settings.json Configuration

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "/Users/username/.claude/hooks/bootstrap-kit/pre-tool-use/security-guard.sh"
          }
        ]
      },
      {
        "matcher": "Write",
        "hooks": [
          {
            "type": "command",
            "command": "/Users/username/.claude/hooks/bootstrap-kit/pre-tool-use/file-guard.sh"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "/Users/username/.claude/hooks/bootstrap-kit/log-bash-event.sh"
          }
        ]
      }
    ],
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "/Users/username/.claude/hooks/bootstrap-kit/session-start/session-start.sh"
          }
        ]
      }
    ],
    "Stop": [
      {
        "matcher": ".*",
        "hooks": [
          {
            "type": "command",
            "command": "/Users/username/.claude/hooks/bootstrap-kit/auto/post_auto_check.sh"
          }
        ]
      }
    ]
  }
}

Common Errors and Solutions

1. Hook Error Keeps Appearing

Symptom:

PreToolUse:Bash hook error: [/path/to/hook.sh]: ...

Cause: Using old format {"decision": "allow"}

Solution: Use exit 0 (no output)

2. Path Cannot Load

Symptom: Hook doesn’t execute

Cause: Using $HOME or ${HOME} in settings.json

Solution: Use absolute paths

// ❌ Wrong
"command": "$HOME/.claude/hooks/my-hook.sh"

// ✅ Correct
"command": "/Users/username/.claude/hooks/my-hook.sh"

3. Hooks Overriding Each Other

Symptom: Configured hooks don’t take effect

Cause: Conflicting configurations in multiple settings files

Solution: Check and clear hooks in:

  • ~/.claude/settings.local.json
  • .claude/settings.local.json

4. Unconsumed stdin Causes Errors

Symptom: Broken pipe or hook anomaly

Cause: Script doesn’t read stdin

Solution: Consume stdin even if not needed

cat > /dev/null
# or
HOOK_INPUT=$(cat)

5. JSON Output Format Error

Symptom: Context not displayed or hook error

Cause: Incorrect JSON structure

Solution: Use jq to generate JSON

# ❌ Manual concatenation prone to errors
echo '{"hookSpecificOutput":{"additionalContext":"msg"}}'

# ✅ Use jq
jq -n --arg ctx "msg" '{
    "hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "additionalContext": $ctx
    }
}'

Testing Methods

1. Direct Execution Test

# Test script syntax
bash -n /path/to/hook.sh

# Simulate input test
echo '{"tool_input":{"command":"ls"}}' | /path/to/hook.sh

2. Test in Claude Code

# Test PreToolUse (Bash)
echo "test command"

# Test PreToolUse (Write) - observe if there's a warning
# Write to a .env file

# Test Security Guard - should be blocked
rm -rf /

3. Check Logs

# Check file change log
cat .claude/logs/file-changes.log

# Check event log
cat .claude/logs/events.log

Best Practices

1. Always Consume stdin

HOOK_INPUT=$(cat)
# or
cat > /dev/null

2. Use jq for JSON Processing

COMMAND=$(echo "$HOOK_INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || echo "")

3. Defensive Programming

# Check empty values
[[ -z "$COMMAND" ]] && exit 0

# Error handling
command -v jq &> /dev/null || exit 0

4. Use stderr for Block Messages

echo "🛡️ BLOCKED: reason" >&2
exit 2

5. Use Absolute Paths

"command": "/Users/username/.claude/hooks/..."

6. Script Permissions

chmod +x /path/to/hook.sh

7. Log Output to stderr

# Debug messages
echo "[DEBUG] something" >&2

References


This guide is based on Claude Code v2.1.9+ testing. Behavior may change with version updates.