Confession. I spent about three weeks pretending Claude Code hooks were a nice-to-have. I read the docs, nodded, and went back to babysitting every agent run like a manager who won’t stop hovering.
Then I let an agent commit a .env file to a public repo.
That was the day I stopped pretending. I opened ~/.claude/settings.json, wrote a five-line PreToolUse hook, and haven’t lost sleep about accidental commits since. Hooks aren’t a nice-to-have. They’re the difference between “I let the agent work” and “I sit here watching every diff scroll by, defeating the whole point of hiring a helper in the first place.”
This post walks through the exact hook setup I ship in every project. The shape of the JSON that comes in on stdin. The exit codes that actually block a tool call. A couple of gotchas that cost me an evening each. If you’re new to Claude Code hooks, this is the mental model I wish someone had handed me first.
What Claude Code hooks actually are
Strip the marketing. A Claude Code hook is a shell command that runs when the agent hits a specific lifecycle event. You wire them up in .claude/settings.json (per project) or ~/.claude/settings.json (global). The events I use most are PreToolUse and PostToolUse, but there are others: UserPromptSubmit, SessionStart, Stop, SubagentStop, Notification.
Every hook gets a JSON payload on stdin. For a tool event you get the tool name, the tool input, the session id, the cwd, and a transcript path. Your script reads it, decides what to do, and communicates back through exit codes or structured JSON on stdout.
Two things you have to know before you write your first one:
- Exit code
2blocks the tool call and sends your stderr back to the agent as a message. Exit code0is a pass. Anything else is a non-blocking error the user sees but the agent ignores. - You can also print JSON like
{"decision": "block", "reason": "..."}on stdout for the same effect, plus richer control onPreToolUse(allow, ask, block).
That’s basically the whole primitive. Everything else is convention. The full event list and payload schema live in the Claude Code hooks reference, and the file locations are covered in the settings docs.
The two hooks I run in every repo
Here’s my baseline. Nothing fancy. It has saved me from the two failure modes I actually hit: writing secrets to disk, and shipping unformatted code.
PreToolUse: block writes to sensitive files
Before I let Claude Code touch a file, I check the path against a small deny list. If it matches, block.
Old way, back when I was doing this by hand:
# Me, in a real Slack message to a coworker, three months ago:
# "hey can you also check that Claude didn't touch .env or ~/.ssh or anything
# in secrets/ before you merge my PR"
That was the “system.” Predictably, I forgot to send the message once.
New way, in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "python3 .claude/guardrails/block_secrets.py"
}
]
}
]
}
}
And the script it points at:
#!/usr/bin/env python3
import json, sys, re, os
DENY = [
r"\.env(\..+)?$",
r"\.pem$",
r"id_rsa(\.pub)?$",
r"secrets/.*",
r"credentials\.json$",
]
payload = json.load(sys.stdin)
tool_input = payload.get("tool_input", {})
path = tool_input.get("file_path") or tool_input.get("path") or ""
rel = os.path.relpath(path, payload.get("cwd", "."))
for pat in DENY:
if re.search(pat, rel):
print(
f"Refused to touch {rel}. Update block_secrets.py to allow.",
file=sys.stderr,
)
sys.exit(2)
sys.exit(0)
Exit code 2 blocks the write and hands the message to the agent, which then explains it to me instead of surprising me on git diff. Ten minutes of setup, no more panicked “did I check .env this time” thoughts.
PostToolUse: run the formatter and lint
This one nobody talks about, but I use it every day. When Claude Code writes or edits a JS/TS file, I want Prettier and ESLint to run on it immediately, and I want lint failures to feed back into the agent so it fixes its own mess.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "bash .claude/guardrails/format_and_lint.sh"
}
]
}
]
}
}
And the script:
#!/usr/bin/env bash
set -e
payload="$(cat)"
file="$(echo "$payload" | jq -r '.tool_input.file_path // .tool_input.path // empty')"
case "$file" in
*.ts|*.tsx|*.js|*.jsx)
npx prettier --write "$file" >/dev/null
if ! npx eslint --max-warnings=0 "$file" 1>&2; then
echo "Lint failed on $file. Fix warnings before continuing." 1>&2
exit 2
fi
;;
esac
The trick is exit 2 on lint failure. Claude Code sees the message, treats it as a tool-use error, and iterates. I’ve watched it clean up its own unused imports about ten times this week without me typing a word.
The real payoff isn’t the automation itself, though that’s nice. What I actually notice is that the agent stops asking me every three minutes whether to run the formatter. It just does, because the hook already did.
The gotchas nobody documents
I lost about an evening each to these. Save yourself.
First, hooks run in your shell, not Claude Code’s process. That means $PATH behaves like a login shell in most cases, but not always. If your hook depends on nvm, pyenv, or a specific venv, source the activation script inside the hook or use absolute paths. Debugging a hook that “works in my terminal” but fails silently in the agent is not how I want to spend a Saturday.
Second, stderr is the message and stdout is protocol. If you want to talk to the agent, write to stderr and exit 2, or print structured JSON on stdout. Mixing them, or printing plain text on stdout, gets you either silence or a confused agent. I misread this on my first attempt and thought hooks didn’t work at all.
Third, matchers are regex over tool names, not glob patterns over file paths. "matcher": "Write|Edit" matches the tool names Write and Edit. If you want to filter by file, do that inside your script (like the case "$file" in block above). The docs are clear enough about this in hindsight, but I definitely wasted twenty minutes staring at "matcher": "*.env" wondering why it wasn’t firing.
Fourth, global settings are less sandboxed than you think. If you put a hook in ~/.claude/settings.json, it runs for every project. Convenient for the secrets blocker. Terrible for a repo-specific lint config that assumes a Node project when you’re currently in a Rust one. Keep per-project scripts under .claude/ in the repo, and only put truly cross-cutting rules in the global config.
When I don’t use hooks
Rigorous mentor mode: this isn’t a silver bullet. There are two situations where I turn hooks off.
Prototyping in a throwaway branch where I want to see what the agent produces raw. If the lint hook blocks every third tool call while I’m trying to explore an API, I lose the rhythm. In that case I switch the branch to a “no-hooks” mode with a local override file and just eyeball things until the shape is clearer.
Slash commands and MCP-based tools that don’t map cleanly to the matcher regex. Some MCP servers surface names your regex won’t anticipate. The workaround is to broaden the matcher and filter inside the script by inspecting the payload. It works, but it’s less clean, and if I only have one flaky MCP I’ll usually just not hook it at all.
If you’re deciding whether hooks or a different tool fits your workflow, you might want to skim my longer take in Codex vs Claude Code. Different tools, different guardrail stories. I also keep a running set of hook scripts and prompt patterns I actually ship on my portfolio; grab whatever bits are useful.
The setup I’d copy if I were you today
If you have twenty minutes this week, here is the order I would do it in.
Create .claude/settings.json in your project with both a PreToolUse block for a secrets deny list and a PostToolUse block for your language’s formatter. Point them at scripts under .claude/guardrails/ so they live in git and travel with the repo.
Start the deny list minimal. .env, id_rsa, secrets/. You can grow it later. A short list you actually maintain beats a long one you copy-pasted from a gist and never audited.
Wire the formatter to exit 2 on real failures, not on style warnings. If you make it too aggressive it will block the agent constantly and you will turn it off inside a day. Aim for block on lint errors and warn on style, not the reverse.
Log every hook invocation to a file for the first week. Something like >> .claude/guardrails/hook.log 2>&1 at the end of each command. Read the log on Friday. You will see which hooks fire, which never do, and which are catching real mistakes versus creating friction.
Then delete the ones that never fired.
That is it. No magic. Just a small pile of shell scripts that make the agent behave the way I would want a careful new hire to behave: don’t touch the secrets, format the code, tell me when something’s wrong. If your agent runs still feel like babysitting, this is where I would start.