All Articles Claude Code

Hook Debugging: Testing and Troubleshooting .mjs Hooks

You've written a Claude Code hook. It looked good on your screen. You've placed it in the right directory. And then... nothing.

The Problem: Your Hook Isn’t Working, and You Can’t See Why

You’ve written a Claude Code hook. It looked good on your screen. You’ve placed it in the right directory. And then… nothing. The hook either doesn’t fire, fires but does the wrong thing, or silently fails and you have no idea why.

The frustration is real. Hooks run outside Claude’s UI. You can’t use console.log() in the browser dev tools. You can’t attach a debugger. You’re flying blind. This is the defining challenge of hook development—the normal debugging tools developers rely on simply don’t work. You don’t have a console to inspect. You don’t have a browser you can open. You don’t have stack traces appearing in a familiar place.

Here’s what we’re solving in this guide: How to test, debug, and troubleshoot Claude Code hooks locally, before they ever run in production. We’ll build a testing harness that simulates real hook invocations, teach you logging strategies that actually work, walk through common errors and their fixes, and show you how to mock tool inputs and permission decisions.

By the end, you’ll have a rock-solid debugging workflow that catches problems before Claude Code ever sees them. You’ll move from “I hope this works” to “I know this works.” That transformation is what separates hobbyist hooks from production-grade hooks.

Why Local Hook Testing Matters

Before we dive into the how, let’s talk about why this matters. The fundamental challenge with hooks is that they operate in darkness. You write code, deploy it, and pray it works. If it doesn’t, you get no error message, no stack trace, no visible feedback. It just silently doesn’t work or works wrong. This is incredibly frustrating and the reason many teams avoid hooks altogether. They find hooks too difficult to debug, so they give up and never implement them.

But here’s the good news: the solution is simpler than you think. Because hooks are fundamentally just programs that read stdin and write stdout, you can test them exactly like you’d test any command-line tool. You can simulate their inputs, verify their outputs, check their exit codes, all without touching Claude Code. This transforms hook development from a guessing game into a normal engineering task with standard debugging tools.

The power of this approach compounds. Once you have a working test harness, you can iterate rapidly. Modify the hook, run the tests, see the results immediately. You catch bugs before Claude Code ever sees them. You gain confidence in your hooks before deployment. You can even test edge cases and error scenarios systematically, ensuring your hooks handle weird inputs gracefully.

The psychological impact matters too. When you have a working testing framework, you’re no longer afraid of breaking things. You can refactor hooks confidently because you know the tests will catch regressions. You can add features without worrying you’ll inadvertently break existing functionality. This confidence translates into better code because you’re not paralyzed by fear of the unknown. You’re working with visibility and control.

When a hook runs inside Claude Code, it’s:

  • Silent by default: No output appears in the Claude Code UI
  • Synchronous: If it hangs, your entire Claude Code session hangs
  • Critical path: A broken hook can block all tool use
  • Hard to inspect: You can’t just “check what happened”

Testing hooks locally, outside Claude Code, means you get:

  • Visible output: console.log(), stderr, file logs—all work normally
  • Fast iteration: Modify, test, repeat in seconds
  • Isolation: Your hook runs alone, with exactly the inputs you control
  • Error messages: Stack traces appear immediately, not hidden in logs
  • Confidence: You know it works before it matters

The gold standard: Every hook should have a test harness before it goes into production. This isn’t optional. This is as important as writing tests for your application code. Maybe more important, because hooks run in a privileged context. A bug in your application code affects your application. A bug in a hook affects everything Claude Code tries to do.

Think about the business impact. If Claude Code is helping you with code generation, and a broken hook breaks all code generation, you’re blocked. Not for five minutes while you debug. You’re blocked until you figure out why the hook is broken and fix it. During that time, you have a broken tool. You lose productivity. Your team gets frustrated. You lose trust in the tool.

By testing hooks locally before deployment, you prevent this scenario entirely. You gain the ability to iterate rapidly on hook logic without worrying about breaking Claude Code. You can test edge cases systematically. You can verify that your permission decisions are working as intended. You can catch bugs in isolation before they propagate into production.

Understanding Hook Lifecycle and Execution Context

Before diving into testing strategies, you need to understand how hooks actually execute within Claude Code. Hooks are not running in a browser. They’re not running in VS Code’s extension host. They’re spawned as independent Node.js processes that exist for a very brief moment—just long enough to make a decision and exit. Understanding this execution model is crucial because it changes how you think about testing.

When Claude Code needs to make a permission decision, it spawns your hook, writes JSON to its stdin, reads the response from stdout, and then the process exits. The entire lifecycle might be 50 to 200 milliseconds. The hook doesn’t maintain state. It doesn’t keep connections open. It doesn’t do background work. It reads input, makes a decision, outputs result, and dies. This is both a constraint and a feature.

