All Articles Claude Code

Combining Multiple Hook Scripts for Complex Policies

When you're building governance systems in Claude Code, a single hook script often isn't enough.

When you’re building governance systems in Claude Code, a single hook script often isn’t enough. You need to layer policies—one hook validates credentials, another checks resource limits, a third logs audit trails, and a fourth routes notifications. The question isn’t “should I combine hooks?” but “how do I orchestrate them so they work together without turning into spaghetti code?”

We’re going to walk through how to structure multiple hooks for the same event type, manage execution order, share state across them, and build a hook orchestrator that handles real-world complexity. By the end, you’ll have patterns that scale from small policy chains to enterprise governance systems running thousands of decisions per day.

The fundamental challenge here is this: as your policy requirements grow, you can’t put everything into a single hook file. It becomes unmaintainable. You can’t trace through the logic. Testing becomes impossible. Adding new policies breaks existing ones. So you break it into multiple hooks, but now you have a new problem: how do they coordinate? How do you pass information from one hook to the next? How do you handle failures? How do you debug when something goes wrong?

This is where orchestration becomes critical. You need a system that treats hooks not as isolated scripts but as a coherent pipeline. Each hook has a specific responsibility. They execute in order. They share context. They fail gracefully. Together, they create a policy enforcement system that’s both powerful and maintainable.

Why Multiple Hooks Matter

Imagine you’re the engineering lead at a company running Claude Code agents. You need five critical governance layers:

  1. Credential validation — does the agent have permission?
  2. Rate limiting — are we hammering the API?
  3. Cost tracking — how much is this costing us?
  4. Audit logging — what happened and when?
  5. Alerting — should we notify the security team?

If you build all this logic into a single hook, you’ve got 500 lines of spaghetti code that’s hard to test, hard to debug, and hard to change. When the security team wants you to add threat detection, you have to modify that hook. When the finance team wants cost policies, you modify it again. Within months, it’s unmaintainable.

The solution is to break these into separate hooks: a credential validator, a rate limiter, a cost tracker, an audit logger, and an alerter. Each hook is focused, testable, and independently deployable. But now you need orchestration to make them work together.

Here’s a concrete example. A user wants to run a high-cost operation. The orchestrator runs the credential validator. It checks: does this user have permission for expensive operations? If not, fail immediately. If yes, continue. The orchestrator runs the rate limiter. It checks: has this user already made 100 calls today? If yes, fail. If no, continue. The orchestrator runs the cost tracker. It checks: if we let this through, will we exceed budget? If yes, warn and ask for confirmation. If no, continue. The orchestrator runs the audit logger. It records the decision point: what happened, who did it, when, why. Finally, the orchestrator runs the alerter. It checks: does this operation meet the criteria for security notification? If yes, page the on-call engineer.

All of this happens transparently, in milliseconds. The user sees a single result: approved or denied. But behind the scenes, five separate policies evaluated the request in sequence.

Architecture Pattern: The Hook Chain

The most scalable pattern for managing multiple hooks is the chain of responsibility pattern. Each hook in the chain handles a specific concern. When it’s done, it passes control to the next hook. If any hook rejects the request, the chain stops.

The chain pattern has several advantages. Each hook is independently testable. You can test the credential validator in isolation. You can test the rate limiter in isolation. You can test their interaction without the cost tracker. Composability lets you build different chains for different scenarios. Your cost-control chain might be: credential → rate limit → cost. Your security-focused chain might be: credential → threat detection → audit. Your audit chain might be: audit → alerter. The same hooks compose into different chains.

Ordering matters. You want to fail fast. Cheap checks first, expensive checks later. Credential validation (cheap) before cost calculation (potentially expensive). This means your chain should be ordered from cheapest to most expensive. Start with credential checks—these are lightweight regex matches or cache lookups. Move to rate limiting—still local data. Then move to cost calculation—this might involve calling external APIs. Finally, move to alerting—only if everything else passed.

Each hook in the chain receives a context object. This object contains the initial request plus all intermediate results. The first hook receives a bare context. It does its work. It adds results to the context. It passes the context to the next hook. The next hook reads those results, does its work, adds more results, and passes it on. By the end of the chain, the context contains the full history of decisions.

Building the Orchestrator

The orchestrator is the conductor of the hook orchestra. It knows which hooks to run and in what order. It handles failure modes. It manages context flow. Here’s the core pattern:

// .claude/orchestrator.mjs
export class HookOrchestrator {
  constructor(hooks) {
    this.hooks = hooks;
    this.context = {};
  }

