The Navigator · 12 min mission

Hooks: Deterministic Guardrails

Enforce rules with code, not hope — gates that fire every time.

hooksautomationguardrailsFact-checked 2026-06-13
On this page

Most of what you tell Claude is a suggestion. CLAUDE.md, your prompts, the tone of your instructions — Claude reads them, weighs them, and usually complies. Usually is the problem. When a rule has to hold every single time — format this file after every edit, never touch .env, run the tests before you call it done — "usually" is not good enough. You want a guarantee.

Hooks are that guarantee. A hook is a shell command Claude Code runs automatically at a specific point in its lifecycle. The hook fires whether or not Claude "decides" to run it, because you configured it, not the model. That is the whole idea: hooks move a behavior out of the model's judgment and into deterministic, code-defined rules. The official docs put it plainly — hooks "provide deterministic control over Claude Code's behavior, ensuring certain actions always happen rather than relying on the LLM to choose to run them."

Why a hook beats a stronger prompt

The instinct, when Claude skips a step, is to write a louder paragraph in CLAUDE.md. That treats a determinism problem as a persuasion problem, and it does not scale. A prompt is advice; a hook is enforcement. If you need formatting to run on every edit with zero exceptions, you do not phrase it better — you attach a PostToolUse hook to the Edit and Write tools and stop thinking about it. The split is worth memorizing: memory and prompts shape what Claude tends to do; hooks and permissions enforce what must or must not happen. Reach for a hook the moment a rule is non-negotiable.

The hook lifecycle

Claude Code fires hooks at named points called events. You attach commands to the events you care about and ignore the rest. These are the core events worth learning first:

SessionStart fires when a session begins or resumes. Anything your command prints to stdout is added to Claude's context — ideal for injecting fresh state like the current branch or recent commits. Its matcher distinguishes startup, resume, clear, and compact.

UserPromptSubmit fires after you submit a prompt but before Claude processes it. It can add context (stdout becomes context here too) or block the prompt entirely.

PreToolUse fires before a tool call runs — the only event that can stop an action before it happens. This is where you block dangerous commands or protect files. Its matcher filters by tool name.

PostToolUse fires after a tool call succeeds. The action already happened, so you cannot undo it, but you can react — format the file that was just edited, lint it, log it.

Stop fires when Claude finishes responding. Block here and Claude keeps working instead of stopping — the basis of a "tests must pass before you're done" gate. SubagentStop is the same idea for a subagent finishing.

Notification fires when Claude Code sends a notification, such as needing your approval or going idle — perfect for a desktop ping. PreCompact fires before context compaction (matcher: manual or auto), and SessionEnd fires when a session terminates, useful for cleanup.

EventFires whenCan block?
SessionStartA session begins or resumesNo (stdout adds context)
UserPromptSubmitYou submit a prompt, before processingYes (erases the prompt)
PreToolUseBefore a tool call runsYes (blocks the call)
PostToolUseAfter a tool call succeedsNo (already ran)
StopClaude finishes respondingYes (keeps working)
SubagentStopA subagent finishesYes (keeps working)
NotificationClaude Code sends a notificationNo
PreCompactBefore context compactionYes (blocks compaction)
SessionEndA session terminatesNo
The core hook events, when they fire, and whether they can block an action. Exit-code-2 blocking behavior differs by event — see the exit-code section below.

Matchers: firing on the right thing

Without a matcher, a hook fires on every occurrence of its event. A PostToolUse hook with no matcher runs after every tool call — almost never what you want. The matcher field narrows it.

What the matcher compares against depends on the event. For the tool events (PreToolUse, PostToolUse) it matches the tool name: Bash, Edit, Write, Read, and so on. For SessionStart it matches the source (startup, resume, clear, compact); for Notification it matches the notification type; for PreCompact it matches manual or auto.

The matcher value is evaluated three ways. An empty string "", "*", or an omitted matcher means match everything. A value of only letters, digits, _, and | is treated as an exact name or a |-separated list — so "Edit|Write" fires on either tool. Anything containing other characters is a JavaScript regular expression, which is how you target MCP tools: "mcp__github__.*" matches every tool from the github server. Matchers are case-sensitive, so bash will not match the Bash tool.

One gap to remember: a PostToolUse matcher of Edit|Write catches those tools, but Claude can also create files by running shell commands through Bash, which that matcher misses. If a hook must see every file change, the docs suggest a Stop hook that scans the working tree once per turn, or also matching Bash and listing changes with git status --porcelain.

Configuring hooks in settings.json

Hooks live in a JSON settings file under a single top-level "hooks" key. Inside it, each key is an event name, whose value is an array of matcher groups. Each group has a "matcher" and a "hooks" array of handlers. The most common handler is { "type": "command", "command": "…" }; you can add an optional "timeout" in seconds.

Where you put that JSON decides its scope. Put it in ~/.claude/settings.json to apply across all your projects (machine-local, not shared). Put it in .claude/settings.json at your project root to scope it to that project and commit it for your team. Put it in .claude/settings.local.json for project-specific hooks you keep out of version control. Organization-wide managed policy settings and plugins can also define hooks.

