Files
gh-sjungling-claude-plugins…/skills/builder/references/hooks-guide.md
2025-11-30 08:57:35 +08:00

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:

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 begins
  • UserPromptSubmit: Runs when user submits a message
  • BeforeToolUse: Additional validation before tool execution
  • AfterToolUse: 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.json must be in the hooks/ 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 called
  • tool_input: Parameters Claude wants to pass to the tool (schema varies by tool)
  • session_id: Unique session identifier
  • transcript_path: Path to session transcript
  • cwd: 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

  1. Blocks tool execution if:

    • mcp-grafana command not found
    • GRAFANA_API_KEY environment variable not set
  2. Allows with warnings if:

    • Setup is complete but connectivity check fails
  3. Allows silently if:

    • All checks pass
  4. 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 permissionDecision field
  • Includes permissionDecisionReason field
  • 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.json exists 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 2 for blocking (not exit 1)
  • Use exit 0 for success
  • Check exit code: echo $? after running script

Permission Decision Not Respected

Ensure:

  • permissionDecision field is present
  • Value is exactly "allow", "deny", or "ask"
  • Exit code is 0 or 2 (not other values)

Best Practices Summary

  1. Always validate before allowing operations
  2. Provide helpful errors with clear resolution steps
  3. Use exit code 2 for blocking errors
  4. Return proper JSON with required fields
  5. Test thoroughly with and without prerequisites
  6. Make scripts executable (chmod +x)
  7. Use environment variables for configuration
  8. Handle missing dependencies gracefully
  9. Warn don't block for non-critical issues
  10. 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