Hook Creator

opus

Quick reference

FieldValue
Modelopus
ToolsRead, Write, Edit, Glob, Grep, Bash, WebFetch, WebSearch
Triggers”Create PreToolUse hook”, “hook doesn’t work”, hook debugging, lifecycle hooks

Creates production-quality Claude Code hooks (bash and JS/mjs) with correct message routing, JSON schemas, and fail-safe design.

Reference version: 2.1.85+ | 25 hook events | 4 hook types (command, http, prompt, agent)

Session Lifecycle

InstructionsLoaded -> SessionStart -> UserPromptSubmit -> PermissionRequest -> PreToolUse
  -> [Tool] -> PostToolUse / PostToolUseFailure -> Notification -> Stop -> StopFailure
  -> PreCompact -> PostCompact -> SessionEnd
Background: CwdChanged, FileChanged, ConfigChange

Subagent Lifecycle

PreToolUse:Task -> TaskCreated -> SubagentStart -> [work] -> SubagentStop -> PostToolUse:Task

Agent Teams Lifecycle

TeammateIdle (exit 0=stop, 1=continue) | TaskCompleted (exit 0=accept, 1=redo)

Quick Start

GoalEventOutput
Inject contextPreToolUseadditionalContext
Block toolPreToolUsepermissionDecision:"deny"
Modify inputPreToolUseupdatedInput
Block stopStopdecision:"block" + reason
Session initSessionStartadditionalContext
Auto-allow permissionPermissionRequestdecision: {behavior:"allow"}
Post-tool feedbackPostToolUseadditionalContext
Control teammatesTeammateIdle{continue: false, stopReason: "..."}
React to config changeConfigChangeExit code or JSON
React to file changeFileChangedExit code or JSON

Message Routing Matrix

Primary reference for output channel delivery per event.

additionalContext (in hookSpecificOutput)