The constraint is that you can’t do certain things in hooks. You can’t maintain state across invocations. You can’t keep a database connection open. You can’t do async operations that take longer than the timeout. You need to be synchronous and fast.

The feature is that hooks are incredibly simple to reason about. There’s no complex lifecycle to manage. There’s no cleanup logic needed. There’s no race conditions between concurrent executions because each execution is isolated. This simplicity makes hooks reliable and easy to test.

This understanding shapes everything about how you debug hooks. You’re not debugging a long-running application. You’re debugging a short-lived command-line tool that runs thousands of times per day. Each execution is independent. Each execution succeeds or fails based on whether the code is correct.

This is exactly why testing hooks locally before they go into production is so powerful. You run the same isolated execution locally that will run in Claude Code. The conditions are nearly identical. If it works in your testing harness, it will work in production.

The Debugging Mindset Shift

When you shift from debugging hooks in Claude Code to testing them locally, you’re making a fundamental change in how you approach the problem. Instead of a guessing game with hidden feedback, you’re using standard engineering practices with visible, repeatable results. This mindset shift is crucial.

In Claude Code, you’re asking “Does this hook work?” and getting silence as an answer. In your testing harness, you’re asking “What does this hook do?” and getting specific, verifiable results. You can inspect every aspect of execution. You can see exactly what the hook received as input, what it processed, what decisions it made, and what it output. You can test the exact same scenario a hundred times and get consistent results. You can modify one line of code and see how it changes behavior.

This visibility is transformative. Once you have it, you’ll wonder how you ever debugged anything differently. Developers who use testing harnesses for hooks consistently report that they’re more confident in their hooks, catch more bugs before production, and spend less time debugging failures. The cognitive load drops dramatically. You’re no longer maintaining a mental model of “what might be happening.” You know exactly what’s happening because you can see it.

The practical implication is that you should invest time upfront in building a robust testing harness. It’s not extra work—it’s the right way to work. It’s not slowing you down—it’s speeding you up because you catch mistakes immediately instead of discovering them in production. It’s not a nice-to-have—it’s essential infrastructure that enables you to work confidently.

Core Strategy: Testing Hooks via stdin/stdout

Claude Code hooks receive JSON on stdin and output decisions to stdout. This is brilliant for testing, because you can:

  1. Echo JSON into the hook like a pipe: echo '{"event": "PreToolUse", ...}' | node hook.mjs
  2. Capture the response: Redirect stdout/stderr to files or inspect it directly
  3. Vary inputs: Test different scenarios without touching Claude Code
  4. Measure performance: See how fast your hook runs
  5. Validate output: Confirm it’s valid JSON and the right decision

Let’s start with the simplest test: running a hook with real input and watching what comes out. Understanding the input/output contract is the foundation of all hook testing. Once you know how to read JSON on stdin and write JSON to stdout, you can build anything on top of that foundation.

Basic Hook Input: PreToolUse Event

A typical PreToolUse hook receives this JSON structure:

{
  "event": "PreToolUse",
  "tool": "Bash",
  "input": {
    "command": "rm -rf /"
  },
  "context": {
    "sessionId": "abc-123",
    "userId": "[email protected]"
  }
}

Your hook reads this from stdin, makes a decision, and outputs JSON to stdout. This is the contract. This is what you need to test. The structure is predictable, which means you can create mock inputs systematically and verify the outputs match your expectations.

Strategy 1: Direct stdin Piping

The simplest test: pipe JSON directly into your hook. Let’s say you have a basic permission hook. Here’s how you’d test it in practice with a real command-line invocation:

// hook-basic-perms.mjs


const input = JSON.parse(readFileSync(0, "utf-8"));

if (input.tool === "Bash" && input.input.command.includes("rm -rf")) {
  console.error("BLOCK: Dangerous command detected");
  process.exit(2); // Fatal error
}

console.log(
  JSON.stringify({
    permissionDecision: "allow",
  }),
);
process.exit(0);

Test it with:

echo '{"event":"PreToolUse","tool":"Bash","input":{"command":"rm -rf /"}}' | node hook-basic-perms.mjs

What you’ll see:

BLOCK: Dangerous command detected

Exit code: 2 (because process.exit(2))

Great! Now you know the hook fires and blocks dangerous commands. But what if the command is safe? Let’s test that scenario:

echo '{"event":"PreToolUse","tool":"Bash","input":{"command":"ls -la"}}' | node hook-basic-perms.mjs

Output:

{"permissionDecision":"allow"}

Exit code: 0

Perfect. This basic loop—pipe input, see output, check exit code—is your foundation. But command-line JSON is fragile. Escaping quotes is annoying. Complex payloads become hard to manage. Let’s build something more robust that scales to dozens of test cases.

Strategy 2: Mock Input Files and Test Harness

Piping JSON on the command line works, but it’s fragile:

  • Escaping quotes is annoying
  • You can’t test complex payloads easily
  • There’s no structure to your tests
  • You can’t rerun tests systematically

