hook-development

v2026.09.24

Canonical guide to authoring Claude Code hooks, both prompt-based and command-based. PROACTIVELY activate for: (1) creating a hook, (2) adding PreToolUse or PostToolUse hooks, (3) validating tool use, (4) implementing prompt-based hooks, (5) blocking dangerous commands, (6) event-driven automation, (7) configuring hooks.json, (8) using ${CLAUDE_PLUGIN_ROOT}, (9) hook matchers and filtering, (10) debugging hooks that do not fire. Provides: hooks.json schema, event reference, security guidance, and examples.

GitHub
Install command
npx skhub add josiahsiegel/hook-development
Markdown
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

EventWhenCommon Use
PreToolUseBefore tool executesValidate, approve/deny, modify input
PostToolUseAfter tool completesTest, lint, log, provide feedback
StopMain agent stoppingVerify task completeness
SubagentStopSubagent stoppingValidate subagent work
UserPromptSubmitUser sends promptAdd context, validate, preprocess
SessionStartSession beginsLoad context, set environment
SessionEndSession endsCleanup, logging
PreCompactBefore context compactionPreserve critical information
NotificationNotification shownCustom 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

CodeMeaning
0Success (stdout shown in transcript)
2Blocking error (stderr fed back to Claude)
OtherNon-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

VariableAvailablePurpose
$CLAUDE_PLUGIN_ROOTAll hooksPlugin directory (portable paths)
$CLAUDE_PROJECT_DIRAll hooksProject root path
$CLAUDE_ENV_FILESessionStart onlyPersist 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
Discovery
Tags

No tags published for this skill.

Version
Latest version metadata

Version

v2026.09.24

Published

Sep 24, 2026

Category

Uncategorized

License

MIT

Source path

plugins/plugin-master/skills/hook-development

Default branch

main

Latest commit

5a1b112

Tree SHA

376c8e0