Module 6: Advanced Hooks and Headless SDK

2. SessionStart and Advanced PreToolUse — Automatic Setup and Validation

2. SessionStart and Advanced PreToolUse — Automatic Setup and Validation

Description

Every time you open Claude Code, the first minute is always the same: you verify that the dependencies are installed, that the dev server isn't running on an occupied port, that the database has the migrations up to date. It's tedious and repetitive. SessionStart eliminates that ritual — a hook that triggers automatically when each session starts and runs your setup script.

PreToolUse, on the other hand, is the gatekeeper. You already know it from previous guides in its basic form — a hook that triggers before running a tool. But advanced PreToolUse goes further: conditional validation based on the command's content, blocking dangerous operations with exit code 2, restricting file paths, and matchers with regex to capture groups of tools.

By the end of this capsule you'll know how to configure hooks in settings.json and in subagent frontmatter, you'll understand the JSON format the hooks receive via stdin, and you'll master the three exit codes that control the execution flow. Your Claude Code will start configured automatically and will block dangerous operations before they run.


SessionStart: Automatic Configuration at Startup

The problem

Without SessionStart, your session start looks like this:

You: "Verify that the deps are installed"
Claude: [runs npm install, pip install, etc.]
You: "Verify that the DB is running"
Claude: [checks PostgreSQL, Redis]
You: "Run the pending migrations"
Claude: [runs alembic upgrade head]
You: "Now, implement feature X"

Four wasted interactions. With SessionStart:

[Session starts → SessionStart hook runs the automatic setup]
You: "Implement feature X"

Configuration in settings.json

Hooks are configured in Claude Code's settings file. There are three levels:

LevelFileScope
Project.claude/settings.jsonOnly this project
User~/.claude/settings.jsonAll your projects
EnterpriseManaged configurationThe whole organization

For project-specific hooks, use .claude/settings.json:

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/session-setup.sh"
          }
        ]
      }
    ]
  }
}

The setup script

./scripts/session-setup.sh:

#!/bin/bash

echo "🔧 Configuring the development environment..."

# Verify Node.js
if ! command -v node &> /dev/null; then
    echo "ERROR: Node.js not found"
    exit 1
fi

# Install dependencies if package-lock.json changed
if [ package-lock.json -nt node_modules/.package-lock.json ] 2>/dev/null; then
    echo "📦 Installing dependencies..."
    npm ci --silent
fi

# Verify Python virtual env
if [ -f "requirements.txt" ]; then
    if [ ! -d ".venv" ]; then
        echo "🐍 Creating virtual environment..."
        python3 -m venv .venv
    fi
    source .venv/bin/activate
    pip install -r requirements.txt -q
fi

# Verify the database
if command -v pg_isready &> /dev/null; then
    if ! pg_isready -q 2>/dev/null; then
        echo "⚠️ PostgreSQL is not running"
        exit 1
    fi
fi

# Pending migrations
if [ -d "alembic" ]; then
    CURRENT=$(alembic current 2>/dev/null | tail -1)
    HEAD=$(alembic heads 2>/dev/null | tail -1)
    if [ "$CURRENT" != "$HEAD" ]; then
        echo "📊 Running pending migrations..."
        alembic upgrade head
    fi
fi

echo "✅ Environment ready"
exit 0

Remember to make the script executable:

chmod +x ./scripts/session-setup.sh

SessionStart has no matcher

Unlike PreToolUse or PostToolUse, SessionStart has no matcher field. It triggers once at the start of any session, without conditions. If you need conditional logic, put it inside the script:

#!/bin/bash

# Only run the full setup on weekdays
DAY=$(date +%u)
if [ "$DAY" -gt 5 ]; then
    echo "Weekend — skip the full setup"
    exit 0
fi

# Full setup here...

Exit codes in SessionStart

Exit CodeEffect
0The session starts normally
1Error reported to Claude — the session continues but Claude knows something failed
2Session blocked — Claude Code doesn't start

Exit code 2 in SessionStart is drastic: it prevents the session from starting. Use it only for critical conditions that would make the session useless (e.g., the production database isn't accessible for a project that needs it).


Advanced PreToolUse: Validation and Blocking

Beyond the basic

In previous guides, you used PreToolUse for simple validations. Now you're going to create hooks that:

  1. Read the input JSON to inspect what the tool is going to do
  2. Make conditional decisions based on the content
  3. Block dangerous operations with exit code 2
  4. Use matchers with regex to capture groups of tools

The JSON input

Every hook receives information via stdin in JSON format. For PreToolUse, the JSON includes:

{
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "rm -rf /tmp/test",
    "description": "Clean temp files"
  },
  "session_id": "abc123",
  "transcript_path": "/tmp/claude/transcript-abc123.json"
}

