The Four Claude Code Hooks I Run on Every Project
The exact hooks in this repo settings.json: a .env guard, a draft-post warn, mix format, a JSON validator, and the real matcher and exit-code mechanics.
TL;DR: Every “Claude Code hooks” post on page one is a catalog — 20, 39, whatever the count, a shopping list you’re supposed to pick from. This isn’t that. This is the actual
.claude/settings.jsonrunning on this repo right now, four hooks, no more, each one earning its slot the same way a CLAUDE.md entry does: something went wrong first. Two of them — the.envguard and the draft-post warn — I haven’t written up anywhere else. Two others already have their origin story told in full elsewhere, so here they get one line and a link. What none of the catalogs give you is the mechanics: PreToolUse versus PostToolUse, what the matcher field actually parses, and what"decision": "block"does and doesn’t do once the tool already ran. Plus the rule I use to decide whether a lesson becomes a hook or a line of prose.
Every catalog post is a shopping list. This is a receipt.
Search “Claude Code hooks examples” and the first page is uniformly the same shape: a big number in the title, a wall of code blocks, one for each hook, no indication of which ones a real project actually kept. That’s a fine format for browsing — I’ve linked to a few of them myself in the Claude Code resource bible — but it answers the wrong question. The question worth asking isn’t “what’s the full menu of things a hook could do.” It’s “which four did one operator actually leave running on a real repo for months, and why did the other candidates not make the cut.”
I run four. Not because four is a tasteful number — because that’s what survived. Every one of them exists because something specific went wrong once, the same discipline I use for what actually earns a line in CLAUDE.md: a hook doesn’t get written speculatively any more than a context-file entry does. The difference is what kind of lesson each mechanism is suited for, and that’s the part this post spends the most time on.
Two of these four hooks I’ve never written about. Those get the full story. The other two already have a detailed origin story published elsewhere — a formatter that stops pre-commit from tripping on whitespace, and a validator that stops a compile-time JSON file from silently corrupting. Retelling those here would just be padding a word count, so they get one line each and a link to where the real story lives.
Hook
1: the .env guard nobody should have to remember to say out
loud
This is a PreToolUse hook, matcher Write|Edit|MultiEdit,
and its whole job is refusing to let an edit touch a .env
file.
The reasoning is not exotic: an agent that’s actively debugging a
config problem is, by definition, staring at the place where the bug
probably lives, and .env is exactly that place more often
than any other file in the repo. An agent trying to be helpful under
that pressure will reach for the fastest fix, and the fastest fix is
often “just edit the file with the broken value in it.” That’s precisely
the file I don’t want edited by a tool call that also, incidentally,
puts the current contents — including whatever’s already in there — into
a diff the agent has to read and reason about. I don’t want a session
where the quickest path to “fixed” runs through a secret showing up in
the model’s context at all, avoidable or not. I’d rather that path not
exist.
So the rule isn’t “be careful with .env.” It’s a hard
deny, mechanically enforced, with exactly one carve-out:
.env.example is fair game, because it’s the template file
that’s supposed to be readable and editable — it never holds a real
secret by construction.
Here’s the shape of it, reformatted for width (the real one-liner in
settings.json doesn’t have line breaks):
f=$(jq -r '.tool_input.file_path // empty')
b=$(basename "$f")
case "$b" in
.env|.env.*)
[ "$b" = ".env.example" ] && exit 0
# emit permissionDecision: deny, exit 0
printf '%s' "$DENY_JSON"
exit 0
;;
esacAnd the JSON it emits, which is the part that actually does the work:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason":
"Editing .env files is blocked."
}
}permissionDecision: "deny" is what makes this a
wall, not a warning — the tool call never runs. That
distinction is the whole reason this is Hook 1 and not somewhere further
down the list. It’s the only one of the four that fires before the edit
happens and can actually stop it.
Hook 2: the published-post draft guard
This one is subtler, and it exists because of a fact about how this
site is built that isn’t obvious from the filesystem:
content/posts/*.md is draft-only. The live, routable
version of a post is a rendered-HTML record injected into
priv/content/site_content.json, which the app compiles in
via @external_resource. Editing the .md file
for a post that’s already published changes nothing a reader will ever
see. It feels like editing the post. It isn’t.
That’s exactly the kind of trap an agent falls into with total
confidence — it opens content/posts/some-old-post.md, makes
a clean, correct-looking edit, reports success, and nothing on the live
site moves. The failure isn’t a bug in the edit. It’s a wrong model of
which file is authoritative, and that model is invisible from inside the
file itself.
So this is a PostToolUse hook, same matcher,
Write|Edit|MultiEdit, that fires after the edit
and checks one thing: does this slug already exist in
site_content.json? If it does, it emits a warning back into
the model’s context — not a block, because by the time PostToolUse runs,
the edit already happened. There’s nothing left to prevent.
f=$(jq -r \
'.tool_input.file_path // .tool_response.filePath')
case "$f" in
*/content/posts/*.md)
s=$(basename "$f" .md)
p="$PROJECT_DIR/priv/content/site_content.json"
r=$(python3 check_if_published.py "$s" "$p")
[ "$r" = "1" ] && printf '%s' "$WARN_JSON"
exit 0
;;
esacThe reason string is deliberately specific rather than generic — it
names the actual escape hatch (edit the record in
site_content.json, or use the publish-post workflow)
instead of just saying “this might not do what you think.” A hook that
only says “warning” without saying what to do instead is a hook that
gets ignored the second time.
Hooks 3 and 4: already told, so here’s the receipt not the story
The other two hooks running on this repo have their full origin stories published already, and re-narrating them here would just be restating what’s a click away. One line each.
mix format, PostToolUse. Every
.ex/.exs/.heex write or edit runs
the formatter automatically afterward, so a formatting-only failure can
never reach mix precommit. The story of the specific
pre-commit friction that earned this one — and why it’s framed as a
guardrail in CLAUDE.md rather than a rule the agent has to remember — is
in what belongs in CLAUDE.md
after fifty commits.
site_content.json validator, PostToolUse. Any edit
that would leave the compile-time content file as invalid JSON gets
blocked with a decision: "block" reason explaining exactly
why: the app loads that file via @external_resource, so
broken JSON means a broken compile, and it’s better to catch that the
moment it happens than three steps later in a failed
mix test. Same post, same section, has the detail.
Both of those are mechanically identical to Hook 2 in structure — PostToolUse, matcher on the file write, a JSON check gating the response — which is worth noticing on its own: once you understand the pattern, adding the fourth hook was mostly copy-and-adjust, not new design.
The mechanics nobody’s catalog post spells out
Here’s the part that actually matters if you’re going to write your own instead of copying mine, and it’s the part I don’t see explained clearly even in the long catalog posts: what these event names and fields actually do, not just what they’re called.
PreToolUse fires before the tool call executes, and it’s the
only one of the two that can stop anything. It receives
tool_input — the arguments about to be sent to the tool —
and it can return hookSpecificOutput.permissionDecision set
to "allow", "deny", "ask", or
"defer". "deny" means the tool call never
happens at all. That’s the only place in this whole system where “block”
means what it sounds like it means.
PostToolUse fires after the tool call has already succeeded,
and it cannot undo anything. The file is already written by the
time this hook runs. It gets both tool_input and
tool_response, and it can return a top-level
"decision": "block" with a "reason" string —
but per the official
hooks reference, that only stops Claude’s next action in
the loop; it’s feedback surfaced into context, not a rollback. One of my
own hooks notes exactly this in a comment:
decision:block here only surfaces feedback to the model — the edit already happened.
If you want prevention, it has to be PreToolUse. If you’re checking the
result of something that already ran, PostToolUse is the only
option and a warning is the ceiling of what it can do.
The matcher field is a name filter, not a
content filter. "Write|Edit|MultiEdit" is regex
alternation over the tool name Claude Code is about to call or just
called — it has no visibility into the file path or arguments until the
hook’s own command runs and inspects tool_input itself.
That’s why every one of these four hooks does its own jq
extraction and its own case statement on the file path —
the matcher only gets you into the room, it doesn’t tell you what’s in
the room.
Exit codes and JSON output are two different signaling
channels, and only one is processed at a time. Exit 2 is a
blunt, JSON-free way to fail a hook — it’s read as a blocking error on
events that support it. JSON on stdout with exit 0 is the structured
channel, and it’s the only one that gets parsed for fields like
permissionDecision or decision. All four of my
hooks use the JSON-on-exit-0 path, because I want a specific reason
string in the model’s context, not just a bare failure.
The decision rule: hook, or CLAUDE.md line?
Every operational lesson from a real project ends up in one of two places, and I use one rule to sort them, the same rule I use when deciding what earns a CLAUDE.md entry versus what stays unwritten: if a rule can be mechanically enforced, it becomes a hook. If it requires judgment the mechanism can’t make, it stays as prose.
“Never edit .env” is purely mechanical — there is no
case where I want an agent’s judgment to override it, so it’s a hard
deny with zero ambiguity. “This post is already published, editing the
.md won’t change the live site” is also mechanical to
detect, but the right response still depends on intent —
sometimes you do want to sync the draft on purpose — so it’s a
warning that hands the decision back, not a deny. “Follow the existing
conventions” or “prefer clarity” can’t be mechanically checked at all;
there’s no jq expression for good taste, so those stay as
prose, if they’re written down anywhere.
The tell that a lesson belongs in a hook instead of a sentence: you’ve written the same CLAUDE.md-style instruction more than once because the agent (or you) forgot it, and the check for whether the instruction was followed is something a shell one-liner could actually answer. A rule that can be forgotten belongs in prose only until it’s been forgotten twice — at that point it’s cheaper to make it un-forgettable.
What’s not on this list, and won’t be until it’s earned
I have a fifth PostToolUse check in this repo — a fast SEO check on frontmatter, title length and tag taxonomy, that runs on every post edit. It’s newer, it’s still proving itself, and I’m not confident yet it’s earned a permanent slot the way these four have. That’s the honest state of it: this post is about the four that have survived long enough to trust, not an inventory of everything currently running. The same discipline that keeps a CLAUDE.md from bloating applies here — a hook that hasn’t paid for its keep yet doesn’t get written up as settled.
If you’re setting up your own project and you’re tempted to install a dozen hooks from one of the big catalog posts on day one: don’t. None of mine started that way. Each one is the mechanical answer to a mistake that already happened at least once, the same as every entry in my CLAUDE.md that survived past commit fifty. Write the hook after the second time you catch yourself typing the same warning into a prompt, not before.
If this kind of guardrail-over-vibes approach to agent tooling is the layer you’re missing, the plugin- and skill-level version of the same discipline — what earns a permanent slot in the stack versus what gets uninstalled — is in the Claude Code plugin stack that actually stuck. And if you’re trying to figure out which of this whole ecosystem is worth your time before you’ve burned a week on it yourself, that’s what the Claude Code resource bible is for.
Setting this up for a team rather than a solo project — where the hooks need to be consistent across everyone’s local config, not just yours — is exactly the kind of AI-tooling groundwork I help founders and engineering leads get right early. If that’s where you are, let’s talk.