jsmanifest logojsmanifest

Claude Code Hooks: Automate Your Workflow with PreToolUse, PostToolUse, and Stop Events

Claude Code Hooks: Automate Your Workflow with PreToolUse, PostToolUse, and Stop Events

Master Claude Code's hook lifecycle to automate security checks, file formatting, and workflow orchestration. Production patterns with exit codes and JSON output.

Most AI workflow bottlenecks stem from manual intervention between tool calls. Engineers watch Claude Code execute, then manually format output, run linters, or verify changes. The pattern that teams overlook is the hook system — a lifecycle API that intercepts every tool call with custom scripts.

Claude Code exposes five hook types: PreToolUse runs before tool execution, PostToolUse runs after, Stop fires when the session ends, Notification handles system events, and SubAgentStop tracks nested agent completions. The distinction between these events determines whether your automation catches errors early or scrambles to fix them afterward.

This matters because the failure mode is expensive. Without PreToolUse guards, Claude writes to production databases. Without PostToolUse formatters, commits fail CI checks. Without Stop hooks, Slack never knows the deployment finished.

Understanding the Hook Lifecycle: PreToolUse, PostToolUse, and Stop Events

The hook lifecycle follows a deterministic state machine. PreToolUse fires first, receives the tool name and arguments as JSON, and can block execution by returning a non-zero exit code. PostToolUse fires after the tool succeeds, receives both the original arguments and the tool's output, and can transform results before Claude sees them. Stop fires once when the conversation ends, regardless of success or failure.

    [*] --> PreToolUse: Tool call triggered
    PreToolUse --> Blocked: Exit code ≠ 0
    PreToolUse --> ToolExecution: Exit code = 0
    Blocked --> [*]
    ToolExecution --> PostToolUse: Tool succeeded
    ToolExecution --> [*]: Tool failed
    PostToolUse --> ClaudeResponse: Processing complete
    ClaudeResponse --> PreToolUse: Next tool call
    ClaudeResponse --> Stop: Session ends
    Stop --> [*]
    
    classDef framework fill:#064e3b,stroke:#34d399,color:#6ee7b7
    classDef userAction fill:#1e3a8a,stroke:#60a5fa,color:#e0eaff
    class PreToolUse,PostToolUse,Stop framework
    class ToolExecution,ClaudeResponse userAction

The configuration lives in ~/.config/claude/hooks.json. Each hook maps a lifecycle event to a shell command. The command receives input via stdin (JSON payload) and signals success or failure through exit codes. Exit code 0 means proceed, non-zero means abort.

The implication here is that hooks compose into pipelines. A PreToolUse hook validates file paths, PostToolUse formats the output, and Stop aggregates metrics. Each hook operates independently but shares a common contract: JSON in, exit code out, optional stdout for transformed data.

Claude Code hook configuration interface showing lifecycle events

PreToolUse Hooks: Security Guards and Auto-Approval Patterns

PreToolUse hooks act as circuit breakers. The hook receives the tool name and arguments before execution, validates the request, and either approves (exit 0) or blocks (exit 1). The most critical pattern is path validation — preventing writes outside project boundaries.

// ~/.config/claude/hooks/pre-write-guard.ts
import * as fs from 'fs';
import * as path from 'path';
 
interface PreToolUsePayload {
  tool: string;
  arguments: {
    path?: string;
    command?: string;
  };
}
 
async function main() {
  const input = await readStdin();
  const payload: PreToolUsePayload = JSON.parse(input);
  
  // Block writes to system directories
  const dangerPaths = ['/etc', '/usr', '/bin', '/System'];
  
  if (payload.tool === 'write_file' && payload.arguments.path) {
    const targetPath = path.resolve(payload.arguments.path);
    
    const isDangerous = dangerPaths.some(danger => 
      targetPath.startsWith(danger)
    );
    
    if (isDangerous) {
      console.error(`Blocked write to system path: ${targetPath}`);
      process.exit(1);
    }
  }
  
  // Block destructive bash commands
  if (payload.tool === 'bash' && payload.arguments.command) {
    const cmd = payload.arguments.command;
    const destructive = ['rm -rf /', 'dd if=', ':(){:|:&};:'];
    
    if (destructive.some(pattern => cmd.includes(pattern))) {
      console.error('Blocked destructive bash command');
      process.exit(1);
    }
  }
  
  // Approve all other operations
  process.exit(0);
}
 