Your script can read this JSON and make decisions:

#!/bin/bash

INPUT=$(cat -)

TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

# Validation logic based on the content

Pattern 1: Block dangerous commands

./scripts/validate-bash.sh:

#!/bin/bash

INPUT=$(cat -)

COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

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

BLOCKED_PATTERNS=(
    "rm -rf /"
    "rm -rf ~"
    "rm -rf \."
    "DROP DATABASE"
    "DROP TABLE"
    "truncate"
    "mkfs"
    "dd if="
    ":(){:|:&};:"
)

for pattern in "${BLOCKED_PATTERNS[@]}"; do
    if echo "$COMMAND" | grep -qi "$pattern"; then
        echo "BLOCKED: Dangerous command detected: $pattern"
        echo "Attempted command: $COMMAND"
        exit 2
    fi
done

DANGEROUS_PATTERNS=(
    "sudo"
    "chmod 777"
    "curl.*|.*sh"
    "wget.*|.*bash"
)

for pattern in "${DANGEROUS_PATTERNS[@]}"; do
    if echo "$COMMAND" | grep -qi "$pattern"; then
        echo "WARNING: Potentially dangerous command: $pattern"
        echo "Command: $COMMAND"
        exit 1
    fi
done

exit 0

Configuration in settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/validate-bash.sh"
          }
        ]
      }
    ]
  }
}

Pattern 2: File path restriction

Prevent Claude from modifying files outside certain directories:

./scripts/validate-file-paths.sh:

#!/bin/bash

INPUT=$(cat -)

TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty')

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

PROTECTED_PATHS=(
    ".env"
    ".env.local"
    ".env.production"
    "credentials"
    "secrets"
    ".ssh"
    ".aws"
)

for protected in "${PROTECTED_PATHS[@]}"; do
    if echo "$FILE_PATH" | grep -qi "$protected"; then
        echo "BLOCKED: Access to protected file: $FILE_PATH"
        exit 2
    fi
done

ALLOWED_DIRS=(
    "src/"
    "tests/"
    "docs/"
    "scripts/"
    ".claude/"
)

ALLOWED=false
for dir in "${ALLOWED_DIRS[@]}"; do
    if echo "$FILE_PATH" | grep -q "^$dir"; then
        ALLOWED=true
        break
    fi
done

if [ "$ALLOWED" = false ]; then
    echo "WARNING: File outside allowed directories: $FILE_PATH"
    echo "Allowed directories: ${ALLOWED_DIRS[*]}"
    exit 1
fi

exit 0

Configuration with a matcher for multiple tools:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/validate-file-paths.sh"
          }
        ]
      }
    ]
  }
}

Pattern 3: Context-based conditional validation

A hook that validates differently depending on the environment:

./scripts/context-validator.sh:

#!/bin/bash

INPUT=$(cat -)

TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

BRANCH=$(git branch --show-current 2>/dev/null)

if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
    if [ "$TOOL_NAME" = "Bash" ]; then
        if echo "$COMMAND" | grep -qiE "(git push|npm publish|deploy)"; then
            echo "BLOCKED: Deployment operation on the main branch"
            echo "Create a feature branch first"
            exit 2
        fi
    fi

    if [ "$TOOL_NAME" = "Write" ] || [ "$TOOL_NAME" = "Edit" ]; then
        echo "WARNING: Editing on the main branch ($BRANCH)"
        echo "Consider creating a feature branch"
        exit 1
    fi
fi

exit 0

Matchers: Regex and pipe-separated

The matcher field accepts two formats:

Exact name:

{ "matcher": "Bash" }

Pipe-separated (OR):

{ "matcher": "Edit|Write" }

Regex:

{ "matcher": ".*" }

The .* matcher captures all tools — useful for universal logging.

No matcher: capture everything

If you omit the matcher field, the hook triggers for all tools:

{
  "hooks": {
    "PreToolUse": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/log-all-tools.sh"
          }
        ]
      }
    ]
  }
}

Hooks in Subagent Frontmatter

When to use hooks in the frontmatter

Hooks in settings.json apply to the whole session. Hooks in a subagent's frontmatter apply only to that subagent. This allows agent-specific rules:

---
name: safe-implementer
description: Implements code with extra safety checks
tools: Read, Write, Edit, Grep, Glob
hooks:
  PreToolUse:
    - matcher: "Write|Edit"
      hooks:
        - type: command
          command: "./scripts/validate-file-paths.sh"
  PostToolUse:
    - matcher: "Write|Edit"
      hooks:
        - type: command
          command: "./scripts/auto-lint.sh"
