Chapter 6 · Hooks 01 / 20
Mastering Claude Code · A 10-Part Series

Hooks — Enforced Automation

Scripts that fire on 30 session events — validating, blocking, formatting, and notifying without being asked. Five hook types, one JSON contract, and the exit code that makes rules real.

Sid Dani · June 2026
01 · The Problem

CLAUDE.md asks Claude to behave — hooks make behaving non-optional

Everything so far has been persuasion: memory suggests conventions, skills package procedures — and the model can still skip them. Hooks are automated actions that execute when specific events occur in a session: shell commands, HTTP webhooks, LLM prompts, MCP tool calls, or subagent evaluations. They receive JSON input and answer with exit codes and JSON output — deterministic code, not a request the model can ignore.

  • Event-driven automation — fire on session, prompt, tool, and agent lifecycle moments
  • JSON-based input/output — every hook reads structured context from stdin
  • Five hook typescommand, http, prompt, mcp_tool, agent
  • Pattern matching — target specific tools, agents, or notification types
The mental shift: a hook doesn't remind Claude that tests must pass — it refuses the action until they do. Validation, permission management, and custom workflows become properties of the session itself.
02 · Configuration

A hook is an event, a matcher, and an action — declared in six places

{
  "hooks": {
    "EventName": [
      {
        "matcher": "ToolPattern",
        "hooks": [
          {
            "type": "command",
            "command": "your-command-here",
            "timeout": 60
          }
        ]
      }
    ]
  }
}

timeout defaults to 60 s per command · "once": true runs a hook a single time per session · all matching hooks run in parallel, identical commands deduplicated.

~/.claude/settings.jsonUser settings — all projects
.claude/settings.jsonProject settings — shareable, committed
.claude/settings.local.jsonLocal project settings — not committed
Managed policyOrganization-wide settings
hooks/hooks.jsonPlugin-scoped hooks
Component frontmatterSkill / agent / command lifetime hooks
Same layering logic as memory and MCP scopes: user-wide, project-shared, project-local, org-managed — plus two component-level homes (plugins and frontmatter) covered later in this chapter.
03 · Matchers

The matcher decides which events reach your script — and it's case-sensitive

Tool-name patterns

"Write"Exact string — one specific tool
"Edit|Write"Regex — multiple tools
"*" or ""Wildcard — all tools
"mcp__memory__.*"MCP tools — mcp__<server>__<tool> pattern

Common PreToolUse matchers: Task, Bash, Glob, Grep, Read, Edit, Write, WebFetch, WebSearch.

Events match other things

SessionStartstartup / resume / clear / compact
SubagentStart/StopAgent type name — e.g. code-review
Notificationpermission_prompt · idle_prompt · auth_success · elicitation_dialog
InstructionsLoadedsession_start · nested_traversal · path_glob_match
PreCompactmanual / auto
The matcher's meaning is event-relative: on tool events it matches tool names, on agent events the agent type, on SessionStart the start reason. A hook with no matcher (or "*") fires every time its event does.
04 · Hook Types

Five hook types: scripts, webhooks, prompts, MCP tools, and full agents

commandThe default — runs a shell command, talks JSON over stdin/stdout plus exit codes. Exec form (v2.1.139): "args": ["python3", "$CLAUDE_PROJECT_DIR/.claude/hooks/validate.py", "--strict"] spawns the binary via execve() — no shell parsing, immune to shell injection. command and args are mutually exclusive; configs with both are rejected at load
httpRemote webhook (v2.1.63) — POSTs the same JSON input to a URL, receives a JSON response. Routed through the sandbox when sandboxing is on; env-var interpolation in URLs requires an explicit allowedEnvVars list
promptSingle-turn LLM evaluation — the hook content is a prompt Claude evaluates, returning a structured decision. Primarily for Stop / SubagentStop completion checks
mcp_toolInvokes a configured MCP tool directly (v2.1.118) — "server" + "tool" instead of a command. Hook input passes as the tool's arguments; use when validation logic already lives in an MCP server
agentSpawns a dedicated subagent for the check — unlike single-turn prompt hooks it can use tools (Read, Grep, Bash) and reason in multiple steps, e.g. "verify changes follow the architecture guidelines, check the design docs"
A useful ladder of escalation: command when the rule is mechanical, prompt when it needs judgment, agent when judgment needs evidence. Use shell-form command for pipes and chaining; exec-form args for one binary with arguments.
05 · The Event Surface