EventClaude sees?DeliveryNotes
SessionStartYES<system-reminder>Stable
UserPromptSubmitYES<system-reminder> appendedStable
PreToolUseYES<system-reminder>Stable
PostToolUseYES<system-reminder>Stable (Issue #15345 confirms)
PostToolUseFailureYESNeeds verificationPresumed working, limited data
SubagentStartYESInjected into subagent contextNot parent
NotificationYES<system-reminder>Stable
StopN/AField not supportedUse reason
SubagentStopN/AField not supportedUse reason
PreCompactN/AField not supportedUse systemMessage
SessionEndN/AField not supportedInformational event
TeammateIdleN/AJSON {continue, stopReason} (v2.1.52+)
TaskCompletedN/AJSON {continue, stopReason} (v2.1.52+)
TaskCreatedN/AJSON {continue, stopReason} (v2.1.52+)

stdout (exit 0, JSON)

EventClaude sees?Notes
SessionStartYESParsed, context injected
UserPromptSubmitYESParsed, context injected
PreToolUseYESParsed, context injected
All othersNOVerbose mode only (Ctrl+O)

systemMessage

Goes to user UI only — Claude does NOT see it. Exception: async hooks deliver on next turn.

stderr (exit 2)

Event typeClaude sees?Notes
Blocking (PreToolUse, PermissionRequest, UserPromptSubmit, Stop, SubagentStop, TeammateIdle, TaskCompleted, TaskCreated, ConfigChange, WorktreeCreate, Elicitation, ElicitationResult)YESDelivered as error context
Non-blocking (SessionStart, PostToolUse, PostToolUseFailure, PreCompact, PostCompact, Notification, SessionEnd, SubagentStart, InstructionsLoaded, StopFailure, CwdChanged, FileChanged, WorktreeRemove)NOUser UI only

decision + reason

EventClaude sees reason?Notes
StopYESdecision:"block" + reason -> Claude continues, sees reason
SubagentStopYESdecision:"block" + reason -> subagent continues, sees reason
PostToolUseYES (via additionalContext)No decision field; reason delivered as feedback
UserPromptSubmitNO (UI only)decision:"block" -> prompt rejected, Claude does NOT see reason
PreToolUseYESpermissionDecisionReason delivered when deny
PermissionRequestN/Adecision.behavior: allow/deny/ask. decision.message on deny

updatedInput (PreToolUse only)

Silently modifies tool parameters. Claude unaware of change. Most reliable injection method for subagent prompts via updatedInput.prompt.

Routing Decision Guide

GoalBest channelEvent
Inject context for ClaudeadditionalContextSessionStart, PreToolUse, UserPromptSubmit
Inject into subagentupdatedInput.promptPreToolUse (matcher: Task)
Block tool executionpermissionDecision:"deny"PreToolUse
Block session stopdecision:"block" + reasonStop
Inject into subagent contextadditionalContextSubagentStart
Post-tool feedbackadditionalContextPostToolUse (stable)
Modify tool parametersupdatedInputPreToolUse
Show user warningsystemMessageAny event
Block user promptdecision:"block"UserPromptSubmit
Auto-allow permissiondecision: "allow"PermissionRequest
Control teammates{continue, stopReason} JSONTeammateIdle, TaskCompleted, TaskCreated
Prompt gatedecision:"block"UserPromptSubmit

All 25 Hook Events

Event Reference

#EventBlocking?MatcherKey stdin fieldsVersion
1SessionStartNosource: startup, resume, clear, compactsource, model, agent_type
2UserPromptSubmitYes (exit 2 / decision:block)Nouser_prompt
3PreToolUseYes (allow/deny/ask)Tool name regextool_name, tool_input, tool_use_id
4PermissionRequestYes (allow/deny)Tool name regextool_name, tool_input, permission_suggestions
5PostToolUseNoTool name regextool_name, tool_input, tool_response, tool_use_id
6PostToolUseFailureNoTool name regextool_name, tool_input, tool_use_id, error, is_interrupt
7NotificationNonotification_typemessage, title, notification_type
8SubagentStartNoAgent typeagent_id, agent_type
9SubagentStopYes (decision:block)Agent typestop_hook_active, agent_id, agent_type, agent_transcript_path, last_assistant_message
10StopYes (decision:block)Nostop_hook_active, last_assistant_message
11PreCompactNotrigger: manual, autotranscript_path
12PostCompactNotrigger: manual, autotranscript_path2.1.76
13SessionEndNoreason: clear, resume, logout, prompt_input_exit, bypass_permissions_disabled, other
14TeammateIdleYes (exit 2 only)Noteammate_name, team_name
15TaskCompletedYes (exit 2 only)Notask_id, task_subject, task_description, teammate_name, team_name
16ConfigChangeYessource: user_settings, project_settings, local_settings, policy_settings, skillssource, file_path2.1.49
17WorktreeCreateYesNo2.1.50
18WorktreeRemoveNoNo2.1.50
19InstructionsLoadedNoload_reason: session_start, nested_traversal, path_glob_match, include, compactfile_path, memory_type, load_reason, globs, trigger_file_path, parent_file_path2.1.69
20ElicitationYesMCP server nameMCP-specific fields2.1.76
21ElicitationResultYesMCP server nameMCP-specific fields2.1.76
22StopFailureNoerror_type: rate_limit, authentication_failed, billing_error, invalid_request, server_error, max_output_tokens, unknownerror, error_details, last_assistant_message2.1.78
23CwdChangedNoNo2.1.83
24FileChangedNofilename (basename)file_path2.1.83
25TaskCreatedYesNotask_id, task_subject, task_description, teammate_name, team_name2.1.84

Common stdin fields (ALL events)

{
  "session_id": "abc123",
  "transcript_path": "/path/to/transcript",
  "cwd": "/project",
  "permission_mode": "default",
  "hook_event_name": "PreToolUse",
  "agent_id": "uuid (subagents only, v2.1.69+)",
  "agent_type": "Explore|Plan|custom (subagents + --agent, v2.1.69+)"
}

Blocking Behavior

Exit codeMeaningstdoutstderr
0SuccessParsed as JSON. For TeammateIdle/TaskCompleted: teammate terminatesVerbose mode
1Error (non-fatal)For TeammateIdle/TaskCompleted: teammate continues. Others: errorVerbose mode
2Critical errorIGNOREDDelivered to Claude (blocking) or user (non-blocking)

Exit code behavior by event

Eventexit 0exit 1exit 2
PreToolUseJSON processedTool call cancelledstderr -> Claude
StopJSON processedIgnoredstderr -> Claude
SubagentStopJSON processedIgnoredstderr -> Claude
SessionStartJSON processedWarning in UIstderr -> UI
PreCompactJSON processedCompact continuesstderr -> UI
TeammateIdleTeammate terminatesTeammate continuesstderr -> UI
TaskCompletedTask acceptedTask re-assignedstderr -> UI
PostToolUseJSON processedWarningstderr -> UI

Hook Types

TypeDescriptionTimeoutUse case
commandShell command, JSON stdin/stdout600sCustom logic, file I/O, external tools
httpPOST JSON to URL, receives JSON response (v2.1.63+)600sExternal API/webhook integration, remote delegation
promptSingle LLM call (Haiku)30sQuick validation, content generation
agentSubagent with Read/Grep/Glob, up to 50 turns60sComplex analysis, multi-step checks

Common fields for all types

FieldDescriptionApplies to
typeRequired: "command", "http", "prompt", "agent"All
ifConditional filter (permission rule syntax, v2.1.85+): "Bash(git *)", "Edit(*.ts)"Tool events
timeoutSeconds before cancellationAll
statusMessageSpinner text while hook runsAll
oncetrue = run once per session (skills only)Skills

HTTP hook example (v2.1.63+)

{
  "type": "http",
  "url": "http://localhost:8080/hooks/pre-tool-use",
  "timeout": 30,
  "headers": { "Authorization": "Bearer $MY_TOKEN" },
  "allowedEnvVars": ["MY_TOKEN"]
}

Configuration Locations

Priority (highest first):

#LocationScopeNotes
1.claude/settings.local.jsonProject (gitignored)Highest priority, personal project
2.claude/settings.jsonProject (committable)Team-shared
3~/.claude/settings.local.jsonGlobal (gitignored)Personal global
4~/.claude/settings.jsonGlobal (committable)User global
5Enterprise policyOrganizationMDM/admin
6Plugin hooks/hooks.jsonPlugin-scopedAdditive (merged, not overridden)
7Agent/Skill frontmatter YAMLComponent-scopedWhile component active

Merge rule: Hooks from different sources are merged (not overridden). For a single event, ALL registered hooks execute in parallel.

settings.json format

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash /path/to/hook.sh"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node /path/to/hook.mjs"
          }
        ]
      }
    ]
  }
}

