I wrote a while back about the slash commands I can’t live without. Those are great, but they share one weakness: you have to remember to run them. A slash command you forget is a slash command that does nothing.
Hooks don’t have that problem. A hook is a shell command that fires automatically on an event. Before a tool runs. After a file gets edited. When you submit a prompt. You set it up once and it runs forever, whether you’re paying attention or not.
That “whether you’re paying attention or not” is the whole point. Here are the handful that actually stuck.
Format on every edit, no exceptions
This is the one I’d set up first if I started over. Every time Claude edits a file, run the formatter on it. Automatically. Done.
It’s a PostToolUse hook that fires after Edit or Write:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "prettier --write \"$CLAUDE_FILE_PATHS\" 2>/dev/null || true"
}
]
}
]
}
}
Before this, my diffs were full of formatting noise. Claude would write code that was correct but spaced differently than the file, and I’d either eyeball it or run prettier myself and forget half the time. Now the file is formatted the instant it’s touched. My diffs only show real changes.
The || true matters. If the formatter chokes on a file it doesn’t understand, I don’t want the whole hook to fail and spook the session. Fail quiet, move on.
Block edits to the files that shouldn’t change
I had a bad afternoon once where Claude helpfully “fixed” my .env file and my lockfile in the same session. Both were wrong. Both caused an hour of confusion.
A PreToolUse hook can stop a tool before it runs. I use it to guard the files nothing should be auto-editing:
#!/usr/bin/env bash
# block-protected.sh
case "$CLAUDE_FILE_PATHS" in
*.env|*.env.*|*package-lock.json|*pnpm-lock.yaml)
echo "Refusing to edit protected file: $CLAUDE_FILE_PATHS" >&2
exit 2 # non-zero blocks the tool call
;;
esac
exit 0
Wire it to PreToolUse on Edit|Write and now those files are off limits unless I do it by hand. Exit code 2 is the part that does the work: it tells Claude Code to block the call and feeds the message back, so Claude knows why it got stopped and doesn’t just retry.
This one is pure insurance. It does nothing 99% of the time. The other 1% it saves me from a mess.
Run the test for the file I just changed
Not the whole suite. That’s too slow to run on every edit and you’ll turn it off within a day. Just the test that matches the file that changed.
#!/usr/bin/env bash
# test-related.sh
file="$CLAUDE_FILE_PATHS"
test_file="${file%.ts}.test.ts"
if [ -f "$test_file" ]; then
npx vitest run "$test_file" 2>&1 | tail -20
fi
Hooked to PostToolUse, this runs the matching test right after the edit and the output goes straight back into the session. So when Claude breaks a test, it finds out immediately, in the same turn, instead of three edits later when I finally run the suite myself.
The feedback loop tightening is the thing. A broken test discovered now is a thirty-second fix. The same break discovered after five more edits is a debugging session.
Tell me what just happened when I look away
I run long tasks and walk off to get coffee. I’d come back not knowing if it finished, errored, or was sitting there waiting for me.
A Stop hook fires when Claude finishes its turn. I use it to ping me:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude is done\" with title \"Claude Code\"'"
}
]
}
]
}
}
That’s the Mac version. On Linux swap in notify-send. It’s a tiny thing and it changed my whole rhythm. I can kick off a big refactor, go do something else, and get pulled back the second it actually needs me instead of babysitting the terminal.
Inject context I always forget to mention
This is the sneaky-useful one. A UserPromptSubmit hook runs before your prompt reaches Claude, and whatever it prints gets added as context. So I use it to attach things I’d otherwise have to type every time.
#!/usr/bin/env bash
# context.sh
echo "Current branch: $(git branch --show-current)"
echo "Node version: $(node -v)"
echo "Uncommitted files: $(git status --porcelain | wc -l | tr -d ' ')"
Now every prompt silently carries the branch I’m on and whether I have uncommitted work. I stopped getting answers that assumed I was on main when I wasn’t. I stopped Claude suggesting I commit when I had nothing staged. Small, constant, and I never have to think about it.
You can push this further. Pull in the current ticket, the test command for this repo, the deploy target. Anything you find yourself explaining at the top of prompts is a candidate to hardcode here.
The one rule I’d give you
Don’t set up ten hooks on day one. You’ll get a slow, noisy session full of things firing that you don’t understand, and you’ll rip them all out in frustration.
Add one. Live with it for a week. The bar is simple: is this hook fixing a thing that actually keeps happening to me? The formatter hook earns its place because messy diffs were a real, daily annoyance. A hook that guards against a problem you’ve never had is just latency.
The good ones disappear. You stop noticing the formatter because your diffs are always clean now. That’s the sign it’s working.
Slash commands are the tools you reach for. Hooks are the floor you stand on. The difference is that you have to remember a command, and a hook just happens. That shift, from “the thing I run when I remember” to “the thing that’s always true,” is what actually changed how I work. Start with the formatter. Add the rest when a real annoyance shows up, not before.