AI
BLOG
AI

Claude Code Hooks & Custom Agents: Build AI Pipelines That Run Themselves

24 lifecycle hooks, 4 handler types, custom agents — every tutorial covers them separately. Here's how to wire them into self-running pipelines that enforce quality automatically.

S
Sebastian
March 26, 2026
14 min read
Scroll

I spent an entire week debugging a hook that should have taken 20 minutes. The hook was supposed to block dangerous Bash commands — rm -rf, DROP TABLE, the usual suspects. I wrote the script, tested it manually, configured the matcher. Everything looked perfect.

Except Claude kept running the dangerous commands anyway. No error, no warning. Just... nothing happened.

The culprit? I was using exit 1 instead of exit 2. In Claude Code hooks, exit code 1 means "non-blocking warning" — it logs to verbose mode and continues. Exit code 2 means "actually block this." One digit. One week of my life.

That experience taught me something important: hooks are the most powerful and most misunderstood feature in Claude Code. Everyone knows about skills and subagents. But hooks are the automation layer that turns Claude from an assistant you babysit into a pipeline that enforces its own quality.

Let me save you from my mistakes.

The Hook Mental Model

Think of hooks as middleware for your AI assistant. Just like Express middleware intercepts HTTP requests, Claude Code hooks intercept lifecycle events — before tools run, after edits happen, when sessions start, when agents finish.

text
User Prompt
    │
    ▼
┌─────────────────┐
│ UserPromptSubmit │ ← Hook can modify or block
└────────┬────────┘
         │
         ▼
    Claude thinks...
         │
         ▼
┌─────────────────┐
│   PreToolUse    │ ← Hook can allow/deny/modify
└────────┬────────┘
         │
         ▼
    Tool executes
         │
         ▼
┌─────────────────┐
│  PostToolUse    │ ← Hook can add context
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│      Stop       │ ← Hook can force Claude to continue
└─────────────────┘

Every hook receives JSON input via stdin with session context: session_id, cwd, permission_mode, and event-specific fields. Every hook responds through exit codes (simple) or JSON output (advanced).

All 24 Hook Events (Grouped by When They Fire)

As of March 2026, Claude Code exposes 24 lifecycle events. Here's how I think about them — grouped by what phase of work they intercept:

Session Lifecycle

EventWhenUse Case
SessionStartSession begins/resumesLoad env vars, set up connections
SessionEndSession terminatesCleanup, usage reporting
CwdChangedWorking directory changesRe-validate project context
ConfigChangesettings.json changes mid-sessionHot-reload configuration

Input Processing

EventWhenUse Case
UserPromptSubmitBefore Claude processes your promptInput validation, prompt enrichment
InstructionsLoadedCLAUDE.md or rules files loadAudit which instructions are active

Tool Execution

EventWhenUse Case
PreToolUseBefore any tool callSecurity gates, validation
PostToolUseAfter successful tool callAuto-format, lint, logging
PostToolUseFailureAfter failed tool callError tracking, auto-retry logic
PermissionRequestPermission dialog appearsAuto-approve/deny based on rules

Agent Lifecycle

EventWhenUse Case
SubagentStartSubagent spawnsSetup, resource allocation
SubagentStopSubagent finishesCleanup, result validation
TaskCreatedTask created via TaskCreateTask tracking, notifications
TaskCompletedTask marked completeProgress reporting
TeammateIdleTeam agent goes idleResource management

Context Management

EventWhenUse Case
PreCompactBefore context compactionSave critical state
PostCompactAfter compactionRestore state, validate context
WorktreeCreateGit worktree createdSetup isolated environment
WorktreeRemoveGit worktree removedCleanup

Communication

EventWhenUse Case
NotificationClaude sends notificationCustom notification routing
StopClaude finishes respondingForce continuation, quality checks
StopFailureTurn ends due to API errorError handling, fallback
ElicitationMCP server requests inputCustom input handling
ElicitationResultUser responds to MCP promptResponse processing
FileChangedWatched file changes on diskHot reload, trigger rebuilds

