writing-hooks

star 0

Write Claude Code hooks using the official API. Use when creating, modifying, or debugging hooks for Claude Code lifecycle events.

2FLing By 2FLing schedule Updated 6/8/2026

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: true runs in background — cannot block or control behavior.
  • once: true runs 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 decision and reason: used by Stop, UserPromptSubmit, PostToolUse, PostToolUseFailure
  • hookSpecificOutput: used by PreToolUse and PermissionRequest for richer control. Requires hookEventName field.

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 args
  • applyRules: auto-create permission rules (destinations: session, localSettings, projectSettings, userSettings)
  • updatedPermissions: echo back a permission_suggestions entry 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: true when 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: manual or auto
  • 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 -p issues
  • 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+)
  • /hooks menu: 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) or node -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.log debug output polluting stdout JSON
  • stop_hook_active checked 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
Install via CLI
npx skills add https://github.com/2FLing/claude-migration --skill writing-hooks
Repository Details
star Stars 0
call_split Forks 1
navigation Branch main
article Path SKILL.md
More from Creator