Skip to main content

Compositions

Hooks alone enforce single rules. Hooks composed with skills, agents, MCP, and CLAUDE.md build layered automation systems where each mechanism handles what it does best. The composition patterns here represent architectures that emerge after months of production use — not theoretical combinations.

Hooks + Skills

Skills can define hooks directly in their YAML frontmatter. These hooks are scoped to the skill's lifecycle — they activate when the skill runs and deactivate when it finishes.

---
name: secure-operations
description: Perform operations with security checks
hooks:
  PreToolUse:
    - matcher: "Bash"
      hooks:
        - type: command
          command: "./scripts/security-check.sh"
          once: true
  PostToolUse:
    - matcher: "Edit|Write"
      hooks:
        - type: prompt
          prompt: "Check if formatting is needed: $ARGUMENTS"
---
 
Instructions for the skill go here...

Three behaviors define this composition:

  1. Skill hooks fire only while the skill is active. The security check above runs during secure-operations invocations, not during general conversation. This makes hooks contextual rather than global.
  2. Skill hooks combine with global/project hooks — they don't replace them. A project-level PreToolUse hook and a skill-level PreToolUse hook both fire. The most-restrictive-wins resolution still applies.
  3. Stop hooks in skill frontmatter are auto-converted to SubagentStop. Skills run as sub-contexts, so Stop semantically becomes SubagentStop to prevent the skill from blocking the parent session.

The practical implication: use project-level hooks for invariants that must always hold (security gates, formatting). Use skill-scoped hooks for context-specific automation (deployment checks during a deploy skill, schema validation during a migration skill).

When to Scope Hooks to Skills

ScenarioScope
Security gates that apply everywhereProject-level hook
Auto-formatting for all file editsProject-level hook
Database migration validationSkill-scoped hook
Deploy-time environment checksSkill-scoped hook
Audit loggingProject-level hook (async)
Context-specific test runnersSkill-scoped hook

Hooks + Subagents

Subagent behavior with hooks has important distinctions from regular tool calls.

Subagents do not automatically inherit parent agent permissions. Without explicit configuration, every tool call in a subagent triggers permission prompts — even for operations the parent already approved. PreToolUse hooks that auto-approve specific tools solve this:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Read|Glob|Grep",
        "hooks": [
          {
            "type": "command",
            "command": "echo '{\"hookSpecificOutput\": {\"hookEventName\": \"PreToolUse\", \"decision\": {\"behavior\": \"allow\"}}}'"
          }
        ]
      }
    ]
  }
}

Parent hooks fire for subagent tool calls. The stdin JSON includes agent_id and agent_type fields, letting you distinguish parent from child operations:

#!/bin/bash
INPUT=$(cat)
AGENT_ID=$(echo "$INPUT" | jq -r '.agent_id // empty')
 
if [ -n "$AGENT_ID" ]; then
  # This is a subagent tool call — apply stricter rules
  COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
  if echo "$COMMAND" | grep -qE '(rm|mv|chmod|chown)'; then
    echo "BLOCKED: Subagents cannot run destructive filesystem commands" >&2
    exit 2
  fi
fi
 
exit 0

This implements the scope-fence pattern: the parent agent has broader permissions while subagents operate within tighter constraints enforced by hooks.

SubagentStart and SubagentStop events fire in the parent session when subagents spawn or finish. Use them for orchestration logging:

{
  "hooks": {
    "SubagentStop": [
      {
        "matcher": "Explore",
        "hooks": [
          {
            "type": "command",
            "command": "echo \"Explore subagent completed at $(date)\" >> /tmp/claude-agents.log"
          }
        ]
      }
    ]
  }
}

Agent-Type Hooks

The "type": "agent" hook spawns a subagent to verify conditions. It gets up to 50 tool-use turns and a 60-second default timeout. Use it when verification requires multi-step reasoning that a shell script can't handle:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "agent",
            "prompt": "The user is about to run this bash command. Check if it could affect production systems. If it could, respond with {\"decision\": {\"behavior\": \"deny\"}} and explain why.",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

Agent hooks are powerful but expensive — each invocation runs an LLM call. Reserve them for high-stakes decisions where pattern matching isn't sufficient.

Hooks + MCP

Hooks and MCP serve complementary roles. MCP tools extend Claude's capabilities — database access, API calls, file system operations. Claude chooses when to use them. Hooks are deterministic — they always fire on matching events regardless of Claude's decisions.

MCP tool calls fire the same PreToolUse/PostToolUse events. Tool names follow the format mcp__<server>__<tool>:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "mcp__github__.*",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/github-audit.sh"
          }
        ]
      }
    ]
  }
}

MCP Matcher Patterns

PatternMatches
mcp__github__.*All GitHub MCP tools
mcp__memory__.*All memory server tools
mcp__.*__write.*Write tools from any MCP server
mcp__filesystem__read_fileSpecific tool from specific server
mcp__postgres__queryDatabase query tool

The regex matcher makes MCP hooks flexible. You can gate all write operations across all MCP servers with a single hook, or target a specific tool on a specific server.

Elicitation Hooks

MCP servers can request user input through elicitation. Hooks can intercept and auto-respond to these requests:

{
  "hooks": {
    "Elicitation": [
      {
        "matcher": "my-mcp-server",
        "hooks": [
          {
            "type": "command",
            "command": "echo '{\"hookSpecificOutput\": {\"hookEventName\": \"Elicitation\", \"action\": \"accept\", \"content\": {\"confirmation\": true}}}'"
          }
        ]
      }
    ]
  }
}

This is useful in headless/automated pipelines where no user is available to respond to elicitation prompts.

Hooks + CLAUDE.md

CLAUDE.md and hooks address the same problem — controlling agent behavior — from opposite directions.

MechanismNatureEnforcementFlexibility
CLAUDE.mdInstructionsSoft — Claude follows as context but can deviateHigh — Claude adapts to edge cases
HooksAutomated scriptsHard — deterministic, always execute, can blockLow — binary pass/fail logic

The convention-file pattern leverages both: CLAUDE.md explains why a rule exists and gives Claude context for working within constraints. Hooks enforce the rule deterministically.

Example: "Use Bun, not npm"

CLAUDE.md entry:

## Package Manager
This project uses Bun exclusively. Never use npm or yarn for package management,
script execution, or dependency installation. Bun's lockfile format is incompatible
with npm — mixing them corrupts the dependency tree.

Corresponding hook:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(npm *)",
            "command": "echo 'BLOCKED: Use bun instead of npm. See CLAUDE.md for details.' >&2; exit 2"
          }
        ]
      }
    ]
  }
}

CLAUDE.md alone would work 95% of the time. The hook catches the other 5% — especially after context compaction when Claude may forget project-specific rules.

InstructionsLoaded Hook

The InstructionsLoaded event fires when CLAUDE.md files are loaded. The stdin JSON includes the file path and the loaded content:

{
  "session_id": "abc123",
  "hook_event_name": "InstructionsLoaded",
  "cwd": "/home/user/project",
  "permission_mode": "default",
  "file_path": "/home/user/project/CLAUDE.md",
  "trigger": "session_start"
}

Use it for validation or logging:

{
  "hooks": {
    "InstructionsLoaded": [
      {
        "matcher": "session_start",
        "hooks": [
          {
            "type": "command",
            "command": "echo 'Instructions loaded from: '$(jq -r '.file_path') >&2"
          }
        ]
      }
    ]
  }
}

Multi-Hook Orchestration

Real projects compose multiple hooks. The architecture that works: separate hooks by concern, use the if field for conditional execution, and rely on the decision resolution system for safety.

Conditional Execution Without Race Conditions

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(git commit*)",
            "command": "./hooks/pre-commit-check.sh"
          },
          {
            "type": "command",
            "if": "Bash(git push*)",
            "command": "./hooks/pre-push-check.sh"
          },
          {
            "type": "command",
            "if": "Bash(rm *)",
            "command": "./hooks/deletion-guard.sh"
          }
        ]
      }
    ]
  }
}

Each hook in the array fires only when its if condition matches. This avoids spawning three processes for every Bash command — only the relevant hook runs.

Decision Resolution Across Multiple Hooks

When multiple hooks return decisions for the same event:

deny  > defer > ask > allow

This means you can layer hooks from different sources without conflict:

  • Project hooks: Block force pushes (deny)
  • User hooks: Auto-approve read operations (allow)
  • Skill hooks: Ask before database writes (ask)

If the project hook returns deny and the user hook returns allow for the same tool call, deny wins. The system is composable by design.

The Layered Architecture

Production-grade hook setups follow a consistent pattern:

LayerHook TypeLocationPurpose
SecurityPreToolUse (sync)Project settingsBlock dangerous operations
FormattingPostToolUse (sync)Project settingsAuto-format edited files
ValidationPostToolUse (sync)Project settingsLint and type-check
LoggingPostToolUse (async)User settingsAudit trail
NotificationsNotification (async)User settingsDesktop alerts
ContextSessionStart (sync)Project settingsPost-compaction re-injection

A complete settings.json implementing this architecture:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(git *)",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/git-policy.sh"
          },
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/bash-safety.sh"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write",
            "statusMessage": "Formatting..."
          },
          {
            "type": "command",
            "command": "FILE=$(jq -r '.tool_input.file_path'); if [[ \"$FILE\" == *.ts || \"$FILE\" == *.tsx ]]; then npx tsc --noEmit 2>&1 | head -20; fi",
            "timeout": 30,
            "statusMessage": "Type-checking..."
          }
        ]
      }
    ],
    "SessionStart": [
      {
        "matcher": "compact",
        "hooks": [
          {
            "type": "command",
            "command": "echo '--- Post-compaction context ---'; echo 'Package manager: bun'; git log --oneline -5"
          }
        ]
      }
    ]
  }
}

Security hooks go in project settings so they apply to all contributors. Logging and notification hooks go in user settings because they're personal preferences. This separation keeps project configurations clean while giving individuals control over their workflow.

The corresponding ~/.claude/settings.json for personal hooks:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.command' >> ~/.claude/command-log.txt",
            "async": true
          }
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude Code needs attention\" with title \"Claude Code\"'"
          }
        ]
      }
    ]
  }
}