Claude Code hooks from first principles: exit codes, JSON output, async commands, HTTP endpoints, PreToolUse and PostToolUse matchers, 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:
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.
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_NAME or ${VAR_NAME} inside header values. Only names listed in allowedEnvVars get 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 /hooks interactive 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).
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
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 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:
The headers field can interpolate environment variables, but only when the variable appears in allowedEnvVars. That stops secrets from leaking by accident:
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:
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:
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 usedconst TOKEN_UPDATE_INTERVAL = 10000; // Update every 10k tokens afterif (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.
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.
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.
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"
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.
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.
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.