What Claude Code Hooks Actually Are
Claude Code hooks are user-defined handlers that run automatically when specific events happen during a session. The docs list five handler types: shell commands, HTTP endpoints, MCP tool calls, LLM prompts and subagents. Every hook in this post is a shell command. Unlike git hooks (which only fire on git events), Claude Code hooks cover the entire session lifecycle - from the moment a session starts to every tool call, compaction event, subagent spawn, and session end.
I used to run a mental checklist before every commit: check for secrets, verify tests pass, monitor context usage. It was exhausting, and I forgot steps when deep in a problem. Six months ago I wrote my first hook - a security checker that blocks commands containing API keys. It saved me on day one. Now I run over 20 hooks across my system, and I haven't manually checked a single thing in months.
The breaking point was pushing credentials to a private repo. Nothing catastrophic, but enough to make me automate everything I was relying on memory for. If I catch myself repeating a check three times, it becomes a hook.
TL;DR: Claude Code has 33 hook event types, and 16 of them block on exit code 2. PermissionRequest ignores exit code 2 and denies through a JSON decision instead. The hooks in this post are shell commands configured in settings.json that receive JSON on stdin. They print JSON back only when they want to change something, and for almost every event exit 0 with no output is how a hook says carry on. WorktreeCreate is the exception, and an expensive one - see below. Start with one high-value hook, then compose.
All 33 Hook Event Types
Claude Code fires hooks at 33 distinct points in the session lifecycle. PreModelSwitch and PostModelSwitch need v2.1.251 or later. Understanding the full surface area is critical for designing useful automations.
The tables below follow the hooks reference as I read it on 2026-10-02. The "Blocks on exit 2?" column is the "Can block?" column of its exit code 2 table, which marks 16 of the 33 events as Yes. Blocking can also happen through JSON fields, which the section after the tables covers. The callout below that section shows how my own count of this number moved, and what I tested.
Session Lifecycle
| Event | When It Fires | Blocks on exit 2? |
|---|---|---|
| SessionStart | Session begins or resumes | No |
| Setup | Repo setup, and only from the CLI: --init-only, --init, or --maintenance. Never on normal startup, and not from typing /init in a session. Receives a trigger of init or maintenance | No |
| SessionEnd | Session terminates | No |
| Stop | Claude finishes responding | Yes |
| StopFailure | API error terminates a response | No |
User Input
| Event | When It Fires | Blocks on exit 2? |
|---|---|---|
| UserPromptSubmit | User submits a prompt, before processing | Yes |
| UserPromptExpansion | A slash command expands into its prompt | Yes |
| MessageDisplay | Assistant text streams to the screen | No |
UserPromptSubmit is the most underrated hook. It runs before Claude sees your message, so you can inject context, calculate scores, or transform the prompt. My delegation enforcer uses this to score every prompt and inject routing hints.
Tool Execution
| Event | When It Fires | Blocks on exit 2? |
|---|---|---|
| PreToolUse | Before a tool executes | Yes |
| PostToolUse | After a tool succeeds | No |
| PostToolUseFailure | After a tool fails | No |
| PostToolBatch | After a batch of parallel tool calls | Yes |
| PermissionRequest | Permission dialog appears | No. Exit code 2 is not honored here; deny with the decision object |
| PermissionDenied | Auto-mode classifier denied a tool call | No |
PreToolUse is the gatekeeper, and it decides through its own response shape. It blocks by returning hookSpecificOutput.permissionDecision set to "deny". A top-level "decision" field is deprecated for this event rather than inert - it is still honoured - so write the nested form in new hooks, but do not rewrite a working legacy hook on the assumption that it has stopped working. This is where security checks, file protection rules, and command validation live. PostToolUse and PostToolUseFailure cannot block: they cannot un-run the tool, so on exit code 2 they only show stderr to Claude. PostToolBatch is different. It fires once after every tool call in a batch has resolved, and a block there stops the agentic loop before the next model call.
Subagents and Tasks
| Event | When It Fires | Blocks on exit 2? |
|---|---|---|
| SubagentStart | Subagent spawns | No |
| SubagentStop | Subagent finishes | Yes |
| TaskCreated | New task created | Yes |
| TaskCompleted | Task marked complete | Yes |
| TeammateIdle | Teammate agent about to go idle | Yes |
Context and Configuration
| Event | When It Fires | Blocks on exit 2? |
|---|---|---|
| PreCompact | Before context compaction | Yes |
| PostCompact | After compaction completes | No |
| PreModelSwitch | Before a requested model switch is applied. Needs v2.1.251 or later | Yes |
| PostModelSwitch | After the session's model changes | No |
| InstructionsLoaded | CLAUDE.md or rules file loaded | No |
| ConfigChange | Configuration file changes | Yes |
| CwdChanged | Working directory changes | No |
| DirectoryAdded | A directory is added to the workspace | No |
| FileChanged | Watched file changes on disk | No |
Workspace
| Event | When It Fires | Blocks on exit 2? |
|---|---|---|
| WorktreeCreate | Git worktree created, via --worktree, isolation: "worktree", or for a background session | Yes |
| WorktreeRemove | A hook-provided worktree is removed. Did not fire for one Claude Code created itself, in any attempt | Yes |
| Notification | System notification sent | No |
| Elicitation | MCP server requests user input | Yes |
| ElicitationResult | User responds to MCP elicitation | Yes |
How Blocking Works: 16 Events Honor Exit Code 2
Sixteen of the 33 events block on exit code 2, but blocking is not one mechanism and it is not limited to those 16. The events that can block do not all use the same field. This is the part that bites people, because a hook that returns the wrong shape fails silently: it exits 0, Claude Code reads no decision, and the action proceeds.
These are not three tidy buckets, and I have published a tidy-bucket version of this twice now and been wrong both times. The mechanisms overlap:
Exit code 2 is the general one. A hook that writes its reason to stderr and exits 2 blocks on those 16 events, and the docs table lists what gets blocked on each. The two worktree events have extra conditions, covered below. I tested this on UserPromptSubmit, which I had previously filed under "top-level decision only": the run comes back "num_turns": 0 with result: "UserPromptSubmit operation blocked by hook: ...". If you only learn one mechanism, learn this one.
A top-level "decision": "block" also works, for many of the same events - UserPromptSubmit, UserPromptExpansion, PostToolBatch, Stop, SubagentStop, PreCompact, and TaskCreated. Same isolated-config test, same result. The docs also list ConfigChange under top-level decision. PostToolUse is in that row too, but the tool has already run, so a decision of block there only adds the reason next to the tool result. For a good number of events either shape blocks, and the two lists are not complements.
Several events have their own nested shape, and here the shape genuinely matters. PreToolUse uses hookSpecificOutput.permissionDecision, and PreModelSwitch accepts the same field, plus a top-level decision of block. PermissionRequest uses hookSpecificOutput.decision.behavior. Elicitation and ElicitationResult use hookSpecificOutput.action with accept, decline or cancel. PermissionRequest is not one of the 16: exit code 2 is not honored there, so you deny with the decision object instead.
And a few take {"continue": false} - TaskCompleted and TeammateIdle among them.
WorktreeCreate is none of the above, and it is the one that will bite you. It is not a veto hook, it is a provider: its job is to return the path of the worktree it created, on stdout for a command hook or as hookSpecificOutput.worktreePath otherwise. Anything else aborts the operation. I fired three hooks at it against v2.1.233 and all three stopped the worktree being created - exit 1, exit 2, and exit 0 with no output:
Error creating worktree: WorktreeCreate hook failed: hook succeeded but
returned no worktree path (command: echo the path to stdout; ...)
So the general rule above - exit 0 and say nothing to let things proceed - is false here, and expensively so. Register an observe-only logger on WorktreeCreate and you break every worktree creation on the machine, including the ones Claude Code makes for background sessions, with an error that does not mention your hook. If you want to watch worktree creation without owning it, log from inside the worktree after the fact. Do not reach for WorktreeRemove as the observation point either: it cannot tell you a worktree was created, and it only fires for a worktree that a WorktreeCreate hook provided in the first place. That precondition is why it looks dead - three of my attempts had it never fire, because Claude Code had built the worktree itself. And it is not a passive observer. When a WorktreeRemove hook exits 0, the worktree counts as removed and Claude Code reads nothing else from the hook, so the hook has to delete the directory itself. A hook that only logs leaves it on disk.
The practical rule: write the event's documented shape, and exit 2 as well, on an event that honors it, if you want belt and braces. What you must not do is assume that because one shape is documented for an event, the other is inert.
The other 17 events do not block on exit code 2. SessionStart, SubagentStart, PostModelSwitch, PostToolUse and PostToolUseFailure can add context through additionalContext. PermissionRequest can allow or deny through its decision object, PermissionDenied can return retry, and MessageDisplay can replace the displayed text. The other nine, Setup, SessionEnd, StopFailure, PostCompact, InstructionsLoaded, CwdChanged, DirectoryAdded, FileChanged and Notification, have no decision control and are for side effects like logging. Most hooks inform rather than control, and that is still the right default.
I have published 3, then 14, then 16 for this number, and each time I had counted rather than tested. On 2026-08-15 I counted 18 of 31 from the v2.1.233 binary, because it is the thing that runs. It printed Exit code 2 - deny the elicitation, Exit code 2 - block the response (action becomes decline), and for ConfigChange Exit code 2 - block the change from being applied to the session. The hooks reference lists exit code 2 as blocking for those three events too. WorktreeCreate is what broke 16: I had it marked observe-only until a reviewer refused a worktree with it.
Make that four wrong answers. I wrote a version of this paragraph claiming 17 and naming WorktreeRemove as the row I most distrusted, on the grounds that I could not get it to fire. A reviewer then fired it - and it blocks. The reason mine never fired is a precondition neither of us had thought to vary: WorktreeRemove only dispatches for a worktree that a WorktreeCreate hook provided. When Claude Code builds the worktree itself, the event never comes. Register a providing WorktreeCreate hook and its sibling starts firing, and an exit 2 leaves the worktree in place with could not remove it. The docs describe the same for any non-zero exit, as long as the directory still exists afterward.
So here is the honest state of 18, my count from 2026-08-15. Nine of those 31 events had actually had a hook fired at them. Neither PreModelSwitch nor PostModelSwitch is among them. Three confirm blocking: UserPromptSubmit (via exit 2 and via a top-level decision), WorktreeCreate, WorktreeRemove. Six confirm the opposite - SessionStart, Setup, SessionEnd, MessageDisplay, InstructionsLoaded, SubagentStart - four of them tested on both mechanisms. The other 22 rows of that count, and the two newer events, rest on reading the binary or the reference, which is exactly what produced the four wrong answers above. Fire a hook at whatever you are about to build on. If it does not fire, look for the precondition before concluding the event is inert - and if it does block on exit 2, that tells you one thing about one exit code, not what kind of hook it is. WorktreeCreate and WorktreeRemove both block, and they are not the same kind of hook at all: an observe-only logger is fatal on WorktreeCreate, and on WorktreeRemove it leaves the directory behind instead of removing it. That is the whole lesson here, and it cost four revisions of this one number to learn. The tables on this page use the docs figure, 16 of 33, because the docs exit code 2 table marks PostToolUse, PostToolUseFailure and PermissionRequest as not blocking, which my 18 had counted, and marks PreModelSwitch as blocking.
This lives in primeline-ai/evolving-lite - the self-evolving Claude Code plugin. Free, MIT, no build step.
How Hook Configuration Works
Hooks live in your settings.json file. The structure maps event names to arrays of matcher/command pairs:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/security-tier-check.py"
}
]
}
],
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/delegation-enforcer.py"
}
]
}
]
}
}
Matcher Patterns
The matcher field filters when a hook fires:
- Empty string
""- matches every event of that type - Tool name
"Bash"- matches only when that specific tool is used - Pipe-separated
"Edit|Write|MultiEdit"- matches any of the listed tools - Regular expressions - a matcher with any character besides letters, digits,
_,-, spaces,,and|is read as a JavaScript regular expression, unanchored, like^Notebookormcp__memory__.*. FileChanged and StopFailure use a narrower exact set of letters, digits,_and|, so a hyphen, space or comma in a matcher for those two events also makes it a regular expression. A matcher is an exact name list or a regular expression, and there is no glob mode
For PreToolUse and PostToolUse, the matcher tests against the tool name. An empty matcher catches all tool calls of that event type.
Where Settings Live
Settings files follow the same scoping as CLAUDE.md:
| Scope | File | Use Case |
|---|---|---|
| User | ~/.claude/settings.json | Hooks you want in every project |
| Project (local) | .claude/settings.local.json | Hooks specific to this project, just for you (gitignored when Claude Code saves a setting to it) |
| Project (shared) | .claude/settings.json (committed) | Hooks shared with team |
Multiple hooks on the same event run in parallel. Results are collected and all additionalContext strings are concatenated into Claude's context.
The Hook Response Protocol
Every command hook receives JSON on stdin. Returning JSON on stdout is optional, and this is the part people get backwards: you print JSON only when you want to change something. Exit 0 with no output is the normal, correct way to say "carry on". The input always includes session_id and hook_event_name. Tool-related events add tool_name and tool_input.
Input Example (PreToolUse)
{
"session_id": "abc-123",
"cwd": "/Users/dev/my-project",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf ./data"
}
}
Output Schema
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Destructive command on a protected branch",
"additionalContext": "Text injected into Claude's context"
}
}
There is no top-level "decision": "allow". This is the single most common mistake. In the general decision table the only value for the top-level decision field is "block". Sending "allow" there fails validation: the v2.1.233 binary threw Unknown hook decision type: allow. Valid types are: approve, block, and the docs say a JSON object that fails validation shows a hook error notice while the action proceeds. To let an action through, omit the field, or just exit 0 printing nothing at all. PreToolUse is the one legacy case, covered below: its deprecated top-level decision still maps "approve" and "block" to "allow" and "deny".
PreToolUse is the exception with the richer vocabulary, and it lives one level down in hookSpecificOutput:
permissionDecision | Effect |
|---|---|
"allow" | Tool runs, no prompt |
"deny" | Tool call is blocked |
"ask" | User is prompted to confirm |
"defer" | Falls through to the normal permission flow. Print-mode only - in an interactive session the binary logs defer is print-mode only and ignores it, which looks exactly like your hook never ran |
The hooks reference is blunt about the older form: "PreToolUse previously used top-level decision and reason fields, but these are deprecated for this event." The deprecated "approve" and "block" values map to "allow" and "deny". Other events, including PostToolUse and Stop, still use top-level decision as their current format - so the shape genuinely differs by event, and you have to check.
The additionalContext string appears in Claude's context window as a system reminder. This is the main way hooks communicate with Claude - they inject information, warnings, or instructions that Claude reads on the next model request.
Hook 1: Security Tier Check (PreToolUse)
This hook blocks dangerous commands on production branches. It checks the command being executed against a severity tier and the current git branch.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/security-tier-check.py"
}
]
}
]
}
}
#!/usr/bin/env python3
import json, sys, subprocess
DANGEROUS_PATTERNS = {
"rm -rf": 8,
"drop database": 9,
"git push --force": 7,
"git reset --hard": 7,
"chmod 777": 6,
"curl | bash": 9,
}
PROTECTED_BRANCHES = ["main", "production", "release"]
def get_branch():
try:
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True, timeout=5
)
return result.stdout.strip()
except Exception:
return "unknown"
data = json.loads(sys.stdin.read())
command = data.get("tool_input", {}).get("command", "")
branch = get_branch()
max_tier = 0
matched = ""
for pattern, tier in DANGEROUS_PATTERNS.items():
if pattern in command.lower():
if tier > max_tier:
max_tier = tier
matched = pattern
if max_tier >= 7 and branch in PROTECTED_BRANCHES:
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": f"BLOCKED: '{matched}' (tier {max_tier}) on protected branch '{branch}'. Switch to a feature branch or remove the dangerous pattern."
}
}))
elif max_tier >= 5:
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": f"WARNING: '{matched}' (tier {max_tier}) detected. Proceed with caution."
}
}))
# Nothing to print in the allow case: exit 0 with no output and the tool runs.
Note the shape. permissionDecision and permissionDecisionReason sit inside hookSpecificOutput, and the allow branch prints nothing at all. An older version of this hook used a top-level {"decision": "deny"}, which is the deprecated form for PreToolUse - it is the kind of thing that keeps working until it quietly does not.
Why Tiers Instead of Block-Everything
My first version blocked all destructive commands everywhere. That lasted two hours before I turned it off - too annoying on feature branches where rm -rf node_modules is routine. The tier system means risk scales with context. Tier 5-6 on a feature branch? Warning. Tier 7+ on main? Hard block.
Since adding this hook, I have been blocked from dangerous operations 23 times. Every single one was a mistake I would have regretted.
Hook 2: Delegation Enforcer (UserPromptSubmit)
This hook scores every prompt for delegation potential and injects routing hints into context. It runs before Claude processes the message, so Claude sees the delegation score as part of the prompt context.
#!/usr/bin/env python3
import json, sys
THRESHOLD = 3
SCORE_FACTORS = {
"explore": 3, "search": 3, "find": 3, "investigate": 3,
"review": 2, "refactor": 2, "research": 2, "audit": 2,
"debug": 2, "test": 2, "bulk": 2,
}
DEDUCTIONS = {
"production": -10, "deploy": -10, "password": -10,
"secret": -10, "credential": -10,
}
def score_prompt(prompt: str) -> int:
score = 0
lower = prompt.lower()
for keyword, points in SCORE_FACTORS.items():
if keyword in lower:
score += points
for keyword, points in DEDUCTIONS.items():
if keyword in lower:
score += points
return score
data = json.loads(sys.stdin.read())
prompt = data.get("prompt", "")
score = score_prompt(prompt)
if score >= THRESHOLD:
output = {
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": (
f"Delegation score: {score}. "
f"Consider delegating this task to a subagent."
)
}
}
else:
output = {}
print(json.dumps(output))
What the Production Version Adds
The simplified version above shows the core pattern. My production version in Evolving Lite adds:
- Model routing: Complexity 1-2 sends to Haiku, 3-7 to Sonnet, 8+ stays on Opus
- Trait injection: Loads personality profiles (curious for research, cautious for debugging) from config
- Coordination awareness: Reads active agent intents to avoid duplicate work across parallel sessions
- Inline hints: Tags like
[explore]or[debug]that Claude picks up for behavior adjustment
The delegation scoring system explains the full factor table and model routing logic.
Hook 3: Context Warning (PostToolUse)
When Claude reads large files or produces long outputs, context fills up fast. This hook tracks approximate token usage and warns at thresholds.
#!/bin/bash
# context-warning.sh - PostToolUse hook
# Reads tool output size from stdin, maintains running count
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | python3 -c "import json,sys; print(json.load(sys.stdin).get('tool_name',''))")
# Track cumulative context growth in a temp file
TRACKER="/tmp/claude-context-$$"
[ -f "$TRACKER" ] || echo "0" > "$TRACKER"
CURRENT=$(cat "$TRACKER")
# Estimate tokens from tool result (rough: 1 token per 4 chars)
TOOL_OUTPUT_SIZE=$(echo "$INPUT" | wc -c)
ESTIMATED_TOKENS=$((TOOL_OUTPUT_SIZE / 4))
NEW_TOTAL=$((CURRENT + ESTIMATED_TOKENS))
echo "$NEW_TOTAL" > "$TRACKER"
# Threshold warnings
if [ "$NEW_TOTAL" -gt 800000 ]; then
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":\"CRITICAL: Context estimated at ${NEW_TOTAL} tokens (80%+). Stop loading new files. Prepare session handoff or run /compact.\"}}"
elif [ "$NEW_TOTAL" -gt 600000 ]; then
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":\"WARNING: Context estimated at ${NEW_TOTAL} tokens (60%+). Prefer subagent delegation for remaining research. Load summaries, not full docs.\"}}"
fi
# Under both thresholds: print nothing. Silence is how a hook says "carry on".
Before this hook existed, I regularly hit context limits mid-task and lost work. Now I get early warnings and can plan accordingly. My sessions stay lean because the hook forces cleanup as I go.
Why PostToolUse, Not PreToolUse?
A PreToolUse hook fires before the tool runs, when the output that grows your context does not exist yet. PostToolUse fires after the tool has finished, so the hook sees the result and can count it, and its warning gives Claude information to adjust its next decision. The hook informs; Claude decides.
Hook 4: Auto-Format on Edit (PostToolUse)
The most common use case from the official docs: automatically format files after Claude edits them.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/auto-format.sh"
}
]
}
]
}
}
#!/bin/bash
# auto-format.sh - runs formatter on edited files
INPUT=$(cat)
FILE=$(echo "$INPUT" | python3 -c "
import json, sys
data = json.load(sys.stdin)
ti = data.get('tool_input', {})
print(ti.get('file_path', ti.get('path', '')))
")
if [ -z "$FILE" ]; then
exit 0
fi
EXT="${FILE##*.}"
case "$EXT" in
ts|tsx|js|jsx)
npx prettier --write "$FILE" 2>/dev/null
;;
py)
python3 -m black --quiet "$FILE" 2>/dev/null
;;
rs)
rustfmt "$FILE" 2>/dev/null
;;
esac
# No output, exit 0: the edit stands and Claude carries on.
This hook fires on every Edit, Write, or MultiEdit tool call. The matcher "Edit|Write|MultiEdit" catches all three. The script extracts the file path from the tool input, detects the language from the extension, and runs the appropriate formatter.
The result: Claude's edits are always formatted according to your project standards, with zero manual intervention. One caveat: in my auto mode test, an Edit|Write hook saw none of the 5 edits that went through Bash.
Hook 5: Pre-Compact State Save (PreCompact)
When compaction runs, conversation history gets summarized and path-scoped rules are lost. This hook saves critical session state before that happens.
#!/usr/bin/env python3
import json, sys, os
from datetime import datetime
data = json.loads(sys.stdin.read())
session_id = data.get("session_id", "unknown")
cwd = data.get("cwd", os.getcwd())
# Save session state snapshot
state = {
"session_id": session_id,
"timestamp": datetime.now().isoformat(),
"cwd": cwd,
"event": "pre_compact"
}
state_dir = os.path.expanduser("~/.claude/state")
os.makedirs(state_dir, exist_ok=True)
state_file = os.path.join(state_dir, f"pre-compact-{session_id[:8]}.json")
with open(state_file, "w") as f:
json.dump(state, f, indent=2)
# Print nothing: PreCompact has no documented way to add context for Claude.
# A SessionStart hook with the compact matcher reads this file after compaction.
Pairing PreCompact with a SessionStart Hook
PreCompact saves state. A second hook restores it after compaction. PostCompact is not that hook: the docs list it with the events that have no decision control, for side effects such as logging the summary or updating external state. SessionStart is a context-only event that can return additionalContext, and its compact matcher fires after compaction. Together they create a compaction-resilient workflow:
- PreCompact - extract decisions, task state, key file paths
- Compaction runs - conversation history summarized
- SessionStart with the
compactmatcher - read the saved file and re-inject the state asadditionalContext, including a reminder that path-scoped rules need reloading (PostCompact can log or update external state)
This is the hook-based version of the compact-stuff strategy for preserving session knowledge through compaction events.
- -Check git branch before dangerous commands
- -Estimate task complexity for delegation
- -Monitor context window usage
- -Run formatter after every edit
- -Remember to save state before compaction
- -Validate no sensitive data staged
- +security-tier-check blocks by branch + tier
- +delegation-enforcer scores and routes automatically
- +context-warning alerts at 60% and 80%
- +auto-format runs Prettier/Black on every edit
- +pre-compact saves session state snapshot
- +All hooks: 0.8s combined execution time
Designing Hooks That Compose
Individual hooks solve individual problems. The real power comes from composition - hooks that work together without explicitly knowing about each other.
The Composition Pattern
Each hook follows the same contract: JSON in, and JSON out only when it has something to say. Where an event accepts it, additionalContext is the channel to Claude. This means hooks compose naturally through context injection:
A single user prompt triggers this cascade:
- UserPromptSubmit - delegation score calculated, routing hint injected
- Claude processes the prompt, decides to call a tool
- PreToolUse - security check validates the tool call
- Tool executes
- PostToolUse - formatter runs, context warning updates, tracker logs
No hook knows about the others. The ones that talk to Claude do it through additionalContext injections that Claude reads naturally.
Performance Budget
Command hooks run as shell commands - they add latency. My rule: each hook gets a 200ms budget. If a hook needs more than that, it spawns a background process and returns immediately. Across all my hooks, average combined execution time is 0.8 seconds per session turn.
Keep hooks fast by:
- Avoid network calls in synchronous hooks (use background for API calls)
- Cache expensive computations (git branch doesn't change mid-command)
- Return early when the matcher already filtered to relevant events
Production Results After 6 Months
After six months running 20+ production hooks across multiple projects:
- Manual checks per commit: 6 - 0
- Context limit hits: 12/month - 1/month
- Accidental dangerous commands: 4 - 0
- Delegation decisions: Manual - Automatic (83% of tasks)
- Average hook execution time: 0.8 seconds combined
- Sessions lost to context overflow: 8 - 0
The biggest surprise was not time saved - it was consistency. Hooks do not forget. They do not skip steps when you are tired. That reliability compounds across hundreds of sessions.
Getting Started With Your First Hook
You do not need 20 hooks on day one. Start with one that solves a real pain point:
- If you hit context limits - start with a PostToolUse context warning
- If you have pushed secrets - start with a PreToolUse security check
- If you handle too much manually - start with a UserPromptSubmit delegation enforcer
- If your code style drifts - start with a PostToolUse auto-formatter
The Minimal Hook Template
Every hook follows the same pattern. Copy this and customize:
#!/usr/bin/env python3
import json, sys
data = json.loads(sys.stdin.read())
# Your logic here
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": data.get("hook_event_name", ""),
"additionalContext": "Your message to Claude here"
}
}))
That template fits events that accept additionalContext, such as UserPromptSubmit, PostToolUse and SessionStart. No decision field: omitting it is how you let the action through. Add {"decision": "block", "reason": "..."} only when you actually want to stop something, and only on an event that supports it.
Add it to your settings.json, pick the right event and matcher, and you have a working hook. The Evolving Lite plugin includes 10 production hooks ready to install - security, delegation, context management, and more.
Run /hooks in Claude Code to inspect your current hook configuration and verify everything is wired up correctly.
Updated 2 October 2026: the event count, the blocking events, the decision fields, the matcher rules, the PreCompact example and the failure behavior were brought in line with the current Claude Code hooks docs.



