Skip to main content
Claude Code Hooks: How We Turn AI Agent Rules into Deterministic Guardrails

Claude Code Hooks: How We Turn AI Agent Rules into Deterministic Guardrails

October 14, 2026
AI & Dev Tools
9 min read

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, and Stop.

Only exit code 2 blocks. A Claude Code command hook that exits 1 is a non-blocking error, so the tool call still runs.

PreToolUse is the only tool event that can prevent an action. PostToolUse fires after the tool already ran and can only give Claude feedback.

A Stop hook can refuse to let Claude finish until checks pass, but it must read the stop_hook_active field to avoid a fix-fail loop.

Hooks run with full user permissions, and in claude -p or Agent SDK runs a repository's committed hooks run without a workspace-trust prompt.

Why did we move rules out of CLAUDE.md and into hooks?

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. Our CLAUDE.md files said "never touch .env files" and "run the formatter after editing", and Claude Code followed both most of the time. Most of the time is fine for style advice and not good enough for a guardrail.

At Devya we now put anything that must never be skipped (blocking secret files, formatting edited code, refusing to finish with a failing type-check) in hooks, and keep CLAUDE.md for guidance and context. The fix was never a louder instruction. It was moving the rule out of the prompt, where the model's attention on a given turn stops mattering.

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 weighing it on that turn.

Our team sorts every rule into one of three layers:

CLAUDE.md: enforced by the model, probabilistically. Best for conventions, architecture notes, and "prefer X over Y" advice.

Permission rules (allow and deny lists in settings): enforced by the harness, by pattern. Best for a hard allow or deny on tools and commands.

Hooks: enforced by the harness, by running our own code. Best for rules that need logic, such as inspecting content, running a formatter, or gating completion.

The events we rely on most are SessionStart (when a session begins or resumes), PreToolUse (before a tool call, and able to block it), PostToolUse (after a tool call succeeds), and Stop (when Claude finishes responding). The current Claude Code docs list many more, including 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 a 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.

A hook entry sits under a "hooks" key, grouped by event name. Our secrets guard lives under PreToolUse with the matcher Read|Edit|Write and one handler of type "command" that runs ${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-secrets.sh.

The matcher is tested against the tool name. A matcher made only of letters, digits, underscores, hyphens, spaces, commas, and the pipe character 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.* also matches NotebookEdit. We write ^Edit$ when we mean one tool.

A handler can narrow further with an "if" field in permission-rule syntax, such as Bash(git *). The Claude Code docs describe that filter as best-effort, so we use permission rules, not "if", for a hard deny.

How do we 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.

Our protect-secrets script reads the hook input from stdin, extracts tool_input.file_path with jq, and matches it against .env, .env.* variants, .pem, and .key files. It allows .env.example, and for every other match it prints a "may contain secrets" message to stderr and exits with code 2.

The mistake we made first: the blocked branch ended with exit 1. Claude Code treats any exit code other than 0 and 2 as a non-blocking error, so 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, then defer, then ask, then 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, so we pair it with deny permission rules for the same paths. PreToolUse can also rewrite a call through updatedInput, but updatedInput replaces the entire input object, so a hook that forgets an unchanged field silently drops it.

How do we 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. We format only the file that changed by reading tool_input.file_path from stdin, because formatting the whole project on every edit is slow and produces noisy diffs.

Our format hook checks the extension (TypeScript, JavaScript, JSON, or CSS), runs npx --no-install biome format --write on that single path, discards the output, and exits 0.

Two rules apply to PostToolUse. It cannot undo anything, because the file is already written, so exit 2 only shows stderr to Claude as feedback. And Claude never sees stderr from a hook that exits 0, so when we want Claude to react to a lint error, the hook exits 2 with the lint output on stderr.

How do we stop Claude from finishing while type-checks fail?

A Stop hook runs when Claude finishes responding, and printing a JSON object with "decision": "block" and a "reason" string 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.

Our gate script exits 0 immediately if the input's stop_hook_active field is true. Otherwise it runs npx --no-install tsc --noEmit, and on failure it prints a block decision whose reason contains the last 30 lines of compiler output.

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. Claude Code also caps consecutive stop-hook continuations at eight, but we prefer not to lean on that backstop.

We keep the gate fast. A full test suite on every stop makes every turn slow, so we 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 the developer's full user permissions, so a hook is as powerful as any script run by hand. We review hook scripts like production code: quote every shell variable, reject file paths containing "..", and reference scripts by absolute path through ${CLAUDE_PROJECT_DIR}.

In an interactive session, Claude Code holds back settings-file hooks until the user accepts the workspace-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 we did not write, we read its .claude directory or pass --settings with disableAllHooks set to 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 makes a SessionStart hook a clean way to inject the current branch or today's date.

Running claude --debug writes each hook's output to a log under ~/.claude/debug/ named after the session ID, and the /hooks command opens 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 a 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. The .claude/settings.json file 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.