Applied Intelligence
Module 11: Skills, Hooks, and Automation

Practical Hook Patterns

From theory to automation

The previous section covered hook mechanics: events, input contracts, exit codes, and JSON output. This section puts that knowledge to work with patterns that solve real problems.

Each pattern here follows a consistent structure: what problem it solves, how the hook implements it, and the configuration. These are not abstract examples. They are the patterns that production teams actually deploy.

Automatic formatting after edits

The most common hook pattern: format code after every edit, regardless of what Claude generates. A PostToolUse hook intercepts file modifications and runs the appropriate formatter.

Language-aware formatting

A single hook can handle multiple languages by inspecting the file extension:

#!/bin/bash
# .claude/hooks/format-code.sh

input=$(cat /dev/stdin)
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
    js|jsx|ts|tsx|json|css|scss|md|mdx|yaml|yml)
        if command -v prettier &> /dev/null; then
            prettier --write "$file_path" 2>/dev/null
        fi
        ;;
    py)
        if command -v ruff &> /dev/null; then
            ruff format "$file_path" 2>/dev/null
        elif command -v black &> /dev/null; then
            black --quiet "$file_path" 2>/dev/null
        fi
        ;;
    go)
        gofmt -w "$file_path" 2>/dev/null
        if command -v goimports &> /dev/null; then
            goimports -w "$file_path" 2>/dev/null
        fi
        ;;
    rs)
        if command -v rustfmt &> /dev/null; then
            rustfmt "$file_path" 2>/dev/null
        fi
        ;;
    kt|kts)
        if command -v ktlint &> /dev/null; then
            ktlint --format "$file_path" 2>/dev/null
        fi
        ;;
esac

exit 0

Configure the hook to fire after any file modification:

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

The hook silences formatter output by redirecting stderr to /dev/null. No noisy formatting messages in the conversation. Claude sees only the final formatted result when it reads files back.

Why PostToolUse, not instructions

The temptation is to put formatting instructions in CLAUDE.md: "Always format code with Prettier before committing." This fails in practice.

Claude might forget. Claude might decide formatting is unnecessary for a quick change. Claude might hit an error and skip formatting to finish faster.

A PostToolUse hook removes that uncertainty. The formatter runs after every edit. Every time. Claude's formatting habits become irrelevant because formatting happens at the application level.

This principle extends beyond formatting. Anything that must happen every time belongs in a hook, not in instructions. Instructions are for guidance Claude should usually follow. Hooks are for requirements that cannot be skipped.

File protection patterns

PreToolUse hooks block operations before they happen. The agent cannot bypass this protection, even accidentally.

Blocking sensitive files

The basic pattern checks file paths against a blocklist:

#!/bin/bash
# .claude/hooks/protect-sensitive.sh

input=$(cat /dev/stdin)
file_path=$(echo "$input" | jq -r '.tool_input.file_path // empty')

# Patterns to block
blocked_patterns=(
    ".env"
    ".env.local"
    ".env.production"
    "credentials.json"
    "secrets.yaml"
    "*.pem"
    "*.key"
    "id_rsa"
    "id_ed25519"
)

for pattern in "${blocked_patterns[@]}"; do
    case "$file_path" in
        *$pattern*)
            echo "Blocked: cannot modify sensitive file matching pattern '$pattern'" >&2
            exit 2
            ;;
    esac
done

# Block path traversal attempts
if [[ "$file_path" == *".."* ]]; then
    echo "Blocked: path traversal detected" >&2
    exit 2
fi

exit 0

Configure for Edit, Write, and Read operations:

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

Blocking Read, Edit, and Write does not block Bash. A command like cat .env or echo "secret" >> .env bypasses the hook entirely. For complete protection, add a separate Bash hook that inspects command content, or use Bash deny patterns in permissions.

Protecting generated files

Some files should never be manually edited because they are generated from other sources:

#!/bin/bash
# .claude/hooks/protect-generated.sh

input=$(cat /dev/stdin)
file_path=$(echo "$input" | jq -r '.tool_input.file_path // empty')

generated_files=(
    "package-lock.json"
    "yarn.lock"
    "pnpm-lock.yaml"
    "composer.lock"
    "Cargo.lock"
    "poetry.lock"
    "dist/"
    "build/"
    "node_modules/"
    ".next/"
    "__pycache__/"
)

