Skip to main content
Claude Code Tutorial: From First Command to Custom Workflows

Hook Recipes: 4 Real-World Automations

Four complete hook implementations: auto-formatting after edits, blocking risky paths, logging every command, and desktop notifications on completion.

Intermediate17 minBy ToolDix Editorial

Learning objectives

  • Implement a post-edit hook that auto-formats changed files
  • Build a path-protection hook that blocks edits to sensitive files
  • Create a logging hook that audits every shell command
  • Write a notification hook that alerts you when Claude finishes

ToolDix original visual

Claude Code Tutorial practice loop
1

Frame

Name the outcome and constraints.

2

Build

Try one bounded workflow.

3

Review

Keep evidence, revise, and share.

This lesson provides four complete, copy-paste-ready hook recipes for real workflows. Each is production-tested and includes the full hook script, settings registration, and expected behavior.

Recipe 1: Auto-format on file edit

Problem: Claude edits code files but doesn't run formatters. You want Prettier, Black, or similar to run automatically on every edit.

Solution: Use PostToolUse after the Edit or Write tool completes, detect the file type, and run the appropriate formatter.

Hook script: .claude/hooks/auto-format.sh

#!/bin/bash

# auto-format.sh
# Runs formatters (Prettier, Black, etc.) after Claude edits files.
# Detects file type and runs the appropriate formatter.

set -euo pipefail

input=$(cat)

# Extract fields
tool_name=$(echo "$input" | jq -r '.tool_name // empty')
event=$(echo "$input" | jq -r '.hook_event_name // empty')

# Only trigger on Edit or Write tool completion
if [[ "$tool_name" != "Edit" && "$tool_name" != "Write" ]]; then
  exit 0
fi

# Get list of recently edited files from transcript
transcript_path=$(echo "$input" | jq -r '.transcript_path // empty')
cwd=$(echo "$input" | jq -r '.cwd // empty')

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

# Parse transcript JSONL to find recently edited files
# (Last few lines, extract file paths)
edited_files=$(tail -20 "$transcript_path" \
  | jq -r 'select(.type == "tool_result")
           | select(.tool == "Edit" or .tool == "Write")
           | .content | fromjson | .file // empty' 2>/dev/null | sort -u)

# Format each file based on its extension
for file in $edited_files; do
  # Skip if file doesn't exist
  [[ -f "$cwd/$file" ]] || continue

  ext="${file##*.}"

  case "$ext" in
    js|jsx|ts|tsx|json|yaml|yml|css|html)
      # Prettier for JS/TS/web files
      if command -v prettier &>/dev/null; then
        prettier --write "$cwd/$file" 2>/dev/null || true
        echo "Formatted $file with Prettier" >&2
      fi
      ;;
    py)
      # Black for Python
      if command -v black &>/dev/null; then
        black "$cwd/$file" 2>/dev/null || true
        echo "Formatted $file with Black" >&2
      fi
      ;;
    go)
      # gofmt for Go
      if command -v gofmt &>/dev/null; then
        gofmt -w "$cwd/$file" 2>/dev/null || true
        echo "Formatted $file with gofmt" >&2
      fi
      ;;
    rs)
      # rustfmt for Rust
      if command -v rustfmt &>/dev/null; then
        rustfmt "$cwd/$file" 2>/dev/null || true
        echo "Formatted $file with rustfmt" >&2
      fi
      ;;
  esac
done

echo '{"reason": "Auto-formatting complete (or skipped if no formatter installed)"}'
exit 0

Settings registration: .claude/settings.json

{
  "hooks": [
    {
      "event": "PostToolUse",
      "matcher": "Edit|Write",
      "type": "command",
      "command": "/absolute/path/.claude/hooks/auto-format.sh",
      "timeout": 10,
      "continueOnError": true
    }
  ]
}

Expected behavior

User: "Add a new function to utils.js"
Claude: [edits utils.js]
Hook fires (PostToolUse):
  ✓ Detected utils.js (JavaScript)
  ✓ Ran Prettier
  ✓ Formatted with 2-space indent
Claude: "Done. The code is formatted."

Recipe 2: Block edits to protected files

Problem: You want Claude to never edit .env, .secrets, production config files, or other sensitive paths.

Solution: Use PreToolUse on Edit/Write to intercept and block by file path.

Hook script: .claude/hooks/protect-sensitive-files.sh

#!/bin/bash

# protect-sensitive-files.sh
# Blocks editing of sensitive files (.env, secrets, config, etc.)
# Exits with code 2 (blocking error) if file is protected.

set -euo pipefail

input=$(cat)

tool_name=$(echo "$input" | jq -r '.tool_name // empty')
tool_input=$(echo "$input" | jq -r '.tool_input // {}')

# Only check Edit and Write tools
if [[ "$tool_name" != "Edit" && "$tool_name" != "Write" ]]; then
  echo '{"decision": "allow"}'
  exit 0