After editing a settings file, type /hooks in Claude Code to open the hooks browser — a read-only menu that lists every event, with a count beside each one that has hooks, and the matcher, type, source file, and command for each. It is the fastest way to confirm a hook actually registered. The menu is read-only by design; to change a hook you edit the JSON (or ask Claude to).

.claude/settings.json — format every edited file with Prettier
json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
          }
        ]
      }
    ]
  }
}

Exit codes and JSON output

A hook talks back to Claude Code through stdin, stdout, stderr, and its exit code. When an event fires, Claude Code feeds the hook a JSON object on stdin — fields like session_id, cwd, hook_event_name, and for tool events tool_name and tool_input. Your script reads that, does its work, and signals what happens next.

The simplest signal is the exit code:

  • Exit 0 means "no objection." The action proceeds normally. For a PreToolUse hook this does not approve the call — the normal permission flow still applies. For SessionStart, UserPromptSubmit, and a couple of others, whatever you printed to stdout is injected into Claude's context.
  • Exit 2 means "block." For a blockable event the action is stopped, and whatever you wrote to stderr is fed back to Claude as feedback so it can adjust. Crucially, what exit 2 blocks depends on the event: PreToolUse blocks the tool call, UserPromptSubmit erases the prompt, Stop and SubagentStop make Claude keep working, PreCompact blocks compaction. For non-blockable events like SessionStart, SessionEnd, and Notification, exit 2 just shows stderr to the user and continues.
  • Any other exit code is a non-blocking error: the action still proceeds, and the transcript shows a "hook error" notice with the first line of stderr.

When exit codes are too blunt, exit 0 and print a JSON object to stdout for finer control. A PreToolUse hook can return a hookSpecificOutput object with a permissionDecision of "allow" (skip the prompt), "deny" (cancel and tell Claude why via permissionDecisionReason), or "ask" (prompt the user as normal). Don't mix the two channels — Claude Code ignores stdout JSON when you exit 2.

Build a hook config

Build a Claude Code hook

Pick an event, scope it with a matcher, and name the shell command to run. The settings.json on the right rewrites itself as you click — in the exact nested shape Claude Code expects.

Event

When should the hook fire? Each event runs at a different point in the loop.

Matcher

Filters on the tool name. Use a pipe for several; * (or omit) means every tool.

Command

The shell command Claude Code runs for this hook. It runs from the project root.

.claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "npm run lint"
}
]
}
]
}
}
Pick an event, a matcher, and a command, and assemble a valid settings.json hooks block. Use it to sketch the recipes below before you paste them into a real settings file.

Four recipes worth stealing

  1. Format on edit

    A PostToolUse hook with matcher "Edit|Write" runs after Claude touches a file. The command jq -r '.tool_input.file_path' | xargs npx prettier --write pulls the edited path out of the hook input and formats it. Formatting stays consistent without you asking.

  2. Block dangerous commands

    A PreToolUse hook with matcher "Bash" runs a script that reads tool_input.command, and if it matches something destructive (say, grep -q "drop table"), writes a reason to stderr and exits 2. The tool call is cancelled and Claude sees why.

  3. Test gate before stop

    A Stop hook runs your test suite; if it fails, exit 2 with the failure on stderr so Claude keeps working instead of declaring victory. Read stop_hook_active and exit 0 early to avoid an infinite loop. A type: "prompt" or type: "agent" hook can make this judgment-based instead of a hard command.

  4. Session setup and notifications

    A SessionStart hook with matcher "compact" re-injects context after compaction — echo a reminder, or git log --oneline -5 for recent commits; stdout becomes context. A Notification hook fires a desktop ping (osascript on macOS, notify-send on Linux) so you can step away from the terminal.

a PreToolUse hook blocking rm -rf
… scroll to run this session
Claude proposes a destructive command, the PreToolUse hook on Bash exits 2, and the stderr message comes back as feedback Claude can act on — no file was deleted.

Beyond command: the four other handler types

Every example so far used "type": "command" — a shell script reading stdin and signalling through exit codes. That is the workhorse, but the official reference defines four other handler types, each a different way for a hook to reach a decision without you writing a script. [V] You can mix them inside one matcher group's "hooks" array; when several hooks match the same event they all run in parallel and the most restrictive answer wins (in the order deny, defer, ask, allow). [V]

type: "http" POSTs the event's JSON input to a url instead of running locally. [V] The endpoint replies through the response body using the same JSON output schema as a command hook: a 2xx with an empty body is success (exit 0), a 2xx with a JSON body is parsed as a decision, a 2xx with plain text adds that text as context, and any non-2xx is a non-blocking error. [V] HTTP status codes alone cannot block — to deny a tool call the endpoint must return 2xx with the right hookSpecificOutput fields. [V] Header values interpolate $VAR_NAME, but only variables you list in allowedEnvVars are resolved; everything else expands to empty. [V] Because a hook URL is an exfiltration surface, the destination must be permitted by the allowedHttpHookUrls setting before Claude Code will call it. [V]