The events you'll use 90% of the time: PreToolUse, PostToolUse, Stop, and UserPromptSubmit. Master those four before exploring the rest.

The 4 Handler Types

Each hook event can trigger one or more handlers. Claude Code offers four types, each with different tradeoffs:

1. Command Hooks (Shell Scripts)

The workhorse. Runs a shell command, reads JSON from stdin, communicates via exit codes.

json
{
  "type": "command",
  "command": ".claude/hooks/validate-bash.sh",
  "timeout": 10
}
When to use: File operations, quick validation, integration with existing CLI tools.

2. HTTP Hooks (Webhooks)

POSTs event JSON to a URL. Perfect for external integrations.

json
{
  "type": "http",
  "url": "http://localhost:8080/hooks/pre-tool-use",
  "headers": {
    "Authorization": "Bearer $MY_TOKEN"
  },
  "allowedEnvVars": ["MY_TOKEN"],
  "timeout": 30
}
When to use: Slack notifications, external logging, team dashboards, CI/CD triggers.

3. Prompt Hooks (LLM Evaluation)

Sends a prompt to a Claude model for single-turn yes/no decisions.

json
{
  "type": "prompt",
  "prompt": "Is this Bash command safe to run in production? $ARGUMENTS",
  "model": "haiku"
}
When to use: Fuzzy validation where rules are hard to encode (e.g., "is this code change reasonable?").

4. Agent Hooks (Subagent Verification)

Spawns a full subagent with tool access to verify conditions.

json
{
  "type": "agent",
  "prompt": "Read the test files and verify this change has test coverage: $ARGUMENTS",
  "model": "sonnet",
  "timeout": 60
}
When to use: Complex validation requiring file reads, code analysis, or multi-step reasoning. This is the most powerful handler type — and the most expensive.

Handler Cost Comparison

TypeLatencyToken CostComplexity
Command~50msFreeLow
HTTP~200msFreeMedium
Prompt~2s~$0.001Low
Agent~15s~$0.01-0.05High

Rule of thumb: Start with command hooks. Escalate to prompt/agent hooks only when shell scripts can't express the logic.

5 Production Patterns That Actually Matter

Pattern 1: Auto-Format on Every Edit

The most impactful hook you can set up. Zero effort, massive consistency gains.

json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "npx prettier --write \"$TOOL_INPUT_FILE_PATH\" 2>/dev/null; exit 0",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

The exit 0 at the end is critical — PostToolUse hooks with non-zero exits don't block anything but they do add noise to Claude's context.

Pattern 2: Security Gate for Dangerous Commands

This is the one that bit me. Here's the correct version:

bash
#!/bin/bash
# .claude/hooks/block-dangerous.sh

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

# Block destructive patterns
if echo "$COMMAND" | grep -iE '\b(rm\s+-rf|DROP\s+TABLE|DELETE\s+FROM|TRUNCATE|FORMAT)\b' > /dev/null; then
  echo "BLOCKED: Destructive command detected: $COMMAND" >&2
  exit 2  # EXIT 2 = BLOCK. Not exit 1!
fi

exit 0
json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/block-dangerous.sh",
            "timeout": 5
          }
        ]
      }
    ]
  }
}

Pattern 3: Auto-Test After Code Changes

Run tests automatically whenever Claude modifies a source file:

bash
#!/bin/bash
# .claude/hooks/auto-test.sh

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

# Only run tests for source files
if [[ "$FILE_PATH" == *.ts ]] || [[ "$FILE_PATH" == *.tsx ]]; then
  # Find related test file
  TEST_FILE="${FILE_PATH%.ts}.test.ts"
  TEST_FILE="${TEST_FILE%.tsx}.test.tsx"

  if [ -f "$TEST_FILE" ]; then
    RESULT=$(npx vitest run "$TEST_FILE" --reporter=json 2>&1)
    if [ $? -ne 0 ]; then
      echo "Tests failed for $TEST_FILE" >&2
      echo "$RESULT" | tail -20 >&2
      # Don't exit 2 — we want Claude to see the failure and fix it
      exit 0
    fi
  fi