fi

# Extract the file path from tool input
file=$(echo "$tool_input" | jq -r '.file // empty')

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

# List of sensitive file patterns (glob patterns)
protected_patterns=(
  ".env*"
  ".secrets*"
  "*.key"
  "*.pem"
  "*.p12"
  ".ssh/*"
  "config/credentials*"
  "src/config/production*"
  ".github/workflows/*"
  "infrastructure/*"
  "terraform/*"
  "deploy/*"
)

# Check if file matches any protected pattern
for pattern in "${protected_patterns[@]}"; do
  if [[ "$file" == $pattern ]]; then
    cat >&2 <<EOF
❌ BLOCKED: Cannot edit protected file: $file

This file is protected from automated editing:
  - Environment files (.env, .env.local, etc.)
  - Secrets and keys (.key, .pem, .secrets)
  - Infrastructure-as-code (terraform/, deploy/)
  - CI/CD configuration (.github/workflows/)

If you need to edit this file, do it manually or remove it from the protected list.
EOF
    exit 2
  fi
done

# File is safe
echo '{"decision": "allow", "reason": "File is not protected"}'
exit 0

Settings registration

{
  "hooks": [
    {
      "event": "PreToolUse",
      "matcher": "Edit|Write",
      "type": "command",
      "command": "/absolute/path/.claude/hooks/protect-sensitive-files.sh",
      "timeout": 2,
      "continueOnError": false
    }
  ]
}

Expected behavior

User: "Update the database connection string"
Claude: "I'll update the config... I'll modify .env to include the new connection string"
Hook blocks:
  ❌ BLOCKED: Cannot edit protected file: .env
Claude: (can't proceed; asks user to manually update .env)

Recipe 3: Audit every shell command

Problem: You want a full audit log of every command Claude runs, including timestamp, exit code, and output snippet.

Solution: Use PreToolUse and PostToolUse on Bash to log command execution.

Hook script: .claude/hooks/audit-bash.sh

#!/bin/bash

# audit-bash.sh
# Logs every Bash command Claude executes.
# Creates an audit log at ~/.claude-audit.jsonl

set -euo pipefail

input=$(cat)

hook_event=$(echo "$input" | jq -r '.hook_event_name // empty')
tool_name=$(echo "$input" | jq -r '.tool_name // empty')
session_id=$(echo "$input" | jq -r '.session_id // empty')
cwd=$(echo "$input" | jq -r '.cwd // empty')
tool_call_id=$(echo "$input" | jq -r '.tool_call_id // empty')
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

# Only log Bash commands
if [[ "$tool_name" != "Bash" ]]; then
  exit 0
fi

# Get audit log path
audit_log="${HOME}/.claude-audit.jsonl"

case "$hook_event" in
  PreToolUse)
    # Log command before execution
    command=$(echo "$input" | jq -r '.tool_input.command // empty')
    cat >> "$audit_log" <<EOF
{"event": "PreToolUse", "timestamp": "$timestamp", "session_id": "$session_id", "tool_call_id": "$tool_call_id", "cwd": "$cwd", "command": "$command"}
EOF
    echo '{"reason": "Logged command"}'
    ;;

  PostToolUse)
    # Log successful execution
    exit_code=$(echo "$input" | jq -r '.exit_code // 0')
    exec_time=$(echo "$input" | jq -r '.execution_time_ms // 0')
    cat >> "$audit_log" <<EOF
{"event": "PostToolUse", "timestamp": "$timestamp", "session_id": "$session_id", "tool_call_id": "$tool_call_id", "exit_code": $exit_code, "execution_time_ms": $exec_time}
EOF
    echo '{"reason": "Logged completion"}'
    ;;

  PostToolUseFailure)
    # Log failed execution
    exit_code=$(echo "$input" | jq -r '.exit_code // 1')
    error=$(echo "$input" | jq -r '.error // "unknown error"')
    cat >> "$audit_log" <<EOF
{"event": "PostToolUseFailure", "timestamp": "$timestamp", "session_id": "$session_id", "tool_call_id": "$tool_call_id", "exit_code": $exit_code, "error": "$error"}
EOF
    echo '{"reason": "Logged failure"}'
    ;;
esac

exit 0

Settings registration

{
  "hooks": [
    {
      "event": "PreToolUse",
      "matcher": "Bash",
      "type": "command",
      "command": "/absolute/path/.claude/hooks/audit-bash.sh",
      "continueOnError": true
    },
    {
      "event": "PostToolUse",
      "matcher": "Bash",
      "type": "command",
      "command": "/absolute/path/.claude/hooks/audit-bash.sh",
      "continueOnError": true
    },
    {
      "event": "PostToolUseFailure",
      "matcher": "Bash",
      "type": "command",
      "command": "/absolute/path/.claude/hooks/audit-bash.sh",
      "continueOnError": true
    }
  ]
}

