Hooks are Claude Code's extension architecture
The hooks system — nine lifecycle events, prompt-based and command-based hooks, a JSON I/O contract, and a self-referential loop pattern — turns Claude Code from a tool into a programmable platform.
Most developer tools have extension points. VS Code has extensions. Git has hooks. Chrome has extensions. Claude Code’s answer is a system of lifecycle events — nine specific moments where your code can inspect, validate, modify, or block what the agent is about to do. Together they turn Claude Code from an application into a programmable platform.
The nine lifecycle events
Every meaningful moment in a Claude Code session has a hook event. Some fire before an action (validate, block, or modify), some after (react, log, or enforce):
| Event | When it fires | Primary use |
|---|---|---|
| PreToolUse | Before any tool executes | Validate, approve, deny, or modify tool calls |
| PostToolUse | After a tool completes | React to results, provide feedback, log activity |
| Stop | When the main agent considers stopping | Enforce completion standards before exit |
| SubagentStop | When a subagent considers stopping | Ensure subagents completed their task |
| SessionStart | When a session begins | Load project context, detect environment, set variables |
| SessionEnd | When a session ends | Cleanup, persist state, audit logging |
| UserPromptSubmit | When the user submits a prompt | Add context, validate input, block forbidden requests |
| PreCompact | Before context compaction | Preserve critical information before context is truncated |
| Notification | When Claude sends a desktop notification | Log, route to external systems, trigger downstream workflows |
The first four events (PreToolUse, PostToolUse, Stop, SubagentStop) operate on agent behavior directly — they can say yes or no. The remaining five operate on session lifecycle — they set up, tear down, and observe.
Two types of hooks: prompt vs. command
Hooks come in two forms, and the choice between them is a tradeoff between flexibility and determinism.
Prompt-based hooks
A prompt-based hook sends a natural language instruction to an LLM alongside the event context. The model decides whether to approve, deny, or warn:
{
"type": "prompt",
"prompt": "Command: $TOOL_INPUT. If the command contains 'rm', 'delete', 'drop', or other destructive operations, return 'ask' to confirm with the user. Otherwise return 'approve'.",
"timeout": 30
}
The LLM reads the tool input, applies the criteria, and returns a structured decision. Prompt-based hooks handle ambiguity well — they recognize intent, not just pattern matches. A command that looks dangerous on regex (rm /tmp/cache) is clearly safe to a model that reads the path.
Supported events: PreToolUse, Stop, SubagentStop, UserPromptSubmit
Command hooks
A command hook executes a shell script with deterministic logic:
{
"type": "command",
"command": "bash ${CLAUDE_PLUGIN_ROOT}/scripts/validate.sh",
"timeout": 10
}
The script receives event data as JSON on stdin and decides by exit code. Command hooks are fast, predictable, and auditable — they do exactly one thing and never hallucinate. Use them for checks where correctness matters more than flexibility: path traversal detection, file size limits, secret scanning.
Default timeouts: 60 seconds for command hooks, 30 seconds for prompt hooks. Set lower timeouts for hot-path checks (PreToolUse on every file write), higher for batch operations (Stop hook with transcript review).
The JSON I/O contract
Hooks communicate through a structured JSON interface. Input arrives on stdin. Output goes to stdout. Exit codes signal the decision.
Input format
Every hook receives a common envelope with event-specific fields appended:
{
"session_id": "abc123",
"transcript_path": "/path/to/transcript.txt",
"cwd": "/current/working/dir",
"permission_mode": "ask",
"hook_event_name": "PreToolUse",
"tool_name": "Write",
"tool_input": {
"file_path": "/src/auth.ts",
"content": "..."
}
}
Event-specific fields:
- PreToolUse / PostToolUse:
tool_name,tool_input(andtool_resultfor PostToolUse) - UserPromptSubmit:
user_prompt - Stop / SubagentStop:
reason
Inside prompt-based hooks, these are accessible as $TOOL_INPUT, $TOOL_RESULT, $USER_PROMPT. Command hooks parse them from stdin JSON with jq.
Output format
Hooks return a JSON decision:
{
"continue": true,
"suppressOutput": false,
"systemMessage": "File write validated and approved"
}
For PreToolUse specifically, the output includes a permission decision:
{
"hookSpecificOutput": {
"permissionDecision": "allow",
"updatedInput": {"file_path": "/corrected/path.ts"}
},
"systemMessage": "Path corrected to match project conventions"
}
The permissionDecision field takes three values: allow (proceed normally), deny (block silently with an error to Claude), or ask (surface to the user for manual approval). The updatedInput field lets hooks modify tool inputs before execution — correct a path, add a flag, strip dangerous arguments.
Exit codes
The exit code of a command hook is the primary signal:
- 0 — Success. stdout is shown in the transcript.
- 2 — Blocking error. stderr is fed back to Claude as feedback.
- Any other — Non-blocking error. The hook failed but the operation continues.
Matchers: routing events to the right hook
Not every hook should fire on every event. The matcher field routes specific tools to specific hooks using regex patterns:
{
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{
"type": "prompt",
"prompt": "Validate file write safety..."
}]
},
{
"matcher": "mcp__.*__delete.*",
"hooks": [{
"type": "prompt",
"prompt": "Deletion via MCP detected — verify this is intentional..."
}]
},
{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "bash ${CLAUDE_PLUGIN_ROOT}/scripts/validate-bash.sh"
}]
}
]
}
Matchers support exact tool names ("Write"), alternation ("Read|Write|Edit"), wildcards ("*" for all tools), and full regex ("mcp__.*__delete.*" for all MCP delete operations).
Multiple hooks on the same matcher run in parallel. This is a design constraint, not a limitation — hooks cannot depend on each other’s output, which forces each hook to be self-contained. A PreToolUse with three hooks (check size, check path, check content) runs all three simultaneously and blocks only if any one returns deny.
The Ralph Wiggum pattern: self-referential agent loops
The most dramatic use of the Stop hook is the Ralph Wiggum pattern — named after the relentlessly persistent Simpsons character. A Stop hook that always blocks exit and feeds the same prompt back creates a self-referential loop where Claude iterates autonomously until work is complete:
#!/bin/bash
# ralph-loop.sh — The Stop hook that never stops
# Reads the session prompt file and blocks exit unless the completion promise appears
PROMPT_FILE="$CLAUDE_PROJECT_DIR/.ralph-prompt.txt"
COMPLETION_PROMISE="${RALPH_COMPLETION_PROMISE:-DONE}"
# Check if the completion promise is in the transcript
if grep -q "$COMPLETION_PROMISE" "$TRANSCRIPT_PATH" 2>/dev/null; then
echo '{"decision": "approve"}'
exit 0
fi
# Not done — block exit, feed the same prompt back
echo "{\"decision\": \"block\", \"reason\": \"Work not complete. Continue iterating.\"}" >&2
exit 2
The loop works like this:
- You write a prompt describing the task and a completion promise (“Output
<promise>COMPLETE</promise>when done”) - Claude works on the task
- Claude tries to exit
- The Stop hook inspects the transcript, finds no completion promise, and blocks exit (code 2)
- Claude receives the block as feedback and continues working
- Repeat until Claude outputs the completion promise or hits the iteration cap
The prompt never changes between iterations. Claude’s previous work persists in files on disk. Each iteration, Claude reads its own past work, sees what tests pass or fail, and improves. The loop is not a bash while true wrapper — it happens inside a single Claude Code session, with full continuity of file state and git history.
The key safeguards: always set --max-iterations as a ceiling, include a completion promise in the prompt so the loop has a defined exit condition, and design prompts for incremental progress (phases, checklists, TDD loops) rather than monolithic goals.
Hookify: rules as configuration, not code
Writing hooks typically means writing JSON configuration and shell scripts. The hookify plugin removes that friction: describe the behavior you want and it generates a markdown configuration file with YAML frontmatter. No code, no JSON, no shell scripts.
A rule is a single .local.md file:
---
name: block-dangerous-rm
enabled: true
event: bash
pattern: rm\s+-rf
action: block
---
🛑 **Dangerous rm command detected!**
This command could delete important files. Verify the path is correct
and consider using a safer approach.
action: block prevents the operation entirely (PreToolUse exits 2). action: warn (the default) shows the message but allows the operation to continue.
Advanced rules with conditions
Multi-condition rules check several fields simultaneously:
---
name: api-key-in-typescript
enabled: true
event: file
action: warn
conditions:
- field: file_path
operator: regex_match
pattern: \.tsx?$
- field: new_text
operator: regex_match
pattern: (API_KEY|SECRET|TOKEN)\s*=\s*["']
---
🔐 **Hardcoded credential in TypeScript!**
Use environment variables instead of hardcoded values.
All conditions must match for the rule to fire. This lets you write targeted rules: “warn about hardcoded credentials, but only in TypeScript files, and only when they appear in new content being written.”
The six operators
| Operator | Description | Example pattern |
|---|---|---|
regex_match | Full regex matching | `.env$ |
contains | Substring anywhere | console.log( |
equals | Exact string match | production |
not_contains | Absence check | `npm test |
starts_with | Prefix match | DROP TABLE |
ends_with | Suffix match | .env |
The fields available vary by event: command for bash events, file_path and new_text for file events, user_prompt for prompt events. For Stop events, you typically check the transcript for evidence of completion (tests run, build succeeded).
Why the markdown format matters
The hookify format has zero dependencies — it parses YAML frontmatter with Python’s stdlib. Rules live in .claude/ as plain files you can grep, diff, and version. There is no registry, no build step, no server. Adding a rule is creating a file; removing a rule is deleting it.
The composition model: what you can build
Individually, each hook event solves a narrow problem. Composed, they form a programmable surface:
Layered validation. A SessionStart hook detects the project type and sets environment variables. PreToolUse hooks validate every file write (no path traversal, no secrets, correct project conventions). PostToolUse hooks run linters on changed files. A Stop hook blocks exit unless tests were run and the build succeeded.
Cross-event workflows. SessionStart initializes tracking counters. PostToolUse increments them when specific tools run. Stop reads the counters and blocks if required operations never happened.
External integration. A Notification hook routes every desktop notification to Slack. A SessionEnd hook writes an audit log entry. A PreToolUse hook on destructive MCP operations posts a confirmation request to a webhook.
Policy enforcement at scale. Enterprise-managed hooks deployed across an organization block specific behaviors regardless of user settings: no writes to /etc, no curl | bash, no hardcoded credentials, tests must pass before any session ends.
| Event | Architecture role |
|---|---|
| PreToolUse | Guard — validate, block, or modify before action |
| PostToolUse | React — enforce quality, provide feedback after action |
| Stop | Gate — prevent exit until standards are met |
| SessionStart | Bootstrap — configure environment before work begins |
| SessionEnd | Cleanup — persist state, audit, notify on close |
| UserPromptSubmit | Intercept — add context or block prompts before processing |
| PreCompact | Preserve — save critical context before truncation |
| Notification | Observe — route events to external systems |
A platform is defined by its extension points. Nine lifecycle events, two hook types, a structured I/O contract, and a composition model that lets you layer them is a credible claim to being one.
Takeaways
Nine events cover the full session lifecycle
PreToolUse, PostToolUse, Stop, and SubagentStop control agent behavior. SessionStart, SessionEnd, UserPromptSubmit, PreCompact, and Notification handle lifecycle.
Prompt hooks handle judgment; command hooks handle determinism
Use prompt hooks when the decision requires reading intent and context. Use command hooks when correctness matters more than flexibility.
The JSON contract is the extension protocol
Stdin JSON in, stdout JSON out, exit code 0 for allow and 2 for block. Every hook speaks the same protocol regardless of event type.
The Stop hook creates self-referential agent loops
Block exit, feed the same prompt back, let Claude iterate by reading its own file state. Works for well-defined tasks with automatic verification.
Hookify turns rules into configuration
Six operators, YAML frontmatter, markdown files with zero dependencies. Rules take effect immediately without restart — no code required.
Composition makes it a platform
Individual hooks solve narrow problems. Layered — SessionStart detection + PreToolUse validation + Stop gating — they form a programmable surface.