---

You are a careful implementer. Write clean, tested code.

Precedence: frontmatter vs settings.json

When a subagent has hooks in its frontmatter AND there are hooks in settings.json, both run. The order is:

  1. Settings.json hooks run first
  2. Frontmatter hooks run after

If either of the two returns exit code 2, the operation is blocked.

Example: Subagent with strict restrictions

A subagent that can only edit files in its assigned directory:

---
name: auth-specialist
description: Only modifies files in src/auth/
tools: Read, Write, Edit, Grep, Glob
hooks:
  PreToolUse:
    - matcher: "Write|Edit"
      hooks:
        - type: command
          command: |
            #!/bin/bash
            INPUT=$(cat -)
            FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty')
            if [ -n "$FILE" ] && ! echo "$FILE" | grep -q "^src/auth/"; then
              echo "BLOCKED: auth-specialist can only edit src/auth/"
              echo "It tried to edit: $FILE"
              exit 2
            fi
            exit 0
---

You are an auth specialist. You ONLY modify files in src/auth/.

With this hook, the system technically enforces the restriction. Even if the system prompt says "only edit src/auth/", LLMs can make mistakes. The hook guarantees that no file outside src/auth/ is modified.


Multiple Hooks per Event

Chaining

You can have multiple hooks for the same event. They run in order:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/validate-bash.sh"
          }
        ]
      },
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/validate-file-paths.sh"
          }
        ]
      },
      {
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/log-all-tools.sh"
          }
        ]
      }
    ]
  }
}

When Claude runs Edit:

  1. validate-bash.sh → does NOT run (matcher doesn't match)
  2. validate-file-paths.sh → DOES run (matcher matches)
  3. log-all-tools.sh → DOES run (no matcher = captures everything)

If validate-file-paths.sh returns exit 2, the operation is blocked and log-all-tools.sh doesn't run.

Multiple commands within one hook

A hook entry can have multiple commands:

{
  "matcher": "Bash",
  "hooks": [
    {
      "type": "command",
      "command": "./scripts/validate-bash.sh"
    },
    {
      "type": "command",
      "command": "./scripts/log-bash.sh"
    }
  ]
}

Both run in order. If the first returns exit 2, the second doesn't run.


Complete Configuration: settings.json with SessionStart + PreToolUse

A complete example that combines both:

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/session-setup.sh"
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/validate-bash.sh"
          }
        ]
      },
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/validate-file-paths.sh"
          }
        ]
      }
    ]
  }
}

With this configuration:

  • Each session starts with automatic setup
  • Each Bash command is validated against dangerous patterns
  • Each file write is validated against protected paths

Troubleshooting

"The SessionStart hook doesn't run"

Cause: The script doesn't have execution permissions, or the path is incorrect.

Solution:

chmod +x ./scripts/session-setup.sh

# Verify that the path is relative to the project root
ls -la ./scripts/session-setup.sh

"The PreToolUse hook doesn't block anything"

Cause: The matcher doesn't match the tool name, or the script always returns exit 0.

Solution:

# Test the script manually
echo '{"tool_name": "Bash", "tool_input": {"command": "rm -rf /"}}' | ./scripts/validate-bash.sh
echo $?

"jq: command not found"

Cause: jq isn't installed. Hook scripts that parse JSON need jq.

Solution:

# macOS
brew install jq

# Ubuntu/Debian
sudo apt-get install jq

# If you can't install jq, use Python as an alternative:
COMMAND=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_input',{}).get('command',''))")

"The hook blocks everything — unexpected exit code 2"

Cause: The script has an error that causes a non-zero exit code by default.

Solution: Add exit 0 at the end of the script as the default and make sure every execution path returns an explicit exit code. Test the script in isolation before configuring it as a hook.

"The hook takes too long and makes the session slow"

Cause: The hook script does heavy operations (npm install, compilation, network requests).

Solution: Keep the hooks lightweight (< 2 seconds). For SessionStart, heavy operations are acceptable. For PreToolUse, which triggers on every tool, the script must be almost instant:

# BAD: heavy PreToolUse hook
npm run build  # 30 seconds every time Claude uses a tool

# GOOD: lightweight PreToolUse hook
grep -q "rm -rf" <<< "$COMMAND" && exit 2  # < 1ms

Comparison: settings.json vs Frontmatter