30 events trace the whole session — 12 of them can block what happens next

Session opens You prompt Claude uses tools Claude responds Session closes
Session lifecycle
4 events
SessionStartSetupInstructionsLoadedSessionEnd
Prompt intake
2 events
UserPromptSubmitUserPromptExpansion
Tools & permissions
6 events
PreToolUsePermissionRequestPermissionDeniedPostToolUsePostToolUseFailurePostToolBatch
Response & turn end
4 events
StopStopFailureMessageDisplayNotification
Agents & tasks
5 events
SubagentStartSubagentStopTeammateIdleTaskCreatedTaskCompleted
Context & environment
5 events
PreCompactPostCompactConfigChangeCwdChangedFileChanged
Worktrees & elicitation
4 events
WorktreeCreateWorktreeRemoveElicitationElicitationResult
Can block — gate the action (12)Observe-only — react, log, enrich (18)ConfigChange can't block managed-policy changes · WorktreeCreate "blocks" by returning a path
06 · Exit Codes

Exit 2 blocks. Exit 1 does not — the most common hook-writing mistake

exit 0
success
Continue — stdout parsed as JSON
Decisions, context, and updated inputs are read from the JSON you print
exit 2
blocking error
Block the operation — stderr becomes the reason
Whatever you print to stderr is shown as the error and fed back to Claude
other
non-blocking error
Continue anyway
stderr shown only in verbose mode — the action proceeds untouched
Watch for it in the wild: this chapter's bundled pre-commit.sh prints "Commit blocked" and exits 1 — a non-blocking error, so the commit proceeds anyway. A hook that must stop the action needs exit 2, with the explanation on stderr (>&2), not stdout.
07 · JSON Input

Every hook reads the same envelope from stdin — who, where, what, and why

{
  "session_id": "abc123",
  "transcript_path": "/path/to/transcript.jsonl",
  "cwd": "/current/working/directory",
  "permission_mode": "default",
  "hook_event_name": "PreToolUse",
  "tool_name": "Write",
  "tool_input": {
    "file_path": "/path/to/file.js",
    "content": "..."
  },
  "tool_use_id": "toolu_01ABC123...",
  "agent_id": "agent-abc123",
  "agent_type": "main",
  "worktree": "/path/to/worktree",
  "effort": { "level": "medium" }
}
session_idUnique session identifier
transcript_pathPath to the conversation transcript file
hook_event_nameWhich event fired — one script can serve several events
agent_id / agent_typeWho is acting — "main" or a subagent type name
worktreeGit worktree path, if the agent runs in one
effort.level(v2.1.133+) low / medium / high / xhigh / max

Event-specific extras ride along: tool events add tool_name + tool_input, Stop adds last_assistant_message, PostToolUse adds duration_ms.

Always read stdin, never command arguments — that's where the JSON arrives. It also means stdin is occupied: a hook that wants interactive keyboard input must open /dev/tty directly.
08 · JSON Output

On exit 0, your stdout JSON steers the session — allow, deny, ask, or rewrite

{
  "continue": true,
  "stopReason": "Optional message if stopping",
  "suppressOutput": false,
  "systemMessage": "Optional warning message",
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "permissionDecisionReason": "File is in allowed directory",
    "updatedInput": {
      "file_path": "/modified/path.js"
    }
  }
}
PreToolUsepermissionDecision: allow / deny / ask · updatedInput rewrites the tool's parameters before it runs
PermissionRequestReturns a decision object: behavior allow|deny, updatedInput, message, interrupt — auto-approve or auto-deny the dialog
UserPromptSubmitdecision: "block" + reason stops the prompt · additionalContext enriches it
continue / stopReasonTop-level kill switch — false halts the session with a message
Two layers of veto: exit 2 is the blunt one (block, stderr as reason); exit 0 + JSON is the precise one — it can deny, escalate to a human (ask), or quietly fix the input and let the action proceed.
09 · PostToolUse