function readStdin(): Promise<string> {
  return new Promise((resolve) => {
    let data = '';
    process.stdin.on('data', chunk => data += chunk);
    process.stdin.on('end', () => resolve(data));
  });
}
 
main();

The auto-approval pattern extends this guard concept. Instead of prompting for confirmation on every file write, the hook checks if the target path matches a whitelist. Writes to src/, tests/, or docs/ get automatic approval. Writes elsewhere trigger manual confirmation.

This distinction is critical. Without path validation, a single hallucinated command can corrupt system files. The failure mode here is subtle but expensive: Claude attempts to write /etc/hosts, the hook blocks it, but the conversation context is now polluted with failed attempts.

PostToolUse Hooks: Auto-Formatting and File Processing

PostToolUse hooks transform tool output before Claude processes the result. The hook receives both the original arguments and the tool's stdout/stderr, allowing stateful transformations. The canonical use case is automatic code formatting after file writes.

// ~/.config/claude/hooks/post-write-format.ts
import { exec } from 'child_process';
import { promisify } from 'util';
import * as path from 'path';
 
const execAsync = promisify(exec);
 
interface PostToolUsePayload {
  tool: string;
  arguments: {
    path?: string;
  };
  output: {
    stdout: string;
    stderr: string;
  };
}
 
async function main() {
  const input = await readStdin();
  const payload: PostToolUsePayload = JSON.parse(input);
  
  if (payload.tool !== 'write_file' || !payload.arguments.path) {
    process.exit(0);
  }
  
  const filePath = payload.arguments.path;
  const ext = path.extname(filePath);
  
  try {
    if (ext === '.ts' || ext === '.tsx') {
      await execAsync(`prettier --write ${filePath}`);
      await execAsync(`eslint --fix ${filePath}`);
      console.log(`Formatted and linted: ${filePath}`);
    } else if (ext === '.json') {
      await execAsync(`prettier --write ${filePath}`);
      console.log(`Formatted JSON: ${filePath}`);
    }
    
    process.exit(0);
  } catch (error) {
    // Don't block on formatting failures
    console.error(`Formatting failed: ${error}`);
    process.exit(0);
  }
}
 
function readStdin(): Promise<string> {
  return new Promise((resolve) => {
    let data = '';
    process.stdin.on('data', chunk => data += chunk);
    process.stdin.on('end', () => resolve(data));
  });
}
 
main();

The key decision is whether to block on formatting failures. The pattern above logs errors but exits 0 — formatting is advisory, not mandatory. For critical transformations like schema validation, the hook should exit 1 to halt execution.

PostToolUse hooks also enable result caching. When Claude reads a large file, the hook can compress the output or extract only relevant sections. This reduces token consumption without changing Claude's behavior.

Terminal output showing PostToolUse hook formatting files automatically

Stop and Notification Events: Workflow Orchestration

Stop hooks fire once per session when the conversation ends. The hook receives a summary payload with total tool calls, success count, and failure count. This enables workflow orchestration: notify Slack, update dashboards, or trigger CI pipelines.

    SessionEnd[Session ends] --> StopHook[Stop hook fires]
    StopHook --> ParseMetrics[Parse tool call metrics]
    ParseMetrics --> CheckFailures{Any failures?}
    CheckFailures -->|Yes| NotifyError[Send error to Slack]
    CheckFailures -->|No| NotifySuccess[Send success to Slack]
    NotifyError --> LogMetrics[Log to dashboard]
    NotifySuccess --> LogMetrics
    LogMetrics --> TriggerCI{Modified files?}
    TriggerCI -->|Yes| RunTests[Trigger test pipeline]
    TriggerCI -->|No| Complete[Complete]
    RunTests --> Complete
    
    classDef framework fill:#064e3b,stroke:#34d399,color:#6ee7b7
    classDef dataStore fill:#1e293b,stroke:#64ffda,color:#e2e8f0
    class StopHook,TriggerCI framework
    class LogMetrics,ParseMetrics dataStore

The practical pattern combines Stop hooks with file system watching. The hook scans for modified files during the session, stages them with git, and runs pre-commit checks. If checks pass, it creates a draft PR. If checks fail, it posts the error log to Slack with a link to the conversation.

