●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
Claude Code Hooks: A Field Guide to the 8 Core Hooks — and the 33-Event Catalog They Now Live In
Claude Code hooks are powerful, but most people give up before figuring out which event does what. Here's the field guide I wish I had when I started — six months of running hooks in production, distilled.
I spent six months running Claude Code in production before I truly understood hooks. The documentation covers what they do, but not why you'd pick one over another in a real scenario. Halfway through, I realized I'd been setting up hooks wrong the whole time: two hooks fighting each other, one never firing because I'd misconfigured the timing, another creating an infinite loop.
The frustration wasn't the hooks themselves—it was the mental model. I was treating them like simple on/off switches when they're actually a control flow system that governs the entire conversation between you, Claude Code, and your tools.
This guide is for people who got hooks working once, then broke something and gave up. Or people who want to avoid that entirely. I'll walk through every hook type, show you when to use it, and reveal the three production bugs I hit that taught me the hard way.
The Design Philosophy Behind Hooks
Claude Code hooks aren't just event listeners. They're the connective tissue between what you ask Claude to do and what actually gets executed.
Think of the traditional flow: user prompt → code generation → done. One direction, no feedback loop.
Hooks create: user ⇄ Claude Code (with hooks) ⇄ tools (git, npm, shell, API calls). Bidirectional. The hook system manages that conversation. Each hook type handles a specific phase.
The first principle: separate concerns. Don't put PostToolUse logic in PreToolUse. Don't use Stop to handle single-tool failures. Mixing them is where everything breaks.
The second: hooks are synchronous by default, but support async. If you put a Promise in a hook, Claude Code will wait. But wait too long and the session times out. The balance is tight—that's our first production trap.
The third: hooks fire in sequence, not in parallel. Misunderstanding this sequence is the most common footgun. Get this wrong and you'll spend hours debugging why a value is undefined or why a tool runs twice.
The fourth: each hook phase has a different responsibility. Don't make SessionStart do what SessionEnd should do. Don't make PreToolUse handle post-execution validation. This separation is what makes debugging possible.
The 8 Core Hooks: A Reference Map
A quick note on scope before the table. When this article was first published, these eight were the main events hooks offered. The catalog has grown a lot since — the official reference now lists 33 events as of late August 2026 — and I cover how to navigate that expansion in two later sections. The eight below are still the backbone, and everything else is easier to place once you know them.
Hook
Fires
Responsibility
Common Use
SessionStart
Immediately after session begins
Initialize environment
Set up dependencies, env files, initial checks
UserPromptSubmit
When user sends a message (before processing)
Pre-filter and enrich input
Detect code, validate syntax, security checks
PreToolUse
Right before a tool executes
Validate preconditions
Security gates, permission checks, state validation
PostToolUse
Right after a tool executes
Verify results
Confirm success, handle errors, log outcomes
Notification
When Claude Code itself raises a notification
Relay waiting states
Forward permission prompts and idle prompts off-screen
Stop
Right before Claude ends its turn
Withhold the ending
Refuse to stop while work is unfinished
SubagentStop
Right before a subagent ends
Withhold the subagent's ending
Send a subagent back for work it skipped
SessionEnd
Before session closes
Final cleanup
Generate reports, archive logs, notifications
This table is deceptive because it makes them look independent. They're not. Understanding the firing sequence is the foundation of hooks that actually work.
One note about the JSON in this guide. Three sections — PreToolUse vs PostToolUse, UserPromptSubmit, and the SessionStart/SessionEnd pair — use a conceptual pseudo-schema to make each hook's responsibility obvious; those blocks won't run as written. Everything from Stop/SubagentStop onward is real: the notification hook, model switching, the three traps, the templates, the config section, and the exit-code section all show configs and scripts running on my machine. If you want something you can paste and run, skip ahead.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦The real hooks config format in settings.json — matcher, command, the stdin JSON contract, and exit code 2 to block — not the conceptual pseudo-schema
✦A map of the hook catalog as of August 2026 — 33 events — plus working PreModelSwitch / PostModelSwitch configs (v2.1.251) that turn model switches into enforceable policy
✦Measured per-tool overhead that hooks add, and the latency budget that keeps them from getting in the way
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
UserPromptSubmit — Every time the user sends a message
PreToolUse — Before each tool call
PostToolUse — After each tool call (on success)
Notification — Any time during the flow, runs in parallel
Stop — Evaluated at completion checks
SessionEnd — Once, at the very end
Subagents have their own sequence within the parent sequence.
This ordering matters more than anything else. Skip this section and you'll spend days debugging mysterious failures.
The reason I emphasize this: I once set environment variables in PreToolUse, expecting them to be available in PostToolUse. They weren't. Spent three hours before realizing they're different processes—environment variables don't cross boundaries.
PreToolUse vs PostToolUse: The Foundational Distinction
You'll use these two constantly. Getting them right means 80% of your automation works.
PreToolUse: Runs before the tool. Use it for "should I even run this?"
Example: before running npm install, check if package-lock.json is corrupted. If it is, regenerate it first. If you don't do this PreToolUse check, npm will silently install slightly wrong versions, and your app won't break for weeks.
When you push to a GPG-protected branch, PreToolUse verifies your key exists before attempting the push. PostToolUse verifies the remote actually accepted your signature.
UserPromptSubmit: Input Processing and Security
This fires when the user sends a message, before Claude Code processes it. Use it to clean, validate, or enrich user input.
Real example: users sometimes paste code snippets. That code might use imports that aren't installed. Catch that before Claude Code tries to run it.
Real injection example: user pastes SQL from a bug report.
Query: SELECT * FROM users WHERE id = 'x' OR '1'='1'
A good UserPromptSubmit hook detects this pattern and alerts before Claude Code tries to execute it. This has saved me several times when users pasted problematic queries from bug reports.
You can also use this hook to inject context. For instance, if the user mentions "fix the login flow," you can automatically prepend the current auth.ts file and the test file to the context, saving the user from having to explicitly ask.
Stop vs SubagentStop: Don't Confuse These
These two get mixed up constantly, and I had the direction backwards for weeks.
Here's the part that trips everyone up. Stop is not a hook that ends a session. It fires right before Claude finishes its turn, and what it can do is refuse to let that happen. The arrow points the other way. "Stop automatically once all tests pass" isn't Stop's job. "Don't let it stop while tests are still red" is.
I spent an embarrassing amount of time writing goal-achieved logic into a Stop hook before noticing that decision accepts exactly one value — "block". That single detail told me I'd had it backwards the whole time.
#!/usr/bin/env bash# stop-if-untested.sh — refuse to end the turn while tests are failinginput=$(cat)# If we're already continuing because of a stop hook, step asideif [ "$(printf '%s' "$input" | jq -r '.stop_hook_active // false')" = "true" ]; then exit 0fiif ! npm test --silent >/dev/null 2>&1; then printf '%s' '{"decision":"block","reason":"Tests are failing. Fix the failing cases before finishing."}' exit 0fiexit 0
The line that matters here is the stop_hook_active check at the top. Without it, the hook blocks, Claude keeps working, Claude tries to finish, the hook blocks again — and nothing in that cycle guarantees progress. The input JSON tells you whether you're already inside a hook-driven continuation, so one early exit collapses the whole loop.
There's a backstop: Claude Code overrides the hook and ends the turn after eight consecutive blocks. I'd rather not spend eight round trips finding that out. A hook that withholds an ending needs its own exit condition, written in the same file. I write the exit condition first now, and the judgment logic second.
Two more things worth knowing. Stop doesn't run when you interrupt Claude yourself. And when the turn ends because of an API error, you get StopFailure instead — a different event entirely. If what you want is "always run something at the end," SessionEnd is the reliable choice, not Stop.
SubagentStop does the same job one level down: it fires right before a subagent finishes, and it can send that subagent back to work. It supports a matcher, and what the matcher tests is the agent type, not a tool name.
Get these two backwards and you'll see a session that keeps going when you wanted it stopped, with no obvious culprit. Decide whether you're holding back the parent or the child, and the choice makes itself.
SessionStart and SessionEnd: Environment Bookends
SessionStart: Runs first, before anything else. Initialize the environment.
SessionStart is where you set up everything Claude Code needs before touching your code. Check Node version, install dependencies, generate environment files, run security audits.
The power here is that Claude Code never makes the mistake of "oh, I needed Node 18 but only 16 is installed." You're baking in those requirements upfront.
SessionEnd: Runs last, before the session closes. Cleanup and reporting.
SessionEnd is where you clean up and report results. Generate a summary of what was done, send it to stakeholders, archive logs for auditing, delete temporary files.
This is useful for compliance. If your workflow touches production or sensitive data, SessionEnd lets you ensure all audit trails are preserved before the session closes.
Notification: What It Actually Fires On
This is the one I misread for longest. The name suggested "send a notification whenever you like," so I went looking for a way to fire it on a commit — and never found one, because there isn't one.
Notification fires when Claude Code itself raises a notification. Claude Code decides when that happens; you don't get to promote arbitrary events into it. The matcher tests the notification type, not a tool name: permission_prompt (waiting on your approval), idle_prompt (waiting on your input), agent_needs_input, agent_completed, and a handful more.
Which makes the real use obvious once you see it. This hook exists to move "Claude is stuck waiting on you" somewhere you'll actually notice. While long batches run, I forward exactly two types and ignore the rest.
#!/usr/bin/env bash# notify-me.sh — forward only the "waiting on you" statesinput=$(cat)message=$(printf '%s' "$input" | jq -r '.message // "Claude Code is waiting for you"')curl -sS -X POST "$SLACK_WEBHOOK_URL" \ -H 'Content-Type: application/json' \ -d "$(jq -n --arg t "$message" '{text:$t}')" >/dev/nullexit 0
The webhook URL lives in the environment, not in the script and not in the settings file. Hooks run with your full user permissions, so a settings file is the last place I want a credential.
One more constraint: Notification ignores your exit code entirely. exit 2 blocks nothing here, and stderr goes nowhere. Put a gate in this hook and it will fail silently, which is the worst way for a gate to fail. I keep judgment out of the places that only announce things. Letting notifications be notifications has been the least breakable arrangement I've found.
So where does "post to Slack when a commit lands" go? PostToolUse, with a Bash matcher and a check on .tool_input.command inside the script. The working version is in the template section below.
The Picture in August 2026: From 8 Events to 33
The eight hooks above were the whole story when I first wrote this guide. They no longer are. The official hooks reference now lists 33 events. That number looks intimidating, but most of the additions are refinements that fill gaps between the core eight — the design philosophy hasn't moved.
The documentation organizes events into three cadences: once per session (SessionStart / SessionEnd), once per turn (UserPromptSubmit / Stop / StopFailure), and once per tool call (PreToolUse / PostToolUse). Hold onto those three layers, then read each new event as a refinement of one of them, and the map shrinks back down to something you can keep in your head.
Model switches, command expansion, and MCP elicitation as new control points
My working advice: design with the core eight first, and only come back to this table when you notice a moment you want to observe that none of the eight firing points covers. Of all the additions, the only ones I touch routinely are the model-switch pair below and PostToolUseFailure — and that has been enough. Not trying to use everything is what keeps a hook setup maintainable.
PreModelSwitch / PostModelSwitch: Turning Model Switches into Policy
Of everything added to the catalog, the pair that changed my day-to-day operations most is the model-switch hooks, introduced in v2.1.251 (released August 28, 2026).
The mechanics are straightforward. PreModelSwitch fires right before Claude Code applies a model switch that you or a client requested, and exit code 2 blocks the switch. PostModelSwitch fires after the session's model changes — including changes Claude Code makes on its own, such as restoring the model when you resume a session. Unlike other events, the stdin JSON carries from_model and to_model instead of a single model field, and the matcher is evaluated against the canonical name derived from to_model, so you can scope a hook to "only when switching to a specific model" entirely in config.
Why does this matter? I run routine maintenance as scheduled tasks, with a different model assigned to each stage. Until now that allocation was a promise written into prompts — nothing enforced it, and nothing told me when it was broken. With weekly usage limits being restructured on September 14, 2026, I wanted switches to expensive models gated by machinery rather than convention. With hooks, that takes one config block and two small scripts.
There is one asymmetry worth internalizing: only PreModelSwitch can block. By the time PostModelSwitch fires, the switch has already happened — exit code 2 there just surfaces your stderr as an error notice and nothing rolls back. What PostModelSwitch can do is inject context: its stdout is added as context Claude can see, delivered with the next request after the switch. That makes it the right place for automated handoff notes like "the model just changed — keep outputs terse for the rest of this session." Block with Pre, inform with Post. It's the same division of labor as PreToolUse / PostToolUse, so the instincts you built on the core eight transfer directly.
Three Production Traps I Hit (And How to Avoid Them)
Trap 1: Misunderstanding Hook Firing Order
Hooks don't fire simultaneously. They fire in a strict sequence:
SessionStart
UserPromptSubmit
PreToolUse
PostToolUse (only if tool succeeds)
Notification (parallel)
Stop (evaluated)
SessionEnd
I once set an environment variable in PreToolUse and tried to use it in PostToolUse. It was undefined. Why? Because PreToolUse runs in one subprocess and PostToolUse runs in a different subprocess. Environment variables don't cross subprocess boundaries.
Real bug I hit: a PreToolUse hook exported NPM_REGISTRY, and the PostToolUse hook that needed it saw nothing. Different processes, so nothing carried over.
Solution: hand the value off through a file instead of the process environment. SessionStart has a dedicated CLAUDE_ENV_FILE for this, but for a per-tool handoff a plain file did the job.
#!/usr/bin/env bash# .claude/hooks/stash-registry.sh — park the value where the next process can find itprintf 'NPM_REGISTRY=%s\n' "https://registry.example.internal" > "$CLAUDE_PROJECT_DIR/.claude/.hook-state"exit 0
#!/usr/bin/env bash# .claude/hooks/verify-registry.sh — different process, so read it back from diskstate="$CLAUDE_PROJECT_DIR/.claude/.hook-state"[ -f "$state" ] || exit 0# shellcheck disable=SC1090. "$state"echo "registry=$NPM_REGISTRY"exit 0
Keep that file under .claude/ and add it to .gitignore. Hooks run with your full user permissions, so a handoff file is not the place for credentials.
Trap 2: Async Wait Timeouts in Long-Running Tasks
I once wrote a hook to poll for CI completion after a git push. Thirty seconds in the test repo, lovely. On a real project with a twenty-minute pipeline, it held the whole session hostage.
What makes this worse than it sounds is the default timeout. For command hooks it's 600 seconds — ten full minutes. If you assumed a slow hook would die quickly, it won't. And the default isn't uniform:
Event
Default timeout for command hooks
PreToolUse, PostToolUse, SessionStart, and most others
600 seconds
UserPromptSubmit, PreModelSwitch, PostModelSwitch
30 seconds
MessageDisplay
10 seconds
SessionEnd
1.5 seconds shared across all its hooks
Then there's an asymmetry that's easy to get burned by. Timing out means opposite things depending on the event. A PreToolUse command hook that times out does not block the tool call — it goes through. A PreModelSwitch hook that times out does block the switch. Both defaults are defensible on their own terms ("a slow hook shouldn't stall your work" versus "don't switch if you can't confirm it's allowed"), but write them with the same assumption and one of them will surprise you.
The fix for genuinely long work is to stop waiting synchronously and set async: true. Timeouts aren't enforced on async command hooks; the work runs in the background and reports back on its own.
Work that waits belongs where nothing waits on it. I needed the CI result in twenty minutes, not immediately, and there was never a reason to sit still for it.
Chain enough hooks together and one hook's output becomes another's input. Sometimes that forms a circle. I've hit two versions.
The Stop round trip. A Stop hook refuses to finish while tests are red. Claude resumes, attempts a fix, tries to finish. Tests are still red, so the hook refuses again. Nothing in that cycle requires progress, and unattended it just keeps going.
The cure is the stop_hook_active check from earlier: one line that says "if I'm the reason we're still going, step aside." Claude Code caps it at eight consecutive blocks, but those are eight real attempts, billed in time and tokens.
PostToolUse re-firing on itself. A formatting hook rewrites a file on PostToolUse, and the rewrite triggers PostToolUse again. Mine settled down once the script only wrote back when the content had actually changed. Not re-writing identical bytes breaks the circle on its own.
What both cases had in common, I realized afterward, was that I'd written a firing condition and no stopping condition. I'd thought hard about when the hook should act and not at all about when it should give up.
If a hook can withhold something, its exit condition belongs in the same file. I now write that condition before the logic it guards — awkward at first, but it's caught two loops before they ever ran.
No pseudo-schema past this point. Each of these is a settings block you can paste into .claude/settings.json plus the executable it calls from .claude/hooks/. They're shell scripts, so you can run them by hand before wiring anything up. Remember chmod +x.
#!/usr/bin/env bash# .claude/hooks/guard-force-push.shinput=$(cat)cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""')case "$cmd" in *"git push"*) if printf '%s' "$cmd" | grep -qE -- '(-f|--force)([[:space:]]|$)'; then echo "Force push is blocked here. Consider --force-with-lease." >&2 exit 2 fi ;;esacexit 0
Letting --force-with-lease through is deliberate. Rejecting the dangerous spelling while leaving the safe one available tells whoever got stopped what to do next. A gate that only says no is a gate someone eventually deletes.
Template 2: Announce successful commits
The "post to Slack on commit" idea from the Notification section lives here, in PostToolUse.
async: true keeps a slow network from stalling the session. A notification that lands three seconds late costs nothing.
Template 3: Format edited files and tell Claude about it
PostToolUse earns its keep less by blocking than by feeding facts back. Plain stdout won't get you there — on most events it goes to the debug log and Claude never sees it. To reach Claude, use hookSpecificOutput.additionalContext.
#!/usr/bin/env bash# .claude/hooks/format-edited.shinput=$(cat)file=$(printf '%s' "$input" | jq -r '.tool_input.file_path // ""')[ -f "$file" ] || exit 0case "$file" in *.ts|*.tsx|*.js|*.jsx|*.json) ;; *) exit 0 ;;esacbefore=$(shasum "$file" | cut -d' ' -f1)npx --no-install prettier --write "$file" >/dev/null 2>&1 || exit 0after=$(shasum "$file" | cut -d' ' -f1)# nothing changed, so say nothing (avoids re-firing and wasted context)[ "$before" = "$after" ] && exit 0jq -n --arg f "$file" '{ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: ("Formatted \($f) with prettier. Base further edits on the formatted content.") }}'exit 0
Two details carry this one: the Edit|Write matcher, and staying quiet when nothing changed. .tool_input.file_path always arrives absolute for Write, Edit, and Read, so you never have to reason about the working directory.
Template 4: Hand Claude the repo state at session start
SessionStart is one of the few events whose plain stdout goes straight into Claude's context. Moving the preamble I used to type by hand into this hook noticeably reduced wrong first moves.
#!/usr/bin/env bash# .claude/hooks/session-context.shbranch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)dirty=$(git status --porcelain 2>/dev/null | head -20)jq -n --arg b "$branch" --arg d "$dirty" '{ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: ("Current branch: \($b)\nUncommitted changes:\n\($d)") }}'exit 0
The matcher here filters on how the session started — startup, resume, clear, compact, or fork. Limiting it to fresh starts and resumes avoids re-injecting the same context right after a compaction.
Keep the text factual. Context written as imperative system instructions ("you must always...") can read as out-of-band injection and get treated accordingly. Worth knowing too: additionalContext is capped at 10,000 characters, which I discovered by trying to hand over an entire log.
How the Config File Actually Looks
Everything above was a conceptual diagram of responsibilities. Here's the shape that actually runs in my setup (Claude Code v2.x). Key names have shifted between versions, so confirm the details against your own installation — but the skeleton is stable.
Hooks live under the hooks key of .claude/settings.json (per project) or ~/.claude/settings.json (per user), as an array per event name. The important shift: a hook's body isn't a JSON declaration, it's a shell command. Your decision logic lives inside your own script.
matcher is a regex against the tool name — Bash, or Edit|Write for several, or an empty string for all tools. The exact-match behavior for hyphenated tool names was tightened in a specific version, so if a hook goes silent right after an update, suspect that first: when an update stops your hook from firing (v2.1.195 matcher change).
The script receives a JSON payload on stdin. The tool name and its arguments are in there.
#!/usr/bin/env bash# guard-git-push.shinput=$(cat) # read the full JSON from stdincmd=$(echo "$input" | jq -r '.tool_input.command // ""')if echo "$cmd" | grep -qE 'git push .*(-f|--force)'; then echo "Force-pushing to main is blocked. Consider --force-with-lease." >&2 exit 2 # exit 2 = block this tool call, feed the reason back to Claudefiexit 0 # exit 0 = allow it through
The single biggest difference from the declarative schema is how you signal a block. Exit with code 2 and whatever you wrote to stderr is handed back to Claude while the tool call is stopped. Exit 0 and it sails through. Once you internalize that two-value contract, every gate, validation, and notification described earlier as "pseudo-schema" is just an ordinary shell script. Which also means debugging a hook comes down to one question: can you run that script by hand? Pipe a sample JSON into it before you ever blame the config file.
What the Exit Code Actually Does (exit 1 Doesn't Block)
Six months in, this was the most expensive thing I had wrong.
I assumed a hook that fails its check should return exit 1. That's the Unix convention, after all. But in Claude Code hooks, exit 2 is the only code that blocks. exit 1 is treated as a non-blocking error: you get a notice, and the tool call proceeds anyway.
Which means a gate you believe is protecting you may be protecting nothing. And because everything goes through, there's no symptom to notice. I found out when I spotted a command in my history that my hook was supposedly blocking.
Exit code
What happens
Where stdout goes
0
Proceeds. Print JSON here for structured control
Debug log on most events
2
Blocks, on events that can block. Your stderr goes back to Claude
Valid JSON is still read
Anything else (1 included)
Does not block. Passes through as a non-blocking error
Plain text surfaces only as a notice
The worktree events are the exception — for those, any non-zero code counts as failure. Going the other way, several events ignore exit 2 completely: Notification, PermissionDenied, Setup, and InstructionsLoaded. PostToolUse doesn't block either, which makes sense once you remember the tool has already run.
The second half of this is which events let stdout reach Claude at all. Raw stdout becomes context on exactly four: UserPromptSubmit, UserPromptExpansion, SessionStart, and PostModelSwitch. Everywhere else it lands in the debug log. I learned this by echo-ing from a PostToolUse hook, watching it vanish, and switching to hookSpecificOutput.additionalContext — which worked immediately.
Exit 2 to stop something; additionalContext to say something. When a hook seems to do nothing, check those two before you go looking at your logic. The answer is usually one of them.
Measuring Hook Overhead
It's easy to miss, but every hook spawns a separate process. Each tool call pays a small startup cost, and those costs stack. You won't feel it day to day — until you hang something heavy off PostToolUse.
I measured this on the CI integration for a wallpaper app I build solo. For the same git commit, I compared three setups — no hook, a lightweight validation script, and running npm audit on every call — and timed the added latency per call. Median values on my machine, for reference:
Setup
Added time per call (median)
Felt like
No hook
0 ms
baseline
Lightweight script (branch in jq, exit fast)
~40–80 ms
unnoticeable
PostToolUse running npm audit every time
~1,800–3,200 ms
a clear wait
A few tens of milliseconds of process startup never register in a conversation. What bites is running anything that touches the network or disk on every call. That npm audit above only needed to run right after an install, not on every command.
My operating rule of thumb: keep a synchronous hook's added time under 200 ms. Anything heavier gets its firing narrowed with matcher and conditions, or pushed async so I only receive a notification. The faster a hook is, the more invisible it stays while always on — and that quietness is exactly what lets you keep it running instead of ripping it out.
Operating Hooks in Production
Test first. Hooks that touch git or npm can silently break things. Test in a dummy project first. Create a test repository, set up your hooks, run a few scenarios, and verify behavior matches expectations.
Log everything. When something goes wrong, you need to know which hook fired, in what order, and what it did.
Version-control hook configs. Commit your hook definitions to git. Later you'll ask, "when did this hook get added?" Git history answers it.
git add .claude/settings.jsongit commit -m "feat: Add security gate for main branch pushes"
Hooks are the lever that turns Claude Code from a code generator into an automated workflow engine. The mental model is the hard part. Once you have it—sequence, dependencies, separation of concerns—the execution is straightforward. Start small, test thoroughly, and you'll have something powerful.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.