February 12, 2026
Claude Development Security Automation

Claude Code Hooks: The Complete Guide to Lifecycle Events

You know that moment when you're running Claude Code on a critical project, and you think: "I wish I could intercept what's about to happen and make a decision before it actually occurs." Or maybe you want to automatically log every tool invocation, update your project state, or inject custom context into your prompts. That's exactly what hooks are built for.

Claude Code hooks are lifecycle event handlers that let you tap into the execution pipeline. Before a tool runs. After it finishes. When the session starts. When you submit a prompt. They're the hidden orchestration layer that turns Claude Code from a smart assistant into an extensible, governance-aware system.

Let's walk through what hooks actually do, how they work, and how to build your first one in JavaScript.

Table of Contents
  1. What Are Hooks and Why Do They Matter?
  2. Why This Matters in Practice
  3. The Six Lifecycle Events Explained
  4. 1. PreToolUse: Before Tools Execute
  5. 2. PostToolUse: After Tools Complete
  6. 3. UserPromptSubmit: When You Submit Prompts
  7. 4. Notification: Status Updates
  8. 5. Stop: When Claude Finishes Responding
  9. 6. SessionStart: When Sessions Begin
  10. Hook Configuration: settings.json
  11. Your First Hook: PreToolUse Validator
  12. PostToolUse: Auto-Format on Write
  13. UserPromptSubmit: Inject Context
  14. Notification: Log Status Updates
  15. Stop: Quality Gate Enforcement
  16. SessionStart: Initialize Context
  17. The Hook Contract: Input and Output
  18. Hook Input (stdin)
  19. Hook Output (stdout)
  20. Exit Codes
  21. Why JavaScript .mjs on Windows?
  22. Testing Your Hooks Manually
  23. Debugging Hook Issues
  24. Advanced: Chaining Hooks
  25. The Real Power: Orchestration
  26. Hook Composition and Orchestration Patterns
  27. Sequential Validation
  28. State Propagation Across Hooks
  29. Time-Based Hook Batching
  30. Real-World Hook Architecture for Teams
  31. Debugging Hook Chains
  32. Hook Performance Considerations
  33. Caching Strategies
  34. Parallel Hook Execution
  35. Security Implications of Hooks
  36. Sandboxing Hooks
  37. Hook Testing and Validation
  38. Hook Versioning and Rollback
  39. Hook Documentation
  40. Summary

What Are Hooks and Why Do They Matter?

Think of hooks like middleware in a web framework. You don't control the core framework, but you can inject logic at specific points in the request-response cycle to modify behavior, add constraints, or trigger side effects.

Claude Code has six lifecycle events where you can hook in:

  1. PreToolUse – Fires before any tool executes (Write, Edit, Read, Bash, etc.)
  2. PostToolUse – Fires after a tool completes and returns results
  3. UserPromptSubmit – Fires when you submit a prompt to Claude
  4. Notification – Fires when Claude sends a notification (progress, status, etc.)
  5. Stop – Fires when Claude finishes responding
  6. SessionStart – Fires when a session begins or resumes

Each hook can inspect the event data, make decisions (allow, deny, or block), and optionally modify context before execution continues.

Why This Matters in Practice

Imagine you're working on a sensitive codebase. You want to:

  • Block dangerous operations (force push, rm -rf) automatically
  • Audit every file write for compliance
  • Enrich prompts with relevant context from your project
  • Track changes for an audit trail
  • Validate inputs before they reach Claude

Without hooks, you'd do this manually—reviewing before every action, copy-pasting context, logging changes by hand. Hooks automate all of it.

Or say you're using Claude Code to power an internal tool. You could:

  • Enforce company policies (no writing to /etc, no accessing production databases)
  • Route operations based on permissions
  • Log activity for compliance
  • Inject user context automatically

Hooks make all this possible without modifying Claude Code's core. This is especially powerful in team environments where standardization and auditability matter. Imagine onboarding a junior developer who has access to Claude Code—hooks let you enforce your team's best practices automatically, without requiring manual review of every action.

The Six Lifecycle Events Explained

Let's break down each event, when it fires, and what you can do with it.

1. PreToolUse: Before Tools Execute

