Claude Code Hooks: Deterministic Guardrails for AI Coding Agents — Field Notes
Headline: A rule in CLAUDE.md is a request the model usually follows; a Claude Code hook is a handler the harness runs every single time a lifecycle event fires. Put anything that must never be skipped — blocking secret
Headline: A rule in
CLAUDE.mdis a request the model usually follows; a Claude Code hook is a handler the harness runs every single time a lifecycle event fires. Put anything that must never be skipped — blocking secret files, formatting edited code, refusing to finish with a failing type-check — in hooks, and keepCLAUDE.mdfor guidance and context.
For months my CLAUDE.md said "never touch .env files" and "run the formatter after editing". Claude Code followed both most of the time. "Most of the time" is fine for style advice and useless for a guardrail. The fix was not a louder instruction. It was moving the rule out of the prompt and into a hook, where the model's attention on a given turn stops mattering.
Key takeaways
-
Claude Code hooks are user-defined handlers — a shell command, HTTP endpoint, MCP tool, LLM prompt, or subagent — that Claude Code runs at fixed lifecycle events such as
PreToolUse,PostToolUse, andStop. - Only exit code 2 blocks. A Claude Code command hook that exits 1 is a non-blocking error, so the tool call still runs.
-
PreToolUseis the only tool event that can prevent an action;PostToolUsefires after the tool already ran and can only give Claude feedback. -
A
Stophook can refuse to let Claude finish until checks pass, but it must readstop_hook_activeto avoid a fix-fail loop. -
Hooks run with your full user permissions, and in
claude -por Agent SDK runs a repository's committed hooks run without a workspace-trust prompt.
What is a Claude Code hook, and how is it different from CLAUDE.md?
A Claude Code hook is a handler that the Claude Code harness — not the model — executes when a specific event happens in a session. The event passes a JSON payload describing what is happening (the tool name, its input, the session ID), and the handler can inspect it, act on it, and optionally return a decision. CLAUDE.md works the opposite way: it is text loaded into the model's context, so whether a rule is honoured depends on the model reading and weighing it on that turn.
I now sort every rule into one of three layers:
| Mechanism | Who enforces it | Best for |
|---|---|---|
CLAUDE.md |
The model, probabilistically | Conventions, architecture notes, "prefer X over Y" |
Permission rules (allow / deny in settings) |
The harness, by pattern | Hard allow or deny on tools and commands |
| Hooks | The harness, by running your code | Rules that need logic: inspect content, run a formatter, gate completion |
The events I actually use are SessionStart (when a session begins or resumes), PreToolUse (before a tool call; can block it), PostToolUse (after a tool call succeeds), and Stop (when Claude finishes responding). The full event list is much longer — the current Claude Code docs also cover subagent, compaction, worktree, and permission events.
Where do Claude Code hooks live, and how does a matcher choose them?
Claude Code reads hooks from settings files: ~/.claude/settings.json applies to every project on the machine, .claude/settings.json is committed and shared with the team, and .claude/settings.local.json stays local to one checkout. Plugins, skills, and subagents can ship hooks too, and hook entries from different levels merge rather than replace each other.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-secrets.sh"
}
]
}
]
}
}
The matcher is tested against the tool name. A matcher made only of letters, digits, _, -, spaces, commas, and | is an exact match or a list of exact matches, so Edit|Write matches exactly those two tools. Any other character turns the matcher into an unanchored JavaScript regular expression, which is how Edit.* quietly matches NotebookEdit as well. Write ^Edit$ when you mean one tool. A handler can narrow further with an if field in permission-rule syntax, such as "Bash(git *)", but the Claude Code docs call that filter best-effort — use permission rules, not if, for a hard deny.
How do I block a dangerous tool call with a PreToolUse hook?
A PreToolUse hook blocks a tool call in one of two ways: exit with code 2 and write the reason to stderr, or exit 0 and print JSON with hookSpecificOutput.permissionDecision set to "deny". In both cases Claude receives the reason and can change course. This is the script behind the config above:
#!/usr/bin/env bash
# .claude/hooks/protect-secrets.sh — refuse to read or edit env files and keys
input=$(cat)
path=$(jq -r '.tool_input.file_path // empty' <<<"$input")
case "$path" in
*.env|*.env.*|*.pem|*.key)
[[ "$path" == *.env.example ]] && exit 0
echo "Blocked: $path may contain secrets. Use .env.example instead." >&2
exit 2
;;
esac
exit 0
The bug that cost me an afternoon: my first version ended the blocked branch with exit 1. Claude Code treats any exit code other than 0 and 2 as a non-blocking error — it shows a hook-error notice and lets the tool call proceed. Only exit 2, or a JSON deny, actually blocks. When several PreToolUse hooks disagree, precedence is deny > defer > ask > allow, so one strict hook always wins over a permissive one.
This hook does not stop a Bash call like cat .env, because that goes through a different tool. I pair it with deny permission rules for the same paths. PreToolUse can also rewrite a call through updatedInput; treat that as a sharp tool, because updatedInput replaces the entire input object and a hook that forgets an unchanged field silently drops it.
How do I auto-format files after every edit Claude makes?
A PostToolUse hook with the matcher Edit|Write runs after each successful file edit, which makes it the right place for a formatter. Format only the file that changed — read tool_input.file_path from stdin — because formatting the whole project on every edit is slow and produces noisy diffs.
#!/usr/bin/env bash
# .claude/hooks/format-edited.sh
path=$(jq -r '.tool_input.file_path // empty')
case "$path" in
*.ts|*.tsx|*.js|*.jsx|*.json|*.css)
npx --no-install biome format --write "$path" >/dev/null 2>&1 || true
;;
esac
exit 0
Two rules apply to PostToolUse. First, it cannot undo anything: the file is already written, so exit 2 only shows your stderr to Claude as feedback. Second, Claude never sees stderr from a hook that exits 0, so a quiet formatter stays quiet. When I want Claude to react to a lint error, the hook exits 2 with the lint output on stderr.
How do I stop Claude from finishing while type-checks fail?
A Stop hook runs when Claude finishes responding, and printing {"decision": "block", "reason": "..."} makes Claude keep working with the reason as its next instruction. That turns "run the type-checker before you say done" from a suggestion into a gate.
#!/usr/bin/env bash
# .claude/hooks/typecheck-gate.sh
input=$(cat)
[[ "$(jq -r '.stop_hook_active' <<<"$input")" == "true" ]] && exit 0
if ! out=$(npx --no-install tsc --noEmit 2>&1); then
jq -n --arg r "Type-check failed. Fix these errors before finishing:
$(tail -n 30 <<<"$out")" '{decision: "block", reason: $r}'
fi
exit 0
The stop_hook_active check matters. The field is true when Claude is already continuing because of a stop hook, so returning early on that pass gives Claude exactly one forced retry instead of an endless fix-fail-fix loop on an error it cannot solve. Claude Code also caps consecutive stop-hook continuations at eight, but I prefer not to lean on that backstop. Keep the gate fast: a full test suite on every stop makes every turn slow, so I gate on tsc --noEmit and leave the tests to CI.
What security and debugging gotchas do Claude Code hooks have?
Claude Code command hooks execute with your full user permissions, so a hook is as powerful as any script you run by hand. Review hook scripts like production code: quote every shell variable, reject file paths containing .., and reference scripts by absolute path through ${CLAUDE_PROJECT_DIR}.
The workspace-trust rule surprised me most. In an interactive session, Claude Code holds back settings-file hooks until you accept the trust dialog for the folder. In a claude -p or Agent SDK session there is no dialog and the folder is treated as trusted, so hooks committed in a cloned repository's .claude/settings.json run immediately. Before scripting claude -p over a repository you did not write, read its .claude/ directory or pass --settings '{"disableAllHooks": true}'.
For debugging, stdout from most events goes to the debug log, not the transcript. The exceptions include UserPromptSubmit and SessionStart, where plain-text stdout is added to Claude's context — which is why a SessionStart hook is a clean way to inject the current branch or today's date. Run claude --debug and read ~/.claude/debug/<session-id>.txt to see each hook's output, and type /hooks for a read-only list of every configured hook and the file it came from.
FAQ
Q: Do Claude Code hooks replace CLAUDE.md?
A: No. Claude Code hooks enforce rules that must hold every time, while CLAUDE.md carries conventions and context the model should weigh. Most projects need both, with only the non-negotiable rules moved into hooks.
Q: Why doesn't my PreToolUse hook block the tool call?
A: The most common cause is exiting with code 1 instead of 2. Claude Code treats any non-zero exit code other than 2 as a non-blocking error, so the tool call proceeds; exit 2 or print a JSON permissionDecision of "deny" instead.
Q: Can a PostToolUse hook undo a file edit?
A: No. A PostToolUse hook fires after the tool has already run, so it can only add feedback or change what Claude sees. To prevent an edit, use a PreToolUse hook.
Q: Do Claude Code hooks run inside subagents?
A: Yes. Hooks from settings files, managed policy, and plugins fire on subagent tool calls too, and the hook input includes agent_id and agent_type so a script can tell which subagent made the call.
Q: Is it safe to commit hooks to a shared repository?
A: Yes — .claude/settings.json is meant to be committed so the whole team shares the same guardrails. Review those scripts as executable code, because they run with each developer's user permissions.
Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.