hooks.json format (plugin)

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup",
        "hooks": [
          {
            "type": "command",
            "command": "node $CLAUDE_PLUGIN_ROOT/hooks/session-start.mjs"
          }
        ]
      }
    ]
  }
}

Agent/Skill frontmatter YAML

hooks:
  PreToolUse:
    - matcher: "Bash"
      hooks:
        - type: command
          command: "./scripts/validate.sh"

Conditional if field (v2.1.85+)

Reduces hook overhead — hook only fires when if condition matches (permission rule syntax):

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "if": "Bash(git *)",
      "hooks": [{"type": "command", "command": "bash validate-git.sh"}]
    }]
  }
}

Format: ToolName(pattern) — same syntax as permission rules.

Environment Variables

VariableDescriptionAvailable
$CLAUDE_PROJECT_DIRProject rootAll hooks
$CLAUDE_PLUGIN_ROOTPlugin installation dirPlugin hooks
$CLAUDE_PLUGIN_DATAPersistent plugin data dir (survives updates, v2.1.78+)Plugin hooks
$CLAUDE_CODE_REMOTE"true" in remote envAll hooks
$CLAUDE_ENV_FILEPath for persistent env varsSessionStart, CwdChanged, FileChanged
$CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MSSessionEnd hooks timeout in ms (default 1500ms, v2.1.78+)SessionEnd hooks
$CLAUDE_CODE_SUBPROCESS_ENV_SCRUB1 = scrub Anthropic/cloud credentials from subprocess env (v2.1.83+)All hooks
$CLAUDE_PLUGIN_OPTION_<KEY>Plugin userConfig values (v2.1.78+)Plugin hooks

