Advanced Claude Code Hooks: Controlling Sub-Agent Behaviour
Claude Code hooks are powerful automation tools that execute at specific points during AI coding sessions. Whilst basic hooks can validate prompts or add context, advanced hooks can enforce sophisticated rules like preventing parallel sub-agents from running test suites that share database connections.
Understanding Claude Code Hooks
Hooks in Claude Code are automated scripts that intercept and control the AI's tool usage. They execute arbitrary shell commands at specific lifecycle events, enabling you to:
- Validate tool usage before execution (PreToolUse)
- Add context to user prompts (UserPromptSubmit)
- Clean up resources when sessions end (SessionEnd)
- Inject environment data at session start (SessionStart)
- Control permissions for file operations
The most powerful hook type is PreToolUse, which runs before any tool executes and can approve, deny, or request user confirmation for the operation.
The Problem: Parallel Execution and Shared Resources
Claude Code's sub-agent system enables parallel task execution, so multiple agents can work simultaneously on different aspects of your codebase - which is excellent for productivity, but creates challenges when those tasks share resources.
Consider a PHP project with PHPUnit tests that use a SQLite database. The test suite isn't optimised for parallel execution because:
- Database locks: SQLite allows only one writer at a time
- Shared state: Tests may create or modify the same fixtures
- Race conditions: Parallel execution causes unpredictable failures
When Claude spawns multiple sub-agents to handle complex refactoring tasks, each might independently decide to run the test suite, and the result is database lock conflicts, failed tests, and confused AI agents.
The Solution: Sub-Agent Detection and Control
We can solve this by creating a hook that detects when it's running in a sub-agent context and blocks test execution, whilst still allowing other QA tools like static analysis and code style checks.
Sub-agents are not separate OS processes with a distinguishable parent-process chain, so PPID inspection doesn't work here. The real signal is in the hook payload itself: Claude Code includes an agent_id field when a tool call originates from a sub-agent, and omits it for main-session calls. That's the only documented, reliable way to make this distinction.
Implementation: The PreToolUse Hook
Here's a complete Python hook that implements sub-agent detection and selective command blocking:
#!/usr/bin/env python3
"""
PreToolUse hook to prevent subagents from running tests.
Subagents can run allCS and allStatic, but NOT unit tests, PHPUnit, or Infection.
Tests cannot run in parallel due to database lock conflicts.
Detection: Claude Code includes an `agent_id` field in the hook payload when
the tool call originates from a subagent; it is absent for main-session calls.
"""
import json
import re
import sys
def is_subagent(payload: dict) -> bool:
"""Check if this tool call originated from a subagent."""
return bool(payload.get('agent_id'))
def is_test_command(command: str) -> bool:
"""Check if command is a test execution (not allowed in subagents)."""
test_patterns = [
r'\bphpunit\b',
r'\bbin/qa\s+.*-t\s+unit\b',
r'\bbin/qa\s+.*--type\s+unit\b',
r'\binfection\b',
r'\bvendor/bin/phpunit\b',
r'\bphp\s+vendor/bin/phpunit\b',
r'\.\/bin\/qa\s+.*-t\s+unit\b',
]
return any(re.search(pattern, command, re.IGNORECASE) for pattern in test_patterns)
def is_allowed_qa_command(command: str) -> bool:
"""Check if command is an allowed QA command (allCS or allStatic)."""
allowed_patterns = [
r'\bbin/qa\s+.*-t\s+allCs\b',
r'\bbin/qa\s+.*--type\s+allCs\b',
r'\bbin/qa\s+.*-t\s+allStatic\b',
r'\bbin/qa\s+.*--type\s+allStatic\b',
r'\.\/bin\/qa\s+.*-t\s+allCs\b',
r'\.\/bin\/qa\s+.*-t\s+allStatic\b',
]
return any(re.search(pattern, command, re.IGNORECASE) for pattern in allowed_patterns)
def main() -> int:
"""Main hook logic."""
try:
# Read hook payload from stdin
payload = json.loads(sys.stdin.read())
# Only check Bash tool invocations
if payload.get('tool_name') != 'Bash':
return 0
# Check if we're in a subagent
if not is_subagent(payload):
return 0 # Not a subagent, allow all commands
# Get the command being executed
command = payload.get('tool_input', {}).get('command', '')
# Allow QA commands that are explicitly allowed
if is_allowed_qa_command(command):
return 0
# Block test commands in subagents
if is_test_command(command):
error_msg = {
'error': 'Test execution blocked in subagent context',
'reason': 'Tests cannot run in parallel due to database lock conflicts',
'command': command,
'allowed': 'Subagents can run: bin/qa -t allCs, bin/qa -t allStatic',
'blocked': 'Blocked commands: phpunit, bin/qa -t unit, infection'
}
print(json.dumps(error_msg), file=sys.stderr)
return 2 # Exit code 2 is what actually blocks a PreToolUse call
return 0 # Allow all other commands
except Exception as e:
# Log error but don't block (fail open for safety)
print(f"Hook error: {e}", file=sys.stderr)
return 0
if __name__ == '__main__':
sys.exit(main())
How It Works
1. Sub-Agent Detection
The is_subagent() function checks a field in the hook payload rather than inspecting processes:
- Reads the
agent_idfield from the PreToolUse JSON payload - Returns
Trueifagent_idis present (the call came from a sub-agent) - Returns
Falseif it's absent (the call came from the main session) - The surrounding hook still fails open on any unexpected error, to avoid blocking legitimate operations
2. Command Pattern Matching
The hook uses regex patterns to categorise commands:
- Test commands: PHPUnit, Infection,
bin/qa -t unit - Allowed QA commands:
bin/qa -t allCs,bin/qa -t allStatic - All other commands: Allowed without restriction
3. Selective Blocking
The hook implements a whitelist/blacklist strategy:
- Main agent: All commands allowed
- Sub-agents: Static analysis allowed, tests blocked
- Error response: Structured JSON explaining the block
Configuration
To enable this hook, add it to your Claude Code settings file (~/.claude/settings.json or .claude/settings.json):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/path/to/prevent-subagent-tests.py",
"timeout": 2000
}
]
}
]
}
}
Make the script executable:
chmod +x prevent-subagent-tests.py
Real-World Benefits
Prevents Database Lock Conflicts
By blocking parallel test execution, you eliminate SQLite database lock errors that would otherwise cause test failures and confuse the AI agents.
Enables Parallel Static Analysis
Sub-agents can still run code style checks (allCs) and static analysis (allStatic) in parallel, since these tools don't share resources.
Clear Error Messages
When a sub-agent attempts to run tests, it receives a structured JSON response explaining why the operation was blocked and what commands are allowed.
Fail-Safe Design
The hook uses a "fail open" strategy - if it can't determine whether it's in a sub-agent, it allows the command rather than risk blocking a legitimate operation because of a hook error.
Extending the Pattern
This technique applies to any shared resource scenario:
- Database migrations: Prevent parallel schema changes
- File system operations: Block concurrent writes to lock files
- External services: Rate-limit API calls across sub-agents
- Build artifacts: Prevent simultaneous builds that share directories
The core pattern remains the same: detect sub-agent context via the agent_id field, match command patterns, and selectively allow or block operations based on resource constraints.
Best Practices
Use Specific Patterns
Make your regex patterns as specific as possible to avoid false positives. Use word boundaries (\b) and full command paths when appropriate.
Fail Open for Safety
When error handling, prefer allowing the operation over blocking it. A blocked legitimate operation is more frustrating than a rare race condition.
Provide Clear Feedback
Structure your error messages as JSON with fields explaining what was blocked, why, and what alternatives are available.
Test Both Contexts
Verify your hook works correctly in both main agent and sub-agent contexts. Trigger the same tool call from each and check agent_id in the logged payload - it should be present for the sub-agent call and absent for the main-agent one.
Keep Hooks Fast
Hooks execute on every tool use, so keep them lightweight - this implementation completes in milliseconds.
Conclusion
Claude Code hooks do a lot more than simple validation, once you start reading the fields the payload already gives you. Matching the agent_id field against command patterns lets you enforce execution policies that adapt to context, allowing parallel execution where it's safe and blocking it where resources are shared.
This sub-agent control pattern turns a potential source of race conditions and lock conflicts into a well-orchestrated parallel execution system: the main agent coordinates test execution, whilst sub-agents handle static analysis in parallel, maximising productivity without sacrificing reliability.
Whether you're managing database locks, preventing concurrent migrations, or rate-limiting external API calls, the underlying idea holds: check the payload for the sub-agent signal, and let that decide what's allowed.
This is exactly the kind of problem the guardrail tooling below is built for.
See the open-source work