for pattern in "${generated_files[@]}"; do
    case "$file_path" in
        *$pattern*)
            echo "Blocked: '$file_path' is generated. Modify the source file instead." >&2
            exit 2
            ;;
    esac
done

exit 0

The error message guides Claude toward the correct approach. Instead of editing package-lock.json, Claude should modify package.json and run npm install. The hook enforces the workflow without requiring Claude to remember it.

Logging for compliance

Enterprise environments often require audit trails of agent activity. Hooks enable logging without modifying Claude Code itself.

Tool call logging

A PreToolUse hook can log every tool invocation:

#!/bin/bash
# .claude/hooks/audit-log.sh

input=$(cat /dev/stdin)
log_file="$CLAUDE_PROJECT_DIR/.claude/audit.log"

timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
session_id=$(echo "$input" | jq -r '.session_id')
tool_name=$(echo "$input" | jq -r '.tool_name')
tool_input=$(echo "$input" | jq -c '.tool_input')

echo "$timestamp | session=$session_id | tool=$tool_name | input=$tool_input" >> "$log_file"

exit 0

This creates an append-only log of every tool Claude attempts to use:

2026-01-26T14:32:15Z | session=abc123 | tool=Edit | input={"file_path":"/src/app.ts","old_string":"...","new_string":"..."}
2026-01-26T14:32:18Z | session=abc123 | tool=Bash | input={"command":"npm test"}
2026-01-26T14:32:45Z | session=abc123 | tool=Write | input={"file_path":"/src/utils.ts","content":"..."}

The log records what Claude attempted, not what succeeded. Pair with a PostToolUse hook to log outcomes:

#!/bin/bash
# .claude/hooks/audit-log-result.sh

input=$(cat /dev/stdin)
log_file="$CLAUDE_PROJECT_DIR/.claude/audit.log"

timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
session_id=$(echo "$input" | jq -r '.session_id')
tool_name=$(echo "$input" | jq -r '.tool_name')
# Truncate output to avoid massive logs
tool_output=$(echo "$input" | jq -r '.tool_output[:500]')

echo "$timestamp | session=$session_id | tool=$tool_name | result=success | output=$tool_output" >> "$log_file"

exit 0

Structured JSON logging

For integration with log aggregation systems, output structured JSON:

#!/usr/bin/env python3
# .claude/hooks/structured-audit.py

import json
import sys
import os
from datetime import datetime

input_data = json.load(sys.stdin)
log_file = os.path.join(os.environ.get('CLAUDE_PROJECT_DIR', '.'), '.claude', 'audit.jsonl')

log_entry = {
    "timestamp": datetime.utcnow().isoformat() + "Z",
    "session_id": input_data.get("session_id"),
    "event": input_data.get("hook_event_name"),
    "tool": input_data.get("tool_name"),
    "input": input_data.get("tool_input"),
    "user": os.environ.get("USER"),
    "project": os.environ.get("CLAUDE_PROJECT_DIR")
}

os.makedirs(os.path.dirname(log_file), exist_ok=True)
with open(log_file, "a") as f:
    f.write(json.dumps(log_entry) + "\n")

sys.exit(0)

JSONL format integrates with tools like Elasticsearch, Splunk, and Datadog for compliance monitoring and anomaly detection.

Custom notifications

The Notification event fires when Claude Code wants to alert the user. Hooks can route these notifications to external systems.

Desktop notifications

On macOS, use osascript for native notifications:

#!/bin/bash
# .claude/hooks/notify-macos.sh

input=$(cat /dev/stdin)
message=$(echo "$input" | jq -r '.message // "Claude Code notification"')

osascript -e "display notification \"$message\" with title \"Claude Code\""

exit 0

On Linux, use notify-send:

#!/bin/bash
# .claude/hooks/notify-linux.sh

input=$(cat /dev/stdin)
message=$(echo "$input" | jq -r '.message // "Claude Code notification"')

notify-send "Claude Code" "$message"

exit 0

Slack notifications

For team visibility, route notifications to Slack:

#!/bin/bash
# .claude/hooks/notify-slack.sh

input=$(cat /dev/stdin)
message=$(echo "$input" | jq -r '.message // "Claude Code notification"')
session_id=$(echo "$input" | jq -r '.session_id')