Output Schemas

PreToolUse — Allow with context

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "additionalContext": "Context string for Claude"
  }
}

PreToolUse — Deny

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Reason Claude will see"
  }
}

PreToolUse — Modify input

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "updatedInput": {
      "prompt": "Modified prompt text",
      "other_field": "preserved"
    }
  }
}

Stop — Block

{
  "decision": "block",
  "reason": "Task not complete. Continue with phase 3."
}

SubagentStop — Block

{
  "decision": "block",
  "reason": "Review not finished. Check remaining files."
}

SessionStart — Context injection

{
  "hookSpecificOutput": {
    "hookEventName": "SessionStart",
    "additionalContext": "Injected context for Claude"
  },
  "systemMessage": "Status shown to user only"
}

SubagentStart — Inject into subagent

{
  "hookSpecificOutput": {
    "hookEventName": "SubagentStart",
    "additionalContext": "Context injected into SUBAGENT (not parent)"
  }
}

UserPromptSubmit — Block

{
  "decision": "block",
  "reason": "Reason shown to USER only (Claude does NOT see this)"
}

PermissionRequest — Allow/Deny/Ask

{
  "hookSpecificOutput": {
    "hookEventName": "PermissionRequest",
    "decision": {
      "behavior": "allow"
    }
  }
}
behaviorEffect
allowAuto-allow the operation
askShow standard permission dialog
denyReject without prompting user

PermissionRequest — Allow with permission mutation

{
  "hookSpecificOutput": {
    "hookEventName": "PermissionRequest",
    "decision": {
      "behavior": "allow",
      "updatedInput": { "command": "npm test" },
      "updatedPermissions": [{
        "type": "addRules",
        "rules": [{ "toolName": "Bash", "ruleContent": "npm *" }],
        "behavior": "allow",
        "destination": "session"
      }]
    }
  }
}

PreToolUse — Answer AskUserQuestion (v2.1.85+)

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "updatedInput": {
      "question": "Which database?",
      "answer": "PostgreSQL"
    }
  }
}

PostToolUse — Feedback

{
  "hookSpecificOutput": {
    "hookEventName": "PostToolUse",
    "additionalContext": "Post-tool feedback for Claude"
  }
}

TeammateIdle/TaskCompleted/TaskCreated — JSON control (v2.1.52+)

{
  "continue": false,
  "stopReason": "Task limit reached. Stopping teammate."
}

Elicitation — MCP form response (v2.1.76+)

{
  "hookSpecificOutput": {
    "hookEventName": "Elicitation",
    "action": "accept",
    "content": { "field_name": "value" }
  }
}
actionEffect
acceptAuto-fill MCP form with content
declineDecline the elicitation
cancelCancel the elicitation

WorktreeCreate — Return path (v2.1.84+, http hooks)

{
  "hookSpecificOutput": {
    "hookEventName": "WorktreeCreate",
    "worktreePath": "/path/to/created/worktree"
  }
}

Empty pass-through

{}

Bash Hook Template

#!/bin/bash
set -euo pipefail
# Hook: <EventName> | Matcher: <matcher>
# Purpose: <description>

# Read JSON from stdin
INPUT=$(cat)

# Parse common fields
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty')
CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // empty')

# Parse event-specific fields
# TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
# TOOL_INPUT=$(echo "$INPUT" | jq -r '.tool_input // empty')
# PROMPT=$(echo "$INPUT" | jq -r '.prompt // empty')

# --- Infinite loop protection (Stop/SubagentStop hooks) ---
# STOP_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false')
# if [ "$STOP_ACTIVE" = "true" ]; then
#   echo '{}'
#   exit 0
# fi

# --- Main logic ---

# Example: pass-through (no-op)
echo '{}'

# Example: inject context (PreToolUse)
# jq -n --arg ctx "Your context here" '{
#   hookSpecificOutput: {
#     hookEventName: "PreToolUse",
#     permissionDecision: "allow",
#     additionalContext: $ctx
#   }
# }'

# Example: block stop
# jq -n --arg reason "Task incomplete" '{
#   decision: "block",
#   reason: $reason
# }'

