Hook Development for Claude Code Plugins
Adapted from claude-plugins-official/plugin-dev/skills/hook-development. Trimmed to what we actually author (our plugin already has 6 event matchers covering 7 hook handlers — see hooks/hooks.json).
Hook types
Prompt-based (LLM-driven, for complex reasoning)
{
"type": "prompt",
"prompt": "Evaluate if this tool use is appropriate: $TOOL_INPUT",
"timeout": 30
}
Supported events: Stop, SubagentStop, UserPromptSubmit, PreToolUse.
Use for: context-aware decisions, flexible evaluation, natural-language reasoning.
Command (deterministic, for fast checks)
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/validate.mjs",
"timeout": 60
}
Use for: fast deterministic validations, file-system ops, external tools, performance-critical paths.
Our convention: all our command hooks are .mjs (Node.js) — see hooks/pre-bash-destructive-guard.mjs, hooks/enforce-scope.mjs. The v3.0 migration moved us off bash for native Windows support.
Configuration formats
This is where people trip up. Two formats exist; they are NOT interchangeable.
Plugin hooks/hooks.json — wrapper format
{
"description": "Plugin hook description (optional)",
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/validate.mjs" }
]
}
]
}
}
hookswrapper is requireddescriptionis optional
User .claude/settings.json — direct format
{
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{ "type": "command", "command": "~/my-hook.sh" }
]
}
]
}
- No wrapper
- No description
Mixing these up is the #1 reason new hooks don't fire.
Hook events
| Event | When | Use for |
|---|---|---|
PreToolUse | Before tool runs | Validate, modify, block |
PostToolUse | After tool completes | React to result, log |
UserPromptSubmit | User submits prompt | Add context, validate |
Stop | Main agent stopping | Completeness check |
SubagentStop | Subagent stopping | Task validation |
SessionStart | Session begins | Context load |
SessionEnd | Session ends | Cleanup, logging |
PreCompact | Before compaction | Preserve critical state |
Notification | User notified | Logging, reactions |
PreToolUse output schema
{
"hookSpecificOutput": {
"permissionDecision": "allow|deny|ask",
"updatedInput": { "field": "modified_value" }
},
"systemMessage": "Explanation shown to Claude"
}
Stop / SubagentStop output
{
"decision": "approve|block",
"reason": "Why blocked / approved",
"systemMessage": "Additional context"
}
SessionStart: persist env vars
echo "export PROJECT_TYPE=nodejs" >> "$CLAUDE_ENV_FILE"
$CLAUDE_ENV_FILE is unique to SessionStart hooks.
Input schema
All hooks receive JSON on stdin:
{
"session_id": "abc123",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/current/working/dir",
"permission_mode": "ask|allow",
"hook_event_name": "PreToolUse"
}
Event-specific extras:
PreToolUse/PostToolUse:tool_name,tool_input,tool_resultUserPromptSubmit:user_promptStop/SubagentStop:reason
Access in prompt hooks via $TOOL_INPUT, $TOOL_RESULT, $USER_PROMPT.
Environment variables
| Var | Scope | Purpose |
|---|---|---|
$CLAUDE_PROJECT_DIR | All | Project root |
$CLAUDE_PLUGIN_ROOT | Plugin hooks | Plugin directory — use this, never hardcode paths |
$CLAUDE_ENV_FILE | SessionStart only | Persist env vars |
$CLAUDE_CODE_REMOTE | All (conditional) | Set if running remote |
Portability rule
// ✅ Portable — works everywhere the plugin installs
{ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/guard.mjs" }
// ❌ Broken — only works on the operator's machine
{ "command": "~/Projects/.../guard.mjs" }
Matchers
"matcher": "Write" // Exact tool
"matcher": "Read|Write|Edit" // Multiple
"matcher": "*" // All tools
"matcher": "mcp__.*__delete.*" // Regex — all MCP delete tools
"matcher": "mcp__gitlab_.*" // Specific MCP server
Matchers are case-sensitive.
Security best practices
Validate inputs (command hooks)
#!/bin/bash
set -euo pipefail
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name')
if [[ ! "$tool_name" =~ ^[a-zA-Z0-9_]+$ ]]; then
echo '{"decision": "deny", "reason": "Invalid tool name"}' >&2
exit 2
fi
In Node/.mjs hooks (our convention), same principle — parse stdin JSON, validate structure before trusting.
Path safety
file_path=$(echo "$input" | jq -r '.tool_input.file_path')
# Deny path traversal
[[ "$file_path" == *".."* ]] && { echo '{"decision":"deny","reason":"Path traversal"}' >&2; exit 2; }
# Deny sensitive files
[[ "$file_path" == *".env"* ]] && { echo '{"decision":"deny","reason":"Sensitive file"}' >&2; exit 2; }
Our enforce-scope.mjs implements this for wave-scope boundaries.
Quote variables
echo "$file_path" # ✅
cd "$CLAUDE_PROJECT_DIR" # ✅
echo $file_path # ❌ unquoted injection risk
Timeouts
Defaults: command hooks 60s, prompt hooks 30s. Set explicitly when the work is known-slow:
{ "type": "command", "command": "...", "timeout": 10 }
Parallel execution
All matching hooks run in parallel — they don't see each other's output, ordering is non-deterministic. Design for independence.
Lifecycle limitation — NO hot-swap
Hooks load at session start. Changes to hooks.json or hook scripts do not affect the running session.
To test hook changes:
- Edit hook
- Exit Claude Code
- Restart (
claudeorcc) - Verify with
/hookscommand orclaude --debug
This is the #2 reason "my hook isn't working" — the change hasn't loaded yet.
Debugging
Debug mode
claude --debug
Surfaces hook registration, execution logs, stdin/stdout JSON, timing.
Test a command hook directly
echo '{"tool_name":"Write","tool_input":{"file_path":"/test"}}' | \
${CLAUDE_PLUGIN_ROOT}/hooks/guard.mjs
echo "Exit code: $?"
Validate JSON output
output=$(./your-hook.mjs < test-input.json)
echo "$output" | jq .
Invalid JSON breaks silently — always verify.
Conditional activation
Pattern: check for a flag file or config before running:
#!/bin/bash
FLAG_FILE="$CLAUDE_PROJECT_DIR/.enable-strict-validation"
[[ ! -f "$FLAG_FILE" ]] && exit 0 # Flag not present, skip
# ... validation logic
Or config-based (matches our Session-Config pattern):
CONFIG_FILE="$CLAUDE_PROJECT_DIR/.claude/config.json"
enabled=$(jq -r '.strictMode // false' "$CONFIG_FILE" 2>/dev/null)
[[ "$enabled" != "true" ]] && exit 0
Our in-house examples (read these, not the upstream examples/)
hooks/pre-bash-destructive-guard.mjs— policy-driven command blocker, 13 rules in.orchestrator/policy/blocked-commands.jsonhooks/enforce-scope.mjs— wave-scope boundary enforcement using.orchestrator/wave-scope.jsonhooks/on-session-start.mjs— banner + session inithooks/post-edit-validate.mjs— validates edits after the facthooks/on-stop.mjs— session-event capture + metrics
Do / Don't
Do:
- Prompt-based hooks for complex logic, command hooks for fast deterministic checks
- Always
${CLAUDE_PLUGIN_ROOT}for paths - Validate every input field before trusting it
- Quote all shell variables
- Set explicit timeouts for known-slow work
- Return structured JSON on stdout
Don't:
- Hardcoded paths
- Trust
tool_inputwithout validation - Long-running ho