After the tool runs, hooks can critique it, time it — or rewrite what Claude sees

"decision": "block"Prompts Claude with your feedback — by default it aborts the current turn
continueOnBlock (v2.1.139)true surfaces the rejection as a tool_result instead, so Claude can read the reason and retry — use when the feedback is actionable ("this file is read-only; write somewhere else"), leave off when a block must halt the turn
additionalContextInjects context for Claude without blocking — the security-scanner pattern
duration_ms (v2.1.119)Input field: tool execution time in milliseconds, excluding permission prompts and PreToolUse hook time — on PostToolUse and PostToolUseFailure
updatedToolOutput (v2.1.121)Rewrites the tool's output before Claude sees it — honored for all tools, not just MCP. Redact secrets, normalize diffs, strip ANSI codes from Bash output
terminalSequence (v2.1.141)Emits raw OSC escape sequences to the host terminal — desktop notifications (OSC 9), window titles, bells, no TTY of your own required. Kitty / iTerm2 / Windows Terminal honor OSC 9
updatedToolOutput changes the trust model: the hook sits between the tool and the model. What Claude believes a command printed is whatever your hook decided to pass through.
10 · Stop & SubagentStop

Stop hooks are the quality gate — "did Claude actually finish?"

// prompt-type hook on Stop
{
  "type": "prompt",
  "prompt": "Review if Claude completed all
    requested tasks. Check: 1) Were all files
    created/modified? 2) Were there unresolved
    errors? If incomplete, explain what's missing.",
  "timeout": 30
}

// the LLM answers with a structured decision
{
  "decision": "approve",
  "reason": "All tasks completed successfully",
  "continue": false,
  "stopReason": "Task complete"
}
  • Both events receive last_assistant_message in their input — the final message before stopping, exactly what a completion check needs
  • A blocking decision sends Claude back to work with the reason as feedback
  • SubagentStop does the same for subagents — matcher is the agent type name
  • prompt hooks are single-turn judgment; agent hooks can gather evidence with tools first
Safety cap (v2.1.143): a Stop hook that blocks 8 times in a row in the same turn gets short-circuited — the session ends with a warning instead of looping forever. Tune with CLAUDE_CODE_STOP_HOOK_BLOCK_CAP (0 disables the cap).
11 · Session Edges

SessionStart provisions the session; SessionEnd cleans up — neither can be skipped

SessionStart — setup with side effects

#!/bin/bash
# persist env vars into the session
if [ -n "$CLAUDE_ENV_FILE" ]; then
  echo 'export NODE_ENV=development' \
    >> "$CLAUDE_ENV_FILE"
fi
exit 0

Matchers: startup / resume / clear / compact. Since v2.1.152, returned JSON can set reloadSkills: true (re-scan skills — same as /reload-skills, so just-installed skills work immediately) and hookSpecificOutput.sessionTitle (names the session on startup/resume).

SessionEnd — the exit interview

  • Fires once when the session terminates — cleanup and final logging
  • Cannot block termination — the session is ending either way
  • Input includes a reason: clear, logout, prompt_input_exit, or other
  • Timeout configurable via CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS
CLAUDE_ENV_FILE is the bridge from hook to session: variables appended there outlive the script. The same mechanism is available in CwdChanged and FileChanged hooks — re-provision when the world moves.
12 · Component Hooks

Hooks travel with the code they guard — frontmatter and plugins carry their own

# in SKILL.md / agent.md / command.md
---
name: secure-operations
description: Perform operations with security checks
hooks:
  PreToolUse:
    - matcher: "Bash"
      hooks:
        - type: command
          command: "./scripts/check.sh"
          once: true  # only once per session
---

Supported events in component frontmatter: PreToolUse, PostToolUse, Stop. The hook lives for the component's lifetime — related code stays together.

  • Subagent auto-conversion: a Stop hook in a subagent's frontmatter becomes a SubagentStop hook scoped to that agent — it fires when that subagent completes, not when the main session stops
  • Plugins ship hooks in hooks/hooks.json, same structure as settings
  • ${CLAUDE_PLUGIN_ROOT} resolves to the plugin's install directory — portable paths like ${CLAUDE_PLUGIN_ROOT}/scripts/validate.sh
  • ${CLAUDE_PLUGIN_DATA} points at the plugin's data directory
