15 KiB
Hooks Guide
Overview
Hooks are scripts that run in response to Claude Code events, enabling validation, automation, and control over tool execution. Plugins can define hooks to ensure prerequisites are met before operations execute.
Official Documentation:
- Hooks: https://docs.claude.com/en/docs/claude-code/hooks
- Plugin Hooks: https://docs.claude.com/en/docs/claude-code/hooks#plugin-hooks
Hook Types
PreToolUse
Executes after Claude creates tool parameters but before the tool actually runs. This allows you to:
- Validate prerequisites (dependencies installed, config set)
- Control tool execution (allow, deny, ask for confirmation)
- Provide context to Claude about environment state
- Block operations that would fail due to missing setup
Common Use Cases:
- Check MCP server installation before MCP tool calls
- Verify API keys/credentials before external service calls
- Validate file paths before destructive operations
- Enforce project-specific constraints
- Provide setup guidance when prerequisites are missing
Other Hook Types
See official documentation for:
SessionStart: Runs when session beginsUserPromptSubmit: Runs when user submits a messageBeforeToolUse: Additional validation before tool executionAfterToolUse: Post-execution actions
Directory Structure
plugin-name/
├── .claude-plugin/
│ └── plugin.json
├── hooks/
│ └── hooks.json # Hook configuration (required location)
├── scripts/ # Hook scripts (recommended location)
│ ├── check-setup.sh
│ └── validate-config.py
└── README.md
Key Points:
hooks/hooks.jsonmust be in thehooks/directory (or specify custom path in plugin.json)- Scripts can be anywhere, commonly in
scripts/directory - Use
${CLAUDE_PLUGIN_ROOT}to reference plugin root path in hooks
Hook Configuration
hooks.json
{
"PreToolUse": [
{
"matcher": "mcp__plugin_name_server__*",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/check-setup.sh"
}
]
}
Matcher Patterns
Exact match:
"matcher": "Write"
Matches only the Write tool.
Regex pattern:
"matcher": "Edit|Write|MultiEdit"
Matches multiple specific tools.
Wildcard:
"matcher": "*"
Matches all tools (use cautiously).
MCP tools:
"matcher": "mcp__*" // All MCP tools
"matcher": "mcp__server__*" // All tools from a server
"matcher": "mcp__server__specific_tool" // Specific MCP tool
Tool name patterns:
- Built-in:
Bash,Read,Write,Edit,Glob,Grep,Task,WebFetch,WebSearch - Notebooks:
NotebookEdit,NotebookExecute - MCP:
mcp__<server>__<tool>
Command Field
Use ${CLAUDE_PLUGIN_ROOT} to reference scripts:
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/check-setup.sh"
This ensures the hook works regardless of where the plugin is installed.
Writing PreToolUse Hook Scripts
Input
Your script receives JSON via stdin:
{
"hook_event_name": "PreToolUse",
"tool_name": "mcp__grafana__create_incident",
"tool_input": {
"title": "Production outage",
"severity": "critical",
"roomPrefix": "incident"
},
"session_id": "abc123",
"transcript_path": "/path/to/transcript.md",
"cwd": "/current/working/directory"
}
Fields:
hook_event_name: Always "PreToolUse"tool_name: Name of the tool being calledtool_input: Parameters Claude wants to pass to the tool (schema varies by tool)session_id: Unique session identifiertranscript_path: Path to session transcriptcwd: Current working directory
Output
Your script must output JSON via stdout:
{
"hookSpecificOutput": {
"permissionDecision": "allow|deny|ask",
"permissionDecisionReason": "Why this decision was made",
"additionalContext": "Optional message shown to Claude"
}
}
Required Fields:
permissionDecision (string, required):
"allow": Auto-approve tool execution, bypass normal permissions"deny": Block tool execution entirely"ask": Prompt user for confirmation in UI
permissionDecisionReason (string, required):
- Brief explanation of why this decision was made
- Shown in logs/UI
- Examples: "Setup verified", "Missing API key", "User confirmation required"
Optional Fields:
additionalContext (string, optional):
- Additional information shown to Claude
- Use for detailed error messages, warnings, setup instructions
- Supports formatted text (newlines, bullets, etc.)
Exit Codes
0 - Success:
- Hook executed successfully
- Permission decision should be respected
2 - Blocking Error:
- Hook failed, block tool execution
- stderr shown to Claude
- Use for critical validation failures
Other codes:
- Treated as errors, behavior may vary
Best Practices
1. Always return permission decision:
# BAD: Missing permissionDecision
{
"hookSpecificOutput": {
"additionalContext": "Some message"
}
}
# GOOD: Includes required fields
{
"hookSpecificOutput": {
"permissionDecision": "allow",
"permissionDecisionReason": "Setup verified"
}
}
2. Use exit code 2 for blocking:
# BAD: Using exit 1
if [ ${#ERRORS[@]} -gt 0 ]; then
echo '{"hookSpecificOutput": {"permissionDecision": "deny", ...}}'
exit 1 # Wrong exit code
fi
# GOOD: Using exit 2
if [ ${#ERRORS[@]} -gt 0 ]; then
echo '{"hookSpecificOutput": {"permissionDecision": "deny", ...}}'
exit 2 # Correct blocking exit code
fi
3. Provide helpful error messages:
# GOOD: Actionable error message
"additionalContext": "❌ Setup Issues:\n\n • mcp-server not installed\n • Install from: https://example.com/install\n • Set API_KEY environment variable\n\nPlease resolve these issues to continue."
4. Silent success is OK:
# When everything is fine, minimal output is acceptable
{
"hookSpecificOutput": {
"permissionDecision": "allow",
"permissionDecisionReason": "Setup verified"
}
}
# No additionalContext needed
5. Use warnings for non-critical issues:
# Allow execution but inform about warnings
{
"hookSpecificOutput": {
"permissionDecision": "allow",
"permissionDecisionReason": "Setup verified with warnings",
"additionalContext": "⚠️ Warnings:\n\n • Connection slow\n • Using fallback config"
}
}
Example: MCP Setup Validation
hooks/hooks.json
{
"PreToolUse": [
{
"matcher": "mcp__grafana__*",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/check-grafana-setup.sh"
}
]
}
scripts/check-grafana-setup.sh
#!/bin/bash
set -e
ERRORS=()
WARNINGS=()
# Check if mcp-grafana is installed
if ! command -v mcp-grafana &> /dev/null; then
ERRORS+=("mcp-grafana is not installed. Install from: https://github.com/grafana/mcp-grafana")
fi
# Check if API key is set
if [ -z "${GRAFANA_API_KEY}" ]; then
ERRORS+=("GRAFANA_API_KEY environment variable is not set. Add to ~/.zshrc: export GRAFANA_API_KEY='your-key'")
fi
# Check connectivity (optional, warns but doesn't block)
GRAFANA_URL="${GRAFANA_URL:-https://grafana.example.com}"
if [ -n "${GRAFANA_API_KEY}" ]; then
if ! curl -sf -H "Authorization: Bearer ${GRAFANA_API_KEY}" "${GRAFANA_URL}/api/health" &> /dev/null; then
WARNINGS+=("Unable to connect to Grafana at ${GRAFANA_URL}")
fi
fi
# Build output message
OUTPUT=""
if [ ${#ERRORS[@]} -gt 0 ]; then
OUTPUT="❌ Grafana Plugin Setup Issues:\n\n"
for error in "${ERRORS[@]}"; do
OUTPUT+=" • ${error}\n"
done
OUTPUT+="\nPlease resolve these issues to use Grafana features.\n"
elif [ ${#WARNINGS[@]} -gt 0 ]; then
OUTPUT="⚠️ Grafana Plugin Warnings:\n\n"
for warning in "${WARNINGS[@]}"; do
OUTPUT+=" • ${warning}\n"
done
fi
# Return JSON with permission decision
if [ ${#ERRORS[@]} -gt 0 ]; then
cat << EOF
{
"hookSpecificOutput": {
"permissionDecision": "deny",
"permissionDecisionReason": "Grafana plugin setup is incomplete",
"additionalContext": "$(echo -e "$OUTPUT")"
}
}
EOF
exit 2
elif [ ${#WARNINGS[@]} -gt 0 ]; then
cat << EOF
{
"hookSpecificOutput": {
"permissionDecision": "allow",
"permissionDecisionReason": "Setup verified with warnings",
"additionalContext": "$(echo -e "$OUTPUT")"
}
}
EOF
exit 0
else
cat << EOF
{
"hookSpecificOutput": {
"permissionDecision": "allow",
"permissionDecisionReason": "Grafana plugin setup verified"
}
}
EOF
exit 0
fi
What This Hook Does
-
Blocks tool execution if:
mcp-grafanacommand not foundGRAFANA_API_KEYenvironment variable not set
-
Allows with warnings if:
- Setup is complete but connectivity check fails
-
Allows silently if:
- All checks pass
-
Provides guidance when blocked:
- Installation instructions
- Configuration steps
- Clear error messages
Testing Hooks
Manual Testing
Create a test input file:
cat > test-input.json << 'EOF'
{
"hook_event_name": "PreToolUse",
"tool_name": "mcp__grafana__create_incident",
"tool_input": {
"title": "Test",
"severity": "critical"
},
"session_id": "test",
"transcript_path": "/tmp/test.md",
"cwd": "/tmp"
}
EOF
Test the hook script:
# Test without setup (should deny)
cat test-input.json | ./scripts/check-setup.sh
echo "Exit code: $?"
# Test with setup (should allow)
export GRAFANA_API_KEY="test-key"
cat test-input.json | ./scripts/check-setup.sh
echo "Exit code: $?"
Validation Checklist
- Script is executable (
chmod +x) - Returns valid JSON to stdout
- Includes
permissionDecisionfield - Includes
permissionDecisionReasonfield - Uses exit code 2 for blocking errors
- Uses exit code 0 for success/warnings
- Error messages are actionable
- Script handles missing environment variables
- Matcher pattern is correct in hooks.json
- Command path uses
${CLAUDE_PLUGIN_ROOT}
Common Patterns
Environment Validation
# Check required environment variables
REQUIRED_VARS=("API_KEY" "API_URL" "PROJECT_ID")
for var in "${REQUIRED_VARS[@]}"; do
if [ -z "${!var}" ]; then
ERRORS+=("$var environment variable not set")
fi
done
Command Availability
# Check if command exists
REQUIRED_COMMANDS=("docker" "kubectl" "helm")
for cmd in "${REQUIRED_COMMANDS[@]}"; do
if ! command -v "$cmd" &> /dev/null; then
ERRORS+=("$cmd is not installed")
fi
done
File/Directory Existence
# Check if required files exist
if [ ! -f "config.yaml" ]; then
ERRORS+=("config.yaml not found")
fi
if [ ! -d ".git" ]; then
ERRORS+=("Not in a git repository")
fi
API Connectivity
# Check if API is reachable (warning, not error)
if ! curl -sf "${API_URL}/health" &> /dev/null; then
WARNINGS+=("API at ${API_URL} is not reachable")
fi
Conditional Blocking
# Block only specific tools
if [[ "$tool_name" == *"create_incident"* ]]; then
# Strict validation for incident creation
if [ -z "${ONCALL_SCHEDULE}" ]; then
ERRORS+=("ONCALL_SCHEDULE required for incident creation")
fi
fi
Troubleshooting
Hook Not Running
Check:
hooks/hooks.jsonexists in correct location- JSON is valid (
cat hooks/hooks.json | python -m json.tool) - Matcher pattern matches the tool name
- Script path is correct and uses
${CLAUDE_PLUGIN_ROOT}
Invalid JSON Output
Check:
- Script outputs to stdout (not stderr)
- JSON is properly formatted
- No extra output before/after JSON
- Special characters are escaped in strings
Exit Code Issues
Remember:
- Use
exit 2for blocking (notexit 1) - Use
exit 0for success - Check exit code:
echo $?after running script
Permission Decision Not Respected
Ensure:
permissionDecisionfield is present- Value is exactly
"allow","deny", or"ask" - Exit code is 0 or 2 (not other values)
Best Practices Summary
- Always validate before allowing operations
- Provide helpful errors with clear resolution steps
- Use exit code 2 for blocking errors
- Return proper JSON with required fields
- Test thoroughly with and without prerequisites
- Make scripts executable (
chmod +x) - Use environment variables for configuration
- Handle missing dependencies gracefully
- Warn don't block for non-critical issues
- Keep hooks fast (< 1 second when possible)
Security Considerations
Input Validation
Hooks receive tool parameters from Claude. Be cautious with:
- File paths (could be outside expected directories)
- Command arguments (could contain injection attempts)
- URLs (could point to unexpected destinations)
Credential Handling
Never:
- Log credentials or API keys
- Include credentials in error messages
- Write credentials to files
Always:
- Read from environment variables
- Validate credential format before use
- Fail safely if credentials are missing
Least Privilege
Hooks run with user's permissions. Ensure:
- Scripts don't require sudo
- File operations are scoped appropriately
- Network calls are to expected endpoints only
Advanced Patterns
Multi-Stage Validation
# Stage 1: Critical requirements (block if missing)
check_critical_requirements
# Stage 2: Optional requirements (warn if missing)
check_optional_requirements
# Stage 3: Connectivity (warn if unreachable)
check_connectivity
Caching Validation Results
# Cache validation for performance
CACHE_FILE="/tmp/plugin-validation-cache"
CACHE_TTL=300 # 5 minutes
if [ -f "$CACHE_FILE" ]; then
CACHE_AGE=$(($(date +%s) - $(stat -f %m "$CACHE_FILE")))
if [ $CACHE_AGE -lt $CACHE_TTL ]; then
cat "$CACHE_FILE"
exit 0
fi
fi
# Run validation and cache result
validate > "$CACHE_FILE"
cat "$CACHE_FILE"
Tool-Specific Validation
# Read tool_name from stdin
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
case "$TOOL_NAME" in
"mcp__server__read_only")
# Light validation for read operations
;;
"mcp__server__write")
# Strict validation for write operations
;;
"mcp__server__delete")
# Extra confirmation for destructive operations
echo '{"hookSpecificOutput": {"permissionDecision": "ask", ...}}'
;;
esac
References
- Official Hooks Documentation: https://docs.claude.com/en/docs/claude-code/hooks
- Plugin Hooks: https://docs.claude.com/en/docs/claude-code/hooks#plugin-hooks
- JSON Specification: https://www.json.org/
- Bash Best Practices: https://google.github.io/styleguide/shellguide.html