From session-orchestrator
Develops, modifies, and debugs Claude Code hooks for PreToolUse, PostToolUse, Stop, SessionStart, and other events. Covers plugin hooks/hooks.json wrapper vs user settings.json, matchers, security patterns, portability, lifecycle limits, and debugging.
npx claudepluginhub kanevry/session-orchestrator --plugin session-orchestratorThis skill uses the workspace's default tool permissions.
Adapted from [claude-plugins-official/plugin-dev/skills/hook-development](https://github.com/anthropics/claude-plugins-official/tree/main/plugins/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`).
Guides implementation of event-driven hooks in Claude Code plugins using prompt-based validation and bash commands for PreToolUse, Stop, and session events.
Guides development of event-driven hooks for Claude Code plugins using prompt-based and command-based configurations in hooks.json for events like PreToolUse, PostToolUse, Stop, and SessionStart to validate tools and automate workflows.
Guides creating Node.js .cjs hook scripts for Claude Code plugins: hooks.json config, events like PreToolUse, stdio processing, timeouts, paths, and testing.
Share bugs, ideas, or general feedback.
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).
{
"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.
{
"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.
This is where people trip up. Two formats exist; they are NOT interchangeable.
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" }
]
}
]
}
}
hooks wrapper is requireddescription is optional.claude/settings.json — direct format{
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{ "type": "command", "command": "~/my-hook.sh" }
]
}
]
}
Mixing these up is the #1 reason new hooks don't fire.
| 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 |
{
"hookSpecificOutput": {
"permissionDecision": "allow|deny|ask",
"updatedInput": { "field": "modified_value" }
},
"systemMessage": "Explanation shown to Claude"
}
{
"decision": "approve|block",
"reason": "Why blocked / approved",
"systemMessage": "Additional context"
}
echo "export PROJECT_TYPE=nodejs" >> "$CLAUDE_ENV_FILE"
$CLAUDE_ENV_FILE is unique to SessionStart hooks.
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: reasonAccess in prompt hooks via $TOOL_INPUT, $TOOL_RESULT, $USER_PROMPT.
| 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 |
// ✅ Portable — works everywhere the plugin installs
{ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/guard.mjs" }
// ❌ Broken — only works on Daisy's machine
{ "command": "/Users/bernhardgoetzendorfer/Projects/.../guard.mjs" }
"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.
#!/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.
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.
echo "$file_path" # ✅
cd "$CLAUDE_PROJECT_DIR" # ✅
echo $file_path # ❌ unquoted injection risk
Defaults: command hooks 60s, prompt hooks 30s. Set explicitly when the work is known-slow:
{ "type": "command", "command": "...", "timeout": 10 }
All matching hooks run in parallel — they don't see each other's output, ordering is non-deterministic. Design for independence.
Hooks load at session start. Changes to hooks.json or hook scripts do not affect the running session.
To test hook changes:
claude or cc)/hooks command or claude --debugThis is the #2 reason "my hook isn't working" — the change hasn't loaded yet.
claude --debug
Surfaces hook registration, execution logs, stdin/stdout JSON, timing.
echo '{"tool_name":"Write","tool_input":{"file_path":"/test"}}' | \
${CLAUDE_PLUGIN_ROOT}/hooks/guard.mjs
echo "Exit code: $?"
output=$(./your-hook.mjs < test-input.json)
echo "$output" | jq .
Invalid JSON breaks silently — always verify.
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
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 + metricsDo:
${CLAUDE_PLUGIN_ROOT} for pathsDon't:
tool_input without validationAll hook handlers support runtime opt-out via two environment variables without any settings-file changes. This is implemented in hooks/_lib/profile-gate.mjs.
| Variable | Values | Behaviour |
|---|---|---|
SO_HOOK_PROFILE | full | minimal | off | Preset bundle (default full = all on). |
SO_DISABLED_HOOKS | Comma-separated names | Disable individual hooks; overrides profile. |
full (default): all hooks run — identical to pre-#211 behaviour when env is unset.minimal: only on-session-start + pre-bash-destructive-guard.off: no hooks run.Every new hook handler must add the gate call as the very first executable statement after imports. The pattern is two lines at the top of the file, immediately after the import block:
import { shouldRunHook } from './_lib/profile-gate.mjs';
if (!shouldRunHook('your-hook-name')) process.exit(0);
Use the kebab-case file stem without the .mjs extension as the hook name (e.g. my-hook for hooks/my-hook.mjs). When the hook exits 0 here it is silent — no stdout, no stderr — so Claude Code sees a clean allow.
SO_HOOK_PROFILE value → falls back to full + single stderr warning.SO_DISABLED_HOOKS with extra whitespace or mixed case is normalised automatically.defaultEnabled param of shouldRunHook is for future opt-in hooks; pass false for any handler that should be off by default in full profile.tests/hooks/profile-gate.test.mjs (10 tests) covers: full/minimal/off profiles, disabled-list override, unknown-profile fallback + warning, defaultEnabled=false, whitespace normalisation, empty disabled-list.
Hook handlers and scripts that need the plugin directory must NOT read
process.env.CLAUDE_PLUGIN_ROOT directly. Use resolvePluginRoot() from
scripts/lib/plugin-root.mjs instead, which implements a 4-level fallback
so manual installs (where the env var is absent) still work.
| Level | Source | Condition |
|---|---|---|
| 1 | CLAUDE_PLUGIN_ROOT env var | Returned immediately when set and is a directory |
| 2 | CODEX_PLUGIN_ROOT env var | Returned immediately when set and is a directory |
| 3 | Walk up from import.meta.url | Looks for package.json with name: "session-orchestrator" |
| 4 | Walk up from process.cwd() | Same marker; catches manual install paths outside the repo tree |
Levels 1 and 2 are fast paths — no filesystem walk is performed when either env var is set. This preserves backward compat with all existing deployments.
When all four levels fail a PluginRootResolutionError is thrown with a
triedPaths array listing what was attempted.
import { resolvePluginRoot, PluginRootResolutionError } from '../scripts/lib/plugin-root.mjs';
// Throws on failure — handle or let it bubble (hooks have top-level catch)
const pluginRoot = resolvePluginRoot();
scripts/lib/platform.mjs's resolvePluginRoot() delegates to this helper
internally, so any caller already using the platform module gets the 4-level
fallback transparently.
tests/lib/plugin-root.test.mjs (10 tests) covers: env-claude, env-codex,
walk-from-import-meta, walk-from-cwd, all-fail-throws-named-error,
env-precedence, PluginRootResolutionError class shape.
hooks/hooks.json uses wrapper format, NOT settings direct format${CLAUDE_PLUGIN_ROOT} for all pathsecho '...' | hook.mjsclaude --debug