Better approach: Create a test harness that loads mock inputs and validates outputs. Here’s a testing harness that simulates multiple hook scenarios:

// hook-test-harness.mjs




// Mock inputs for different scenarios
const testCases = [
  {
    name: "Safe bash command",
    input: {
      event: "PreToolUse",
      tool: "Bash",
      input: { command: "ls -la" },
    },
    expectedExit: 0,
    shouldBlock: false,
  },
  {
    name: "Dangerous rm -rf",
    input: {
      event: "PreToolUse",
      tool: "Bash",
      input: { command: "rm -rf /" },
    },
    expectedExit: 2,
    shouldBlock: true,
  },
  {
    name: "Git force push",
    input: {
      event: "PreToolUse",
      tool: "Bash",
      input: { command: "git push --force origin main" },
    },
    expectedExit: 2,
    shouldBlock: true,
  },
  {
    name: "Safe npm install",
    input: {
      event: "PreToolUse",
      tool: "Bash",
      input: { command: "npm install lodash" },
    },
    expectedExit: 0,
    shouldBlock: false,
  },
];

// Run each test
let passed = 0;
let failed = 0;

for (const testCase of testCases) {
  await runTest(testCase);
}

console.log(`\n=== SUMMARY ===`);
console.log(`Passed: ${passed}`);
console.log(`Failed: ${failed}`);

async function runTest(testCase) {
  return new Promise((resolve) => {
    const hook = spawn("node", ["hook-basic-perms.mjs"]);

    let stdout = "";
    let stderr = "";

    hook.stdout.on("data", (data) => {
      stdout += data.toString();
    });

    hook.stderr.on("data", (data) => {
      stderr += data.toString();
    });

    hook.on("close", (exitCode) => {
      const testPassed = exitCode === testCase.expectedExit;

      console.log(`\n[${testPassed ? "✓" : "✗"}] ${testCase.name}`);
      console.log(
        `  Expected exit: ${testCase.expectedExit}, Got: ${exitCode}`,
      );

      if (stdout) console.log(`  stdout: ${stdout.trim()}`);
      if (stderr) console.log(`  stderr: ${stderr.trim()}`);

      if (testPassed) {
        passed++;
      } else {
        failed++;
      }

      resolve();
    });

    hook.stdin.write(JSON.stringify(testCase.input));
    hook.stdin.end();
  });
}

Run it:

node hook-test-harness.mjs

Output:

[✓] Safe bash command
  Expected exit: 0, Got: 0
  stdout: {"permissionDecision":"allow"}

[✓] Dangerous rm -rf
  Expected exit: 2, Got: 2
  stderr: BLOCK: Dangerous command detected

[✓] Git force push
  Expected exit: 2, Got: 2
  stderr: BLOCK: Dangerous command detected

[✓] Safe npm install
  Expected exit: 0, Got: 0
  stdout: {"permissionDecision":"allow"}

=== SUMMARY ===
Passed: 4
Failed: 0

Now you have structure, clarity, and instant feedback on whether your hook works across different scenarios. This is the pattern you’ll use for all hook testing. It’s simple, it’s effective, and it scales to dozens of test cases without breaking a sweat.

Strategy 3: Logging and stderr for Debugging

Hooks don’t print to the Claude Code console. So where should your debug output go?

Answer: stderr. Stderr is separate from stdout. Your hook outputs decisions to stdout (which Claude Code reads), and debug logs to stderr (which appears in your terminal). Here’s a hook with proper logging that shows exactly what’s happening at each step:

// hook-with-logging.mjs


// Simple logger that writes to stderr
function log(level, message, data = null) {
  const timestamp = new Date().toISOString();
  const msg = `[${timestamp}] ${level}: ${message}`;

  if (data) {
    console.error(msg);
    console.error(JSON.stringify(data, null, 2));
  } else {
    console.error(msg);
  }
}

try {
  const input = JSON.parse(readFileSync(0, "utf-8"));

  log("DEBUG", "Hook invoked", { event: input.event, tool: input.tool });

  // Simulate some validation logic
  if (input.tool === "Bash") {
    log("DEBUG", "Bash tool detected", { command: input.input.command });

    if (input.input.command.includes("rm -rf")) {
      log("WARN", "Dangerous command detected");
      console.error("BLOCK: rm -rf is not permitted");
      process.exit(2);
    }
  }

  log("DEBUG", "Permission granted");
  console.log(JSON.stringify({ permissionDecision: "allow" }));
  process.exit(0);
} catch (error) {
  log("ERROR", "Hook failed with exception", {
    message: error.message,
    stack: error.stack,
  });
  console.error(`FATAL: ${error.message}`);
  process.exit(1);
}

Test it:

echo '{"event":"PreToolUse","tool":"Bash","input":{"command":"git push --force"}}' | node hook-with-logging.mjs

Output:

[2026-03-16T14:32:21.456Z] DEBUG: Hook invoked
{
  "event": "PreToolUse",
  "tool": "Bash"
}
[2026-03-16T14:32:21.457Z] DEBUG: Bash tool detected
{
  "command": "git push --force"
}
[2026-03-16T14:32:21.458Z] DEBUG: Permission granted
{"permissionDecision":"allow"}

Now you see what happened at each step. The timestamp helps you correlate with Claude Code logs. And because it’s going to stderr, Claude Code ignores it but your terminal captures it.

Pro tip: Redirect stderr to a file for persistent logging:

echo '{"event":"PreToolUse",...}' | node hook-with-logging.mjs 2> hook-debug.log
cat hook-debug.log  # Review later

This is how you build visibility into hook execution without interfering with the hook’s actual output. The logging is separate from the decision. The decision is clean JSON to stdout. The logs are timestamped debug information to stderr. You get the best of both worlds.

Strategy 4: Validating Hook Output

Your hook must output valid JSON. If Claude Code reads invalid JSON from your hook, it either ignores it or crashes. Let’s build a validator that ensures your output is correct before deployment:

// hook-output-validator.mjs


async function validateHookOutput(hookPath, input) {
  return new Promise((resolve) => {
    const hook = spawn("node", [hookPath]);

    let stdout = "";
    let stderr = "";
    let isValid = true;
    let errors = [];

    hook.stdout.on("data", (data) => {
      stdout += data.toString();
    });

    hook.stderr.on("data", (data) => {
      stderr += data.toString();
    });

    hook.on("close", (exitCode) => {
      // Validate exit code
      if (![0, 1, 2].includes(exitCode)) {
        isValid = false;
        errors.push(`Invalid exit code: ${exitCode}. Expected 0, 1, or 2.`);
      }

      // Validate JSON output (if exit code is 0)
      if (exitCode === 0 && stdout.trim()) {
        try {
          const json = JSON.parse(stdout.trim());

          // Validate that permissionDecision exists
          if (!["allow", "deny", "block"].includes(json.permissionDecision)) {
            isValid = false;
            errors.push(
              `Invalid permissionDecision: "${json.permissionDecision}". ` +
                `Expected "allow", "deny", or "block".`,
            );
          }

          // If deny, reason is required
          if (json.permissionDecision === "deny" && !json.reason) {
            isValid = false;
            errors.push(`Deny decision missing "reason" field.`);
          }
        } catch (e) {
          isValid = false;
          errors.push(`stdout is not valid JSON: ${e.message}`);
        }
      }

      resolve({
        isValid,
        exitCode,
        stdout: stdout.trim(),
        stderr: stderr.trim(),
        errors,
      });
    });

    hook.stdin.write(JSON.stringify(input));
    hook.stdin.end();
  });
}

// Test it
const result = await validateHookOutput("hook-basic-perms.mjs", {
  event: "PreToolUse",
  tool: "Bash",
  input: { command: "ls" },
});

if (result.isValid) {
  console.log("✓ Hook output is valid");
  console.log(`  Decision: ${result.stdout}`);
} else {
  console.log("✗ Hook output is invalid");
  result.errors.forEach((err) => console.log(`  - ${err}`));
}

This validator catches the most common mistake: invalid or missing fields in the JSON response. Before you deploy a hook to production, you want to know that it outputs valid JSON. This validator ensures that. It’s a safety net that catches subtle bugs before they hit production.

Strategy 5: Mocking Complex Tool Inputs

Real Claude Code tools pass complex nested data. Your hook needs to handle it. Let’s mock a realistic Write tool invocation with detailed file paths and content:

// hook-with-file-validation.mjs


function log(msg) {
  console.error(`[LOG] ${msg}`);
}

const input = JSON.parse(readFileSync(0, "utf-8"));

log(`Event: ${input.event}`);
log(`Tool: ${input.tool}`);

// Block writes to sensitive directories
const BLOCKED_PATHS = [
  "/etc/passwd",
  "/root/.ssh",
  "C:\\Windows\\System32",
  "/System/Library",
  ".env",
  "secrets.json",
  "credentials.yml",
];

if (input.tool === "Write") {
  const filePath = input.input.file_path;
  log(`Write to: ${filePath}`);

  // Check if it's a blocked path
  const isBlocked = BLOCKED_PATHS.some(
    (blocked) => filePath.includes(blocked) || filePath.endsWith(blocked),
  );

  if (isBlocked) {
    log(`BLOCK: Cannot write to ${filePath}`);
    console.error(`BLOCK: Writing to "${filePath}" is not permitted.`);
    process.exit(2);
  }

  log(`Allowed: ${filePath}`);
}

console.log(JSON.stringify({ permissionDecision: "allow" }));
process.exit(0);

Test with mock file writes:

cat > mock-write-safe.json << 'EOF'
{
  "event": "PreToolUse",
  "tool": "Write",
  "input": {
    "file_path": "/home/user/project/config.json",
    "file_content": "{\"key\": \"value\"}"
  }
}
EOF