When it fires: Immediately before any tool (Write, Edit, Read, Bash, etc.) is executed.

What you can do:

  • Inspect the tool name and inputs
  • Block the operation (exit with code 2)
  • Log the action for audit trails
  • Validate against security policies
  • Modify the input before execution

Example use case: Block dangerous Bash commands like rm -rf or sudo before they run.

Deep dive: This is where you implement your security boundary. You might have a policy that says "never delete files older than 7 days without approval." PreToolUse hooks can inspect the command and check git history to see when a file was created. Or you might want to require all Write operations to go to specific directories—a hook can validate the file_path before it's written.

2. PostToolUse: After Tools Complete

When it fires: After a tool completes and returns its result to Claude.

What you can do:

  • Inspect the tool output
  • Trigger side effects (format files, update memory, log results)
  • Transform output before Claude sees it
  • Capture metrics or performance data

Example use case: Auto-format files whenever a Write or Edit completes. Update a changelog whenever files are modified.

Real-world scenario: You could use PostToolUse to automatically commit changes after Claude writes files. Every Write becomes "write file, then commit with message." This creates an atomic, auditable history of Claude's changes. Or imagine capturing the execution time of every Bash command Claude runs—you can build a performance profile to identify slow operations.

3. UserPromptSubmit: When You Submit Prompts

When it fires: The instant you hit Enter after typing a prompt.

What you can do:

  • Inspect your prompt text
  • Inject context (git branch, timestamp, relevant files)
  • Block prompts containing sensitive data
  • Enrich the prompt with memory or project state
  • Route to different agents based on keywords

Example use case: Automatically prepend the current git branch and timestamp to every prompt. Inject relevant code snippets based on keywords you mention.

Advanced pattern: Imagine you mention "payment processing" in your prompt. A UserPromptSubmit hook could automatically search your codebase for files related to payments, read the key functions, and inject them into Claude's context. Claude suddenly has relevant code without you having to copy-paste it. This is context-driven AI—the hook acts as an intelligent research assistant, pre-fetching information Claude will likely need.

4. Notification: Status Updates

When it fires: When Claude sends you a notification (progress, status updates, etc.).

What you can do:

  • Inspect notification content
  • Transform or suppress notifications
  • Trigger external alerts (Slack, email)
  • Log notifications

Example use case: Send a Slack message whenever Claude completes a major milestone. Track notification patterns to understand where Claude gets stuck.

Team integration: In a team environment, you might route different notification types to different channels. "File written" goes to #code-changes, but "error occurred" goes to #incidents. This keeps your Slack organized and ensures critical alerts reach the right people.

5. Stop: When Claude Finishes Responding

When it fires: After Claude finishes generating a response and is ready for your next input.

What you can do:

  • Validate the response against quality standards
  • Trigger consolidation (memory, logs, artifacts)
  • Check for evidence of claims
  • Log the conversation turn

Example use case: Enforce quality gates (every claim must have evidence). Trigger memory consolidation to track learnings.

Quality enforcement: This is where you implement your quality gates from the CLAUDE.md rulebook. A Stop hook could scan Claude's response for claims like "done" or "fixed" without corresponding evidence. If found, it flags the issue and prevents you from moving forward until evidence is provided. This prevents the "fake done" problem where Claude claims to have completed something but hasn't actually provided proof.

6. SessionStart: When Sessions Begin

When it fires: When you start a new Claude Code session or resume an existing one.

What you can do:

  • Initialize session state
  • Load context from files or databases
  • Set environment variables
  • Prepare memory for the session

Example use case: Load your project's memory, initialize git context, prepare style guides.

Onboarding automation: SessionStart is where you bootstrap Claude into your project's context. A hook could read your project.yaml, load relevant documentation, initialize memory from previous sessions, and set environment variables for your build system. By the time Claude is ready to help, it's already embedded in your project's conventions.

Hook Configuration: settings.json

Hooks are configured in .claude/settings.json. Here's the complete structure:

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit|Bash",
        "hooks": [
          {
            "type": "command",
            "command": "node .claude/hooks/preToolUse/validate-write.mjs"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "node .claude/hooks/postToolUse/auto-format.mjs"
          }
        ]
      }
    ],
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node .claude/hooks/userPromptSubmit/add-context.mjs"
          }
        ]
      }
    ],
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node .claude/hooks/sessionStart/init-session.mjs"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node .claude/hooks/stop/quality-gate.mjs"
          }
        ]
      }
    ],
    "Notification": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node .claude/hooks/notification/log-notifications.mjs"
          }
        ]
      }
    ]
  }
}