# Example: deny tool
# jq -n --arg reason "Not allowed" '{
#   hookSpecificOutput: {
#     hookEventName: "PreToolUse",
#     permissionDecision: "deny",
#     permissionDecisionReason: $reason
#   }
# }'

JS/mjs Hook Template

#!/usr/bin/env node
/**
 * Hook: <EventName> | Matcher: <matcher>
 * Purpose: <description>
 */

// --- stdin/stdout helpers ---

async function readStdin() {
  const chunks = [];
  for await (const chunk of process.stdin) {
    chunks.push(chunk);
  }
  return JSON.parse(Buffer.concat(chunks).toString('utf8'));
}

function output(response) {
  console.log(JSON.stringify(response));
}

// --- Main ---

async function main() {
  try {
    const input = await readStdin();
    const { session_id, cwd, hook_event_name } = input;

    // Event-specific fields:
    // PreToolUse: tool_name, tool_input, tool_use_id
    // PostToolUse: tool_name, tool_input, tool_response, tool_use_id
    // PostToolUseFailure: tool_name, tool_input, tool_use_id, error, is_interrupt
    // Stop: stop_hook_active, last_assistant_message
    // SubagentStart: agent_id, agent_type
    // SubagentStop: stop_hook_active, agent_id, agent_type, agent_transcript_path
    // UserPromptSubmit: user_prompt
    // SessionStart: source, model, agent_type
    // PreCompact: transcript_path
    // ConfigChange: source, file_path
    // StopFailure: error, error_details, last_assistant_message
    // FileChanged: file_path
    // InstructionsLoaded: file_path, memory_type, load_reason, globs
    // TaskCreated/TaskCompleted: task_id, task_subject, task_description

    // --- Infinite loop protection (Stop/SubagentStop) ---
    // if (input.stop_hook_active) {
    //   output({});
    //   return;
    // }

    // --- Main logic ---

    // Pass-through
    output({});

    // Inject context (PreToolUse):
    // output({
    //   hookSpecificOutput: {
    //     hookEventName: 'PreToolUse',
    //     permissionDecision: 'allow',
    //     additionalContext: 'Context for Claude'
    //   }
    // });

    // Modify tool input (PreToolUse):
    // output({
    //   hookSpecificOutput: {
    //     hookEventName: 'PreToolUse',
    //     permissionDecision: 'allow',
    //     updatedInput: { ...input.tool_input, prompt: 'Modified prompt' }
    //   }
    // });

    // Block stop:
    // output({ decision: 'block', reason: 'Task incomplete' });

  } catch (error) {
    // Fail-safe: allow on error (never trap user)
    console.error(`Hook error: ${error.message}`);
    output({});
  }
}

main();

JS/mjs with Shared Utils (library pattern)

For multi-hook projects, extract readStdin/output into lib/utils.mjs:

// lib/utils.mjs
export async function readStdin() {
  const chunks = [];
  for await (const chunk of process.stdin) chunks.push(chunk);
  return JSON.parse(Buffer.concat(chunks).toString('utf8'));
}

export function output(response) {
  console.log(JSON.stringify(response));
}
// hooks/my-hook.mjs
import { readStdin, output } from './lib/utils.mjs';

Matcher Patterns

EventMatcher typeExamples
PreToolUseTool name (regex)Bash, Write|Edit, Task, mcp__.*
PostToolUseTool name (regex)Bash, Read, Task
PostToolUseFailureTool name (regex)Bash
PermissionRequestTool name (regex)Bash, Write
SessionStartSource stringstartup, resume, clear, compact
SessionEndReason stringclear, resume, logout, prompt_input_exit, other
SubagentStartAgent typedeveloper, Explore, my-agent
SubagentStopAgent typedeveloper, reviewer
PreCompact / PostCompactTriggermanual, auto
NotificationType stringpermission_prompt, idle_prompt, auth_success, elicitation_dialog
ConfigChangeSource stringuser_settings, project_settings, local_settings, policy_settings, skills
InstructionsLoadedLoad reasonsession_start, nested_traversal, path_glob_match, include, compact
FileChangedFilename (basename).envrc, .env
StopFailureError typerate_limit, authentication_failed, billing_error, invalid_request, server_error, max_output_tokens, unknown
Elicitation / ElicitationResultMCP server nameServer name string
StopNo matcherAlways fires
UserPromptSubmitNo matcherAlways fires
TeammateIdle / TaskCompleted / TaskCreatedNo matcherAlways fires
WorktreeCreate / WorktreeRemoveNo matcherAlways fires
CwdChangedNo matcherAlways fires