fi

exit 0

Note: this uses PostToolUse, not PreToolUse, because we want tests to run after the edit completes.

Pattern 4: Cost Tracker

Track token usage across sessions with an HTTP hook:

json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "http",
            "url": "http://localhost:3001/api/claude-usage",
            "timeout": 5
          }
        ]
      }
    ]
  }
}

The Stop event includes last_assistant_message — parse it to estimate tokens and costs. I run a simple Express server that logs to SQLite. After a month you'll have real data on where your budget goes.

Pattern 5: Slack Notification on Completion

Alert your team when Claude finishes a long task:

json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "curl -s -X POST -H 'Content-Type: application/json' -d '{\"text\":\"Claude finished a task in '\"$PWD\"'\"}' $SLACK_WEBHOOK_URL",
            "async": true,
            "timeout": 10
          }
        ]
      }
    ]
  }
}

The async: true flag is key — it fires the notification without blocking Claude's response.

The Exit Code Trap (And Other Gotchas)

This section exists because I wasted days learning these the hard way.

Gotcha #1: Exit 1 ≠ Exit 2

Exit CodeEffect
0Success. Parse stdout for JSON. Continue.
1Non-blocking error. Shows in verbose mode only. Does NOT block.
2Blocking error. Stops the action. Feeds stderr to Claude.

If your security gate uses exit 1, it's a warning that nobody sees. You need exit 2.

Gotcha #2: Exit 2 Behavior Varies by Event

Exit 2 doesn't always mean "block." It depends on the event:

  • PreToolUse, PermissionRequest, UserPromptSubmit: Blocks the action
  • Stop, SubagentStop: Tells Claude to keep going (prevents stopping)
  • PostToolUse, Notification: Just shows stderr, doesn't block anything
  • InstructionsLoaded, StopFailure: Exit code completely ignored

Gotcha #3: Stop Hook Infinite Loops

A Stop hook that returns exit 2 tells Claude "don't stop, keep working." But if Claude has nothing more to do, it tries to stop again... triggering the hook again... forever.

Solution: check the stop_hook_active field:

bash
#!/bin/bash
INPUT=$(cat)
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false')

if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
  # Already in a stop-hook retry, let it stop
  exit 0
fi

# Your actual stop validation logic here
exit 0

Gotcha #4: Hooks Run Synchronously

Every hook adds latency. A hook that takes 5 seconds runs 5 seconds before every matched tool use. Budget 500ms max for PreToolUse hooks, or your workflow will feel sluggish.

Gotcha #5: Session Restart Required

Changes to hooks in settings.json require a full session restart. There's no /reload command for hooks (as of March 2026). Save yourself the confusion — restart the session after editing hook configs.

Combining Hooks + Custom Agents: Self-Validating Pipelines

Here's where it gets interesting. Hooks and custom agents aren't just two separate features — they compose into something greater.

The Pattern: Agent With Built-In Quality Gates

Create a custom agent that has hooks baked into its definition:

markdown
---
name: safe-deployer
description: Deploys code changes with built-in safety validation
tools: Bash, Read, Grep, Glob
model: sonnet
hooks:
  PreToolUse:
    - matcher: "Bash"
      hooks:
        - type: command
          command: ".claude/hooks/validate-deploy-command.sh"
  PostToolUse:
    - matcher: "Bash"
      hooks:
        - type: command
          command: ".claude/hooks/verify-deploy-health.sh"
  Stop:
    - hooks:
        - type: prompt
          prompt: "Review the deployment steps taken so far. Were all safety checks passed? Is the deployment complete and healthy? Respond with continue if more work is needed. $ARGUMENTS"
