Claude Code Hooks: The Complete 2026 Guide
How Claude Code hooks let you intercept every tool call to lint, format, block, and notify automatically, with real settings.json examples.
Who this is for: developers already running Claude Code day-to-day who are tired of manually runningprettier, checking that a file was actually saved, or hoping the agent doesn'trm -rfsomething it shouldn't. If you've ever wanted a deterministic checkpoint between "Claude decided to do X" and "X actually happens," Claude Code hooks are that checkpoint.
Claude Code hooks are shell commands that the CLI runs at fixed points in its lifecycle: before a tool executes, after it finishes, when the agent stops, and when you submit a prompt. They are not a suggestion the model can ignore. They are enforced by the harness itself, which is the entire point. This guide covers the four hook events that matter in production, how to wire them in settings.json, seven working automations, and the gotchas that cost people an afternoon the first time they touch this system.
What hooks actually are (and are not)
A hook is a command registered against an event name. When that event fires, Claude Code spawns the command, pipes it a JSON payload on stdin describing what's happening, and reads the exit code and optional JSON response back. That's the whole mechanism. No plugin API, no special DSL — just a process you control.
This matters because it means hooks are:
- Deterministic. Unlike a
CLAUDE.mdinstruction ("please always run lint after edits"), a hook cannot be forgotten mid-conversation or reasoned away by the model under context pressure. - Language-agnostic. Bash, Python, Node, a compiled Go binary — anything executable works.
- Composable with exit codes. Exit 0 means proceed. Exit 2 on
PreToolUseblocks the tool call entirely and feeds your stderr back to Claude as the reason. That's your enforcement lever.
Hooks are not a replacement for writing a good CLAUDE.md — memory still shapes intent and style. Hooks shape what's allowed to actually happen.
The four events you need
| Event | Fires | Typical use |
|---|---|---|
PreToolUse | Right before any tool call executes | Block dangerous commands, validate file paths, enforce permission policy |
PostToolUse | Right after a tool call completes | Auto-format, auto-lint, run type-checks, log activity |
Stop | When Claude finishes responding and is about to hand control back | Run full test suite, send a desktop notification, cut a summary |
UserPromptSubmit | The instant you hit enter on a prompt | Inject dynamic context (git branch, ticket number), block prompts that match a blocklist |
There's also SubagentStop (fires when a subagent — see Claude Code subagents — finishes its turn) and Notification, which fires on the CLI's own permission-request and idle notifications. Most teams get 90% of the value from just PreToolUse and PostToolUse.
Wiring hooks into settings.json
Hooks live in .claude/settings.json (project-wide, checked into git) or ~/.claude/settings.json (personal, machine-wide). Project settings win for anything the whole team should share; personal settings are for your own habits (a Stop hook that pings your phone, for instance).
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "npx prettier --write \"$CLAUDE_FILE_PATHS\""
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node .claude/hooks/block-dangerous-bash.js"
}
]
}
]
}
}
The matcher field is a regex tested against the tool name (Edit, Write, Bash, WebFetch, etc). Leave it empty or omit it to match every tool for that event — useful for a Stop hook, which has no tool to match against anyway.
Tip: keep the matcher as narrow as you can. APostToolUsehook matched againstEdit|Write|MultiEditthat shells out toprettieron every single keystroke-adjacent edit adds up. On a file with 40 sequential edits in one turn, that's 40 process spawns.
Real example 1: auto-format and auto-lint on save
This is the highest-value hook for almost every team, and it took me about 15 minutes to set up on a client's Next.js repo.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{ "type": "command", "command": "bash .claude/hooks/format-and-lint.sh" }
]
}
]
}
}
#!/usr/bin/env bash
# .claude/hooks/format-and-lint.sh
FILE=$(echo "$CLAUDE_FILE_PATHS" | head -n1)
[[ "$FILE" =~ \.(ts|tsx|js|jsx)$ ]] || exit 0
npx prettier --write "$FILE" 2>/dev/null
npx eslint --fix "$FILE" --quiet
if ! npx eslint "$FILE" --quiet; then
echo "ESLint still has errors in $FILE after autofix" >&2
exit 2
fi
exit 0
Exit 2 here doesn't just log a failure — it feeds the stderr message straight back to Claude's context, so the next turn it sees "ESLint still has errors in Foo.tsx" and actually fixes them, instead of you copy-pasting the terminal output yourself.
Real example 2: block risky Bash commands before they run
This is the one every team should have. PreToolUse on Bash with exit-2 blocking is cheap insurance against the model running something destructive during a long autonomous session. For a broader look at what else needs locking down, see Claude Code permissions and safety.
// .claude/hooks/block-dangerous-bash.js
const input = JSON.parse(require('fs').readFileSync(0, 'utf8'));
const cmd = input.tool_input?.command || '';
const banned = [
/rm\s+-rf\s+(\/|~|\$HOME)/,
/git\s+push\s+--force/,
/git\s+reset\s+--hard/,
/DROP\s+(TABLE|DATABASE)/i,
/>\s*\/dev\/sd[a-z]/,
];
for (const pattern of banned) {
if (pattern.test(cmd)) {
console.error(`Blocked: command matches banned pattern ${pattern}`);
process.exit(2);
}
}
process.exit(0);
Warning: this is a blocklist, not a sandbox. A determined agent (or a prompt-injected one, if it's reading untrusted web content) can usually find a way around a regex blocklist — base64-encoding the command, aliasing rm, etc. Treat this as a speed bump for accidental damage, not a security boundary. For anything sensitive, run Claude Code inside a container or VM with no access to what you can't afford to lose.
Real example 3: desktop notification when Claude finishes a long task
Useful when you kick off a big refactor and tab away.
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "node .claude/hooks/notify-done.js" }
]
}
]
}
}
// .claude/hooks/notify-done.js (macOS example, swap for your OS's notifier)
const { execSync } = require('child_process');
execSync(`osascript -e 'display notification "Claude finished" with title "Claude Code"'`);
On Windows, swap in BurntToast's New-BurntToastNotification, or shell out to a curl against a webhook (Slack, Discord, ntfy.sh) — the same pattern works for a team channel instead of your own desktop.
Real example 4: run the test suite before letting Claude declare victory
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "bash .claude/hooks/run-tests-on-stop.sh" }
]
}
]
}
}
#!/usr/bin/env bash
# .claude/hooks/run-tests-on-stop.sh
if ! npm test --silent 2>/tmp/test-output.log; then
echo "Tests failed:" >&2
tail -n 30 /tmp/test-output.log >&2
exit 2
fi
exit 0
Combine this with an eval-driven development mindset: hooks are the CI-lite layer that runs inside the session itself, catching regressions before you even get to a PR, not instead of proper CI.
The gotchas that will actually bite you
1. Hooks run with your full shell environment and permissions
A hook is not sandboxed from the rest of your machine any more than a normal terminal command is. If your hook script has a bug that does rm -rf $SOME_UNSET_VAR/*, an unset variable expands to nothing and you've just run rm -rf /*. Quote your variables, set set -euo pipefail at the top of every bash hook, and test the script standalone before wiring it in.
2. Slow hooks stall every single tool call
A PostToolUse hook that runs a full TypeScript project-wide typecheck (tsc --noEmit with no --incremental) on every Edit will make a 40-edit refactor take 40x as long. Scope hooks to the single changed file wherever the tool supports it, and reserve project-wide checks for Stop.
3. Exit code 2 is special — everything else just logs
Exit 0 is silent success. Any non-zero, non-2 exit code is treated as a hook error and logged, but the tool call still proceeds. Only exit code 2 on PreToolUse actually blocks execution and feeds stderr back into the conversation. This trips people up constantly — they write a validation hook, get the logic wrong, exit with 1, and wonder why the "blocked" command ran anyway.
4. Matchers are regex against the tool name, not the file path
"matcher": "Edit" matches the Edit tool for any file. If you want to scope by file type, filter inside the hook script itself using $CLAUDE_FILE_PATHS, not in the matcher.
5. Project settings vs. personal settings vs. enterprise policy
If your org has enterprise-managed settings, they can override project-level hooks silently. If a hook you added to .claude/settings.json doesn't seem to fire, check claude config list (or the equivalent doctor command) to confirm nothing upstream is stripping it before you spend an hour debugging the script itself.
Hooks vs. other automation layers in Claude Code
| Mechanism | When it runs | Enforced or advisory | Best for |
|---|---|---|---|
| CLAUDE.md instructions | Model reads it as context | Advisory — model can ignore under pressure | Style, conventions, project facts |
| Hooks (this guide) | Fixed lifecycle events | Enforced by the harness | Formatting, blocking, notifications, tests |
| Custom slash commands | You explicitly invoke them | Enforced (the command runs what you wrote) | Repeatable multi-step workflows you trigger by name |
| Subagents | Spawned by the main agent for a subtask | Advisory (subagent still reasons) | Delegating scoped, isolated work |
| MCP servers | Available as tools the model can call | Advisory (model chooses to call them) | External data/API access |
The short version: if it must happen every time no matter what the model decides, it's a hook. If it's guidance the model should follow but occasionally needs to deviate from, it belongs in CLAUDE.md.
A minimal starter config worth copying today
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "node .claude/hooks/block-dangerous-bash.js" }]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [{ "type": "command", "command": "bash .claude/hooks/format-and-lint.sh" }]
}
],
"Stop": [
{
"hooks": [{ "type": "command", "command": "node .claude/hooks/notify-done.js" }]
}
]
}
}
Three hooks, maybe 40 lines of script code total, and you've closed the two biggest risk categories (destructive commands, unformatted/broken code slipping through) while adding the one quality-of-life feature everyone asks for (a ping when it's done). Start here, add Stop-based test runs once the team trusts the setup.
FAQ
What's the difference between a Claude Code hook and a git hook?
A git hook (like pre-commit) fires on git operations regardless of who or what triggered them. A Claude Code hook fires on the agent's own tool calls and lifecycle events — before/after edits, bash commands, and when the agent stops — independent of whether a commit ever happens. You typically want both: hooks catch problems during the session, git hooks catch anything that slips through at commit time.
Can a hook stop Claude from making an edit, not just running a bash command?
Yes. Register the hook against PreToolUse with a matcher of Edit|Write|MultiEdit, then exit with code 2 from the script if the target path fails your check (for example, blocking edits to .env or package-lock.json). The tool call never executes and Claude sees your stderr as the reason.
Do hooks slow down Claude Code noticeably?
A well-scoped hook (single-file lint, single-file format) adds tens to a few hundred milliseconds per tool call, which is not noticeable in practice. A poorly-scoped one (whole-project typecheck on every edit) can add seconds per call and will visibly slow a long session — scope by file and reserve expensive checks for the Stop event.
Where do I put hook scripts so they're shared with my team?
Put both settings.json and the hook scripts themselves under .claude/hooks/ inside the repo and commit them. Anyone who clones the repo and runs Claude Code inside it inherits the same enforcement automatically, which is one of the more underrated team-consistency wins in the whole toolchain.
Can hooks call external services, like Slack or a ticketing system?
Yes — a hook is just a shell command, so curl to a webhook, a Slack bot token, or a ticket-system API works exactly as it would from any script. This is commonly used on Stop to post a summary to a team channel, or on PreToolUse to check a feature-flag service before allowing a deploy-related command.
Do hooks work the same way in headless / CI usage of Claude Code?
Yes, hooks fire identically whether you're in an interactive terminal session or running Claude Code non-interactively in a script or CI pipeline — see Claude Code headless automation for the broader pattern. This makes hooks especially valuable in CI, where there's no human watching to catch a bad command before it runs.
Get this set up for your team
If you want a working .claude/settings.json plus hook scripts tailored to your stack (Next.js, Python, whatever) instead of adapting the snippets above yourself, we do this as part of onboarding teams onto Claude Code. Grab a free 30-minute call through the contact form or message us directly on WhatsApp.