Aspectsettings.jsonSubagent frontmatter
ScopeThe whole session / projectOnly that subagent
Applies toMain Claude + all subagentsOnly the specific subagent
Where it lives.claude/settings.json.claude/agents/my-agent.md
When to useGlobal project rulesAgent-specific rules
FormatJSONYAML
PrecedenceRuns firstRuns after
DistributionManual or via settings syncWith the agent file

Practical rule: If the rule is "no one should do X in this project" → settings.json. If the rule is "this specific agent shouldn't do Y" → frontmatter.


Exercises

Exercise 1: Basic SessionStart (Easy)

Create a SessionStart hook that checks whether git is initialized and whether there are uncommitted changes. If there are more than 10 modified files without a commit, report a warning (exit 1).

See solution

./scripts/git-check.sh:

#!/bin/bash

if ! git rev-parse --git-dir > /dev/null 2>&1; then
    echo "WARNING: Not a git repository"
    exit 1
fi

MODIFIED=$(git status --porcelain | wc -l | tr -d ' ')

if [ "$MODIFIED" -gt 10 ]; then
    echo "WARNING: $MODIFIED uncommitted files"
    echo "Consider committing before starting"
    exit 1
fi

echo "Git OK: $MODIFIED pending files"
exit 0

In .claude/settings.json:

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          { "type": "command", "command": "./scripts/git-check.sh" }
        ]
      }
    ]
  }
}

Exercise 2: PreToolUse — Block recursive rm (Easy)

Create a PreToolUse hook that blocks (exit 2) any Bash command containing rm -rf followed by /, ~, or ..

See solution

./scripts/block-rm.sh:

#!/bin/bash

INPUT=$(cat -)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

if echo "$COMMAND" | grep -qE "rm\s+-rf\s+[/~.]"; then
    echo "BLOCKED: rm -rf with a dangerous path detected"
    echo "Command: $COMMAND"
    exit 2
fi

exit 0

In .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "./scripts/block-rm.sh" }
        ]
      }
    ]
  }
}

Exercise 3: PreToolUse — Path restriction per subagent (Medium)

Create an api-specialist.md subagent that can only edit files inside src/api/. Use a PreToolUse hook in the frontmatter that blocks any Write or Edit outside that directory.

See solution

.claude/agents/api-specialist.md:

---
name: api-specialist
description: API endpoint specialist. Only modifies src/api/.
tools: Read, Write, Edit, Grep, Glob
hooks:
  PreToolUse:
    - matcher: "Write|Edit"
      hooks:
        - type: command
          command: |
            #!/bin/bash
            INPUT=$(cat -)
            FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty')
            if [ -z "$FILE" ]; then
              exit 0
            fi
            if ! echo "$FILE" | grep -q "^src/api/"; then
              echo "BLOCKED: api-specialist can only edit src/api/"
              echo "It tried: $FILE"
              exit 2
            fi
            exit 0
---

You are an API specialist. Implement and modify REST endpoints.
Only modify files in src/api/. Read any file for context.

The technical validation guarantees that, even if the LLM tries to edit outside src/api/, the hook blocks it.

Exercise 4: PreToolUse — Conditional validation by branch (Medium)

Create a PreToolUse hook that:

  • On main/master: blocks (exit 2) any Write/Edit
  • On feature branches: allows everything but logs warnings for configuration files
  • On any branch: blocks access to .env*
See solution

./scripts/branch-validator.sh:

#!/bin/bash

INPUT=$(cat -)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty')

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

if echo "$FILE" | grep -qE "^\.env"; then
    echo "BLOCKED: .env files are protected on all branches"
    exit 2
fi

BRANCH=$(git branch --show-current 2>/dev/null)

if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
    echo "BLOCKED: Editing files on the $BRANCH branch is not allowed"
    echo "Create a feature branch: git checkout -b feature/my-feature"
    exit 2
fi

if echo "$FILE" | grep -qE "(config|settings|\.yaml|\.yml|\.toml)"; then
    echo "WARNING: Editing a configuration file: $FILE"
    echo "Branch: $BRANCH"
    exit 1
fi

exit 0

Exercise 5: Multiple chained hooks (Hard)

Design a settings.json configuration with:

  1. SessionStart: verifies git and dependencies
  2. PreToolUse for Bash: blocks dangerous commands
  3. PreToolUse for Write/Edit: restricts paths
  4. Universal PreToolUse (no matcher): logging of all tools

Write the complete JSON configuration and the 4 scripts.

See solution

.claude/settings.json:

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          { "type": "command", "command": "./scripts/hooks/session-setup.sh" }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "./scripts/hooks/validate-bash.sh" }
        ]
      },
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "./scripts/hooks/validate-paths.sh" }
        ]
      },
      {
        "hooks": [
          { "type": "command", "command": "./scripts/hooks/log-tools.sh" }
        ]
      }
    ]
  }
}

