Hooks Guide
Claude Code hooks from first principles: exit codes, JSON output, async, HTTP, and production patterns.
Problem: You approve every file write by hand. Then every command. Then every format pass. Twenty interruptions later, the feature you were building has slipped out of your head.
Quick Win: Add this to .claude/settings.json and never approve a Prettier format again:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "npx prettier --write \"$CLAUDE_TOOL_INPUT_FILE_PATH\""
}
]
}
]
}
}Every file Claude writes now auto-formats. Zero clicks. Zero context switches.
The 12 Hook Lifecycle Events
Every Claude Code event can trigger a hook. A hook runs a shell command, or sends an LLM prompt, when that event fires. The full list:
| Hook | When It Fires | Can Block? | Best Use |
|---|---|---|---|
| SessionStart | Session begins or resumes | NO | Load context, set env vars |
| UserPromptSubmit | You hit enter | YES | Context injection, validation |
| PreToolUse | Before tool runs | YES | Security blocking, auto-approve (extends permission system) |
| PermissionRequest | Permission dialog appears | YES | Auto-approve/deny |
| PostToolUse | After tool succeeds | NO* | Auto-format, lint, log |
| PostToolUseFailure | After tool fails | NO | Error handling |
| SubagentStart | Spawning subagent | NO | Subagent initialization |
| SubagentStop | Subagent finishes | YES | Subagent validation |
| Stop | Claude finishes responding | YES | Task enforcement |
| PreCompact | Before compaction | NO | Transcript backup |
| Setup | With --init/--maintenance | NO | One-time setup |
| SessionEnd | Session terminates | NO | Cleanup, logging |
| Notification | Claude sends notification | NO | Desktop alerts, TTS |
*A PostToolUse hook can feed a message back to Claude, but it cannot undo the tool that already ran.
Exit Codes: The Control Mechanism
Exit codes are how a hook talks to Claude:
| Exit Code | What Happens |
|---|---|
| 0 | Success - hook ran, stdout processed for JSON |
| 2 | Block - operation stopped, stderr sent to Claude |
| Other | Error - stderr shown to user, execution continues |
Exit code 2 is the one that blocks things. Return it from a PreToolUse hook and the tool never runs. Return it from a Stop hook and Claude has to keep going instead of finishing.
Hook Types: Command, HTTP, Prompt, and Agent
Four handler types exist. Pick whichever one matches the job.
Command hooks run shell scripts:
{
"type": "command",
"command": "python validator.py",
"timeout": 30
}HTTP hooks POST to an endpoint and receive JSON back: New - Feb 2026
{
"type": "http",
"url": "http://localhost:8080/hooks/pre-tool-use",
"timeout": 30,
"headers": {
"Authorization": "Bearer $MY_TOKEN"
},
"allowedEnvVars": ["MY_TOKEN"]
}Claude Code posts the event JSON as the request body (Content-Type: application/json), and the reply follows whichever JSON output schema a command hook would have used. A few things differ:
- Non-blocking errors: A non-2xx response, a connection failure, or a timeout will not stop execution. To actually block a tool call, return a 2xx response with
decision: "block"in the JSON body. - Header env vars: Reference variables with
$VAR_NAMEor${VAR_NAME}inside header values. Only names listed inallowedEnvVarsget resolved. Anything else resolves to an empty string. - Deduplication: HTTP hooks dedupe by URL. Command hooks dedupe by command string.
- Config only: You have to edit the settings JSON directly. The
/hooksinteractive menu only supports command hooks.
Prompt hooks use LLM evaluation (a nice fit for Stop and SubagentStop):
{
"type": "prompt",
"prompt": "Evaluate if Claude should stop: $ARGUMENTS. Check if all tasks complete.",
"timeout": 30
}The LLM replies with {"ok": true} or {"ok": false, "reason": "..."}.
Agent hooks start a subagent that can read files (Read, Grep, Glob) for a deeper check:
{
"type": "agent",
"prompt": "Verify all test files have corresponding implementation files",
"timeout": 60
}An agent hook pokes around the codebase first, then decides. That makes it more thorough than a prompt hook. It is also slower (default timeout: 60s versus 30s for prompts).
Async Hooks (Non-Blocking) New - Jan 2026
Set async: true and the hook runs in the background instead of blocking Claude. Anthropic shipped this in January 2026:
{
"type": "command",
"command": "node backup-script.js",
"async": true,
"timeout": 30
}Best for:
- Logging and analytics
- Backup creation (PreCompact)
- Notifications
- Any side-effect that shouldn't slow things down
Not suitable for:
- Security blocking (PreToolUse with exit code 2)
- Auto-approve decisions (PermissionRequest)
- Any hook where Claude needs the result
HTTP Hooks: Post to Endpoints New - Feb 2026
Point an HTTP hook at a web URL you own and the event flows there instead of into a local script. That opens up a handful of patterns that command hooks couldn't handle cleanly:
- Remote validation services that enforce team-wide policies
- Centralized logging to a shared audit system
- Webhook integrations with Slack, PagerDuty, or custom dashboards
- Microservice architectures where hook logic runs alongside your API
Basic HTTP Hook
Route all Bash commands through a validation endpoint:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "http",
"url": "http://localhost:8080/hooks/pre-tool-use",
"timeout": 30,
"headers": {
"Authorization": "Bearer $MY_TOKEN"
},
"allowedEnvVars": ["MY_TOKEN"]
}
]
}
]
}
}What lands on your server is the exact stdin payload a command hook would read, delivered through the POST body. Reply in the JSON output format and Claude Code parses it the same way.
HTTP Response Handling
HTTP hooks read responses differently. No exit codes. Just HTTP status and the response body:
| Response | Behavior |
|---|---|
| 2xx + empty body | Success, equivalent to exit code 0 with no output |
| 2xx + plain text body | Success, the text is added as context to Claude |
| 2xx + JSON body | Success, parsed using the same JSON output schema as command hooks |
| Non-2xx status | Non-blocking error, execution continues |
| Connection failure / timeout | Non-blocking error, execution continues |
The thing to watch: An HTTP hook cannot block on status codes alone. Send back a 4xx or a 5xx and you get a logged error while execution rolls on. Real blocking, or a real permission denial, needs a 2xx response whose JSON body carries the decision fields:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Blocked by security policy"
}
}Secure Header Authentication
The headers field can interpolate environment variables, but only when the variable appears in allowedEnvVars. That stops secrets from leaking by accident:
{
"type": "http",
"url": "https://hooks.example.com/validate",
"headers": {
"Authorization": "Bearer $API_KEY",
"X-Team-Id": "$TEAM_ID"
},
"allowedEnvVars": ["API_KEY", "TEAM_ID"]
}A $VAR that isn't on the allowlist silently becomes an empty string. No warning. No error. Just empty. Review the allowlist twice before you ship.
JSON Output: Advanced Control
When an exit code is too blunt, a hook can hand back a structured JSON response instead.
PreToolUse Decisions
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "Safe read operation",
"updatedInput": { "command": "modified-command" },
"additionalContext": "Context for Claude"
}
}"allow": Bypasses permission system"deny": Blocks tool, tells Claude why"ask": Prompts user for confirmationupdatedInput: Modify tool parameters before execution
PermissionRequest Decisions
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "allow",
"updatedInput": { "command": "npm run lint" }
}
}
}Stop/SubagentStop Enforcement
{
"decision": "block",
"reason": "Tests failing. Fix them before completing."
}Hook 1: Auto-Format on Save
Run formatters after every file write:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "npx prettier --write \"$CLAUDE_TOOL_INPUT_FILE_PATH\""
},
{
"type": "command",
"command": "npx eslint --fix \"$CLAUDE_TOOL_INPUT_FILE_PATH\""
}
]
}
]
}
}Both hooks run in parallel. Format and lint both finish before Claude's reply appears.
Hook 2: Session Context Injection
Load context at session start:
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "echo '## Git Status' && git status --short && echo '## TODOs' && grep -r 'TODO:' src/ | head -5"
}
]
}
]
}
}Persist Environment Variables
A SessionStart or Setup hook can define environment variables that stick for the whole session:
#!/bin/bash
if [ -n "$CLAUDE_ENV_FILE" ]; then
echo 'export NODE_ENV=production' >> "$CLAUDE_ENV_FILE"
echo 'export API_KEY=your-key' >> "$CLAUDE_ENV_FILE"
fi
exit 0Hook 3: Security Blocking
Stop dangerous operations with PreToolUse:
#!/usr/bin/env python3
import json
import sys
import re
DANGEROUS_PATTERNS = [
r'\brm\s+.*-[a-z]*r[a-z]*f',
r'sudo\s+rm',
r'chmod\s+777',
r'git\s+push\s+--force.*main',
]
input_data = json.load(sys.stdin)
if input_data.get('tool_name') == 'Bash':
command = input_data.get('tool_input', {}).get('command', '')
for pattern in DANGEROUS_PATTERNS:
if re.search(pattern, command, re.IGNORECASE):
print("BLOCKED: Dangerous pattern", file=sys.stderr)
sys.exit(2)
sys.exit(0)Hook 4: Auto-Approve Safe Commands
Use PermissionRequest to skip the prompt when a command is known safe:
{
"hooks": {
"PermissionRequest": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python .claude/hooks/auto-approve.py"
}
]
}
]
}
}#!/usr/bin/env python3
import json
import sys
SAFE_PREFIXES = ['npm test', 'npm run lint', 'git status', 'ls']
input_data = json.load(sys.stdin)
command = input_data.get('tool_input', {}).get('command', '')
for prefix in SAFE_PREFIXES:
if command.startswith(prefix):
output = {
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {"behavior": "allow"}
}
}
print(json.dumps(output))
sys.exit(0)
sys.exit(0) # Normal flow for other commandsHook 5: Transcript Backup
PreCompact is the moment to snapshot a transcript. Mark the hook async: true since a backup never has to block Claude:
{
"hooks": {
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "python .claude/hooks/backup-transcript.py",
"async": true
}
]
}
]
}
}Matchers manual and auto separate /compact from automatic compaction.
Hook 6: Task Completion Enforcement
A Stop hook keeps Claude from calling a job done too early:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Check if all tasks are complete: $ARGUMENTS. Return {\"ok\": false, \"reason\": \"...\"} if work remains."
}
]
}
]
}
}The Stop Hook guide covers command-based versions of this pattern.
Hook 7: Skill Activation
A Skill Activation Hook steps in on the prompt before Claude sees it and tacks skill recommendations onto the end. Each keyword you type is checked against a rule set, so the Postgres tuning skill lands in context the moment you mention a slow query. Twenty-one skill categories already use this pattern inside the Code Kit's SkillActivationHook:
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node .claude/hooks/SkillActivationHook/skill-activation-prompt.mjs"
}
]
}
]
}
}Hook 8: Threshold-Based Backups via StatusLine
StatusLine is the only place you get live context metrics. That makes it the right spot to kick off a backup when you hit a threshold:
{
"statusLine": {
"type": "command",
"command": "node .claude/hooks/ContextRecoveryHook/statusline-monitor.mjs"
}
}Critical: The remaining_percentage field already bakes in a fixed 33K-token autocompact buffer. To get the real "free until autocompact" number, do the math yourself:
const AUTOCOMPACT_BUFFER_TOKENS = 33000;
const autocompactBufferPct = (AUTOCOMPACT_BUFFER_TOKENS / windowSize) * 100;
const freeUntilCompact = Math.max(0, pctRemainTotal - autocompactBufferPct);Two trigger systems run side by side inside the backup script. Tokens are the primary trigger. Percentages sit underneath as a safety net:
// Token-based triggers (primary - works across all window sizes)
const TOKEN_FIRST_BACKUP = 50000; // First backup at 50k tokens used
const TOKEN_UPDATE_INTERVAL = 10000; // Update every 10k tokens after
if (currentTotalTokens >= TOKEN_FIRST_BACKUP) {
if (lastBackupTokens < TOKEN_FIRST_BACKUP) {
runBackup(
sessionId,
`tokens_${Math.round(currentTotalTokens / 1000)}k_first`,
);
} else if (currentTotalTokens - lastBackupTokens >= TOKEN_UPDATE_INTERVAL) {
runBackup(
sessionId,
`tokens_${Math.round(currentTotalTokens / 1000)}k_update`,
);
}
}
// Percentage-based triggers (safety net, especially for 200k windows)
const THRESHOLDS = [30, 15, 5];
for (const threshold of THRESHOLDS) {
if (state.lastFree > threshold && freeUntilCompact <= threshold) {
runBackup(sessionId, `crossed_${threshold}pct`);
}
}
if (freeUntilCompact < 5 && freeUntilCompact < state.lastFree) {
runBackup(sessionId, "continuous");
}PreCompact only fires at the moment of compaction. StatusLine-based backups snapshot the session ahead of time, while nothing has broken yet. The token trigger matters most on large windows like 1M, where a percentage trigger would arrive way too late to help.
Architecture: Three-File Structure
The backup system splits into three files with separate jobs:
.claude/hooks/ContextRecoveryHook/
├── backup-core.mjs # Shared backup logic (parsing, formatting, saving)
├── statusline-monitor.mjs # Threshold detection + display (calls backup-core)
└── conv-backup.mjs # PreCompact trigger (calls backup-core)| File | Trigger | Responsibility |
|---|---|---|
backup-core.mjs | Called by others | Parse transcript, format markdown, save file, update state |
statusline-monitor.mjs | StatusLine (continuous) | Monitor tokens/context %, detect triggers, display status |
conv-backup.mjs | PreCompact hook | Handle pre-compaction event |
One file owns the actual backup work. Tweak how markdown renders, how files get named, or how state is recorded inside backup-core.mjs, and both callers inherit the change for free.
Backup File Naming
Backups use numbered filenames with timestamps so the history is easy to scan:
.claude/backups/1-backup-26th-Jan-2026-4-30pm.md
.claude/backups/2-backup-26th-Jan-2026-5-15pm.md
.claude/backups/3-backup-26th-Jan-2026-5-45pm.mdStatusLine Display
If a backup exists for the current session, the statusline prints its path right there:
[!] 25.0% free (50.0K/200K)
-> .claude/backups/3-backup-26th-Jan-2026-5-45pm.mdYou know at a glance which file to pull back in once compaction runs.
State Tracking
Both hooks share a state file at ~/.claude/claudefast-statusline-state.json:
{
"sessionId": "abc123",
"lastFreeUntilCompact": 25.5,
"currentBackupPath": ".claude/backups/3-backup-26th-Jan-2026-5-45pm.md"
}Recommended workflow: The moment compaction triggers, hit /clear for a clean slate and reload whichever backup path the statusline printed. Otherwise the auto-generated summary and your restored notes end up stepping on each other.
Hook 9: Setup Hooks for Installation and Maintenance
A Setup hook runs before your session starts, triggered by special CLI flags:
claude --init # Triggers Setup hook with matcher "init"
claude --init-only # Same as above, but exits after hook (CI-friendly)
claude --maintenance # Triggers Setup hook with matcher "maintenance"Wire them up with matchers in settings.json:
{
"hooks": {
"Setup": [
{
"matcher": "init",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/setup_init.py",
"timeout": 120
}
]
},
{
"matcher": "maintenance",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/setup_maintenance.py",
"timeout": 60
}
]
}
]
}
}Combine flags with prompts: Tack a prompt onto the flag itself.
claude --init "/install"
The hook runs first, deterministic. Then the /install command fires, agentic. One invocation, two execution modes: a scripted setup followed by an agent that reasons over it.
The Setup Hooks Guide walks through the whole pattern end to end, including justfile setups and interactive onboarding flows.
Configuration Locations
| Location | Scope | Priority |
|---|---|---|
| Managed policy | Enterprise | Highest |
.claude/settings.json | Project (shared) | High |
.claude/settings.local.json | Project (personal) | Medium |
~/.claude/settings.json | All projects | Lowest |
Disabling and Restricting Hooks
Disable All Hooks
If a hook is causing trouble or you just want a clean baseline, flip disableAllHooks to true in your settings:
{
"disableAllHooks": true
}Every hook at every scope goes quiet. User, project, local. Reach for this when debugging a runaway hook or when you need a known-clean baseline.
Managed Hook Restrictions
Organizations that need centralized control can set allowManagedHooksOnly to true in managed settings:
{
"allowManagedHooksOnly": true
}Flip it on and the only hooks Claude Code will run are the ones declared in managed settings plus SDK hooks. User scope, project scope, plugin scope: all three get ignored. No local hook can tunnel around the org's security posture anymore.
Pair this setting with allowManagedPermissionRulesOnly, which clamps down on permission rules the same way. Together they put admins in charge of the whole surface: permissions themselves, plus every hook that extends them.
Matcher Syntax
| Pattern | Matches |
|---|---|
"" or omitted | All tools |
"Bash" | Only Bash (exact, case-sensitive) |
| `"Write | Edit"` |
"mcp__memory__.*" | All memory MCP tools |
Critical: Leave no spaces around the |. And matcher strings are case-sensitive.
Event-Specific Matchers
SessionStart: startup, resume, clear, compactPreCompact: manual, autoSetup: init, maintenanceNotification: permission_prompt, idle_prompt, auth_success
Environment Variables
| Variable | Description |
|---|---|
CLAUDE_PROJECT_DIR | Project root (all hooks) |
CLAUDE_ENV_FILE | Persist env vars (SessionStart, Setup) |
CLAUDE_CODE_REMOTE | "true" if web, empty if CLI |
Debugging
Hook not triggering?
- Check matcher syntax (case-sensitive, no spaces)
- Verify settings file location
- Test:
echo '{"session_id":"test"}' | python your-hook.py
Command failing?
- Add logging:
command 2>&1 | tee ~/.claude/hook-debug.log - Run with debug:
claude --debug - Hooks breaking on other operating systems? See cross-platform hook patterns
Infinite loops with Stop?
- Always check the
stop_hook_activeflag first
Start With One Hook
Pick the friction point that hurts most:
- Formatting every file by hand? PostToolUse formatter
- Approving safe commands all day? PermissionRequest auto-approve
- Session opens with no context? SessionStart injection
- Losing progress to compaction? PreCompact backup
- Tasks getting called done too early? Stop enforcement
One hook. One friction point gone. Then iterate. If you want to skip the setup, the Code Kit arrives with 5 hooks already configured: skill activation, auto-formatting, context recovery, permission automation, and code validation. Every one of them is built on the patterns above.
Stop configuring. Start building.