cat mock-write-safe.json | node hook-with-file-validation.mjs
# Output: [LOG] Allowed: /home/user/project/config.json
# Exit code: 0

cat > mock-write-dangerous.json << 'EOF'
{
  "event": "PreToolUse",
  "tool": "Write",
  "input": {
    "file_path": ".env",
    "file_content": "API_KEY=secret123"
  }
}
EOF

cat mock-write-dangerous.json | node hook-with-file-validation.mjs
# Output: [LOG] BLOCK: Cannot write to .env
# Exit code: 2

This pattern—mocking realistic tool inputs and verifying your hook rejects dangerous ones—is the heart of hook testing. It ensures that your hooks work against real-world inputs, not just toy examples.

Strategy 6: Common Errors and How to Fix Them

Even when your logic is right, small mistakes break hooks. Here’s a reference guide to the most common errors and their solutions:

Error 1: Invalid JSON Output

Symptom: Hook fires but Claude Code ignores the decision.

Cause: Hook outputs non-JSON to stdout, like:

console.log("allow"); // WRONG - not JSON

Fix: Always output valid JSON:

console.log(JSON.stringify({ permissionDecision: "allow" }));

Error 2: Mixing stdout and stderr for Decisions

Symptom: Hook blocks but Claude Code doesn’t see the block message.

Cause: You’re sending the decision to stderr instead of stdout:

console.error(JSON.stringify({ permissionDecision: "block" })); // WRONG

Fix: Decisions go to stdout. Messages go to stderr:

console.log(JSON.stringify({ permissionDecision: "block" })); // RIGHT
console.error("BLOCK: Reason for the block"); // Logs for debugging
process.exit(2);

Error 3: Uncaught Exceptions

Symptom: Hook crashes, Claude Code hangs, no error message visible.

Cause: Your hook throws an error and doesn’t catch it:

const input = JSON.parse(readFileSync(0, "utf-8"));
// If input is invalid JSON, this throws and crashes

Fix: Wrap in try/catch:

try {
  const input = JSON.parse(readFileSync(0, "utf-8"));
  // ... your logic
} catch (error) {
  console.error(`FATAL: ${error.message}`);
  process.exit(1);
}

Error 4: Hanging or Timeout

Symptom: Hook runs but never returns. Claude Code session hangs.

Cause: Async code that never resolves:

const input = JSON.parse(readFileSync(0, "utf-8"));

// Async operation that never finishes
setTimeout(() => {
  console.log(JSON.stringify({ permissionDecision: "allow" }));
}, 10000); // Waits 10 seconds!

Fix: Keep hooks synchronous. If you must use async, ensure it resolves:

// Good: Synchronous code
const input = JSON.parse(readFileSync(0, "utf-8"));
console.log(JSON.stringify({ permissionDecision: "allow" }));
process.exit(0);

// If you must use async, wait for it:
await someAsyncOperation();
console.log(JSON.stringify({ permissionDecision: "allow" }));
process.exit(0);

Error 5: Wrong Exit Codes

Symptom: Hook runs but Claude Code treats it wrong (e.g., block becomes allow).

Cause: Using wrong exit codes:

process.exit(1); // Treated as non-fatal error

Fix: Use the correct codes:

Exit Code Meaning
0 Hook succeeded (allow/deny decision)
1 Non-fatal error (logged, operation continues)
2 Fatal error, block the operation

Advanced Strategies: Profiling and Performance Testing

As your hooks become more sophisticated, you’ll want to understand not just whether they work, but how fast they work and where time gets spent. This is crucial because hooks run on the critical path of Claude Code operations. A hook that takes three seconds to evaluate permission decisions will slow down every tool use. A slow hook is almost as bad as a broken hook—it breaks the user experience even if it technically works.

Performance profiling helps you identify bottlenecks before they become problems in production. You’ll want to know:

  • How long does each hook take to run on average?
  • What’s the worst-case latency (outliers that matter)?
  • Which parts of the hook are slow?
  • Does performance degrade with certain types of input?

Here’s a performance test harness that measures execution time and identifies slow operations:

// hook-performance-test.mjs



async function measureHookPerformance(hookPath, testInput, iterations = 5) {
  const timings = [];

  for (let i = 0; i < iterations; i++) {
    const startTime = performance.now();

    await runHookWithInput(hookPath, testInput);

    const endTime = performance.now();
    const duration = endTime - startTime;
    timings.push(duration);
  }

  // Calculate statistics
  const sorted = timings.sort((a, b) => a - b);
  const min = sorted[0];
  const max = sorted[sorted.length - 1];
  const avg = timings.reduce((a, b) => a + b, 0) / timings.length;
  const median = sorted[Math.floor(sorted.length / 2)];
  const p95 = sorted[Math.floor(sorted.length * 0.95)];

  console.log(`Performance Metrics (${iterations} runs):`);
  console.log(`  Min:    ${min.toFixed(2)}ms`);
  console.log(`  Max:    ${max.toFixed(2)}ms`);
  console.log(`  Avg:    ${avg.toFixed(2)}ms`);
  console.log(`  Median: ${median.toFixed(2)}ms`);
  console.log(`  P95:    ${p95.toFixed(2)}ms`);

  // Flag if too slow
  if (avg > 100) {
    console.warn(
      `⚠️  Average execution time ${avg.toFixed(2)}ms exceeds 100ms threshold`,
    );
  }

  return { min, max, avg, median, p95 };
}

