Hooks are the feature that turns Claude Code from a chat tool into a workflow engine. They’re shell commands that fire automatically at specific lifecycle events — before a tool call, after an edit, when the session ends, when you submit a prompt — and they’re how you stop trusting the agent to “remember the rules” and start enforcing them in code.
I ran my first 8 weeks on Claude Code without a single hook. I wrote rules in my CLAUDE.md file and hoped the agent would follow them. Most of the time it did. The other 12% of the time, I had to roll back commits, restore .env files, or undo a npm install it ran when I told it not to.
Hooks fixed that. By month 4, I had 7 hooks running on the 500k.io repo. None of them are clever. All of them save me 5-10 minutes per week each. This is the tutorial I wish I’d had at week 1.
What is a Claude Code hook?
A hook is a JSON entry in your settings file that tells Claude Code: “when event X happens, run shell command Y.” Claude Code runs the command synchronously (for blocking hooks) or asynchronously (for non-blocking hooks), captures the exit code and stdout/stderr, and decides what to do next based on the result.
The full event list, as of Claude Code 1.0 (May 2026):
| Event | Fires when | Can block? |
|---|---|---|
PreToolUse | Before any tool call (Edit, Write, Bash, etc.) | Yes — non-zero exit blocks the call |
PostToolUse | After a tool call succeeds | No (informational) |
UserPromptSubmit | When you press Enter on a prompt | Yes — can rewrite the prompt or block it |
Stop | When Claude Code finishes its turn | No |
SubagentStop | When a subagent finishes | No |
SessionStart | When Claude Code starts a new session | No |
Notification | When Claude Code shows a notification (waiting for input, etc.) | No |
PreCompact | Before context compaction | Yes — can preserve specific data |
The two events you’ll use 90% of the time are PreToolUse (block unsafe actions) and PostToolUse (react to successful ones). The rest are useful but situational.
Where hooks live
Two settings files, merged at startup:
- Project-level:
.claude/settings.json(committed to git, shared across the team) - User-level:
~/.claude/settings.json(your machine only)
Project wins on conflicts. A typical .claude/settings.json looks like:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node .claude/hooks/secrets-scan.mjs",
"timeout": 2000
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node .claude/hooks/lint-and-format.mjs"
}
]
}
]
}
}
Three things to notice:
- The
matcheris a regex against the tool name.Bashmatches only Bash.Edit|Writematches both..*matches everything. - The
commandis a shell command — anything your shell can run. I keep mine as Node scripts in.claude/hooks/for testability. - The
timeoutis in milliseconds. Default is 60s but you should set tight limits onPreToolUsebecause they run synchronously.
Claude Code passes the tool’s input to the hook on stdin as JSON, and the hook can write to stdout (informational) or stderr (surfaced to the model). For blocking decisions, exit code 0 = allow, non-zero = block.
The 7 hooks I run on 500k.io
These are the actual hooks running on this repo as of May 2026. Each one solves a specific problem I hit at least 3 times before automating it.
Hook 1 — Secrets scan on every Bash call
The problem: Claude Code occasionally tries to cat .env or echo $STRIPE_SECRET_KEY in a session. I needed a hard “no” on any command touching files containing secrets.
The hook (PreToolUse on Bash):
// .claude/hooks/secrets-scan.mjs
import { readFileSync } from 'fs';
const input = JSON.parse(readFileSync(0, 'utf-8'));
const cmd = input.tool_input?.command || '';
const FORBIDDEN = [
/\.env(?:\.|$)/,
/credentials?\.(json|yaml|yml)/,
/\.pem$/,
/id_rsa/,
/\$\{?[A-Z_]+SECRET/,
/\$\{?[A-Z_]+_KEY/,
];
const hit = FORBIDDEN.find((re) => re.test(cmd));
if (hit) {
process.stderr.write(
`BLOCKED: command matches sensitive pattern ${hit}. Re-route through documented secret manager.`
);
process.exit(2);
}
process.exit(0);
Runs in ~12ms on my M-series Mac. Has blocked 4 unsafe commands in 90 days. Zero false positives because the patterns are tight.
Hook 2 — Auto-lint and format on edit
The problem: Claude Code writes great code, but the indentation and import order doesn’t always match my Prettier config. I was running pnpm lint:fix manually after every session.
The hook (PostToolUse on Edit|Write):
// .claude/hooks/lint-and-format.mjs
import { readFileSync } from 'fs';
import { execSync } from 'child_process';
const input = JSON.parse(readFileSync(0, 'utf-8'));
const path = input.tool_input?.file_path;
if (!path) process.exit(0);
if (/\.(ts|tsx|js|jsx|astro|mdx)$/.test(path)) {
try {
execSync(`pnpm prettier --write "${path}"`, { stdio: 'pipe' });
execSync(`pnpm eslint --fix "${path}"`, { stdio: 'pipe' });
} catch (e) {
process.stderr.write(`Lint warning: ${e.message}`);
}
}
process.exit(0);
Fires on every Edit and Write. ~400ms per call on a hot Prettier cache. Saves me ~10 minutes a week and keeps git diffs clean.
Hook 3 — Blocked paths
Some files should never be edited by an agent — production keys, snapshot files, generated migrations. I encode that in a hook rather than a prompt.
The hook (PreToolUse on Edit|Write):
// .claude/hooks/blocked-paths.mjs
import { readFileSync } from 'fs';
const input = JSON.parse(readFileSync(0, 'utf-8'));
const path = input.tool_input?.file_path || '';
const BLOCKED = [
/^\.env/,
/\/migrations\/\d{14}_.*\.sql$/, // historical migrations
/\/public\/lead-magnets\/.*\.pdf$/, // built artifacts
/\/node_modules\//,
/pnpm-lock\.yaml$/,
];
const hit = BLOCKED.find((re) => re.test(path));
if (hit) {
process.stderr.write(`BLOCKED: ${path} is in the blocked-paths list (${hit}). If this is intentional, edit manually.`);
process.exit(2);
}
process.exit(0);
The agent sees the stderr message and immediately stops trying. I added the lockfile rule after Claude Code “helpfully” edited my pnpm-lock.yaml during a dependency upgrade and broke CI for 2 hours.
Hook 4 — Auto-commit on Stop
The problem: I was forgetting to commit small wins from Claude Code sessions. By the end of the day, I’d have 3 unrelated changes staged together.
The hook (Stop event):
#!/bin/bash
# .claude/hooks/auto-stage-and-suggest-commit.sh
cd "$CLAUDE_PROJECT_DIR" || exit 0
# only suggest, don't auto-commit (that's too aggressive for me)
DIFF_SIZE=$(git diff --shortstat | awk '{print $4+$6}')
if [ "$DIFF_SIZE" -gt 0 ]; then
echo "[hint] Uncommitted changes: $DIFF_SIZE lines. Suggested commit: 'git add -p && git commit'"
fi
exit 0
This one just prints a reminder at the end of each session. I kept the auto-commit version for 3 weeks then disabled it — committing without reviewing the diff felt unsafe. The hint is the right level of pressure.
Hook 5 — Deploy notification
When Claude Code runs pnpm deploy or wrangler deploy, I want a desktop notification 30 seconds later. I’m usually multi-tasking and miss the terminal output.
The hook (PostToolUse on Bash matcher):
#!/bin/bash
# .claude/hooks/deploy-notify.sh
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
if echo "$CMD" | grep -qE "(wrangler deploy|pnpm deploy|cf-pages deploy)"; then
osascript -e 'display notification "Cloudflare deploy triggered" with title "Claude Code"'
fi
exit 0
Mac-only (the osascript line). On Linux you’d use notify-send. On Windows, a PowerShell New-BurntToastNotification. This is the kind of small QoL hook that compounds.
Hook 6 — Session start context
The problem: Every new Claude Code session, I’d start by re-explaining what I was working on. Wasted prompts. The fix: a SessionStart hook that dumps the last 3 git commit messages, current branch, and any open TODO items into the session context.
The hook (SessionStart):
#!/bin/bash
# .claude/hooks/session-start-context.sh
cd "$CLAUDE_PROJECT_DIR" || exit 0
echo "## Session context (auto-injected)"
echo ""
echo "**Branch:** $(git branch --show-current)"
echo ""
echo "**Last 3 commits:**"
git log --oneline -3
echo ""
echo "**Open TODOs in code:**"
grep -rn "TODO\|FIXME" src/ --include="*.ts" --include="*.tsx" --include="*.astro" 2>/dev/null | head -5
echo ""
echo "**Articles in draft:**"
grep -l "draft: true" src/content/blog/*.mdx 2>/dev/null | wc -l | xargs echo "Draft count:"
exit 0
Whatever this writes to stdout gets prepended to the session context. Costs ~50ms at session start. Saves the first 60-90 seconds of “where were we” on every resume.
Hook 7 — Quality gate before publish
This is the most 500k.io-specific hook. Before any commit that touches src/content/blog/*.mdx and changes draft: false, I run the Quality Auditor (a separate Claude Opus call) and block the commit if the score is below 85. This is the content quality gate enforced at the hook level.
The hook (PreToolUse on Bash matching git commit):
// .claude/hooks/quality-gate.mjs
import { readFileSync } from 'fs';
import { execSync } from 'child_process';
const input = JSON.parse(readFileSync(0, 'utf-8'));
const cmd = input.tool_input?.command || '';
if (!/git commit/.test(cmd)) process.exit(0);
const staged = execSync('git diff --cached --name-only', { encoding: 'utf-8' })
.split('\n')
.filter((f) => f.startsWith('src/content/blog/') && f.endsWith('.mdx'));
if (staged.length === 0) process.exit(0);
for (const file of staged) {
const content = readFileSync(file, 'utf-8');
// toggling draft: true → false is the publish signal
const wasPublish = /^draft:\s*false/m.test(content);
if (!wasPublish) continue;
const score = await scoreArticle(content); // calls Quality Auditor agent
if (score < 85) {
process.stderr.write(
`QUALITY GATE FAILED: ${file} scored ${score}/100. Move to review queue (draft: true + add 'qualityScore' frontmatter) or improve content. See docs/11-CONTENT-FACTORY.md.`
);
process.exit(2);
}
}
process.exit(0);
This hook is the difference between “I built a quality system” and “I built a quality system that runs without me.” When the auditor flags a 78/100 article, the commit blocks, I see the feedback in stderr, and I either fix the article or move it back to draft. No willpower involved.
The 4 mistakes I made in my first month of hooks
Mistake 1 — Putting business logic in hook commands
My first quality-gate hook was a 200-line bash script inline in settings.json. It became unmaintainable in 2 days. Move logic into .claude/hooks/*.mjs files. The settings entry should be one line that calls the script.
Mistake 2 — Slow PreToolUse hooks
I added a hook that did a full pnpm tsc --noEmit on every Edit. 3 seconds per call. After 80 edits in a long session, that’s 4 minutes of waiting. Moved it to PostToolUse and made it informational instead of blocking. The latency stopped mattering.
The rule: PreToolUse hooks must be fast (under 100ms ideally, 500ms hard ceiling) because they block the agent. PostToolUse can be slower because it runs in parallel with the next prompt.
Mistake 3 — Over-blocking
My first blocked-paths list included anything in src/. The agent could barely do anything. I rolled it back to specifically dangerous files (env, migrations, lockfile, generated artifacts). Blocking should be the exception, not the default. If you’re blocking more than 5% of tool calls, you’ve over-engineered.
Mistake 4 — Not testing hooks in isolation
I shipped a hook with a regex bug that blocked git status. Couldn’t figure out why nothing worked for 20 minutes. Now I test every hook by piping mock input to it from the CLI before adding it to settings:
echo '{"tool_input":{"command":"git status"}}' | node .claude/hooks/secrets-scan.mjs
echo "exit: $?"
This single line saves hours. Test your hooks like you test your code.
How hooks compose with the rest of Claude Code
Hooks aren’t a standalone feature — they get most of their value when chained with the other Claude Code primitives:
- Hooks + Plan Mode: Plan Mode catches scope before execution. Hooks enforce rules during execution. Use them together for multi-file refactors.
- Hooks + CLAUDE.md: CLAUDE.md tells the agent the rules. Hooks make sure the rules are enforced even when the agent forgets. Belt and suspenders.
- Hooks + subagents: A
SubagentStophook can run lightweight verification on each subagent’s output before the orchestrator continues. This is the pattern in my content factory workflow. - Hooks + slash commands: Slash commands are explicit triggers (you choose to run them). Hooks are implicit (Claude Code chooses). Use slash commands for “I want X right now,” hooks for “always X when Y.”
A real hooks-driven workflow on 500k.io
Here’s what happens, end to end, when I write an article on a fresh terminal:
- I open Claude Code.
SessionStarthook fires — context is injected: branch, last commits, draft count. - I prompt: “draft an article on Claude Code hooks, file at
src/content/blog/claude-code-hooks-tutorial.mdx.” - Claude Code writes the file.
PostToolUseWrite hook fires — Prettier formats it, ESLint validates the MDX. - I review, then ask Claude to flip
draft: true→draft: falseand commit. - Claude runs
git add+git commit.PreToolUseBash hook fires (quality gate) — scores the article. If <85, blocks the commit and surfaces the feedback. If ≥85, allows. - Commit lands.
PostToolUseBash hook fires — desktop notification appears. - I type
/exitor Ctrl-D.Stophook fires — reminds me of any other uncommitted changes.
None of those 7 steps required me to remember anything. The hooks made the workflow self-policing. That’s the leverage.
Where to find hook templates
Two starting points:
- Anthropic’s hook docs: docs.anthropic.com/en/docs/claude-code/hooks (the canonical reference)
- Community repo: the unofficial
claude-code-hooksGitHub org has 60+ MIT-licensed example hooks across linting, security, deploy, and analytics. Search “claude code hooks” on GitHub for the current top-starred repo.
Copy-paste a few, adjust the matchers, test with mock stdin, and you’ll have a working hook setup in 30 minutes.
The honest verdict on hooks
Hooks are the feature that ends “vibes-based” agent usage. Without hooks, you’re trusting the agent + your willpower. With hooks, you’re trusting the agent + a deterministic safety net.
The cost: ~3-4 hours of setup the first time, then 10-20 minutes per new hook. The save: every regression that doesn’t ship, every secret that doesn’t leak, every linter run you don’t have to remember.
If you’ve been using Claude Code for more than a month without hooks, you’re in the same place I was at week 8 — paying the same subscription, missing the safety layer. Add the first 3 hooks (secrets-scan, auto-lint, blocked-paths) tonight. The other 4 will follow naturally as you hit the friction they solve.
For the rest of the Claude Code stack, see Plan Mode tutorial, How to write CLAUDE.md, and Claude Code workflow deep-dive.
FAQ
What is a Claude Code hook in one sentence?
A hook is a shell command that Claude Code runs automatically at a specific lifecycle event (before a tool call, after an edit, when the session ends, etc.) so you can enforce rules, run linters, send notifications, or short-circuit unsafe actions without ever leaving the chat.
Where do I configure hooks?
In `.claude/settings.json` (project-scoped) or `~/.claude/settings.json` (user-scoped). Each hook is a JSON object with a matcher (which tool or event triggers it), a command (the shell to run), and an optional timeout. Claude Code merges user and project settings at startup; project overrides user.
What's the difference between a hook and a slash command?
Slash commands run when you type them. Hooks run automatically on lifecycle events — Claude Code doesn't ask, it just fires them. Hooks are how you encode 'every time X happens, do Y' without trusting the agent to remember.
Can hooks block Claude Code from doing something?
Yes. PreToolUse hooks can return a non-zero exit code to block the tool call, and they can write a reason to stderr that Claude Code surfaces to the model. This is how you enforce 'never edit .env' or 'never run git push without confirmation' without relying on prompt instructions.
Do hooks slow Claude Code down?
A 200ms hook on every tool call adds up over a long session. Keep PreToolUse hooks under 100ms (use cached results or simple regex matchers). PostToolUse can be slower — it doesn't block the next action.
Are hooks safe to share across teams?
Share project-level hooks (in `.claude/settings.json`, checked into git). Keep machine-specific hooks (paths, API keys, personal notifications) in `~/.claude/settings.json`. Never commit secrets in hook commands — use env vars.