  async execute(initialContext) {
    this.context = { ...initialContext, decisions: [] };

    for (const hook of this.hooks) {
      try {
        const result = await hook.execute(this.context);
        this.context.decisions.push({
          hook: hook.name,
          status: result.status,
          timestamp: new Date().toISOString(),
        });

        if (result.status === "blocked" && hook.critical) {
          return { allowed: false, reason: result.reason };
        }
      } catch (error) {
        this.context.decisions.push({
          hook: hook.name,
          status: "error",
          error: error.message,
        });
      }
    }

    return { allowed: true, context: this.context };
  }
}

The orchestrator is deliberately simple. It iterates through hooks. It catches errors so one failing hook doesn’t break the chain. It tracks decisions so you have an audit trail. It respects criticality—some hooks must block, others are advisory.

Sharing State Across Hooks

The biggest challenge in multi-hook systems is sharing state. Hook 1 calculates something that Hook 3 needs. How does Hook 3 get that information? The answer is the context object.

Each hook receives a context object. It reads what it needs. It adds what it computed. It passes the context to the next hook. This is your inter-hook communication mechanism.

// .claude/hooks/credential-validator.mjs
export async function validateCredentials(context) {
  const { userId, operation } = context;
  const user = await getUser(userId);

  if (!user) {
    throw new Error("User not found");
  }

  context.user = user;
  context.permissions = user.permissions;
  context.userTier = user.tier;

  return { status: "allowed" };
}

// .claude/hooks/cost-calculator.mjs
export async function calculateCost(context) {
  const { operation, userTier } = context; // Use data from credential validator
  const estimatedCost = await estimateCost(operation);

  context.estimatedCost = estimatedCost;
  context.costBreakdown = {
    compute: estimatedCost.compute,
    api: estimatedCost.api,
    storage: estimatedCost.storage,
  };

  return { status: "allowed" };
}

Notice how the cost calculator uses userTier that the credential validator added. That’s the pattern. Each hook adds information to context that downstream hooks can use. This creates a pipeline where each hook builds on the previous one’s work.

Handling Failures and Exceptions

Multi-hook systems are more fragile than single-hook systems. You have more places for things to break. When something goes wrong in one hook, what happens to the rest of the chain? Do they continue or stop? Do they log the failure or ignore it?

Handle this with defensive programming. Each hook should handle its own errors. The orchestrator should handle inter-hook communication failures. The philosophy is: one hook’s failure shouldn’t break the entire system.

// .claude/hooks/cost-calculator.mjs
export async function calculateCost(context) {
  try {
    const estimatedCost = await estimateCost(context.operation);
    context.estimatedCost = estimatedCost;
    return { status: "allowed" };
  } catch (error) {
    // Cost calculation failed, but don't block the user
    context.costError = error.message;
    return {
      status: "allowed_with_warning",
      reason: "Cost calculation failed",
    };
  }
}

The key is distinguishing between “this operation failed, block the user” and “this operation failed, but continue with reduced capability.” Cost calculation failing should probably be a warning, not a blocking error. Credential validation failing should be a blocking error. Your hook’s criticality determines which kind of error it is.

You can also implement retry logic for transient failures:

async function executeWithRetry(fn, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fn();
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      await new Promise((resolve) => setTimeout(resolve, 100 * Math.pow(2, i)));
    }
  }
}

This exponential backoff approach handles temporary failures gracefully. The first retry happens after 100ms, the second after 200ms, the third after 400ms. If it fails after three tries, you give up and raise the error.

Ordering Hooks: The Critical Dependency Graph

Hook execution order matters enormously. A badly ordered chain can be slow, insecure, or both. The right order follows several principles:

Fail fast principle: Run cheap checks first. If you’re going to block the user, block them quickly. Credential validation before cost calculation. This prevents you from doing expensive work that you’ll just discard.

Dependency principle: Hooks that depend on other hooks’ results must run after those hooks. The cost calculator depends on the credential validator (it needs userTier). So credential validator runs first.

Criticality principle: Critical hooks that can block should run before advisory hooks that can only warn. The credential validator is critical (it can block). The alerter is advisory (it only notifies). So credential validator runs first.

Isolation principle: Independent hooks can run in any order (or even in parallel with careful design). The audit logger is independent—it doesn’t depend on anything. The alerter is independent. These two could run in parallel safely.

A good ordering heuristic is: credentials, rate limiting, cost tracking, business logic validation, audit logging, alerting. Each level depends only on previous levels.

// .claude/hooks/orchestrator-config.mjs
export const HOOK_CHAIN = [
  { name: "credential-validator", critical: true, timeout: 1000 },
  { name: "rate-limiter", critical: true, timeout: 500 },
  { name: "cost-tracker", critical: false, timeout: 2000 },
  { name: "audit-logger", critical: false, timeout: 1000 },
  { name: "alerter", critical: false, timeout: 3000 },
];

