name: writing-hooks description: Write Claude Code hooks using the official API. Use when creating, modifying, or debugging hooks for Claude Code lifecycle events.
Writing Claude Code Hooks
Type: RIGID — follow the API exactly. No guessing, no inventing fields.
Hook Lifecycle
Hooks fire at specific points during a Claude Code session. Some fire once, others fire repeatedly in the agentic loop.
SessionStart → [agentic loop: PreToolUse → PermissionRequest → PostToolUse → SubagentStart/Stop → TaskCompleted] → Stop/StopFailure
Standalone async events: InstructionsLoaded, Notification, ConfigChange, WorktreeCreate, WorktreeRemove, PreCompact, PostCompact, Elicitation, ElicitationResult, TeammateIdle, SessionEnd.
Hook Types
| Type | How it works | Use for |
|---|---|---|
command |
Shell script, receives JSON on stdin, responds via exit code + stdout | Gates, guards, state tracking, file I/O |
http |
POST request with JSON body, responds via response body | External integrations, webhooks |
prompt |
Single-turn LLM eval (Haiku by default), no shell needed | Semantic classification, intent detection, guardian reviews |
agent |
Subagent with tools (Read, Grep, Glob, Bash), up to 50 tool turns | Complex verification requiring codebase inspection |
Choosing the Right Type
Need file I/O, state, or complex logic? → command
Need LLM judgment on hook input alone? → prompt (fastest, cheapest)
Need LLM judgment + codebase inspection? → agent (slower, can use tools)
Need external service integration? → http
Rule of thumb: If you're writing a command hook that spawns claude -p for LLM classification, you should be using a prompt hook instead. The prompt hook handles auth, model selection, and response parsing natively — zero custom code.
Prompt Hooks — Deep Dive
Prompt hooks send $ARGUMENTS (the hook's JSON input) plus your prompt to a fast Claude model. Claude Code handles everything: auth, API call, response parsing, decision mapping.
Configuration:
{
"type": "prompt",
"prompt": "Your instructions here. $ARGUMENTS contains the hook input data.",
"model": "haiku",
"timeout": 15
}
| Field | Required | Default | Description |
|---|---|---|---|
type |
yes | — | Must be "prompt" |
prompt |
yes | — | Prompt text. Use $ARGUMENTS to inject hook input JSON |
model |
no | "haiku" |
Model alias (haiku, sonnet, opus) |
timeout |
no | 30 | Seconds before timeout |
Response format (what the LLM must return):
{"ok": true} // allow
{"ok": false, "reason": "Why it was blocked"} // deny/block
How it maps to events:
- PreToolUse:
ok: true→ allow,ok: false→ deny (tool call blocked, reason fed to Claude) - Stop:
ok: true→ Claude stops,ok: false→ Claude continues working with reason - Other events:
ok: false→ blocks with reason
Key advantage over command hooks for classification: No auth issues, no subprocess overhead, no shell escaping, no JSON parsing code. Just a prompt string.
Agent Hooks — Deep Dive
Like prompt hooks but the LLM gets tool access (Read, Grep, Glob, Bash) and up to 50 tool-use turns. Use when the hook input alone isn't enough — you need to inspect actual files or run commands.
Configuration:
{
"type": "agent",
"prompt": "Verify all tests pass before allowing Claude to stop. $ARGUMENTS",
"model": "haiku",
"timeout": 120
}
Same response format as prompt hooks ({ok, reason}). Default timeout: 60 seconds.
Configuration (settings.json)
{
"hooks": {
"EventName": [
{
"matcher": "ToolName|OtherTool",
"hooks": [
{
"type": "command",
"command": "node \"/path/to/hook.js\"",
"timeout": 600,
"statusMessage": "Checking...",
"async": false
}
]
}
]
}
}
- matcher: filters when hook fires. Omit or use
"*"for all. Pipe-delimited for multiple:"Bash|Edit|Write". - timeout: seconds. Defaults: 600 (command), 30 (prompt), 60 (agent).
- async:
trueruns in background — cannot block or control behavior. - once:
trueruns once per session then auto-removes (skills only). - statusMessage: custom spinner text while hook runs.
- Matching hooks run in parallel. Identical handlers are deduplicated.
Hook Locations (precedence: managed > project > user)
- Project:
.claude/settings.json(checked into repo) - User:
~/.claude/settings.json(personal) - Local:
.claude/settings.local.json(gitignored) - Managed: enterprise-managed settings
Path Variables
$CLAUDE_PROJECT_DIR— project root${CLAUDE_PLUGIN_ROOT}— plugin install dir${CLAUDE_PLUGIN_DATA}— plugin persistent data dir
Stdin Input (Command Hooks)
Every hook receives JSON on stdin with common fields:
{
"session_id": "abc123",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/current/working/dir",
"permission_mode": "default",
"hook_event_name": "PreToolUse"
}
Plus event-specific fields documented below.
Exit Codes
| Exit Code | Meaning | stdout | stderr |
|---|---|---|---|
| 0 | Success | Parsed as JSON for decision control | Ignored |
| 2 | Blocking error | Ignored (all JSON ignored) | Fed back to Claude as error message |
| Other | Non-blocking error | Ignored | Shown in verbose mode (Ctrl+O) only |
CRITICAL: Exit 0 + JSON and exit 2 are mutually exclusive. Pick one per hook. Exit 2 ignores all stdout.
JSON Output (exit 0 only)
stdout must contain ONLY the JSON object. No extra text.
Universal Fields
| Field | Default | Description |
|---|---|---|
continue |
true |
false stops the agent loop entirely |
suppressOutput |
false |
true hides hook output from transcript |
statusMessage |
— | Overrides the spinner message |
Decision Fields
- Top-level
decisionandreason: used by Stop, UserPromptSubmit, PostToolUse, PostToolUseFailure hookSpecificOutput: used by PreToolUse and PermissionRequest for richer control. RequireshookEventNamefield.
Event Reference
SessionStart
- Fires: once at session start
- Matcher: none
- Extra input: none beyond common fields
- stdout: added as context Claude can see (unlike most events)
- Decision:
{"decision": "block", "reason": "..."}prevents session start - Exit 2: blocks session start, stderr shown to user
- Env vars: return
{"env": {"KEY": "value"}}to persist env vars for the session
UserPromptSubmit
- Fires: every time user sends a message
- Matcher: none
- Extra input:
prompt(user's message text) - stdout: added as context Claude can see
- Decision:
{"decision": "block", "reason": "..."}rejects the prompt - Exit 2: rejects the prompt, stderr shown to user
PreToolUse
- Fires: before every tool execution
- Matcher: tool name (
Bash,Edit,Write,Agent,Read,Grep,Glob,WebFetch,WebSearch,mcp__server__tool) - Extra input:
tool_name,tool_input(tool-specific args),tool_use_id - Decision control (via
hookSpecificOutput):
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Reason shown to Claude"
}
}
permissionDecision |
Effect |
|---|---|
"allow" |
Skips permission prompt, tool runs |
"deny" |
Prevents tool call, reason fed to Claude |
"ask" |
Prompts user to confirm |
- Modify input: add
"updatedInput": {...}to change tool args before execution - Inject context: add
"additionalContext": "..."— shown to Claude - Exit 2: blocks tool call, stderr fed to Claude
PermissionRequest
- Fires: when a permission dialog is about to be shown
- Matcher: tool name (same as PreToolUse)
- Extra input:
tool_name,tool_input,permission_suggestions[] - Decision control:
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "allow",
"updatedInput": {"command": "safe-command"},
"applyRules": [{"toolName": "Bash", "ruleContent": "safe-*", "behavior": "allow", "destination": "session"}]
}
}
}
behavior |
Effect |
|---|---|
"allow" |
Allows without prompting user |
"deny" |
Denies without prompting user |
updatedInput: modify tool argsapplyRules: auto-create permission rules (destinations:session,localSettings,projectSettings,userSettings)updatedPermissions: echo back apermission_suggestionsentry to apply it
PostToolUse
- Fires: after tool completes successfully
- Matcher: tool name
- Extra input:
tool_name,tool_input,tool_response,tool_use_id - Decision:
{"decision": "block", "reason": "..."}— feeds reason back to Claude as error - Exit 2: stderr fed to Claude as error
PostToolUseFailure
- Fires: after tool fails
- Extra input:
tool_name,tool_input,tool_error,tool_use_id - Decision: same as PostToolUse
SubagentStart
- Fires: when a subagent is about to start
- Matcher: agent type (
Explore,gsd-executor, etc.) - Extra input:
agent_id,agent_type - No decision control — observability only
SubagentStop
- Fires: when a subagent finishes
- Matcher: agent type
- Extra input:
agent_id,agent_type,agent_transcript_path,last_assistant_message,stop_hook_active - Decision: same as Stop hooks
Stop
- Fires: when Claude finishes responding (every turn, not just session end)
- Does NOT fire on user interrupts
- Matcher: none
- Extra input:
last_assistant_message,stop_hook_active stop_hook_active:truewhen Claude is already re-running due to a previous Stop hook — use to prevent infinite loops- Decision:
{"decision": "block", "reason": "..."}— Claude receives reason and continues working - Exit 2: stderr fed to Claude, Claude continues working
StopFailure
- Fires: on API errors instead of Stop
- Extra input:
error,error_details,last_assistant_message - No decision control — notification only
TeammateIdle
- Fires: when an agent team teammate finishes its turn
- Extra input:
teammate_name,team_name - Exit 2: teammate receives stderr and continues working
- JSON:
{"continue": false, "stopReason": "..."}stops the teammate
Notification
- Fires: when Claude Code sends notifications
- Matcher: notification type (
permission_prompt,idle_prompt,auth_success,elicitation_dialog) - Extra input:
message,title,notification_type
PreCompact / PostCompact
- Matcher:
manualorauto - PreCompact extra:
trigger,custom_instructions - PostCompact extra:
trigger,compact_summary
Other Events
- InstructionsLoaded: fires when CLAUDE.md/rules loaded. Matcher:
load_reason(session_start,nested_traversal,path_glob_match,include,compact) - ConfigChange: fires when settings change. Extra:
changed_fields[] - WorktreeCreate/WorktreeRemove: fires on git worktree operations. Extra:
worktree_path,branch - Elicitation/ElicitationResult: MCP server auth dialogs
- SessionEnd: fires once when session ends. No decision control.
- TaskCompleted: fires in the agentic loop (details in lifecycle diagram)
Patterns & Best Practices
Pattern: PreToolUse Gate (deny with JSON)
function deny(message) {
console.log(JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: message,
},
}));
process.exit(0); // EXIT 0, not 2!
}
Pattern: PreToolUse Gate (deny with exit 2)
#!/bin/bash
command=$(jq -r '.tool_input.command' < /dev/stdin)
if [[ "$command" == rm* ]]; then
echo "Blocked: rm commands not allowed" >&2
exit 2
fi
exit 0
Pattern: Context Injection (SessionStart or UserPromptSubmit)
// stdout goes directly into Claude's context
console.log('REMINDER: Always run tests before marking work complete.');
process.exit(0);
Pattern: Stop Hook Quality Gate
const data = JSON.parse(raw);
if (data.stop_hook_active) { process.exit(0); return; } // prevent infinite loop
const message = data.last_assistant_message || '';
if (needsMoreWork(message)) {
console.log(JSON.stringify({ decision: 'block', reason: 'Tests not passing yet.' }));
process.exit(0);
}
Pattern: File-Based State Between Hooks
// Hook A writes state
fs.writeFileSync(STATE_PATH, JSON.stringify({ done: true, at: new Date().toISOString() }));
// Hook B reads state
const state = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'));
if (!state.done) deny('Prerequisite not met');
Pattern: Async Hook (fire-and-forget)
{
"type": "command",
"command": "node /path/to/logger.js",
"async": true
}
Async hooks cannot block or control behavior — all decision fields are ignored.
Pattern: Prompt Hook (LLM classification)
{
"type": "prompt",
"prompt": "Is this message claiming work is done without evidence? $ARGUMENTS",
"model": "haiku"
}
Returns {"ok": true} (allow) or {"ok": false, "reason": "..."} (block).
Pattern: Agent Hook (verification with tools)
{
"type": "agent",
"prompt": "Verify all unit tests pass. Run the test suite. $ARGUMENTS",
"timeout": 120
}
Agent can use Read, Grep, Glob to verify. Same response schema as prompt hooks.
Pattern: Guardian (LLM-based behavioral oversight)
Inspired by Codex Guardian Approvals. Uses a prompt hook to classify Claude's actions by risk level. Catches intent-level violations that pattern-matching hooks miss (e.g., using remove instead of update --status complete to bypass a completion gate).
{
"matcher": "Agent|Bash|Edit|Write",
"hooks": [
{
"type": "prompt",
"prompt": "You are a guardian reviewer for an AI coding agent. Assess whether this action is safe, rule-compliant, and not a workaround for a previously blocked action.\n\nRULES:\n- [HIGH] Rule description here\n- [MEDIUM] Another rule\n- [CRITICAL] Destructive action rule\n\nACTION: $ARGUMENTS\n\nIf safe: {\"ok\": true}\nIf risky: {\"ok\": false, \"reason\": \"GUARDIAN [RISK_LEVEL]: explanation. Suggestion: what to do instead.\"}\n\nRisk levels: LOW (minor), MEDIUM (user should decide), HIGH (likely violation), CRITICAL (destructive).\nFor MEDIUM+: ALWAYS deny.",
"timeout": 15,
"statusMessage": "Guardian reviewing..."
}
]
}
Key design decisions:
- Prompt hook, not command: handles auth natively, no subprocess overhead, no
claude -pissues - Rules embedded in prompt: static but simple. Edit the prompt to add/remove rules.
- Risk escalation: MEDIUM+ denied → user sees reason in conversation, can override by instructing Claude to proceed
- Workaround detection: prompt instructs LLM to check if action is "an alternative path to achieve a previously blocked goal"
- Complements pattern-matching hooks: permission-gate.js handles structural validation (DISPATCH blocks, envelopes), guardian handles intent classification
When to use command hooks vs guardian prompt hooks:
| Need | Use |
|---|---|
| Structural validation (JSON shape, required fields) | Command hook |
| File-based state (review evidence, recent blocks) | Command hook |
| Intent classification ("is this a workaround?") | Prompt hook |
| Semantic rule evaluation ("does this violate X?") | Prompt hook |
| Codebase verification (tests pass, files exist) | Agent hook |
Common Mistakes
| Mistake | Fix |
|---|---|
Using process.exit(2) when you want JSON deny |
Use process.exit(0) + JSON. Exit 2 ignores all stdout JSON. |
Using process.exit(0) in block() for Stop hooks |
For Stop hooks, exit 0 + {"decision": "block"} OR exit 2 + stderr. Both work. |
| Mixing exit 2 and JSON output | Pick one. They are mutually exclusive. |
| Shell profile prints text before JSON | stdout must contain ONLY the JSON object. |
| Stop hook infinite loop | Check stop_hook_active — if true, exit immediately. |
| Assuming Stop = session end | Stop fires EVERY turn. Use SessionEnd for session end. |
| Blocking in async hooks | async: true hooks cannot block — decision fields ignored. |
| PostToolUse trying to undo | Tool already ran. Can only observe and feed back errors. |
Using console.log for debugging |
stdout is parsed as JSON. Use console.error for debug output. |
Forgetting hookEventName in hookSpecificOutput |
Required field. Must match the event name exactly. |
Tool Input Schemas (PreToolUse)
Bash
{"command": "npm test", "description": "Run tests", "timeout": 120000}
Edit
{"file_path": "/path/to/file", "old_string": "...", "new_string": "...", "replace_all": false}
Write
{"file_path": "/path/to/file", "content": "..."}
Agent
{"prompt": "...", "description": "...", "subagent_type": "Explore", "model": "sonnet", "run_in_background": true}
Read
{"file_path": "/path/to/file", "offset": 0, "limit": 100}
Debugging
- Verbose mode (Ctrl+O): shows hook stdout (exit 0) and stderr (exit 1+)
/hooksmenu: list, disable, or remove hooks interactively- Test a hook manually:
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | node hook.js - Validate syntax:
node -c hook.js(JS) ornode -e "JSON.parse(require('fs').readFileSync('settings.json','utf8'))" - Check exit code:
echo '...' | node hook.js; echo $?
Checklist Before Deploying
-
node -c hook.js— syntax valid -
node -e "JSON.parse(...)"— settings.json valid - Exit code semantics correct (0 for JSON, 2 for stderr block)
- No
console.logdebug output polluting stdout JSON -
stop_hook_activechecked in Stop hooks (prevent loops) - Matcher correctly scoped (not too broad, not too narrow)
- Timeout appropriate for the work being done
- Fail-open or fail-closed? Decide and implement in catch blocks
- Test with real tool calls, not just manual stdin piping