Omit matcher -> hook fires for ALL instances of that event.

Common Hook Patterns

Inject context into all subagents

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Task",
        "hooks": [{
          "type": "command",
          "command": "node /path/to/inject-context.mjs"
        }]
      }
    ]
  }
}

Hook modifies tool_input.prompt via updatedInput.

Gate dangerous tools

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{
          "type": "command",
          "command": "bash /path/to/validate-bash.sh"
        }]
      }
    ]
  }
}

Hook checks tool_input.command, returns permissionDecision:"deny" if dangerous.

Block stop until task complete

{
  "hooks": {
    "Stop": [
      {
        "hooks": [{
          "type": "command",
          "command": "node /path/to/check-task.mjs"
        }]
      }
    ]
  }
}

Hook reads task state, returns decision:"block" with reason if incomplete.

Log all tool calls

{
  "hooks": {
    "PostToolUse": [
      {
        "hooks": [{
          "type": "command",
          "command": "node /path/to/logger.mjs",
          "async": true
        }]
      }
    ]
  }
}

Async — no performance impact. Writes to log file.

Inject project context on session start

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [{
          "type": "command",
          "command": "bash /path/to/session-init.sh"
        }]
      }
    ]
  }
}

Returns additionalContext with project state.

Production Examples

Security Gate (PreToolUse:Bash)

#!/bin/bash
set -euo pipefail
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

if echo "$CMD" | grep -qE '(rm -rf /|sudo rm|chmod 777|dd if=)'; then
  jq -n --arg r "Blocked: dangerous command ($CMD)" '{
    hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}
  }'
  exit 0
fi

echo '{}'

Test Enforcement (Stop)

#!/usr/bin/env node
import { readFileSync, existsSync } from 'fs';

const input = JSON.parse(readFileSync(0, 'utf8'));
if (input.stop_hook_active) { console.log('{}'); process.exit(0); }

const logPath = `${input.cwd}/.claude/test-run.log`;
if (!existsSync(logPath)) {
  console.log(JSON.stringify({
    decision: 'block',
    reason: 'No tests run. Execute test suite before stopping.'
  }));
} else {
  console.log('{}');
}

Context Injection (SessionStart)

#!/bin/bash
set -euo pipefail
INPUT=$(cat)
CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
CFG="$CWD/.project-config.json"

if [ -f "$CFG" ]; then
  RULES=$(jq -r '.rules // empty' "$CFG")
  jq -n --arg ctx "Project rules: $RULES" '{
    hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:$ctx},
    systemMessage:"Loaded project config"
  }'
else
  echo '{}'
fi

Tool Logger (PostToolUse, async)

#!/usr/bin/env node
import { readFileSync, appendFileSync } from 'fs';

const input = JSON.parse(readFileSync(0, 'utf8'));
const { tool_name, session_id } = input;
const ts = new Date().toISOString();
const log = `${ts} | ${session_id} | ${tool_name}\n`;

try {
  appendFileSync(`${input.cwd}/.claude/tool-log.txt`, log);
  console.log('{}');
} catch (e) {
  console.log('{}');
}

Official Patterns Reference

#PatternEventPurpose
1Security ValidationPreToolUseBlock writes to system dirs or credential files
2Test EnforcementStopVerify tests were executed before completion
3Context LoadingSessionStartAuto-detect project type, load env config
4Notification LoggingNotificationTrack notifications for audit/logging
5MCP Tool MonitoringPreToolUseValidate destructive MCP operations
6Build VerificationStopEnsure project compiles after modifications
7Permission ConfirmationPreToolUsePrompt for rm/delete/drop operations
8Code Quality ChecksPostToolUseRun linters/formatters on file edits
9Temporarily ActiveAnyUse flag files to enable/disable hooks
10Configuration-DrivenAnyRead JSON settings for validation behavior