Understanding performance characteristics helps you optimize hooks before deploying them. You might discover that loading a large denylist on every execution is the bottleneck, and caching it would improve performance dramatically.

Integration with Version Control

Your hook testing isn’t complete unless it’s part of your development workflow. Integrate hook testing into your git hooks so hooks are tested before you even commit them:

#!/bin/bash
# .git/hooks/pre-commit

# Find all .mjs files in .claude directory
for hook in .claude/hooks/*.mjs; do
  if [ -f "$hook" ]; then
    echo "Testing hook: $hook"
    node hook-test-harness.mjs "$hook"

    if [ $? -ne 0 ]; then
      echo "❌ Hook tests failed for $hook"
      exit 1
    fi
  fi
done

echo "✅ All hooks passed testing"
exit 0

This ensures that broken hooks never make it into version control. Tests run automatically as part of your commit workflow. Developers get immediate feedback if they’ve broken something. And the barrier to entry is low—just commit and the tests run automatically.

Understanding Hook Lifecycle and State

Hooks exist in an execution context that extends beyond the hook invocation itself. Understanding this context helps you debug subtle state-related issues:

  • Persistent state: Hooks can read files from disk, but they shouldn’t maintain state across invocations
  • Environment: Hooks inherit environment variables from Claude Code’s runtime
  • Permissions: Hooks run with whatever permissions the Claude Code process has
  • Timing: Hooks execute synchronously and block until they return

A common mistake is assuming hooks can maintain state. They can’t. Each invocation is independent. If you need to track something across invocations, you need to persist it to disk (logs, cache files, etc.). This is both a limitation and a feature—it prevents hooks from becoming stateful monsters that are hard to understand.

Your test harness should reflect this reality. Each test case should be truly independent. Hooks shouldn’t assume anything about previous executions.

Hook Anti-Patterns and How to Avoid Them

Over time, you’ll encounter patterns that look like they should work but actually create problems. Let’s talk about anti-patterns you should avoid and the right way to handle each situation.

Anti-Pattern 1: Trying to Maintain State Between Invocations

You might think: “I’ll write a hook that learns from previous decisions and gets smarter over time.” You create a database connection in your hook to log decisions, then try to query past decisions to inform new ones.

This sounds great in theory but fails in practice. Hooks are ephemeral. Each invocation is independent. If your hook depends on state from previous invocations, you’re creating a brittle system where the hook behavior depends on unpredictable factors.

The right way: If you need to track state, use immutable logs. Every decision the hook makes goes to a log file. If you need to query past decisions, do that before the hook invocation from Claude Code, and pass the relevant information as context. The hook itself remains stateless.

Anti-Pattern 2: Optimistic Error Handling

You might write a hook that tries to be helpful by recovering from errors. Something like:

try {
  const config = JSON.parse(readFileSync(".claude/config.json", "utf-8"));
  // Use config
} catch (e) {
  // Silent fallback
  console.error("Failed to load config, using defaults");
  // Continue with defaults
}

This seems reasonable, but it creates hidden failures. If the config file has a problem, you silently use defaults. Later, you’re confused why the behavior changed. The hook succeeded when it should have failed.

The right way: If something essential fails, fail loudly. Use exit code 2 (fatal error) when something critical goes wrong. Log the error in detail. Let the operator know something is wrong instead of silently degrading.

Anti-Pattern 3: Complex Async Logic

You think async/await will make your hook more powerful. You add database lookups, external API calls, all asynchronously. Your hook becomes complex but doesn’t actually improve because it becomes slower and less reliable.

The right way: Hooks should be synchronous and fast. If you need async operations, do them outside the hook. Have Claude Code CLI handle the async work, then invoke the hook with pre-computed results. This keeps hooks simple and fast.

Anti-Pattern 4: Undocumented Configuration

You create a hook that reads various environment variables and configuration files. You remember what they’re for, but six months later, you don’t. Someone else tries to debug the hook and has no idea what inputs it expects.

The right way: Document every input your hook uses. Create a HOOK_CONFIG.md that explains every environment variable, every config file, every input parameter. Make assumptions explicit. Write your hook as if someone else will need to understand it because someone will.

Best Practices for Robust Hook Development

Now that we’ve covered what not to do, let’s talk about what you should be doing to build reliable hooks.

Practice 1: Fail Fast and Clearly

When something goes wrong, fail immediately with a clear error message. Don’t try to be clever. Don’t try to recover from errors that shouldn’t happen. Log the error to stderr with full context, then exit with the appropriate code.

try {
  const input = JSON.parse(readFileSync(0, "utf-8"));
} catch (e) {
  console.error("FATAL: Failed to parse stdin as JSON");
  console.error(`Error: ${e.message}`);
  process.exit(1); // Non-fatal parse error
}

When someone troubleshoots this hook, they get clear information about what went wrong and why.

Practice 2: Be Explicit About Behavior

Document not just what the hook does, but what it decides in each scenario. Create test documentation that shows example inputs and expected outputs. This serves as both documentation and test specification.

/**
 * Permission Hook: Database Access Control
 *
 * Rules:
 * - Block all direct database access from untrusted sources
 * - Allow from authenticated CI/CD pipelines
 * - Allow from specific approved environments
 *
 * Input: PreToolUse event with tool context
 * Output: { permissionDecision: "allow" | "deny" | "block" }
 *
 * Exit codes:
 * - 0: Success (allow/deny/block decision made)
 * - 1: Non-fatal error (log and continue)
 * - 2: Fatal error (block and alert operator)
 */