This configuration explicitly specifies order and criticality. The orchestrator reads this and executes hooks in sequence. Each hook has a timeout—if it takes too long, the orchestrator moves on (or fails, depending on criticality).

Performance Optimization: Caching and Parallelization

Multi-hook systems can get slow. Five hooks running sequentially means five times the latency. If each hook takes 100ms, the entire chain takes 500ms. For a system processing thousands of requests per day, 500ms per request means you’re limited to about 7 requests per second. A popular service might receive 100 requests per second. Your governance layer becomes a bottleneck.

Here’s how you optimize:

Caching: The credential validator does a database lookup. If the same user runs two operations in sequence, you don’t want to do the lookup twice. Use local caching:

const credentialCache = new Map();

export async function validateCredentials(context) {
  const { userId } = context;

  if (credentialCache.has(userId)) {
    context.user = credentialCache.get(userId);
    return { status: "allowed" };
  }

  const user = await getUser(userId);
  credentialCache.set(userId, user);
  context.user = user;

  return { status: "allowed" };
}

Add cache invalidation when appropriate. If a user’s permissions change, invalidate their entry. This prevents serving stale data.

Parallelization: Some hooks are truly independent. The audit logger and alerter don’t depend on each other. You can run them in parallel:

// Run credential validator first (everything depends on this)
const credResult = await credentialValidator.execute(context);

// Then run rate limiter and cost tracker in parallel
const [rateResult, costResult] = await Promise.all([
  rateLimiter.execute(context),
  costTracker.execute(context),
]);

// Then run audit logger and alerter in parallel
await Promise.all([auditLogger.execute(context), alerter.execute(context)]);

This reduces latency significantly. Instead of 1ms + 1ms + 2ms + 1ms + 3ms = 8ms sequential, you could have 1ms + max(1ms, 2ms) + max(1ms, 3ms) = 1 + 2 + 3 = 6ms. Small improvement here, but when hooks are slower (API calls taking 100ms), parallelization saves huge amounts of time.

Testing and Debugging Multi-Hook Systems

Testing becomes complex with multiple hooks. You need to test individual hooks, interactions, and the full chain:

// Test credential validator in isolation
test("credential-validator allows known users", async () => {
  const context = { userId: "known-user" };
  const result = await credentialValidator(context);
  expect(result.status).toBe("allowed");
  expect(context.user).toBeDefined();
});

// Test orchestrator with multiple hooks
test("orchestrator runs hooks in order", async () => {
  const orchestrator = new HookOrchestrator([
    credentialValidator,
    rateLimiter,
    costTracker,
  ]);

  const result = await orchestrator.execute({
    userId: "user-123",
    operation: "deploy",
  });

  expect(result.allowed).toBe(true);
  expect(result.context.decisions).toHaveLength(3);
});

Mock external dependencies so tests run fast. The credential validator hits a database—mock getUser(). The cost tracker calls an API—mock estimateCost(). This lets you test the orchestration logic without waiting for real network calls.

Real-World Composition Examples

Three real patterns that work in production:

Pattern 1: Security-First Chain. Credential → Threat Detection → Audit → Alerter. This prioritizes security above all else. Every operation is validated for credentials, checked for suspicious patterns, logged, and flagged if necessary. Used for high-security operations where safety matters more than speed.

Pattern 2: Cost-Control Chain. Credential → Rate Limit → Cost → Approval → Audit. Prioritizes cost control. Operations are authenticated, rate-limited, evaluated for cost, requiring approval for expensive operations, and logged. Used for resource-intensive operations where budget is the constraint.

Pattern 3: Compliance Chain. Credential → Data Classification → Retention Check → Encryption Verify → Audit → Reporting. Prioritizes regulatory compliance. Every data operation validates access, classifies the data, verifies retention authority, confirms encryption, logs the action, and generates compliance reports. Used in regulated industries where data handling is non-negotiable.

Each pattern composes the same basic hooks into different sequences that prioritize different concerns. The orchestrator makes this possible.

Real-World Implementation: A Case Study

Consider how a growing fintech company implemented multi-hook governance. They started with two hooks: credential validation and audit logging. Simple. Worked well.

As they grew and onboarded more teams, they added a third hook: rate limiting. Teams were accidentally hammering their API. The rate limiter caught this. Requests that exceeded limits were rejected with a clear message explaining the quota.

Next came cost tracking. Some operations were burning credits rapidly. The cost tracker estimated the cost of each operation upfront. Expensive operations required explicit approval. This visibility helped teams optimize their usage.

Then threat detection was added. The security team wanted to catch suspicious patterns—unusual access patterns, operations from new regions, unusual data access. The threat detector ran after credential validation, checking if the operation matched known attack patterns.