Chapter 3's skills just gained teeth: a skill can enforce its own preconditions instead of documenting them — the check ships in the same file as the procedure it protects.
13 · Environment

Eight variables connect hooks to the session that invoked them

CLAUDE_PROJECT_DIRAll hooks — absolute path to project root. The portability rule: "$CLAUDE_PROJECT_DIR/.claude/hooks/format.sh", never a hardcoded home directory
CLAUDE_ENV_FILESessionStart, CwdChanged, FileChanged — append export lines to persist env vars into the session
CLAUDE_CODE_REMOTEAll hooks — "true" when running in remote environments
${CLAUDE_PLUGIN_ROOT} / _DATAPlugin hooks — plugin install directory and data directory
CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MSOverrides the default timeout for SessionEnd hooks
CLAUDE_CODE_SESSION_IDBash tool subprocesses (v2.1.132+) — matches session_id in hook JSON; correlate bash logs with hook telemetry
CLAUDE_EFFORTBash tool subprocesses (v2.1.133+) — mirrors effort.level
CLAUDE_CODE_STOP_HOOK_BLOCK_CAPProcess-wide (v2.1.143+) — max consecutive Stop-hook blocks, default 8, 0 disables
The last three aren't read by hooks — they're how the rest of your tooling meets the hook system: bash scripts see the same session id and effort level the hooks do.
14 · Worked Example

A 20-line PreToolUse guard makes rm -rf / physically impossible

#!/usr/bin/env python3
# .claude/hooks/validate-bash.py
import json, sys, re

BLOCKED = [
  (r"\brm\s+-rf\s+/", "Blocking dangerous rm -rf /"),
  (r"\bsudo\s+rm",    "Blocking sudo rm command"),
]

data = json.load(sys.stdin)
if data.get("tool_name") != "Bash":
    sys.exit(0)
cmd = data.get("tool_input", {}).get("command", "")
for pattern, message in BLOCKED:
    if re.search(pattern, cmd):
        print(message, file=sys.stderr)
        sys.exit(2)  # exit 2 = block
sys.exit(0)

The bundled big sibling: three tiers

  • pre-tool-check.sh grades each Bash command BLOCK / WARN / ALLOW — fork bombs and mkfs exit 2; git push --force and DROP TABLE warn and proceed
  • stderr discipline: on exit 2, the block reason must go to stderr (>&2) — stdout shows "No stderr output" instead of your explanation
  • On exit 0, stderr is silently dropped — so the WARN tier writes an audit log (.claude/hooks/audit.log) as the only reliable record
  • Anchor patterns carefully: rm -rf /([[:space:]]|$), or /tmp/foo gets falsely flagged
Wire it once: matcher "Bash" on PreToolUse, command python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/validate-bash.py" — every shell command in every future session now passes through it.
15 · Hook Pairs

Two events, one script: bracket each request to measure what it cost

The course's context tracker registers the same script on two events. UserPromptSubmit fires before processing — it reads the transcript, counts tokens, and saves the figure to a temp file keyed by session_id. Stop fires after the response — it recounts, subtracts, and reports the delta to stderr: "This request: ~3,200 tokens." The script branches on hook_event_name to know which side of the bracket it's on.

Character estimation~4 chars/token · ~80–90% accuracy · no dependencies · <1 ms
tiktoken (p50k_base)~90–95% accuracy · pip install tiktoken · <10 ms

No official offline tokenizer exists — both are approximations. The transcript covers prompts, responses, and tool outputs, but not system prompts or internal context.

  • Sessions isolated via session_id in the temp filename — parallel sessions don't collide
  • Report goes to stderr so it never interferes with hook JSON on stdout
  • Ships in two variants: zero-dependency estimation and a tiktoken upgrade