type: "mcp_tool" calls a tool on an already-connected MCP server — it never triggers an OAuth or connection flow, so the server must already be live. [V] You name the server and tool and pass an input object whose string values support ${tool_input.file_path}-style substitution from the hook input. The tool's text output is treated exactly like command-hook stdout: valid JSON becomes a decision, anything else is shown as plain text. If the server is disconnected or the tool returns isError: true, you get a non-blocking error. [V]

type: "prompt" sends your prompt plus the hook input to a Claude model for a single-turn yes/no call — Haiku by default, overridable with model. [V] Use $ARGUMENTS as the placeholder for the hook input JSON. The model returns {"ok": true} to proceed or {"ok": false, "reason": "…"} to object; on a Stop hook a false ok feeds reason back so Claude keeps working. [V] Its default timeout is 30 seconds, not the 600 of a command hook. [V] This is the cheap-gate pattern: a judgment call too fuzzy for grep, too small to spend Sonnet on.

type: "agent" spawns a sandboxed subagent that can Read, Grep, and Glob to verify a condition against the actual codebase before answering — same ok/reason format as a prompt hook, but with a 60-second default timeout and up to 50 tool-use turns. [V] It is flagged experimental in the docs and may change; for production guardrails the docs steer you to command hooks. [V] Reach for an agent hook only when the hook input alone is not enough and you need to inspect real files.

`type`How it decidesKey fieldsBest for
commandShell script, exit codecommand, timeout, asyncDeterministic rules
httpPOST to a webhookurl, headers, allowedEnvVarsShared/remote logic
mcp_toolCall a connected MCP toolserver, tool, inputReuse an MCP server
promptOne Claude call, ok/reasonprompt, modelCheap judgment gate
agentSubagent with file toolsprompt, model, timeoutVerify against the repo
The five hook handler types and what each is for. command/http/mcp_tool default to a 600s timeout; prompt 30s; agent 60s (all overridable with `timeout`). [V]
.claude/settings.json — POST every tool use to a Slack webhook (http handler)
json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "http",
            "url": "https://hooks.slack.com/services/T000/B000/XXXX",
            "headers": { "Content-Type": "application/json" },
            "allowedEnvVars": []
          }
        ]
      }
    ]
  }
}

The Slack webhook above receives the PostToolUse JSON as its POST body and posts it to the channel; an empty 2xx response means "no objection," so the tool result is untouched. [V] For this to fire at all, https://hooks.slack.com/... must be allowed by the allowedHttpHookUrls setting — Claude Code refuses to call a hook URL that is not on the allowlist. [V] In practice you point the webhook at a tiny relay (a Worker or Lambda) that reshapes the raw hook JSON into Slack's { "text": "…" } block format rather than dumping the payload verbatim. [P]

Async hooks: don't block the turn

A slow command hook stalls Claude until it returns. Two fields on type: "command" hooks fix that. async: true runs the command in the background and lets Claude continue immediately — fire-and-forget for a deploy, an upload, a long lint. [V] asyncRewake: true also backgrounds the command (it implies async), but Claude Code wakes Claude on exit code 2: the hook's stderr — or stdout if stderr is empty — is delivered as a system reminder so Claude can react to a background failure mid-turn. [V] Both fields are command-only; http and mcp_tool hooks have no async behaviour and simply time out at their configured duration. [V]

A cheap-judge Stop gate with type: "prompt"

A command Stop gate is binary — your script either knows the work is done or it does not. When "done" needs judgment, swap the script for a type: "prompt" hook on a cheap model and let it read the transcript. The model returns {"ok": false, "reason": "…"} to push Claude to keep going, and the cost is one Haiku-class call per turn rather than a Sonnet round-trip. [V]

.claude/settings.json — cheap-judge Stop gate on claude-haiku-4-5
json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "prompt",
            "model": "claude-haiku-4-5",
            "prompt": "Read the transcript at $ARGUMENTS (field transcript_path). If every task the user asked for is finished, respond {\"ok\": true}. Otherwise respond {\"ok\": false, \"reason\": \"<what remains>\"}."
          }
        ]
      }
    ]
  }
}

The hook input handed to that prompt includes transcript_path (the JSONL of the conversation) alongside session_id and stop_hook_active, so the judge can inspect what actually happened, not just the last message. [V] Because it is a Stop hook, the same 8-consecutive-block override applies: a prompt gate that never returns ok: true is overridden after eight blocks, so keep the criterion reachable. [V]

A cross-provider Stop gate: Codex reviews before Claude stops

The handler types let one assistant gate on another. The official Codex plugin ships exactly this. Running /codex:setup --enable-review-gate registers a Stop hook that, when Claude finishes a turn, runs a synchronous Codex review of that turn through the local Codex CLI. [V] If Codex returns BLOCK:, the hook emits { "decision": "block", "reason": … } and Claude keeps working to address the findings before it is allowed to stop; ALLOW: lets the stop through. [V] It is the same "tests must pass before done" pattern, except the gate is a second model's code review rather than a test command — a real cross-provider guardrail wired entirely through the Stop event. Toggle it back off with /codex:setup --disable-review-gate. [V]

Knowledge check

You want the test suite to run before Claude is allowed to finish, and Claude should keep working if tests fail. Which event do you hook, and how do you signal failure?

Reach the end and this star joins your charted sky.