The key fields:

  • matcher – Which tools trigger this hook (optional, regex pattern). If omitted, hook runs for all operations
  • type – Always "command" for external hooks
  • command – Path to your hook script (Node.js .mjs file)

The matcher is powerful—you can be very specific. "Write|Edit" only runs for file modifications. "Bash" only runs for shell commands. Or omit it entirely for hooks that should always run, like UserPromptSubmit or SessionStart.

Your First Hook: PreToolUse Validator

Let's build a real, working hook. This one logs every tool invocation and optionally blocks dangerous operations.

Create .claude/hooks/preToolUse/validate-operations.mjs:

javascript
import { readFileSync, appendFileSync } from "fs";
import { join } from "path";
 
// Read the hook input from stdin
const input = JSON.parse(readFileSync("/dev/stdin", "utf8"));
 
const { tool_name, tool_input, session_id, cwd, hook_event_name } = input;
 
// Log this operation
const logPath = join(cwd, ".claude/hook-audit.log");
const timestamp = new Date().toISOString();
const logEntry = `[${timestamp}] ${tool_name} - ${JSON.stringify(tool_input)}\n`;
 
try {
  appendFileSync(logPath, logEntry, "utf8");
} catch (err) {
  // Silently fail on write errors, don't block the operation
  console.error(`Failed to log operation: ${err.message}`);
}
 
// Block dangerous patterns
const dangerousPatterns = [
  /rm\s+-rf\s+\//, // rm -rf /
  /sudo\s+/, // sudo commands
  /git\s+push\s+--force/, // force push
  /DROP\s+TABLE/i, // SQL drop
];
 
if (tool_name === "Bash") {
  const command = tool_input.command || "";
 
  for (const pattern of dangerousPatterns) {
    if (pattern.test(command)) {
      // Block with exit code 2
      console.error(`BLOCKED: Dangerous command detected: ${command}`);
      process.exit(2);
    }
  }
}
 
// Allow the operation
const result = {
  continue: true,
  suppressOutput: false,
};
 
console.log(JSON.stringify(result));
process.exit(0);

To enable it, add to .claude/settings.json:

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit|Bash",
        "hooks": [
          {
            "type": "command",
            "command": "node .claude/hooks/preToolUse/validate-operations.mjs"
          }
        ]
      }
    ]
  }
}

Now every tool invocation is logged, and dangerous commands are automatically blocked. You'll find your audit trail in .claude/hook-audit.log.

PostToolUse: Auto-Format on Write

Create .claude/hooks/postToolUse/auto-format.mjs:

javascript
import { readFileSync, readdir, writeFileSync } from "fs";
import { join } from "path";
import { execSync } from "child_process";
 
const input = JSON.parse(readFileSync("/dev/stdin", "utf8"));
 
const { tool_name, tool_input, tool_output, cwd } = input;
 
// Only format after Write or Edit operations
if (!["Write", "Edit"].includes(tool_name)) {
  console.log(JSON.stringify({ continue: true }));
  process.exit(0);
}
 
const filePath = tool_input.file_path;
 
// Auto-format if it's a JavaScript or JSON file
if (filePath.match(/\.(mjs|js|json)$/i)) {
  try {
    // Check if prettier is available
    execSync("prettier --version", { stdio: "ignore" });
 
    // Format the file
    execSync(`prettier --write "${filePath}"`, { cwd });
 
    console.error(`Auto-formatted: ${filePath}`);
  } catch (err) {
    // Prettier not installed, silently continue
  }
}
 
// Format markdown with trailing newlines
if (filePath.match(/\.md$/i)) {
  try {
    let content = readFileSync(filePath, "utf8");
    // Ensure trailing newline
    if (!content.endsWith("\n")) {
      content += "\n";
      writeFileSync(filePath, content, "utf8");
      console.error(`Normalized line endings: ${filePath}`);
    }
  } catch (err) {
    // Silently fail
  }
}
 