Notification hooks handle system events like MCP server errors or context window overflows. These hooks fire asynchronously and don't block execution. The primary use case is alerting: when Claude hits token limits repeatedly, the notification hook escalates to a human operator.

Production-Ready Hook Configurations (With Exit Codes and JSON Output)

Production hooks require three components: exit code discipline, structured logging, and idempotency. Exit codes must be deterministic — the same input always produces the same result. Logs must be machine-readable JSON, not human-readable prose. Hooks must handle re-execution gracefully.

{
  "preToolUse": {
    "write_file": "node ~/.config/claude/hooks/pre-write-guard.js",
    "bash": "node ~/.config/claude/hooks/pre-bash-guard.js"
  },
  "postToolUse": {
    "write_file": "node ~/.config/claude/hooks/post-write-format.js",
    "read_file": "node ~/.config/claude/hooks/post-read-compress.js"
  },
  "stop": "node ~/.config/claude/hooks/stop-workflow.js",
  "notification": "node ~/.config/claude/hooks/notify-error.js"
}

The logging pattern uses JSON Lines: one JSON object per log statement, written to stderr. This allows downstream tools to parse logs without brittle regex patterns. Each log object includes a timestamp, severity, hook name, and structured data.

Idempotency requires that hooks check whether their action already completed. A PostToolUse formatter should verify the file isn't already formatted before running Prettier. A Stop hook should check if the PR already exists before creating a new one.

Common Hook Failure Modes and How to Prevent Them

Hook failures fall into three categories: timeout, environment mismatch, and payload schema drift. Timeouts occur when hooks perform expensive operations synchronously. The solution is to spawn background jobs and exit immediately, then poll for completion in subsequent hooks.

    HookStarts[Hook execution starts] --> CheckTimeout{Operation > 5s?}
    CheckTimeout -->|Yes| SpawnBackground[Spawn background job]
    CheckTimeout -->|No| ExecuteSync[Execute synchronously]
    SpawnBackground --> WriteJobID[Write job ID to temp file]
    WriteJobID --> ExitZero[Exit 0 immediately]
    ExecuteSync --> Success{Succeeded?}
    Success -->|Yes| ExitZero
    Success -->|No| LogError[Log error to stderr]
    LogError --> ExitOne[Exit 1]
    ExitZero --> Complete[Hook complete]
    ExitOne --> Complete
    
    classDef framework fill:#064e3b,stroke:#34d399,color:#6ee7b7
    classDef badPattern stroke:#ef4444,fill:#450a0a,color:#fca5a5
    class SpawnBackground,WriteJobID framework
    class LogError badPattern

Environment mismatches happen when hooks assume tools are globally available. A hook that calls prettier will fail if Prettier isn't in PATH. The fix is to use absolute paths or activate virtual environments explicitly in the hook script.

Payload schema drift is the most insidious failure mode. Claude Code updates its JSON schema over time, adding new fields or changing existing ones. Hooks that rely on specific field names break silently. The defense is to parse payloads defensively: check for required fields, provide defaults, and log schema mismatches to a monitoring system.

The production pattern is to version hooks alongside Claude Code releases. When Claude updates to version 2.x, hooks read from CLAUDE_VERSION environment variable and adjust their behavior accordingly. This prevents silent breakage during auto-updates.

Building Your First Hook Pipeline: A Complete Example

A practical hook pipeline combines all three lifecycle events. PreToolUse validates paths and auto-approves safe operations. PostToolUse formats code and compresses large files. Stop orchestrates the final workflow: commit changes, run tests, and notify stakeholders.

The pipeline runs in development environments where engineers iterate rapidly. PreToolUse hooks prevent accidental system modifications. PostToolUse hooks maintain code quality without manual intervention. Stop hooks ensure every conversation results in actionable artifacts: a PR, a test report, or a deployment notification.

The key insight is that hooks compose into higher-level abstractions. A "safe development mode" combines strict PreToolUse guards with aggressive PostToolUse formatters. A "CI integration mode" runs tests in Stop hooks and blocks deployment if coverage drops. A "pair programming mode" logs every tool call to a shared dashboard for team visibility.

That covers the essential patterns for Claude Code hooks. Apply these in production and the difference will be immediate: fewer manual interventions, faster iteration cycles, and higher confidence in AI-generated changes. The hook system transforms Claude from an assistant into an automated workflow orchestrator.