The pattern generalizes: any before/after measurement — timing, file counts, git status drift — is a pre-event that saves state plus a post-event that diffs it.
16 · The Example Shelf

The chapter ships ten hooks — each one models a pattern worth stealing

session-end.sh — the learning diary

SessionEnd, not StopFires once on exit — not after every response
read -r </dev/ttyHooks own stdin (the JSON payload); interactive input must open the terminal directly
Guard clause at topExits unless the project path matches — safe to install globally
Store outside the repoProgress in ~/ survives git pull
Python for JSON writesNotes passed as an argument so escaping can't break the file

A companion browser tracker stores progress in localStorage, with export/import as JSON.

And the rest of the shelf

  • format-code.sh — PostToolUse on Write/Edit; runs prettier/black/gofmt by extension
  • security-scan.sh — warns on hardcoded secrets via additionalContext, non-blocking
  • log-bash.sh — appends every Bash command to an audit log
  • validate-prompt.sh — UserPromptSubmit gate: blocks production deploys unless .deployment-approved exists; nudges "add tests" on refactor prompts via additionalContext
  • dependency-check.sh — npm/pip/go/cargo vulnerability scan on manifest writes; advisory, always exits 0
  • notify-team.sh — Slack/Discord/email webhooks; note: no native PostPush event exists — filter PostToolUse:Bash for "git push"
  • A one-time permissions seeder (~67 safe allow rules) — a setup script, not a hook
17 · Security

Hooks run arbitrary shell with your permissions — treat them like production code

Do

  • Validate and sanitize all inputs; block path traversal (..)
  • Quote shell variables: "$VAR", never bare $VAR
  • Use absolute paths with $CLAUDE_PROJECT_DIR
  • Skip sensitive files: .env, .git/, keys
  • Test hooks in isolation before deploying; explicit allowedEnvVars for HTTP hooks

Don't

  • Trust input data blindly, or process every file indiscriminately
  • Hardcode paths or expose all env vars to webhooks
  • Deploy untested hooks — you own the data loss they cause

USE AT YOUR OWN RISK — the docs are explicit: you are solely responsible for what your hooks do.

Platform hardening to know: statusLine / fileSuggestion commands need workspace trust · disableAllHooks respects the managed hierarchy (org policy can force hooks off, users can't override) · since v2.1.145, FOO=bar somecommand no longer auto-approves off the bare-assignment allowlist — re-allow the full command explicitly.
18 · Debugging

Hooks fail silently by design — debug them like the pipes they are

# watch hook execution live
claude --debug
# or press Ctrl+O in-session for verbose mode

# test a hook outside Claude entirely
echo '{"tool_name": "Bash",
  "tool_input": {"command": "ls -la"}}' \
  | python3 .claude/hooks/validate-bash.py
echo $?   # 0 = allow, 2 = block
Hook not executingCheck JSON syntax · matcher matches the tool name · script exists and is chmod +x · reads stdin, not args
Blocks unexpectedlyPipe sample JSON in and check the exit code; on exit 2 read stderr — that's the reason being shown
JSON parsing errorsParse properly (no string hacking), handle missing fields gracefully
Execution model60 s default timeout (per-command override) · all matching hooks run in parallel · identical commands deduplicated · current directory + Claude Code's environment
The fastest loop never opens Claude at all: echo a fake payload, pipe it through the script, read the exit code. A hook is just a filter — test it like one.
19 · Recap

Hooks turn conventions into physics — the session enforces its own rules

30 events trace the session from start to end, and 12 of them can block. Five hook types ladder up from shell commands through webhooks and MCP tools to LLM prompts and tool-using agents. The contract is always the same: JSON in on stdin, then exit 0 to continue (stdout JSON steering permissions, inputs, even tool outputs), exit 2 to block with stderr as the reason — and exit 1 blocks nothing. Hooks live in settings at four scopes, in plugin manifests, and in component frontmatter, where a subagent's Stop quietly becomes SubagentStop.

Start tonight: wire validate-bash.py onto PreToolUse:Bash and try to rm -rf / — watch the block reason come back. Chapter 7: plugins — packaging commands, skills, agents, MCP servers, and these hooks into one installable unit.
Sid Dani