Each addition was organic, driven by actual problems. The orchestration pattern meant adding each new hook required minimal changes to existing code. Each hook was independent. The orchestrator just executed it in sequence.

Advanced Patterns: Conditional Execution and Branching

As systems mature, you sometimes need conditional logic. Not every request needs every hook. A read operation might skip cost tracking. A write operation might require approval. A deletion might trigger enhanced logging.

Implement conditional execution:

export const HOOK_CHAINS = {
  read: [
    { name: "credential-validator", critical: true },
    { name: "rate-limiter", critical: true },
    { name: "audit-logger", critical: false },
  ],
  write: [
    { name: "credential-validator", critical: true },
    { name: "rate-limiter", critical: true },
    { name: "cost-tracker", critical: false },
    { name: "audit-logger", critical: false },
  ],
  delete: [
    { name: "credential-validator", critical: true },
    { name: "approval-required", critical: true },
    { name: "enhanced-logging", critical: false },
    { name: "alerter", critical: false },
  ],
};

export async function execute(context) {
  const chain = HOOK_CHAINS[context.operation.type];
  const orchestrator = new HookOrchestrator(chain);
  return orchestrator.execute(context);
}

This flexibility is powerful. Different operation types get different governance. Reads are fast and lightweight. Writes are checked for cost. Deletions require approval and extra logging. Each path is optimized for its purpose.

Debugging Complex Hook Chains

When something goes wrong in a multi-hook system, you need debugging tools. The decision log (which we track in the context) is your primary debugging artifact.

When an operation gets blocked, you can examine the decision log:

{
  allowed: false,
  reason: "User exceeded rate limit",
  context: {
    decisions: [
      {
        hook: "credential-validator",
        status: "allowed",
        user: "alice",
        timestamp: "2026-03-17T10:00:00Z"
      },
      {
        hook: "rate-limiter",
        status: "blocked",
        reason: "User exceeded 100 calls/day quota",
        callCount: 105,
        timestamp: "2026-03-17T10:00:05Z"
      }
    ]
  }
}

This tells you exactly what happened: credential validation passed, but rate limiting rejected the request. Alice exceeded her daily quota. You can tell her “you’ve made 105 calls today, your limit is 100.”

Store these decision logs for analysis. You can query: “Which operations are most commonly rate-limited?” “Which users hit cost limits most often?” “Which hooks are slowest?” This data guides improvements.

Failure Modes and Recovery

Real systems fail. A hook might crash. An external service might be down. Network might be flaky. Your orchestrator needs to handle these gracefully.

Hook Crash: If a hook throws an uncaught exception, the orchestrator catches it, logs it, and continues (if the hook isn’t critical). This prevents one failing hook from breaking the entire system. The operation might proceed with reduced visibility (missing audit logs) but succeed.

Timeout: If a hook takes too long (exceeds its configured timeout), the orchestrator moves on. The operation continues. You lose that particular check but don’t block the user indefinitely.

Dependency Failure: If an external service (API, database) is down, the hook should fail gracefully. Return a cached result if available. Continue with reduced capability. Never let external failures cascade.

Monitoring and Observability

In production, you need to understand hook performance. Track:

  • Hook execution count (how many times each hook ran)
  • Hook latency (how long each hook took)
  • Hook failure rate (what percentage failed)
  • Hook block rate (what percentage rejected operations)

These metrics reveal problems. If a hook’s latency suddenly doubles, something’s wrong. If failure rate spikes, the hook might be broken. If block rate increases, policies might need adjustment.

Conclusion: Scalable Governance

Multi-hook orchestration is how you build governance systems that scale with your organization. Start simple: credential validator and audit logger. Add rate limiting when you need it. Add cost tracking when budget becomes a concern. Add threat detection when your threat model demands it. Add compliance checks when regulations demand it.

Each hook is focused, testable, and independently deployable. The orchestrator composes them into a coherent system. Together, they create a policy enforcement infrastructure that grows with your needs without becoming unmaintainable.

The magic is that this grows gracefully. Your first two hooks are simple. Adding the third is straightforward. By the time you have ten hooks across multiple teams, you have a proven pattern and a tested orchestrator. You’re not hacking—you’re following an established pattern that scales.

Your system scales not just technically but organizationally. You don’t need one superhuman person who understands all governance. You need ten people who each understand one domain deeply. Each specializes, goes deep, becomes an expert. The orchestrator is the glue that holds it all together.

This is the power of composition. Instead of building one mega-hook that does everything, you build many focused hooks that coordinate through an orchestrator. The system scales gracefully, driven by actual requirements rather than over-engineering from the start. This approach has proven itself in organizations from startups to enterprises, handling thousands of policy decisions per day reliably and transparently.


-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.