Hook Development Skill
Guide to creating event-driven hooks for Claude Code plugins.
When to Use
Activate when:
- Creating event hooks
- User asks about automation
- Implementing validation logic
- Questions about hook events
What Are Hooks?
Hooks are executable scripts that respond to events:
- PreToolUse - Before any tool execution
- PostToolUse - After tool execution
- SessionStart - Session initialization
- SessionEnd - Session cleanup
- Stop - Before session ends
- SubagentStop - When agent completes
- UserPromptSubmit - After user input
- PreCompact - Before context compression
- Notification - System notifications
When to use hooks:
- Automatic validation before actions
- Event-driven automation
- Logging and auditing
- Safety checks and enforcement
When NOT to use hooks:
- User-invoked actions → Use Commands
- Guidance and workflows → Use Skills
- Focused analysis → Use Agents
Hook Structure
File Location
plugin-name/
└── hooks/
├── PreToolUse.sh
├── PostToolUse.sh
├── SessionStart.sh
└── Stop.sh
Basic Hook Format
#!/bin/bash
# hooks/PreToolUse.sh
# Hook receives event data via environment variables
# Exit codes:
# 0 - Allow action to proceed
# 1 - Block action
# Output to stderr - shown to user
# Example: Block dangerous operations
if [[ "$TOOL_NAME" == "Bash" ]] && [[ "$COMMAND" == *"rm -rf"* ]]; then
echo "⛔ Dangerous command blocked: rm -rf" >&2
exit 1
fi
# Allow all other actions
exit 0
Making Hooks Executable
CRITICAL: Hooks must be executable:
chmod +x hooks/PreToolUse.sh
chmod +x hooks/PostToolUse.sh
Available Hooks
PreToolUse
Triggers: Before any tool execution
Use cases:
- Validate tool arguments
- Block dangerous operations
- Enforce workflow rules
- Check prerequisites
Environment variables:
TOOL_NAME- Tool being invoked (Bash, Read, Edit, etc.)TOOL_ARGS- Tool arguments (JSON)- Additional tool-specific vars
Example - Block destructive commands:
#!/bin/bash
# hooks/PreToolUse.sh
if [[ "$TOOL_NAME" == "Bash" ]]; then
COMMAND=$(echo "$TOOL_ARGS" | jq -r '.command')
# Block force push to main
if [[ "$COMMAND" == *"git push"*"--force"* ]] && [[ "$COMMAND" == *"main"* ]]; then
echo "⛔ Force push to main blocked" >&2
echo "This is dangerous. Use a feature branch." >&2
exit 1
fi
fi
exit 0
PostToolUse
Triggers: After tool execution
Use cases:
- Log operations
- Trigger follow-up actions
- Update external systems
- Collect metrics
Environment variables:
TOOL_NAME- Tool that was executedTOOL_RESULT- Tool outputEXIT_CODE- Tool exit code
Example - Log all bash commands:
#!/bin/bash
# hooks/PostToolUse.sh
if [[ "$TOOL_NAME" == "Bash" ]]; then
COMMAND=$(echo "$TOOL_ARGS" | jq -r '.command')
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
echo "$TIMESTAMP | $COMMAND | Exit: $EXIT_CODE" >> ~/.claude/command.log
fi
exit 0
SessionStart
Triggers: When Claude Code session begins
Use cases:
- Initialize environment
- Load configuration
- Check dependencies
- Set up workspace
Example - Verify environment:
#!/bin/bash
# hooks/SessionStart.sh
# Check required tools
MISSING=()
command -v git >/dev/null 2>&1 || MISSING+=("git")
command -v node >/dev/null 2>&1 || MISSING+=("node")
command -v jq >/dev/null 2>&1 || MISSING+=("jq")
if [ ${#MISSING[@]} -gt 0 ]; then
echo "⚠️ Missing tools: ${MISSING[*]}" >&2
echo "Some plugin features may not work." >&2
fi
# Load project config
if [ -f ".claude/config.json" ]; then
echo "✓ Loaded project configuration" >&2
fi
exit 0
SessionEnd
Triggers: When session ends normally
Use cases:
- Cleanup temporary files
- Save session state
- Push uncommitted work
- Generate reports
Example - Cleanup:
#!/bin/bash
# hooks/SessionEnd.sh
# Clean up temp files
rm -rf /tmp/claude-session-*
# Save session summary
SUMMARY_FILE="$HOME/.claude/sessions/$(date +%Y%m%d-%H%M%S).log"
echo "Session ended at $(date)" > "$SUMMARY_FILE"
exit 0
Stop
Triggers: Before session stops (user can cancel)
Use cases:
- Warn about uncommitted changes
- Prompt for cleanup
- Check for running processes
- Save work in progress
Example - Check uncommitted changes:
#!/bin/bash
# hooks/Stop.sh
# Check for uncommitted changes
if git rev-parse --git-dir > /dev/null 2>&1; then
if ! git diff-index --quiet HEAD --; then
echo "⚠️ You have uncommitted changes" >&2
echo "Consider committing or stashing before exiting." >&2
# Don't block, just warn
exit 0
fi
fi
exit 0
SubagentStop
Triggers: When a spawned agent completes
Use cases:
- Process agent results
- Trigger next step in workflow
- Log agent completion
- Update task status
Environment variables:
AGENT_NAME- Agent that completedAGENT_RESULT- Agent outputAGENT_STATUS- Success/failure
Example - Log agent completion:
#!/bin/bash
# hooks/SubagentStop.sh
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
echo "$TIMESTAMP | Agent: $AGENT_NAME | Status: $AGENT_STATUS" >> ~/.claude/agents.log
exit 0
UserPromptSubmit
Triggers: After user submits a prompt
Use cases:
- Track user requests
- Analyze patterns
- Trigger automations
- Log interactions
Environment variables:
USER_PROMPT- The user's input
Example - Track prompts:
#!/bin/bash
# hooks/UserPromptSubmit.sh
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
echo "$TIMESTAMP | $USER_PROMPT" >> ~/.claude/prompts.log
exit 0
PreCompact
Triggers: Before context window compression
Use cases:
- Save context snapshot
- Export important data
- Preserve state
- Generate checkpoint
Example - Save context snapshot:
#!/bin/bash
# hooks/PreCompact.sh
SNAPSHOT_DIR="$HOME/.claude/snapshots"
mkdir -p "$SNAPSHOT_DIR"
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
SNAPSHOT_FILE="$SNAPSHOT_DIR/context-$TIMESTAMP.json"
# Save context metadata
cat > "$SNAPSHOT_FILE" <<EOF
{
"timestamp": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
"working_directory": "$(pwd)",
"git_branch": "$(git branch --show-current 2>/dev/null || echo 'none')",
"git_commit": "$(git rev-parse HEAD 2>/dev/null || echo 'none')"
}
EOF
exit 0
Notification
Triggers: System notifications
Use cases:
- Send alerts
- Update external systems
- Trigger integrations
- Custom notifications
Example - Send notification:
#!/bin/bash
# hooks/Notification.sh
# Forward to system notification
osascript -e "display notification \"$NOTIFICATION_MESSAGE\" with title \"Claude Code\""
exit 0
Hook Patterns
Validation Hook
Purpose: Enforce rules and prevent mistakes
#!/bin/bash
# hooks/PreToolUse.sh
# Enforce TDD: Block code changes without tests
if [[ "$TOOL_NAME" == "Edit" ]] || [[ "$TOOL_NAME" == "Write" ]]; then
FILE_PATH=$(echo "$TOOL_ARGS" | jq -r '.file_path')
# If editing source code
if [[ "$FILE_PATH" == *"/src/"* ]] && [[ "$FILE_PATH" != *".test."* ]]; then
# Check if corresponding test exists
TEST_FILE="${FILE_PATH/.ts/.test.ts}"
if [ ! -f "$TEST_FILE" ]; then
echo "⛔ TDD Violation" >&2
echo "No test file found for: $FILE_PATH" >&2
echo "Create test first: $TEST_FILE" >&2
exit 1
fi
fi
fi
exit 0
Logging Hook
Purpose: Audit trail of operations
#!/bin/bash
# hooks/PostToolUse.sh
LOG_FILE="$HOME/.claude/audit.log"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Log all tool usage
echo "$TIMESTAMP | $TOOL_NAME | $TOOL_ARGS" >> "$LOG_FILE"
# Rotate log if too large
if [ -f "$LOG_FILE" ] && [ $(wc -l < "$LOG_FILE") -gt 10000 ]; then
mv "$LOG_FILE" "$LOG_FILE.old"
fi
exit 0
Environment Setup Hook
Purpose: Prepare workspace
#!/bin/bash
# hooks/SessionStart.sh
# Load environment variables
if [ -f ".env" ]; then
export $(cat .env | xargs)
fi
# Start required services
if command -v docker-compose >/dev/null 2>&1; then
if [ -f "docker-compose.yml" ]; then
echo "🐳 Starting Docker services..." >&2
docker-compose up -d
fi
fi
# Initialize git hooks
if [ -d ".git" ] && [ ! -f ".git/hooks/pre-commit" ]; then
echo "⚙️ Setting up git hooks..." >&2
# Install git hooks
fi
exit 0
Workflow Enforcement Hook
Purpose: Ensure process compliance
#!/bin/bash
# hooks/PreToolUse.sh
# Check if in workflow mode
WORKFLOW_STATE="$HOME/.claude/workflow-state.json"
if [ -f "$WORKFLOW_STATE" ]; then
CURRENT_PHASE=$(jq -r '.phase' "$WORKFLOW_STATE")
# If in SPEC phase, block implementation
if [[ "$CURRENT_PHASE" == "spec" ]]; then
if [[ "$TOOL_NAME" == "Edit" ]] || [[ "$TOOL_NAME" == "Write" ]]; then
FILE_PATH=$(echo "$TOOL_ARGS" | jq -r '.file_path')
# Block implementation code during spec phase
if [[ "$FILE_PATH" == *"/src/"* ]] && [[ "$FILE_PATH" != *".spec."* ]]; then
echo "⛔ Workflow Violation" >&2
echo "Currently in SPEC phase" >&2
echo "Complete specification before implementation" >&2
exit 1
fi
fi
fi
fi
exit 0
Advanced Techniques
Conditional Hooks
#!/bin/bash
# hooks/PreToolUse.sh
# Only enforce in CI environment
if [ -z "$CI" ]; then
# Not in CI, allow everything
exit 0
fi
# In CI, enforce strict rules
if [[ "$TOOL_NAME" == "Bash" ]]; then
COMMAND=$(echo "$TOOL_ARGS" | jq -r '.command')
# Block interactive commands in CI
if [[ "$COMMAND" == *"vim"* ]] || [[ "$COMMAND" == *"nano"* ]]; then
echo "⛔ Interactive editors not allowed in CI" >&2
exit 1
fi
fi
exit 0
State Management
#!/bin/bash
# hooks/PreToolUse.sh
STATE_FILE="$HOME/.claude/plugin-state.json"
# Initialize state if not exists
if [ ! -f "$STATE_FILE" ]; then
echo '{"commits": 0, "tests_run": 0}' > "$STATE_FILE"
fi
# Update state based on tool usage
if [[ "$TOOL_NAME" == "Bash" ]]; then
COMMAND=$(echo "$TOOL_ARGS" | jq -r '.command')
if [[ "$COMMAND" == *"git commit"* ]]; then
# Increment commit counter
COMMITS=$(jq -r '.commits' "$STATE_FILE")
COMMITS=$((COMMITS + 1))
jq ".commits = $COMMITS" "$STATE_FILE" > "$STATE_FILE.tmp"
mv "$STATE_FILE.tmp" "$STATE_FILE"
fi
fi
exit 0
Integration with External Tools
#!/bin/bash
# hooks/PostToolUse.sh
# Send to external monitoring system
if [ -n "$MONITORING_WEBHOOK" ]; then
curl -X POST "$MONITORING_WEBHOOK" \
-H "Content-Type: application/json" \
-d "{
\"tool\": \"$TOOL_NAME\",
\"timestamp\": \"$(date -u +"%Y-%m-%dT%H:%M:%SZ")\",
\"status\": \"$EXIT_CODE\"
}" \
>/dev/null 2>&1
fi
exit 0
Testing Hooks
Manual Testing
# Test PreToolUse hook
TOOL_NAME="Bash" TOOL_ARGS='{"command":"echo test"}' ./hooks/PreToolUse.sh
echo "Exit code: $?"
# Test with blocked command
TOOL_NAME="Bash" TOOL_ARGS='{"command":"rm -rf /"}' ./hooks/PreToolUse.sh
echo "Exit code: $?"
Test Checklist
- Hook is executable (
chmod +x) - Exit code 0 allows action
- Exit code 1 blocks action
- Error messages go to stderr
- Handles missing environment vars gracefully
- No infinite loops or hangs
- Fast execution (< 100ms if possible)
- Idempotent (can run multiple times safely)
Best Practices
Fast Execution
Good:
#!/bin/bash
# Quick check, minimal processing
if [[ "$TOOL_NAME" == "Bash" ]]; then
# Simple string match
if [[ "$COMMAND" == *"rm -rf"* ]]; then
echo "⛔ Blocked" >&2
exit 1
fi
fi
exit 0
Bad:
#!/bin/bash
# Slow: Complex processing
# Don't do heavy computation in hooks
RESULT=$(find / -name "*.js" | xargs grep "pattern" | wc -l)
# This will block every tool call!
Error Handling
#!/bin/bash
# hooks/PreToolUse.sh
set -euo pipefail # Exit on error
# Check required commands exist
if ! command -v jq >/dev/null 2>&1; then
# Graceful degradation
exit 0
fi
# Your hook logic
# ...
exit 0
Clear Messages
Good:
echo "⛔ Force push to main blocked" >&2
echo "" >&2
echo "Force push is dangerous and can destroy history." >&2
echo "" >&2
echo "To update main:" >&2
echo " 1. Create feature branch" >&2
echo " 2. Open pull request" >&2
echo " 3. Merge after review" >&2
Bad:
echo "Error: operation not allowed" >&2
Configuration
#!/bin/bash
# hooks/PreToolUse.sh
# Allow configuration
CONFIG_FILE=".claude/hook-config.json"
if [ -f "$CONFIG_FILE" ]; then
STRICT_MODE=$(jq -r '.strict_mode' "$CONFIG_FILE")
else
STRICT_MODE="false"
fi
# Use configuration
if [[ "$STRICT_MODE" == "true" ]]; then
# Enforce strict rules
else
# Relaxed mode
fi
Common Pitfalls
Blocking Everything
Problem:
# This blocks ALL tool usage!
exit 1
Fix:
# Only block specific dangerous operations
if [[ dangerous_condition ]]; then
exit 1
fi
# Allow everything else
exit 0
Slow Hooks
Problem:
# This makes every tool call slow
sleep 5
Fix:
# Keep hooks fast
# Run heavy operations in background if needed
(heavy_operation &)
exit 0
Not Executable
Problem:
# Hook file created but not executable
# Won't run at all
Fix:
chmod +x hooks/PreToolUse.sh
Infinite Recursion
Problem:
# Hook triggers itself
if [[ "$TOOL_NAME" == "Bash" ]]; then
# This triggers PreToolUse again!
bash -c "echo test"
fi
Fix:
# Don't call tools from hooks
# Only validate and return
Debugging Hooks
Add Logging
#!/bin/bash
# hooks/PreToolUse.sh
DEBUG_LOG="$HOME/.claude/hook-debug.log"
if [ "$HOOK_DEBUG" = "true" ]; then
echo "$(date) | PreToolUse | $TOOL_NAME | $TOOL_ARGS" >> "$DEBUG_LOG"
fi
# Your hook logic
Test in Isolation
# Set environment variables manually
export TOOL_NAME="Bash"
export TOOL_ARGS='{"command":"rm -rf test"}'
# Run hook
./hooks/PreToolUse.sh
# Check exit code
echo "Exit code: $?"
References
- Official hooks documentation: https://code.claude.com/docs/en/hooks
- Plugin reference: https://code.claude.com/docs/en/plugins-reference
- Bash best practices: https://www.gnu.org/software/bash/manual/