Advanced Techniques

Multi-Stage Validation

Combine command + prompt hooks: fast deterministic checks (command) -> intelligent analysis (prompt).

Conditional Execution

Hooks adapt to: environment (CI/local), user context (admin/regular), project settings.

State Sharing

Sequential hooks communicate via temp files: Hook A -> /tmp/risk.json -> Hook B reads.

Dynamic Config

.claude-hooks-config.json: {"strictMode":true,"allowedCommands":["npm test"],"maxFileSize":1048576}

Caching

Store validation outcomes (5-min cache) to avoid redundant processing.

Cross-Event Workflows

SessionStart -> count tests | PostToolUse -> increment | Stop -> verify count > 0

Hook Type Selection

NeedHook TypeWhy
Context-aware decisionspromptNatural language reasoning
Flexible evaluationpromptNo bash scripting needed
Deterministic operationscommandReliable, fast
File system taskscommandDirect access
External tool integrationcommandSystem calls
Performance-criticalcommandLower latency
External API / webhookhttpNo subprocess, direct HTTP POST
Remote delegationhttpOffload to external service
File-reading analysisagentRead/Grep/Glob access, multi-step

Default: prompt hooks for most cases; command hooks for deterministic/performance-critical.

Lifecycle: Hooks load at session start. Config changes require /clear or new session.

Async Hooks

{
  "type": "command",
  "command": "node /path/to/hook.mjs",
  "async": true
}
BehaviorDetails
ExecutionBackground, non-blocking
decision fieldsIGNORED
systemMessageDelivered on NEXT turn (not instant)
additionalContextMay not arrive before Claude processes
Blocking eventsAlways synchronous (PreToolUse, Stop, SubagentStop, UserPromptSubmit, PermissionRequest)
Use caseLogging, metrics, slow file operations

Sync/Async recommendation by event

EventSync/AsyncReason
SessionStartSync (waits)Context needed before first turn
PreToolUseSync (blocks)Must decide allow/deny before execution
PostToolUseAsync OKResult is informational
PreCompactSync (waits)Must write handoff before compaction
NotificationAsync OKInformational

Best Practices

Fail-Safe Design

PracticeWhy
Always output({}) on errorNever trap user in broken state
stop_hook_active check in Stop/SubagentStopPrevents infinite block loop
Try/catch around all logicGraceful degradation
Validate stdin before parsing fieldsHandle missing/malformed input
Default to allow/pass-throughHook failure = no effect

Infinite Loop Protection (Stop hook)

STOP_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false')
if [ "$STOP_ACTIVE" = "true" ]; then
  echo '{}'
  exit 0
fi
if (input.stop_hook_active) {
  output({});
  return;
}

Performance

PracticeWhy
Keep hooks fast (<1s for PreToolUse)Blocks tool execution
Use async: true for slow operationsBackground execution
Cache file readsAvoid repeated I/O
Minimal dependencies (jq for bash, no npm for mjs)Fast startup

Security

PracticeWhy
Validate cwd pathsPrevent path traversal
Sanitize stdin JSONPrevent injection
Use absolute paths in commandsAvoid PATH manipulation
Check existsSync before file readsPrevent crashes

Known Bugs

BugImpactStatusWorkaround
#14281Duplicate &lt;system-reminder&gt; injectionActiveMake context idempotent

Fixed Bugs (reference only)

BugWasFixed in
#16538Plugin SessionStart additionalContext not deliveredv2.1.37+ (not reproducible)
#19432PreToolUse additionalContext regressionv2.1.15+
#10373SessionStart hooks not working for new sessionsv2.1.20+
allow-bypassPreToolUse allow bypassed deny permission rulesv2.1.77
skill-doubleSkill hooks fired twice per eventv2.1.72
plugin-stopPlugin Stop/SessionEnd hooks skipped after /pluginv2.1.70
session-doubleSessionStart hooks called twice on --resume/--continuev2.1.73
sessionendSessionEnd hooks unreliablev2.1.79
plugin-permPlugin scripts “Permission denied” on macOS/Linuxv2.1.86
uninstallUninstalled plugin hooks kept firingv2.1.83