Practice 3: Test Edge Cases Explicitly

Your hook needs to handle not just normal cases but edge cases. Empty strings, missing fields, unexpected types, extremely long values. Test each of these explicitly in your test harness.

const edgeCases = [
  { description: "Empty command", input: { command: "" } },
  { description: "Very long command", input: { command: "a".repeat(10000) } },
  { description: "Missing command field", input: {} },
  { description: "Command with null bytes", input: { command: "ls\x00rm" } },
  { description: "Command with Unicode", input: { command: "ls 文件" } },
];

Practice 4: Monitor Hook Performance

Slow hooks degrade the entire Claude Code experience. Keep hooks under 100ms. If you find yourself needing more time, you’re probably trying to do too much in the hook. Offload work to happen before or after.

Use the performance profiling approach we discussed earlier to understand where time is spent. Are you doing string matching that’s too complex? File I/O that’s unnecessary? Loading data that’s too large?

Strategy 7: Complete Testing Harness

Combine everything above into one comprehensive harness:

// complete-hook-test-harness.mjs



class HookTestRunner {
  constructor(hookPath) {
    this.hookPath = hookPath;
    this.results = [];
  }

  async run(testCases) {
    console.log(`Testing hook: ${this.hookPath}`);
    console.log(`Test cases: ${testCases.length}\n`);

    for (const testCase of testCases) {
      const result = await this.runTest(testCase);
      this.results.push(result);
      this.printResult(result);
    }

    this.printSummary();
  }

  async runTest(testCase) {
    return new Promise((resolve) => {
      const hook = spawn("node", [this.hookPath]);

      let stdout = "";
      let stderr = "";

      hook.stdout.on("data", (data) => {
        stdout += data;
      });
      hook.stderr.on("data", (data) => {
        stderr += data;
      });

      hook.on("close", (exitCode) => {
        let outputJson = null;
        let parseError = null;

        if (stdout.trim()) {
          try {
            outputJson = JSON.parse(stdout.trim());
          } catch (e) {
            parseError = e.message;
          }
        }

        resolve({
          name: testCase.name,
          input: testCase.input,
          expectedExit: testCase.expectedExit,
          expectedDecision: testCase.expectedDecision,
          actualExit: exitCode,
          actualDecision: outputJson?.permissionDecision || null,
          stdout: stdout.trim(),
          stderr: stderr.trim(),
          parseError,
          passed:
            exitCode === testCase.expectedExit &&
            outputJson?.permissionDecision === testCase.expectedDecision,
        });
      });

      hook.stdin.write(JSON.stringify(testCase.input));
      hook.stdin.end();
    });
  }

  printResult(result) {
    const icon = result.passed ? "✓" : "✗";
    console.log(`${icon} ${result.name}`);

    if (!result.passed) {
      console.log(
        `  Expected exit: ${result.expectedExit}, got ${result.actualExit}`,
      );
      console.log(
        `  Expected decision: ${result.expectedDecision}, got ${result.actualDecision}`,
      );

      if (result.parseError) {
        console.log(`  JSON parse error: ${result.parseError}`);
      }

      if (result.stderr) {
        console.log(`  stderr: ${result.stderr}`);
      }
    }
  }

  printSummary() {
    const passed = this.results.filter((r) => r.passed).length;
    const total = this.results.length;

    console.log(`\n=== RESULTS ===`);
    console.log(`Passed: ${passed}/${total}`);
    console.log(`Success rate: ${((passed / total) * 100).toFixed(1)}%`);

    if (passed < total) {
      process.exit(1);
    }
  }
}

// Usage
const runner = new HookTestRunner("hook-with-file-validation.mjs");