Audit log inspection

# View the audit log
tail -20 ~/.claude-audit.jsonl | jq .

# Output:
# {"event": "PreToolUse", "timestamp": "2026-07-23T14:30:45Z", "session_id": "sess_abc", "tool_call_id": "tool_1", "cwd": "/home/user/project", "command": "npm test"}
# {"event": "PostToolUse", "timestamp": "2026-07-23T14:30:47Z", "session_id": "sess_abc", "tool_call_id": "tool_1", "exit_code": 0, "execution_time_ms": 2450}

# Analyze: count commands by type
cat ~/.claude-audit.jsonl | jq -r '.command' | sort | uniq -c | sort -rn

# Analyze: find failed commands
cat ~/.claude-audit.jsonl | jq 'select(.event == "PostToolUseFailure")'

Recipe 4: Desktop notification on completion

Problem: You want a desktop notification (macOS, Linux, or Windows) when Claude finishes a task so you don't have to watch the terminal.

Solution: Use Stop hook to trigger system notifications.

Hook script: .claude/hooks/notify-completion.sh

#!/bin/bash

# notify-completion.sh
# Sends desktop notification when Claude finishes a turn.
# Supports macOS (osascript), Linux (notify-send), and Windows (PowerShell).

set -euo pipefail

input=$(cat)

session_id=$(echo "$input" | jq -r '.session_id // "unknown"')
turn=$(echo "$input" | jq -r '.turn_number // 0')
tool_count=$(echo "$input" | jq -r '.tool_call_count // 0')

# Determine OS and send notification accordingly
if [[ "$OSTYPE" == "darwin"* ]]; then
  # macOS: use osascript
  osascript <<EOF
    display notification "Claude Code completed turn #$turn ($tool_count tools)" \
      with title "Claude Code" \
      subtitle "Session: $session_id"
EOF

elif command -v notify-send &>/dev/null; then
  # Linux: use notify-send
  notify-send -a "Claude Code" "Claude finished" \
    "Turn #$turn completed with $tool_count tools\nSession: $session_id"

elif command -v powershell &>/dev/null; then
  # Windows: use PowerShell toast
  powershell -Command "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null; [Windows.UI.Notifications.ToastNotification, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null; @('<toast><visual><binding template=\"ToastText02\"><text id=\"1\">Claude Code</text><text id=\"2\">Turn #$turn completed with $tool_count tools</text></binding></visual></toast>') | foreach {[xml]\$xml = \$_; [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Claude Code').Show([Windows.UI.Notifications.ToastNotification]::new(\$xml)) }" 2>/dev/null || true
fi

echo '{"reason": "Notification sent"}'
exit 0

Settings registration

{
  "hooks": [
    {
      "event": "Stop",
      "type": "command",
      "command": "/absolute/path/.claude/hooks/notify-completion.sh",
      "continueOnError": true
    }
  ]
}

Expected behavior

When Claude finishes a turn:

  • macOS: Small notification pops from top-right corner: "Claude Code: Claude completed turn #3 (5 tools)"
  • Linux: System tray notification appears
  • Windows: Toast notification in lower-right corner

You can now step away from the terminal and get a notification when Claude finishes.


Testing recipes locally

Before registering in settings, test each hook manually:

# Test Recipe 1 (auto-format)
echo '{
  "hook_event_name": "PostToolUse",
  "tool_name": "Write",
  "transcript_path": "~/.claude/sessions/transcript.jsonl",
  "cwd": "/tmp"
}' | bash .claude/hooks/auto-format.sh

# Test Recipe 2 (protect-sensitive)
echo '{
  "hook_event_name": "PreToolUse",
  "tool_name": "Edit",
  "tool_input": {"file": ".env"}
}' | bash .claude/hooks/protect-sensitive-files.sh
# Expected: Exit code 2, error on stderr

# Test Recipe 4 (notify)
echo '{
  "hook_event_name": "Stop",
  "session_id": "test",
  "turn_number": 1,
  "tool_call_count": 5
}' | bash .claude/hooks/notify-completion.sh

Common mistake

Assuming hooks fire in the order registered. Hooks don't have a guaranteed execution order. If you depend on one hook running before another (e.g., validation before formatting), register them separately and test thoroughly. Also, never put long-running operations in PreToolUse hooks — if your hook takes 30 seconds, Claude will timeout waiting for it. Keep Pre/Post hooks fast (under 5 seconds) and offload heavy work to background tasks.

Sources and license context

These references informed the lesson. ToolDix adds its own explanation, workflow, and practice rather than reproducing source material. Every link below leaves ToolDix and opens the publisher's own site in a new tab.

Keep going

Read these next on ToolDix.

Original lessons that build on what you just read.