---

You are a deployment specialist. When deploying:
1. Run pre-deploy checks (tests, lint, type-check)
2. Execute the deployment
3. Verify health endpoints respond
4. Report deployment status

Never skip pre-deploy checks. Never deploy without running tests first.

This agent can't accidentally skip safety checks — the hooks enforce them regardless of what the system prompt says. The LLM can be creative about how it deploys, but the guardrails are hard-coded.

The Pattern: Hook-Triggered Agent Validation

Use an agent hook (handler type) for complex PreToolUse validation:

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "agent",
            "prompt": "A file is about to be modified. Read the file at the path specified in tool_input, check if it contains API keys, secrets, passwords, or credentials. If it does, respond with 'BLOCKED: file contains secrets'. Context: $ARGUMENTS",
            "model": "haiku",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

This spawns a Haiku agent that actually reads the file before allowing edits — something a simple shell script can't do efficiently for all file types. If you're building vibe-coded apps, this is exactly the kind of guardrail that prevents the security disasters I've audited in AI-generated code.

The Pattern: Pipeline Orchestrator

Combine session-level hooks with custom agents for a full pipeline:

json
{
  "hooks": {
    "SubagentStart": [
      {
        "matcher": "code-reviewer",
        "hooks": [
          {
            "type": "command",
            "command": "echo 'Starting code review at $(date)' >> .claude/logs/pipeline.log"
          }
        ]
      }
    ],
    "SubagentStop": [
      {
        "matcher": "code-reviewer",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/post-review-actions.sh"
          }
        ]
      }
    ]
  }
}

This logs when the code-reviewer agent starts, and triggers follow-up actions (open PR, run CI, notify Slack) when it finishes. This kind of pipeline orchestration is what makes 5-day AI automation sprints possible — you build the guardrails once and reuse them across projects.

Performance: Keeping Hooks Under 500ms

Hooks run synchronously by default. Here's how to keep them fast:

  1. Use async: true for non-blocking hooks (notifications, logging)
  2. Keep shell scripts minimal — avoid heavy operations in PreToolUse
  3. Cache expensive checks — write results to a temp file, check mtime before re-running
  4. Use jq efficiently — pipe once, extract multiple fields: jq '{name: .tool_name, cmd: .tool_input.command}'
  5. Measure everything — add time to your hook commands during development:
bash
# Debug: measure hook execution time
START=$(date +%s%N)
# ... your hook logic ...
END=$(date +%s%N)
echo "Hook took $(( (END - START) / 1000000 ))ms" >&2

When NOT to Use Hooks

Hooks are powerful, but they're not always the right tool:

ScenarioUse Instead
Reusable workflow with multiple stepsSkills — they preserve context
One-off prompt transformationCLAUDE.md rules — simpler, no code
Isolated task with fresh contextSubagent — separate context window
Complex multi-agent coordinationAgent teams — dedicated orchestration
Something that needs human judgmentPermissionRequest — just ask

The decision heuristic: If the automation needs to intercept and modify Claude's behavior at specific lifecycle points, use hooks. If it needs to teach Claude how to do something, use skills. If it needs to isolate work, use subagents.

Putting It All Together

Here's my production setup — the hooks I run on every project:

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/block-dangerous.sh",
            "timeout": 5
          }
        ]
      },
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/check-secrets.sh",
            "timeout": 5
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/auto-format.sh",
            "timeout": 10
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/notify-complete.sh",
            "async": true
          }
        ]
      }
    ]
  }
}

Four hooks. Two security gates (block dangerous commands, check for secrets), one quality gate (auto-format), one convenience (notification). This setup catches 90% of issues before they become problems.

Start here. Add complexity only when you need it. The best hook system is the one that runs invisibly and never makes you think about it — until it saves you from a disaster.

References

Sources

Further Reading


~Seb 👊

Share this article