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:
- Skill hooks fire only while the skill is active. The security check above runs during
secure-operationsinvocations, not during general conversation. This makes hooks contextual rather than global. - 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.
- Stop hooks in skill frontmatter are auto-converted to SubagentStop. Skills run as sub-contexts, so
Stopsemantically becomesSubagentStopto 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
| Scenario | Scope |
|---|---|
| Security gates that apply everywhere | Project-level hook |
| Auto-formatting for all file edits | Project-level hook |
| Database migration validation | Skill-scoped hook |
| Deploy-time environment checks | Skill-scoped hook |
| Audit logging | Project-level hook (async) |
| Context-specific test runners | Skill-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 0This 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
| Pattern | Matches |
|---|---|
mcp__github__.* | All GitHub MCP tools |
mcp__memory__.* | All memory server tools |
mcp__.*__write.* | Write tools from any MCP server |
mcp__filesystem__read_file | Specific tool from specific server |
mcp__postgres__query | Database 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.
| Mechanism | Nature | Enforcement | Flexibility |
|---|---|---|---|
| CLAUDE.md | Instructions | Soft — Claude follows as context but can deviate | High — Claude adapts to edge cases |
| Hooks | Automated scripts | Hard — deterministic, always execute, can block | Low — 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 > allowThis 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:
| Layer | Hook Type | Location | Purpose |
|---|---|---|---|
| Security | PreToolUse (sync) | Project settings | Block dangerous operations |
| Formatting | PostToolUse (sync) | Project settings | Auto-format edited files |
| Validation | PostToolUse (sync) | Project settings | Lint and type-check |
| Logging | PostToolUse (async) | User settings | Audit trail |
| Notifications | Notification (async) | User settings | Desktop alerts |
| Context | SessionStart (sync) | Project settings | Post-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\"'"
}
]
}
]
}
}