const testCases = [
  {
    name: "Allow safe file write",
    input: {
      event: "PreToolUse",
      tool: "Write",
      input: { file_path: "config.json" },
    },
    expectedExit: 0,
    expectedDecision: "allow",
  },
  {
    name: "Block .env write",
    input: { event: "PreToolUse", tool: "Write", input: { file_path: ".env" } },
    expectedExit: 2,
    expectedDecision: null,
  },
];

await runner.run(testCases);

Run it:

node complete-hook-test-harness.mjs

Now you have a production-grade testing system that validates hooks before they go live. You know exactly whether your hooks work. You can modify them with confidence. You can refactor them without breaking things. All because you have a comprehensive test harness that exercises them thoroughly.

Real-World Hook Debugging Scenarios

Let’s walk through some real debugging scenarios you’ll actually encounter, and how to solve them using the strategies we’ve covered.

Scenario 1: Hook Works Locally, Fails in Claude Code

You’ve tested your hook thoroughly with your test harness. All tests pass. You deploy it to Claude Code. And then it fails silently. The tool runs but behaves as if the hook isn’t installed.

Root cause checklist:

  1. File permissions – Is the hook readable by the Claude Code process? chmod +x .claude/hooks/your-hook.mjs
  2. Node version – Does the Claude Code environment have Node.js 18+? Hooks require modern Node.
  3. Path issues – Is the hook in the wrong directory? Check .claude/hooks/ exactly.
  4. Exit codes – Are you exiting with 0, 1, or 2? Any other exit code might be treated as unknown.
  5. JSON output – Is your JSON valid? Test with node -e "JSON.parse(require('fs').readFileSync(0, 'utf-8'))"

Debugging steps:

  1. Add verbose logging to stderr – Log every step of your hook to understand where it fails
  2. Create a minimal reproduction – Test with the simplest possible input
  3. Check Claude Code logs – They might contain hints about what went wrong
  4. Use the performance test harness – Maybe it’s timing out

Scenario 2: Hook Behaves Inconsistently

Your hook works sometimes and fails other times. The behavior is unpredictable. This is almost always a sign of state-dependent behavior or race conditions.

Root cause checklist:

  1. File I/O – Are you reading files? They might not exist yet or might be locked by another process.
  2. Environment variables – Are you depending on environment variables that might not be set?
  3. Async operations – Are you using promises or async functions? This creates timing issues.
  4. Floating point comparisons – Are you comparing floating point numbers for equality?
  5. External service calls – Are you calling external services that sometimes timeout?

Debugging steps:

  1. Add timestamps to logs – See if failures correlate with specific times or patterns
  2. Increase determinism – Remove all sources of non-determinism
  3. Add timeouts to external calls – Fail fast if services are slow
  4. Test with many iterations – Run your test harness with 100+ iterations to catch inconsistencies
  5. Log all inputs – Maybe the inputs are different than you think

Scenario 3: Permission Decisions Are Wrong

Your hook is outputting decisions, but they’re wrong. It’s allowing things that should be blocked or vice versa.

Root cause checklist:

  1. Logic errors – Is your if/else logic backwards?
  2. Pattern matching – Are your regex patterns or string matching working as expected?
  3. Input parsing – Is the JSON input being parsed correctly?
  4. Missing fields – Are you checking for fields that might not exist?
  5. Type confusion – Are you comparing strings to numbers accidentally?

Debugging steps:

  1. Start simple – Test with absolute minimal logic first
  2. Add assertion logging – Log every condition you check
  3. Test each branch – Create test cases that hit each code path
  4. Compare with manual review – Does your hook agree with human judgment?
  5. Use your validator – Run the output validation to catch structural errors

Comprehensive Hook Testing Strategy

The testing strategies in this guide matter so much because hooks are invisible. If something goes wrong, you don’t get nice error messages. You get silence. Your hook fails, Claude Code doesn’t know why, and you’re left wondering what happened.

This is exactly why systematic testing matters. You need to build confidence in your hooks before they go into production. You need to test them in isolation, with mock inputs, where you can see what’s happening. You need to verify that they output what you expect, exit with the codes you expect, and handle edge cases correctly.

The teams that succeed with hooks are the ones that treat hook testing like production code testing. They have test harnesses. They have mock data. They have assertions. They have CI/CD integration that validates hooks before deployment. They don’t ship hooks that haven’t been tested.

And here’s the thing: all of this is easier than you might think. The testing harnesses in this guide are intentionally simple. You don’t need complicated frameworks. You just need stdin/stdout, JSON, and a way to check exit codes. That’s enough to build confidence. That’s enough to catch bugs before they become problems. That’s enough to ship hooks you can rely on.


-iNet

Free Discovery Call

Start With a Conversation, Not a Commitment

Every engagement begins with a free 30-minute discovery call. We'll map what's slowing your business down and tell you exactly what we'd fix first – no pitch deck, no obligation.