webhook_url="$SLACK_WEBHOOK_URL"

if [ -n "$webhook_url" ]; then
    curl -s -X POST "$webhook_url" \
        -H "Content-type: application/json" \
        --data "{\"text\": \"*Claude Code*: $message\nSession: \`$session_id\`\"}" \
        > /dev/null
fi

exit 0

Stop event notifications

The Stop event fires when Claude finishes responding. A hook can notify when long-running tasks complete:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Task complete\" with title \"Claude Code\" sound name \"Glass\"'"
          }
        ]
      }
    ]
  }
}

The sound cue helps when working on other tasks while Claude runs a lengthy operation.

Input modification with updatedInput

Since Claude Code v2.0.10, PreToolUse hooks can modify tool inputs before execution. This enables transformations that would otherwise require blocking and re-prompting.

Enforcing safe defaults

A hook can inject safety flags into commands:

#!/usr/bin/env python3
# .claude/hooks/safe-bash.py

import json
import sys

input_data = json.load(sys.stdin)

if input_data.get('tool_name') != 'Bash':
    sys.exit(0)

command = input_data.get('tool_input', {}).get('command', '')

# Add --dry-run to destructive git commands
if 'git clean' in command and '--dry-run' not in command:
    modified_command = command.replace('git clean', 'git clean --dry-run')

    output = {
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "allow",
            "permissionDecisionReason": "Added --dry-run for safety",
            "updatedInput": {
                "command": modified_command,
                "description": input_data.get('tool_input', {}).get('description', '')
            },
            "additionalContext": "Note: --dry-run was automatically added. Review output before running without it."
        }
    }
    print(json.dumps(output))

sys.exit(0)

Claude sees the modified command in the output, understands the safety flag was added, and can adjust if actually destructive execution is needed.

Redirecting writes to sandbox

For risky experimentation, redirect writes to a sandbox directory:

#!/usr/bin/env python3
# .claude/hooks/sandbox-writes.py

import json
import sys
import os

input_data = json.load(sys.stdin)
tool_name = input_data.get('tool_name', '')

if tool_name not in ['Write', 'Edit']:
    sys.exit(0)

tool_input = input_data.get('tool_input', {})
original_path = tool_input.get('file_path', '')

# Only sandbox paths outside project
project_dir = os.environ.get('CLAUDE_PROJECT_DIR', '')
if not original_path.startswith(project_dir):
    sandbox_path = os.path.join(project_dir, '.sandbox', original_path.lstrip('/'))

    output = {
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "allow",
            "permissionDecisionReason": f"Redirected to sandbox: {sandbox_path}",
            "updatedInput": {
                **tool_input,
                "file_path": sandbox_path
            }
        }
    }
    print(json.dumps(output))

sys.exit(0)

Hook composition patterns

Complex automation requires multiple hooks working together.

Layered validation

Separate hooks for different concerns keep configuration readable:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {"type": "command", "command": ".claude/hooks/protect-sensitive.sh"},
          {"type": "command", "command": ".claude/hooks/protect-generated.sh"},
          {"type": "command", "command": ".claude/hooks/validate-paths.sh"}
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {"type": "command", "command": ".claude/hooks/format-code.sh"},
          {"type": "command", "command": ".claude/hooks/audit-log.sh"}
        ]
      }
    ]
  }
}

Hooks in the same array execute sequentially. If any PreToolUse hook exits with code 2, subsequent hooks do not run and the tool call is blocked.

Team hook distribution

Store hooks in the repository so the team shares automation:

.claude/
├── settings.json      # Hook configuration
└── hooks/
    ├── protect-sensitive.sh
    ├── format-code.sh
    ├── audit-log.sh
    └── README.md      # Documents what each hook does

The README documents what each hook does. New team members understand the automation without reading every script. When hooks change, code review ensures everyone agrees on the new behavior.

Hooks in .claude/settings.json affect everyone working on the project. Personal hooks that should not apply to teammates belong in ~/.claude/settings.json instead.

Hooks transform Claude Code from a conversational tool into a programmable platform. Formatting, protection, logging, notifications, input modification: these patterns are the foundation. The automation you build on top depends on your workflow and compliance requirements.

On this page