hook-development

Originally fromanthropics/claude-code
SKILL.md

Hook Development for Claude Code Plugins

Overview

Hooks are event-driven automation that execute in response to Claude Code events. Use hooks to validate operations, enforce policies, load context, and integrate external tools.

Two hook types:

  • Prompt-based (recommended): LLM-driven, context-aware decisions
  • Command-based: Shell commands for fast, deterministic checks

Hook Events Reference

Event When Common Use
PreToolUse Before tool executes Validate, approve/deny, modify input
PostToolUse After tool completes Test, lint, log, provide feedback
Stop Main agent stopping Verify task completeness
SubagentStop Subagent stopping Validate subagent work
UserPromptSubmit User sends prompt Add context, validate, preprocess
SessionStart Session begins Load context, set environment
SessionEnd Session ends Cleanup, logging
PreCompact Before context compaction Preserve critical information
Notification Notification shown Custom alert reactions

Configuration Formats

Plugin hooks.json (in hooks/hooks.json)

Uses wrapper format with hooks field:

{
  "description": "What these hooks do (optional)",
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PLUGIN_ROOT}/scripts/validate.sh",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

User settings format (in .claude/settings.json)

Direct format, no wrapper:

{
  "PreToolUse": [
    {
      "matcher": "Write|Edit",
      "hooks": [{ "type": "command", "command": "script.sh" }]
    }
  ]
}

Critical difference: Plugin hooks.json wraps events inside {"hooks": {...}}. Settings format puts events at top level.

Prompt-Based Hooks (Recommended)

Use LLM reasoning for context-aware decisions:

{
  "type": "prompt",
  "prompt": "Evaluate if this tool use is appropriate. Check for: system paths, credentials, path traversal. Return 'approve' or 'deny'.",
  "timeout": 30
}

Supported events: PreToolUse, PostToolUse, Stop, SubagentStop, UserPromptSubmit

Benefits: Context-aware, flexible, better edge case handling, easier to maintain.

Command Hooks

Execute shell commands for deterministic checks:

{
  "type": "command",
  "command": "bash ${CLAUDE_PLUGIN_ROOT}/scripts/validate.sh",
  "timeout": 60
}

Always use ${CLAUDE_PLUGIN_ROOT} for portable paths.

Exit Codes

Code Meaning
0 Success (stdout shown in transcript)
2 Blocking error (stderr fed back to Claude)
Other Non-blocking error

Matchers

Control which tools trigger hooks:

"matcher": "Write"              // Exact match
"matcher": "Write|Edit|Bash"    // Multiple tools
"matcher": "mcp__.*__delete.*"  // Regex (all MCP delete tools)
"matcher": "*"                  // All tools (use sparingly)

Matchers are case-sensitive.

Hook Input/Output

Input (all hooks receive via stdin)

{
  "session_id": "abc123",
  "transcript_path": "/path/to/transcript.txt",
  "cwd": "/current/working/dir",
  "hook_event_name": "PreToolUse",
  "tool_name": "Write",
  "tool_input": { "file_path": "/path/to/file" }
}

Event-specific fields: tool_name, tool_input, tool_result, user_prompt, reason

Access in prompts: $TOOL_INPUT, $TOOL_RESULT, $USER_PROMPT

Output

Standard (all hooks):

{
  "continue": true,
  "suppressOutput": false,
  "systemMessage": "Message for Claude"
}

PreToolUse decisions:

{
  "hookSpecificOutput": {
    "permissionDecision": "allow|deny|ask",
    "updatedInput": { "field": "modified_value" }
  }
}

Stop/SubagentStop decisions:

{
  "decision": "approve|block",
  "reason": "Explanation"
}

Environment Variables

Variable Available Purpose
$CLAUDE_PLUGIN_ROOT All hooks Plugin directory (portable paths)
$CLAUDE_PROJECT_DIR All hooks Project root path
$CLAUDE_ENV_FILE SessionStart only Persist env vars for session

SessionStart can persist variables:

echo "export PROJECT_TYPE=nodejs" >> "$CLAUDE_ENV_FILE"

Common Patterns

Validate file writes (PreToolUse)

{
  "PreToolUse": [{
    "matcher": "Write|Edit",
    "hooks": [{
      "type": "prompt",
      "prompt": "Check if this file write is safe. Deny writes to: .env, credentials, system paths, or files with path traversal (..). Return 'approve' or 'deny' with reason."
    }]
  }]
}

Auto-test after changes (PostToolUse)

{
  "PostToolUse": [{
    "matcher": "Write|Edit",
    "hooks": [{
      "type": "command",
      "command": "npm test -- --bail",
      "timeout": 60
    }]
  }]
}

Verify task completion (Stop)

{
  "Stop": [{
    "matcher": "*",
    "hooks": [{
      "type": "prompt",
      "prompt": "Verify: tests run, build succeeded, all questions answered. Return 'approve' to stop or 'block' with reason to continue."
    }]
  }]
}

Load project context (SessionStart)

{
  "SessionStart": [{
    "matcher": "*",
    "hooks": [{
      "type": "command",
      "command": "bash ${CLAUDE_PLUGIN_ROOT}/scripts/load-context.sh",
      "timeout": 10
    }]
  }]
}

Security Best Practices

In command hook scripts:

#!/bin/bash
set -euo pipefail

input=$(cat)
file_path=$(echo "$input" | jq -r '.tool_input.file_path')

# Always validate inputs
if [[ ! "$file_path" =~ ^[a-zA-Z0-9_./-]+$ ]]; then
  echo '{"decision": "deny", "reason": "Invalid path"}' >&2
  exit 2
fi

# Block path traversal
if [[ "$file_path" == *".."* ]]; then
  echo '{"decision": "deny", "reason": "Path traversal detected"}' >&2
  exit 2
fi

# Block sensitive files
if [[ "$file_path" == *".env"* ]]; then
  echo '{"decision": "deny", "reason": "Sensitive file"}' >&2
  exit 2
fi

# Always quote variables
echo "$file_path"

Lifecycle and Limitations

Hooks load at session start. Changes to hook configuration require restarting Claude Code.

  • Editing hooks/hooks.json won't affect the current session
  • Adding new hook scripts won't be recognized until restart
  • All matching hooks run in parallel (not sequentially)
  • Hooks don't see each other's output - design for independence

To test changes: Exit Claude Code, restart with claude or claude --debug.

Debugging

# Enable debug mode to see hook execution
claude --debug

# Test hook scripts directly
echo '{"tool_name": "Write", "tool_input": {"file_path": "/test"}}' | \
  bash ${CLAUDE_PLUGIN_ROOT}/scripts/validate.sh

# Validate hook JSON output
output=$(./hook-script.sh < test-input.json)
echo "$output" | jq .

# View loaded hooks in session
# Use /hooks command

Validation Checklist

  • hooks.json uses correct format (plugin wrapper or settings direct)
  • All script paths use ${CLAUDE_PLUGIN_ROOT} (no hardcoded paths)
  • Scripts are executable and handle errors (set -euo pipefail)
  • Scripts validate all inputs and quote all variables
  • Matchers are specific (avoid * unless necessary)
  • Timeouts are set appropriately (default: command 60s, prompt 30s)
  • Hook output is valid JSON
  • Tested with claude --debug
Weekly Installs
8
GitHub Stars
20
First Seen
8 days ago
Installed on
opencode8
gemini-cli8
github-copilot8
codex8
amp8
cline8