const result = {
  continue: true,
  suppressOutput: false,
};
 
console.log(JSON.stringify(result));
process.exit(0);

What this does: Every time Claude writes or edits a file, this hook automatically formats it. JavaScript files get prettier-formatted (if prettier is installed). Markdown files get normalized line endings. This removes the friction of manual formatting and ensures consistency without Claude having to think about it.

UserPromptSubmit: Inject Context

Create .claude/hooks/userPromptSubmit/enrich-prompt.mjs:

javascript
import { readFileSync, existsSync } from "fs";
import { join } from "path";
import { execSync } from "child_process";
 
const input = JSON.parse(readFileSync("/dev/stdin", "utf8"));
 
const { prompt_text, cwd, session_id } = input;
 
// Gather context to inject
const context = [];
 
// Get current git branch
try {
  const branch = execSync("git rev-parse --abbrev-ref HEAD", {
    cwd,
    encoding: "utf8",
    stdio: "pipe",
  }).trim();
 
  context.push(`(Git branch: ${branch})`);
} catch (err) {
  // Not in a git repo
}
 
// Get timestamp
const timestamp = new Date().toISOString();
context.push(`(Timestamp: ${timestamp})`);
 
// Check if there's a memory directory and recent notes
const memoryPath = join(cwd, "memory", "context");
if (existsSync(memoryPath)) {
  context.push(`(Memory available at: ./memory/context)`);
}
 
// Prepend context to prompt
const enrichedPrompt =
  context.length > 0 ? `${context.join(" ")}\n\n${prompt_text}` : prompt_text;
 
const result = {
  continue: true,
  suppressOutput: false,
  hookSpecificOutput: {
    enrichedPrompt,
    contextInjected: context.length,
  },
};
 
console.log(JSON.stringify(result));
process.exit(0);

How it helps: Every prompt you submit now automatically includes the current git branch, timestamp, and a reminder that memory is available. This tiny boost of context helps Claude give better answers because it knows where in your codebase you're working and what time it is. You could extend this to inject the current file you're editing, recent git commits, or project configuration.

Notification: Log Status Updates

Create .claude/hooks/notification/log-notifications.mjs:

javascript
import { readFileSync, appendFileSync } from "fs";
import { join } from "path";
 
const input = JSON.parse(readFileSync("/dev/stdin", "utf8"));
 
const { notification_text, notification_level, cwd, session_id } = input;
 
// Log notifications to a file
const logPath = join(cwd, ".claude/notifications.log");
const timestamp = new Date().toISOString();
const logEntry = `[${timestamp}] [${notification_level}] ${notification_text}\n`;
 
try {
  appendFileSync(logPath, logEntry, "utf8");
} catch (err) {
  // Silently fail
}
 
const result = {
  continue: true,
  suppressOutput: false,
};
 
console.log(JSON.stringify(result));
process.exit(0);

Audit trail: This hook creates a permanent record of every notification Claude sends. Useful for compliance, understanding patterns ("Claude got stuck at this point in the process"), and post-mortem analysis ("What did Claude report before the failure?").

Stop: Quality Gate Enforcement

Create .claude/hooks/stop/quality-gate.mjs:

javascript
import { readFileSync } from "fs";
 
const input = JSON.parse(readFileSync("/dev/stdin", "utf8"));
 
const { response_text, session_id, cwd } = input;
 
// Check for evidence requirement: don't allow claims without proof
const unsubstantiatedClaims = [
  /\b(done|complete|fixed|tested|verified|works)\b/gi,
];
 
const issues = [];
 
