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.
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.
{
"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.json | User settings — all projects |
| .claude/settings.json | Project settings — shareable, committed |
| .claude/settings.local.json | Local project settings — not committed |
| Managed policy | Organization-wide settings |
| hooks/hooks.json | Plugin-scoped hooks |
| Component frontmatter | Skill / agent / command lifetime hooks |
| "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.
| SessionStart | startup / resume / clear / compact |
| SubagentStart/Stop | Agent type name — e.g. code-review |
| Notification | permission_prompt · idle_prompt · auth_success · elicitation_dialog |
| InstructionsLoaded | session_start · nested_traversal · path_glob_match |
| PreCompact | manual / auto |
| command | The 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 |
| http | Remote 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 |
| prompt | Single-turn LLM evaluation — the hook content is a prompt Claude evaluates, returning a structured decision. Primarily for Stop / SubagentStop completion checks |
| mcp_tool | Invokes 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 |
| agent | Spawns 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" |
{
"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_id | Unique session identifier |
| transcript_path | Path to the conversation transcript file |
| hook_event_name | Which event fired — one script can serve several events |
| agent_id / agent_type | Who is acting — "main" or a subagent type name |
| worktree | Git 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.
{
"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"
}
}
}
| PreToolUse | permissionDecision: allow / deny / ask · updatedInput rewrites the tool's parameters before it runs |
| PermissionRequest | Returns a decision object: behavior allow|deny, updatedInput, message, interrupt — auto-approve or auto-deny the dialog |
| UserPromptSubmit | decision: "block" + reason stops the prompt · additionalContext enriches it |
| continue / stopReason | Top-level kill switch — false halts the session with a message |
| "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 |
| additionalContext | Injects 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 |
// 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" }
#!/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).
# 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.
| CLAUDE_PROJECT_DIR | All hooks — absolute path to project root. The portability rule: "$CLAUDE_PROJECT_DIR/.claude/hooks/format.sh", never a hardcoded home directory |
| CLAUDE_ENV_FILE | SessionStart, CwdChanged, FileChanged — append export lines to persist env vars into the session |
| CLAUDE_CODE_REMOTE | All hooks — "true" when running in remote environments |
| ${CLAUDE_PLUGIN_ROOT} / _DATA | Plugin hooks — plugin install directory and data directory |
| CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS | Overrides the default timeout for SessionEnd hooks |
| CLAUDE_CODE_SESSION_ID | Bash tool subprocesses (v2.1.132+) — matches session_id in hook JSON; correlate bash logs with hook telemetry |
| CLAUDE_EFFORT | Bash tool subprocesses (v2.1.133+) — mirrors effort.level |
| CLAUDE_CODE_STOP_HOOK_BLOCK_CAP | Process-wide (v2.1.143+) — max consecutive Stop-hook blocks, default 8, 0 disables |
#!/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 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.
| SessionEnd, not Stop | Fires once on exit — not after every response |
| read -r </dev/tty | Hooks own stdin (the JSON payload); interactive input must open the terminal directly |
| Guard clause at top | Exits unless the project path matches — safe to install globally |
| Store outside the repo | Progress in ~/ survives git pull |
| Python for JSON writes | Notes passed as an argument so escaping can't break the file |
A companion browser tracker stores progress in localStorage, with export/import as JSON.
USE AT YOUR OWN RISK — the docs are explicit: you are solely responsible for what your hooks do.
# 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 executing | Check JSON syntax · matcher matches the tool name · script exists and is chmod +x · reads stdin, not args |
| Blocks unexpectedly | Pipe sample JSON in and check the exit code; on exit 2 read stderr — that's the reason being shown |
| JSON parsing errors | Parse properly (no string hacking), handle missing fields gracefully |
| Execution model | 60 s default timeout (per-command override) · all matching hooks run in parallel · identical commands deduplicated · current directory + Claude Code's environment |
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.