Claude Code Guide

The complete guide to Claude Code. Opus 4.7, Sonnet 4.6, Haiku 4.5. 1M token context window. 27 hook events. 43 production-tested chapters across 6 topical Parts (Foundation, Workflow, Extension, Context Engineering, Advanced, Reference). Three install tiers. CC 2.1.121+ compatible.

View the Project on GitHub ytrofr/claude-code-guide

Hook Event Catalog

Reference for the 27 hook events in CC 2.1.111, grouped by lifecycle phase. Every hook receives JSON on stdin; the legacy env vars $CLAUDE_HOOK_INPUT, $CLAUDE_HOOK_EVENT, and $CLAUDE_TOOL_INPUT are dead and always empty. Use $CLAUDE_PROJECT_DIR for portable paths.

Hooks from all scopes (global, project, local) run in parallel — they don’t override one another. Identical commands are deduplicated; different commands for the same event all fire.


stdin pattern

Every hook reads a single JSON object on stdin. The canonical safe pattern:

#!/bin/bash
# Read stdin safely — timeout prevents hang, fallback prevents crash
INPUT=$(timeout 2 cat 2>/dev/null || true)

# Extract fields with jq (always use default // empty)
EVENT=$(echo "$INPUT"  | jq -r '.hook_event_name // empty' 2>/dev/null)
TOOL=$(echo "$INPUT"   | jq -r '.tool_name // empty'       2>/dev/null)
FILE=$(echo "$INPUT"   | jq -r '.tool_input.file_path // empty' 2>/dev/null)

# Portable paths — never hard-code your home dir
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
LOG="${CLAUDE_PROJECT_DIR:-.}/.claude/logs/events.log"

echo "[${EVENT}] tool=${TOOL} file=${FILE}" >> "$LOG"

Inline hooks configured in settings.json follow the same pattern:

{
  "command": "INPUT=$(cat); echo \"[${(echo \"$INPUT\" | jq -r '.hook_event_name')}]\" >> $CLAUDE_PROJECT_DIR/.claude/logs/events.log"
}

Available env vars

Variable Value Available in
CLAUDE_PROJECT_DIR Absolute path to project root All hooks
CLAUDE_ENV_FILE Writable env file path (export lines get sourced by CC) SessionStart, CwdChanged, FileChanged

Dead env vars (never use)

Dead variable Use instead
$CLAUDE_HOOK_INPUT INPUT=$(cat) then echo "$INPUT" \| jq
$CLAUDE_HOOK_EVENT jq -r '.hook_event_name' from stdin
$CLAUDE_TOOL_INPUT jq -r '.tool_input' from stdin
$CLAUDE_TOOL_INPUT_FILE_PATH jq -r '.tool_input.file_path' from stdin
$CLAUDE_TOOL_NAME jq -r '.tool_name' from stdin

Output channels — stdout vs stderr

PreToolUse hooks that block (exit 2) must write the user-visible reason to stderr (>&2). CC reads stderr and shows it to the model as the block reason. Stdout is reserved for hook JSON protocol output (e.g. hookSpecificOutput). A hook that exits 2 with its block message on stdout produces “No stderr output” in the model’s view — the model sees that it was blocked but cannot see why, and cannot self-recover.

# WRONG — block message invisible to the model
echo "BLOCKED: <reason>"
exit 2

# CORRECT — message reaches the model via stderr
echo "BLOCKED: <reason>" >&2
exit 2

# Multi-line block — wrap in { ... } >&2 once
{
  echo "==="
  echo "BLOCK REASON:"
  echo "  - $missing_thing"
  echo "==="
} >&2
exit 2

Internal pipe-stages (echo "$X" | grep ...) stay on stdout — those are inputs to other commands, not user-facing.

Exit code semantics

Exit Meaning
0 Pass — tool proceeds; stderr is informational only
2 Block — stderr is shown to the model as the block reason; stdout is ignored
Other Treated as hook failure; behavior depends on event and CC version

tool_input is authoritative — never ls -t disk state

For PreToolUse hooks, the canonical source for “which tool call is happening” is .tool_input.* in the stdin envelope. Reading file state via ls -t / find -newer is racy under parallel sessions — two sessions writing to the same directory concurrently can swap which file your hook validates. Read directly from stdin:

# ExitPlanMode envelope carries both: .tool_input.plan (markdown) AND .tool_input.planFilePath (path)
PLAN_PATH=$(echo "$INPUT" | jq -r '.tool_input.planFilePath // empty')
if [ -n "$PLAN_PATH" ] && [ -f "$PLAN_PATH" ]; then
    validate "$PLAN_PATH"
else
    # Defensive fallback only if stdin is unavailable / malformed
    validate "$(ls -t "$DIR"/*.md | head -1)"
fi

When trusting a path from stdin, validate it falls inside an expected scope (case "$path" in "$EXPECTED"/*) ... ;; esac) before reading.

Authoring checklist

Before shipping a PreToolUse hook that can exit 2:


Events by phase

Startup

SessionStart

InstructionsLoaded

CwdChanged

Prompt

UserPromptSubmit

PostCompact

Tool use

Tool-use hooks support matchers scoped to tool name. Matcher syntax mirrors permission rules — e.g. "Bash(git *)", "Write|Edit", or "*" for all tools.

PreToolUse

PostToolUse

Classic formatting hook (single matcher on Write/Edit):

{
  "matcher": "Write|Edit",
  "hooks": [
    {
      "type": "command",
      "command": "FILE=$(cat | jq -r '.tool_input.file_path // empty'); [ -n \"$FILE\" ] && npx prettier --write \"$FILE\" 2>/dev/null || true"
    }
  ]
}

PermissionRequest

# CORRECT
echo '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}'

# WRONG — "approve" is not a valid behavior
echo '{"decision": "approve"}'

Valid behavior values: allow, deny, ask.

PermissionDenied

FileChanged

Subagent

SubagentStart

SubagentStop

TeammateIdle

Task lifecycle

TaskCreated

TaskCompleted

Worktree

WorktreeCreate

WorktreeRemove

EnterWorktree

ExitWorktree

Compaction

PreCompact

MCP elicitation

Elicitation

ElicitationResult

Stop and session end

Stop

SessionEnd


Hook configuration in settings.json

Hooks are declared per event, with an optional matcher and one or more command entries. Blocks run in parallel.

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash $CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh"
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Bash(rm -rf *)",
        "hooks": [
          {
            "type": "command",
            "command": "echo '{\"decision\":\"block\",\"reason\":\"rm -rf blocked by policy\"}'"
          }
        ]
      },
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "bash $CLAUDE_PROJECT_DIR/.claude/hooks/protect-secrets.sh"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "FILE=$(cat | jq -r '.tool_input.file_path // empty'); [ -n \"$FILE\" ] && npx prettier --write \"$FILE\" 2>/dev/null || true"
          }
        ]
      }
    ],
    "SessionEnd": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash $CLAUDE_PROJECT_DIR/.claude/hooks/session-summary.sh"
          }
        ]
      }
    ]
  }
}

Common gotchas


See also