for (const pattern of unsubstantiatedClaims) {
  const matches = response_text.match(pattern);
  if (matches) {
    // Check if there's evidence (file path, output, etc.) nearby
    const hasEvidence = /(`\/|path|output|result|evidence|line \d+)/i.test(
      response_text,
    );
 
    if (!hasEvidence) {
      issues.push(`Possible unsupported claim: "${matches[0]}"`);
    }
  }
}
 
if (issues.length > 0) {
  console.error(`Quality Gate: ${issues.join("; ")}\nPlease add evidence.`);
}
 
const result = {
  continue: true,
  suppressOutput: false,
  hookSpecificOutput: {
    qualityIssues: issues,
  },
};
 
console.log(JSON.stringify(result));
process.exit(0);

The power of this hook: It enforces the "evidence requirement" from your quality gates. If Claude says something is "done" or "fixed" but doesn't provide proof (a file path, command output, or evidence marker), the hook flags it. This prevents the fake-done problem mentioned in your validation rules.

SessionStart: Initialize Context

Create .claude/hooks/sessionStart/init-session.mjs:

javascript
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import { join } from "path";
 
const input = JSON.parse(readFileSync("/dev/stdin", "utf8"));
 
const { session_id, cwd } = input;
 
// Ensure hook directories exist
const hookDirs = [
  ".claude/hooks/preToolUse",
  ".claude/hooks/postToolUse",
  ".claude/hooks/userPromptSubmit",
  ".claude/hooks/sessionStart",
  ".claude/hooks/notification",
  ".claude/hooks/stop",
];
 
for (const dir of hookDirs) {
  const dirPath = join(cwd, dir);
  if (!existsSync(dirPath)) {
    mkdirSync(dirPath, { recursive: true });
  }
}
 
// Initialize session metadata
const sessionMetaPath = join(cwd, `.claude/sessions/${session_id}.json`);
const sessionMeta = {
  session_id,
  started_at: new Date().toISOString(),
  cwd,
  hooks_active: hookDirs.length,
};
 
try {
  mkdirSync(join(cwd, ".claude/sessions"), { recursive: true });
  writeFileSync(sessionMetaPath, JSON.stringify(sessionMeta, null, 2), "utf8");
} catch (err) {
  // Silently fail, don't block session start
}
 
const result = {
  continue: true,
  suppressOutput: false,
};
 
console.log(JSON.stringify(result));
process.exit(0);

Bootstrapping: When your session starts, this hook ensures all necessary directories exist and records session metadata. Future sessions can read this metadata to understand what was accomplished in previous sessions.

The Hook Contract: Input and Output

Every hook receives JSON on stdin and must output JSON to stdout. Understanding this contract is critical.

Hook Input (stdin)

json
{
  "session_id": "abc123-def456",
  "transcript_path": "/path/to/transcript.jsonl",
  "cwd": "/current/working/directory",
  "hook_event_name": "PreToolUse",
  "tool_name": "Write",
  "tool_input": {
    "file_path": "/path/to/file.md",
    "content": "..."
  },
  "timestamp": "2026-03-16T14:32:00.000Z"
}

The input varies by hook type, but always includes session_id, cwd, and hook_event_name. Tool-related hooks (PreToolUse, PostToolUse) also include tool_name and tool_input.

Hook Output (stdout)

json
{
  "continue": true,
  "suppressOutput": false,
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "permissionDecisionReason": "File is not in restricted directory"
  }
}

The output is standardized. continue: true means proceed normally. continue: false blocks the operation. suppressOutput controls whether Claude sees your hook's messages. hookSpecificOutput is for custom data your hook wants to communicate.

Exit Codes

  • 0 – Success, continue normally
  • 2 – Block the operation (stdout shown to Claude as rejection reason)
  • Other – Error, logged but non-blocking

Exit code 2 is your "stop" signal. Use it when you want to prevent an operation from proceeding.

Why JavaScript .mjs on Windows?

If you're reading hooks documentation elsewhere, you'll see Python and Bash examples. Here's why we use JavaScript with the .mjs extension:

Python dependency: Python might not be installed, and downloading it adds friction. Your team might use Windows and prefer not to manage Python.

Bash limitation: Bash doesn't exist by default on Windows. Even with Git Bash or WSL, it's an extra dependency. We want hooks that work out-of-the-box.

Node.js is already there: Claude Code requires Node.js. It's already installed on your system. Using Node.js means zero additional dependencies.

.mjs (ES modules): Using .mjs avoids CommonJS compatibility issues and gives you native async/await, import/export syntax, and modern JavaScript. It's explicit about being a module, which matters on Windows where file extensions have special meaning.

Cross-platform path handling: Using Node.js's path.join() and process.platform makes hooks work identically on Windows, macOS, and Linux. No shell syntax translation needed.

Here's the pattern every hook follows:

javascript
import { readFileSync } from "fs";
 
// 1. Read stdin
const input = JSON.parse(readFileSync("/dev/stdin", "utf8"));
 
// 2. Do your work
const { tool_name, tool_input } = input;
// ... your logic ...
 
// 3. Output JSON
const result = { continue: true };
console.log(JSON.stringify(result));
process.exit(0);

This pattern works on Windows 10+, macOS 10.15+, and Linux (any distro). No Python. No Bash. Just Node.js.

Testing Your Hooks Manually

You can test hooks without triggering them through Claude Code. Just pipe JSON to stdin:

bash
echo '{"tool_name":"Write","tool_input":{"file_path":"test.md"},"cwd":"/home/user/project"}' | \
  node .claude/hooks/preToolUse/validate-operations.mjs

Or for UserPromptSubmit:

bash
echo '{"prompt_text":"Hello","cwd":"/home/user/project","session_id":"123"}' | \
  node .claude/hooks/userPromptSubmit/enrich-prompt.mjs

This makes debugging super fast—you don't have to trigger the hook through Claude Code every time. You can test your logic instantly.

Debugging Hook Issues

If a hook isn't firing:

  1. Check settings.json – Is the hook configured?
  2. Verify the command path – Does the file exist at that path?
  3. Check file permissions – Is it executable? (Usually not required on Windows, but worth checking on Mac/Linux)
  4. Look for stderr – Hooks write errors to stderr. Check Claude's debug output.
  5. Test manually – Use the manual testing pattern above.

If a hook is blocking too aggressively:

  1. Loosen the matcher regex – Maybe it's catching operations you didn't intend
  2. Add logging – Write debug info to a file, not stdout (stdout is reserved for the hook result)
  3. Check exit codes – Exit 2 blocks, exit 0 allows. Make sure you're using the right one.

Common pitfall: forgetting that exit code 2 blocks the operation. If your hook exits with 0 (success) but you intended to block, the operation proceeds. Always be explicit about your exit codes.

Advanced: Chaining Hooks

You can run multiple hooks for a single event:

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write",
        "hooks": [
          {
            "type": "command",
            "command": "node .claude/hooks/preToolUse/validate-operations.mjs"
          },
          {
            "type": "command",
            "command": "node .claude/hooks/preToolUse/check-credentials.mjs"
          }
        ]
      }
    ]
  }
}

Hooks run in order. If any hook exits with code 2 (block), the operation is blocked and subsequent hooks don't run. This is powerful for composable security policies—you might have one hook that checks for dangerous commands, another that checks for credential leaks, another that verifies permissions. Each adds a layer of protection.

The Real Power: Orchestration

The reason hooks matter isn't just individual governance. It's orchestration.

Imagine:

  • Every time you submit a prompt, hooks inject relevant memory
  • Every time you write a file, hooks auto-format and log it
  • Every time a session starts, hooks load context
  • Every time Claude finishes, hooks validate quality

Together, these create an intelligent, self-improving system. Claude Code isn't just an assistant—it's a system that learns, enforces standards, and adapts to your team's needs.

That's the hidden power of hooks. They're the infrastructure for governance, automation, and learning at scale. In a large team or mission-critical project, hooks are how you scale Claude Code from "helpful AI" to "compliant, auditable, self-enforcing system."

Hook Composition and Orchestration Patterns

Once you have individual hooks working, the real power emerges when you compose them. Hooks don't exist in isolation—they're building blocks of more sophisticated systems.

Sequential Validation

You can chain hooks so that each one adds another layer of validation. A PreToolUse event might first check if the operation is dangerous, then check if it violates file permissions, then check if it's accessing restricted paths. Each hook is independent, but together they form a comprehensive security boundary.

The key is exit codes. If hook A exits with code 2 (block), hook B never runs—the operation is already stopped. This creates an early-exit pattern where the most important checks run first and can short-circuit less critical ones. This is more efficient than running all checks and then deciding.

State Propagation Across Hooks

Sometimes you want information from one hook to influence another. For example, PreToolUse might detect that you're writing to a test file. PostToolUse might want to know that, to automatically run tests only for that file instead of the whole suite.

Hooks can't communicate directly, but they can use shared state files. Write context to .claude/hook-state.json in PreToolUse, read it in PostToolUse. This is simple but effective: each hook reads the latest state before deciding what to do.

Time-Based Hook Batching

Some hooks you want running on every operation. Others are expensive and should batch. For example, logging every single tool invocation is cheap. Running a compliance scan on every tool invocation is expensive.

Create a batching hook that collects operations over time and runs expensive checks in bulk. Write timestamps to a file. Every 100 operations or every 5 minutes, trigger an expensive check. This amortizes cost across many operations.

Real-World Hook Architecture for Teams

In a large team, you'll want a more sophisticated hook architecture. Here's what production-grade looks like:

Tier 1 Hooks (Always Run):

  • Security validation (dangerous commands)
  • Audit logging
  • Session tracking

Tier 2 Hooks (Conditional):

  • Compliance checks (only in production branches)
  • Performance profiling (only in performance-sensitive code)
  • Custom business logic (only for specific domains)

Tier 3 Hooks (Optional):

  • Slack notifications
  • Custom analytics
  • Integration with external systems

This tiering ensures critical logic always runs, conditional logic runs only when needed, and optional enhancements don't block core work.

Debugging Hook Chains

When multiple hooks run in sequence and one fails, debugging can be tricky. The operation gets blocked but you don't know which hook did it.

Create a debugging mode. In .claude/settings.json, add a debug flag. When enabled, each hook logs its decisions to a central file. Not just errors—every decision. You can then replay the sequence and see exactly where the chain broke.

javascript
// Add to your hooks
const DEBUG = process.env.CLAUDE_HOOK_DEBUG === "true";
const debugLog = (msg) => {
  if (DEBUG) {
    appendFileSync(
      ".claude/hook-debug.log",
      `${new Date().toISOString()} ${msg}\n`,
    );
  }
};

Now when something breaks, you have a detailed execution trace.

Hook Performance Considerations

Hooks run synchronously—they block execution until they complete. A slow hook delays everything that comes after. This means you need to think about performance.

Profile your hooks. How long does each one take? If any hook consistently takes >1 second, investigate. Maybe it's reading a large file or making a network call. For expensive operations, consider caching results or batching checks.

Use the async/await pattern in Node.js hooks, but remember: Claude Code will wait for the entire hook to complete before continuing. If a hook spawns a background job, that's fine—use execSync or spawn with explicit waiting.

Caching Strategies

Repeated hook calls often check the same thing. Don't recompute every time. Implement smart caching:

File-based cache: For expensive checks (reading large config files), cache the result on disk with a timestamp. Check once per minute, not once per tool call.

In-memory cache: For frequently-checked patterns (regex matching against a blacklist), keep results in memory. This is fast but limited by available RAM.

Distributed cache: For team deployments, use a shared cache (Redis, memcached) so all team members benefit from cached results. One person checks a file, everyone gets the cached answer.

The key metric: cache hit rate. If you're caching but hitting misses 80% of the time, caching isn't helping. Adjust cache TTL or strategy based on actual access patterns.

Parallel Hook Execution

If you have multiple independent hooks, can they run in parallel? Currently, hooks run sequentially. But you could spawn parallel processes for non-blocking hooks, collect results, and apply them atomically. This is advanced but useful for large deployments.

javascript
// Pseudo-code: parallel hooks
async function runHooksInParallel(toolName, toolInput) {
  const results = await Promise.all([
    hook1(toolName, toolInput),
    hook2(toolName, toolInput),
    hook3(toolName, toolInput),
  ]);
 
  // Merge results: if any hook blocks, block
  const blocked = results.find((r) => r.action === "deny");
  if (blocked) return blocked;
 
  // Merge all modifications
  let modifiedInput = toolInput;
  for (const r of results.filter((r) => r.action === "modify")) {
    modifiedInput = { ...modifiedInput, ...r.input };
  }
 
  return { action: "allow", input: modifiedInput };
}

This is complex to implement correctly but gives you the responsiveness of single hooks with the breadth of multiple hooks.

Security Implications of Hooks

Hooks have full access to your system. They can read your private keys, write files, make network calls, execute arbitrary commands. This is powerful but dangerous.

Never hardcode secrets in hooks. Always use environment variables. Never trust user input to hooks without validation. Never use untrusted hook code from external sources. Treat hooks like you'd treat shell scripts—they're powerful and dangerous.

If you're sharing hooks across teams, code-review them. Someone should verify that a hook isn't exfiltrating data or performing unauthorized operations.

Sandboxing Hooks

For ultra-high-security deployments, you might want to sandbox hook execution. Run hooks in containers with limited filesystem access, no network, restricted resource limits. This prevents a buggy or malicious hook from causing systemic damage.

Modern CI/CD systems (GitHub Actions, GitLab CI) do this by default. You can replicate it locally using Docker or native OS sandboxing (seccomp, AppArmor on Linux, sandbox on macOS).

The tradeoff: sandboxed hooks can't access your filesystem, can't read config files, can't make decisions based on system state. They're restricted but safer. Use sandboxing for hooks you don't fully trust.

Hook Testing and Validation

Like any code, hooks need testing. You can't deploy a hook without confidence it works.

Unit tests: Test hook logic in isolation. Mock the input, verify the output.

Integration tests: Test hooks with real Claude Code execution. Submit actual prompts, watch hooks fire, verify decisions.

Regression tests: As you modify hooks, maintain a test suite of known scenarios. "When bash command is rm -rf /, hook should deny" should pass every time.

Chaos testing: What if your hook throws an exception? Crashes? Takes 30 seconds? Test failure modes. Verify Claude Code handles hook failures gracefully.

Create a testing directory: .claude/hooks/__tests__/. Write test cases. Run them before deploying to production. This prevents surprise failures at critical moments.

Hook Versioning and Rollback

Over time, hooks evolve. You'll want to version them, test new versions, and roll back if something breaks.

.claude/hooks/
├── preToolUse/
│   ├── validate-operations.mjs (current version)
│   └── validate-operations.v1.mjs (previous version)

In settings.json, specify which version to use. If a new version causes problems, quick rollback:

json
{
  "hooks": {
    "PreToolUse": [
      {
        "command": "node .claude/hooks/preToolUse/validate-operations.v1.mjs"
      }
    ]
  }
}

This simple strategy prevents hooks from being a point of failure that's hard to recover from.

Hook Documentation

Your hooks are code, but they're also policy. Document them clearly:

javascript
/**
 * ValidateOperations Hook
 *
 * Purpose: Prevent dangerous bash operations (rm -rf, sudo, etc.)
 * Scope: Applies to all Bash tool invocations
 * Decision Logic:
 *   - Check command against dangerous patterns
 *   - If match found: DENY
 *   - If no match: ALLOW
 *
 * Dangerous Patterns:
 *   - rm -rf /
 *   - sudo commands
 *   - git push --force
 *
 * Performance: <5ms per check (regex matching)
 * Last Updated: 2025-03-15
 * Owner: @security-team
 */

This documentation helps future maintainers understand the intent, scope, and logic. It also helps debug when hooks behave unexpectedly.

Summary

Claude Code hooks are lifecycle event handlers that intercept and modify execution at six critical points: PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, and SessionStart.

They're configured in .claude/settings.json and implemented as Node.js .mjs scripts—no external dependencies, cross-platform compatibility, and full access to your project context.

You've now seen working examples of all six lifecycle events. Start with one (PreToolUse for security, PostToolUse for formatting) and expand as your needs grow.

The practical reality: hooks often reveal themselves organically. You'll build Claude Code for a while, hit a pain point ("I wish I could automatically prevent dangerous commands"), and suddenly hooks are the obvious solution. Rather than building them preemptively for imaginary problems, let your actual workflow guide their development. The best hook ecosystems grow from real friction points, not theoretical possibilities. Start with what hurts, automate it, then observe what new patterns emerge.

The real magic happens when hooks work together: enforcing policies, enriching context, logging activity, and validating quality—automatically, silently, in the background. Composition is where hooks become powerful. Individual hooks are useful. Hook ecosystems are transformative.

Hooks are how Claude Code becomes truly extensible. They're also how you scale governance—making sure that as Claude Code is more trusted with critical work, it's also more constrained by policy. Use them wisely.

Need help implementing this?

We build automation systems like this for clients every day.