./scripts/hooks/session-setup.sh:

#!/bin/bash
if ! git rev-parse --git-dir > /dev/null 2>&1; then
    echo "WARNING: No git repo"
    exit 1
fi
if [ -f "package.json" ] && [ ! -d "node_modules" ]; then
    npm ci --silent
fi
echo "Setup complete"
exit 0

./scripts/hooks/validate-bash.sh:

#!/bin/bash
INPUT=$(cat -)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$CMD" | grep -qiE "(rm -rf [/~.]|DROP DATABASE|DROP TABLE)"; then
    echo "BLOCKED: $CMD"
    exit 2
fi
exit 0

./scripts/hooks/validate-paths.sh:

#!/bin/bash
INPUT=$(cat -)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty')
if [ -z "$FILE" ]; then exit 0; fi
if echo "$FILE" | grep -qE "^\.env"; then
    echo "BLOCKED: $FILE"
    exit 2
fi
exit 0

./scripts/hooks/log-tools.sh:

#!/bin/bash
INPUT=$(cat -)
TOOL=$(echo "$INPUT" | jq -r '.tool_name')
TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
echo "$TIMESTAMP | $TOOL" >> .claude/tool-log.txt
exit 0

Exercise 6: Debugging hooks (Hard)

A developer configured this hook but reports that "it doesn't block anything." Find and fix all the bugs:

{
  "hooks": {
    "preToolUse": [
      {
        "match": "bash",
        "hooks": [
          {
            "type": "cmd",
            "command": "scripts/validate.sh"
          }
        ]
      }
    ]
  }
}
#!/bin/bash
# scripts/validate.sh
COMMAND=$1
if [ "$COMMAND" == "rm -rf" ]; then
    echo "Blocked"
fi
See solution

There are 5 bugs:

  1. preToolUse → It must be PreToolUse (PascalCase)
  2. match → It must be matcher
  3. bash → It must be Bash (PascalCase, Claude Code's tool name)
  4. type: "cmd" → It must be type: "command"
  5. COMMAND=$1 → Hooks receive input via stdin, not arguments. It must be INPUT=$(cat -) and then parse with jq
  6. No exit code → The script doesn't return exit 2 to block; the echo only prints
  7. Path without ./ → It must be ./scripts/validate.sh for a relative path

Corrected configuration:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/validate.sh"
          }
        ]
      }
    ]
  }
}

Corrected script:

#!/bin/bash
INPUT=$(cat -)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$COMMAND" | grep -q "rm -rf"; then
    echo "BLOCKED: $COMMAND"
    exit 2
fi
exit 0

Summary

  • SessionStart triggers when each session starts — ideal for automatic dependency setup, database verification, and migrations
  • PreToolUse triggers before each tool execution — the gatekeeper that validates, warns, or blocks operations
  • Hooks receive JSON input via stdin with tool_name, tool_input, and session metadata
  • Exit codes: 0 = continue, 1 = error (Claude decides), 2 = block the operation
  • The matcher field filters by tool: exact name, pipe-separated (Edit|Write), regex (.*), or no matcher (captures everything)
  • Hooks are configured in settings.json (project/user scope) or in the subagent frontmatter (agent scope)
  • The settings.json hooks run first, the frontmatter ones after
  • Multiple hooks per event run in order; if one returns exit 2, the next ones don't run
  • Keep PreToolUse hooks lightweight (< 2 seconds) — they run on every tool invocation
  • Install jq to parse JSON in bash scripts — it's essential for hooks that inspect input

Additional Resources

  1. Claude Code Hooks (Anthropic Docs) — Official documentation of all events, matchers, and hook types
  2. Claude Code Settings — Location and format of settings.json
  3. Create Custom Subagents — Hooks in YAML frontmatter
  4. jq Manual — jq reference for parsing JSON in bash
  5. Claude Code CLI Reference — Tool reference and names for matchers
  6. Claude Code Best Practices — Security and validation best practices
  7. Bash Exit Codes — Bash exit code reference
  8. Claude Code Overview — General context to understand the tool lifecycle

Next capsule: In capsule 03 you'll see the reaction hooks: PostToolUse for auto-lint after edits, SubagentStart and SubagentStop for tracking the subagent lifecycle, Stop for cleanup at the end of the session, and PermissionRequest for custom approval flows. You go from preventing (PreToolUse) to reacting (PostToolUse) — the other half of the nervous system.