Agent Skills: Hook Development for Claude Code Plugins

|

UncategorizedID: josiahsiegel/claude-plugin-marketplace/hook-development

Install this agent skill to your local

pnpm dlx add-skill https://github.com/JosiahSiegel/claude-plugin-marketplace/tree/HEAD/plugins/plugin-master/skills/hook-development

Skill Files

Browse the full folder contents for hook-development.

Download Skill

Loading file tree…

plugins/plugin-master/skills/hook-development/SKILL.md

Skill Metadata

Name
hook-development
Description
|

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