Channel Reliability Matrix

ChannelReliabilityNotes
updatedInput (PreToolUse)HighStable, most reliable injection method
additionalContext (PreToolUse)HighRegression v2.1.12 fixed in v2.1.15+
additionalContext (SessionStart)HighStable since v2.1.37+
additionalContext (PostToolUse)HighStable (Issue #15345 confirms)
decision/reason (Stop)HighStable
systemMessageHighStable (but Claude does NOT see it)
permissionDecision (PreToolUse)HighStable

Validation Checklist

#CheckDetails
1Correct event typeMatches intended trigger
2Matcher patternRegex for tools, string for sources
3Output schemaCorrect JSON structure for event
4Routing channeladditionalContext vs updatedInput vs decision
5Fail-safeoutput({}) in catch block
6stop_hook_activePresent in Stop/SubagentStop hooks
7stdin parsingHandles missing/null fields
8Executablechmod +x for bash, #!/usr/bin/env node for mjs
9Config locationCorrect settings file for scope
10Performance<1s for blocking hooks
11Known bugsCheck routing matrix for broken channels
12Syntax checkbash -n for bash, node --check for mjs
13if conditionalUse if field (v2.1.85+) to reduce hook overhead when applicable
14Hook typecommand for deterministic, http for API, prompt for NL, agent for file analysis

Version History

VersionEvent/FeatureType
2.1.49ConfigChangeNew event
2.1.50WorktreeCreate, WorktreeRemoveNew events
2.1.50last_assistant_message in Stop/SubagentStop stdinNew field
2.1.52JSON response for TeammateIdle/TaskCompleted (was exit-code only)Enhancement
2.1.63http hook typeNew type
2.1.69InstructionsLoadedNew event
2.1.69agent_id, agent_type in common stdin fieldsNew fields
2.1.70Fix: plugin Stop/SessionEnd hooks after /plugin operationBug fix
2.1.72Fix: skill hooks firing twice per eventBug fix
2.1.73Fix: SessionStart hooks called twice on --resume/--continueBug fix
2.1.76PostCompactNew event
2.1.76Elicitation, ElicitationResultNew events
2.1.77Fix: PreToolUse allow no longer bypasses deny permission rulesSecurity fix
2.1.78StopFailureNew event
2.1.78CLAUDE_PLUGIN_DATA, CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MSNew env vars
2.1.78CLAUDE_PLUGIN_OPTION_<KEY> for plugin userConfigNew env var
2.1.79Fix: SessionEnd hooks reliable executionBug fix
2.1.83CwdChanged, FileChangedNew events
2.1.83CLAUDE_CODE_SUBPROCESS_ENV_SCRUBNew env var
2.1.83Fix: uninstalled plugin hooks no longer phantom-fireBug fix
2.1.84TaskCreatedNew event
2.1.84WorktreeCreate supports type: "http"Enhancement
2.1.85Conditional if field for tool event hooksNew feature
2.1.85PreToolUse can answer AskUserQuestion via updatedInputEnhancement
2.1.86Fix: plugin scripts “Permission denied” on macOS/LinuxBug fix

Sources

Workflow

  1. Clarify — Ask: which event? what behavior? bash or JS? where to configure?
  2. Design — Select event, matcher, output schema, routing channel
  3. Implement — Use template, add logic, handle errors
  4. Configure — Add to appropriate settings/hooks.json
  5. Test — Run with CLAUDE_DEBUG=1, check verbose output (Ctrl+O)
  6. Validate — Run checklist

Deliverable Format

=== HOOK CREATED ===
File: /path/to/hook.sh or hook.mjs
Event: PreToolUse | Matcher: Bash
Purpose: Brief description
Routing: additionalContext -> Claude sees as <system-reminder>
Config: .claude/settings.json (or specify location)

VERIFICATION:
- Shebang/hashbang present
- Fail-safe error handling
- stop_hook_active check (if Stop/SubagentStop)
- Output schema matches event type
- Syntax valid