Skip to content
Zumkai

Hooks: the one layer that guarantees

A hook that denies blocks the tool even under bypassPermissions. Hooks tighten restrictions and never loosen them, and that is what makes them the policy layer.

  • claude code
  • hooks
Card showing that a PreToolUse hook fires before any permission mode check.
Contents
  1. The 30 events, grouped by what they serve
  2. Five types, and two that are not deterministic
  3. The rule that makes a hook count: tighten, never loosen
  4. Hooks run in parallel, and that has a consequence
  5. Where the hook lives defines its reach
  6. Three hooks worth the effort
  7. When to skip the hook
  8. Frequently asked questions
  9. Sources

CLAUDE.md is context and the agent can ignore it. A skill is context and the agent can leave it uninvoked. A hook is a shell command Claude Code runs at a fixed point in the lifecycle, and it happens whatever the model decides.

That is the difference that matters, and it has a documented proof: a PreToolUse hook returning deny blocks the tool even in bypassPermissions mode, even with --dangerously-skip-permissions.

No other layer of Claude Code does that.

The 30 events, grouped by what they serve

Most writing about hooks covers two events. Thirty exist.

Session lifecycle: SessionStart, SessionEnd, Setup, PreCompact, PostCompact, ConfigChange, InstructionsLoaded.

Turn lifecycle: UserPromptSubmit, UserPromptExpansion, Stop, StopFailure, MessageDisplay, Notification.

Tool lifecycle: PreToolUse, PostToolUse, PostToolUseFailure, PostToolBatch, PermissionRequest, PermissionDenied.

Delegation: SubagentStart, SubagentStop, TeammateIdle, TaskCreated, TaskCompleted.

Environment: CwdChanged, DirectoryAdded, FileChanged, WorktreeCreate, WorktreeRemove.

MCP: Elicitation, ElicitationResult.

A few deserve attention because they solve problems people try to solve another way:

  • InstructionsLoaded fires when a CLAUDE.md or a .claude/rules/ rule enters the context, lazy mid-session loads included. It is the right tool for debugging a paths rule you cannot tell has loaded.
  • PostCompact runs after compaction, the place for reinjecting context that fails to survive /compact.
  • CwdChanged fires when the agent runs a cd. It suits reactive environment management, in the style of direnv.
  • FileChanged watches a file on disk, with the matcher setting which names to watch.

Several of those events exist to solve traps that degrade in silence.

Five types, and two that are not deterministic

Here is the part that contradicts the running definition of a hook.

TypeWhat it does
commandRuns a shell command. The default
httpPOSTs the event data to a URL
mcp_toolCalls a tool from an already-connected MCP server
promptA single-pass LLM evaluation
agentMulti-turn verification with tool access, experimental

The last two exist for decisions needing judgment rather than a fixed rule.

The prompt hook. Instead of running shell, Claude Code sends your prompt and the event data to a model (Haiku by default), which returns the decision as JSON:

json
{
  "hooks": {
    "Stop": [{
      "hooks": [{
        "type": "prompt",
        "prompt": "Check whether every task finished. If not, answer {\"ok\": false, \"reason\": \"what is missing\"}."
      }]
    }]
  }
}

With "ok": false on a Stop event, the reason goes back to Claude as the next instruction and it keeps working. An important escape valve exists: "impossible": true marks the condition as impossible to satisfy, and the turn then ends instead of looping.

The agent hook. When the verification needs to read a file or run a command, type: "agent" creates a subagent that investigates before deciding. A 60-second default timeout and up to 50 tool turns.

json
{
  "type": "agent",
  "prompt": "Check whether every unit test passes. Run the suite and check the result. $ARGUMENTS",
  "timeout": 120
}

The documentation marks agent hooks as experimental and recommends command hooks for production flows.

The criterion between the two: use prompt when the event data suffices for the decision; use agent when you have to check against the code's real state.

The rule that makes a hook count: tighten, never loosen

This is the property that turns a hook into a policy layer.

PreToolUse fires before any permission mode check, in every mode, dontAsk included. A hook returning permissionDecision: "deny" blocks the tool under bypassPermissions and with --dangerously-skip-permissions.

In other words: you can impose a policy the user cannot get around by changing the permission mode. For a team with a compliance requirement, it is the one documented way to do that.

The inverse does not hold. A hook returning allow:

  • cannot bypass a deny rule from the settings
  • cannot suppress the connector tool prompt the organization marked as ask
  • cannot suppress an MCP tool marked requiresUserInteraction

The asymmetry is deliberate, and it is what makes the mechanism dependable. A hook tightens; it never lets go.

Worth knowing too that exit code 2 blocks, and blocks hard: even JSON carrying permissionDecision: "allow" cannot override an exit 2.

The delegation events (SubagentStart, SubagentStop, TeammateIdle) close the loop described in subagents: when to delegate and when not to.

Hooks run in parallel, and that has a consequence

When several hooks match the same event, all of them run in parallel and all of them complete before Claude Code combines the results.

The consequence that catches people: a hook returning deny stops no side effect from a sibling hook.

The documentation's example is clear. Two hooks on Bash: one records the command in a log and exits 0; the other exits 2 to deny when the command contains rm -rf. Claude tries to run rm -rf /tmp/build and both execute. The command gets blocked, and the log line gets written anyway, because the logging hook already ran.

To combine decisions, the most restrictive answer wins, in the order denydeferaskallow. The additionalContext text, meanwhile, gets preserved from every hook and delivered together.

And a concurrency trap exists: when more than one PreToolUse hook returns updatedInput to rewrite a tool's arguments, the last one to finish wins, and the order is non-deterministic. The official recommendation is to avoid two hooks modifying the same tool's input.

Where the hook lives defines its reach

Seven possible places exist, and the choice decides who inherits the rule.

WhereReachShareable
~/.claude/settings.jsonAll your projectsNo
.claude/settings.jsonOne projectYes, versionable
.claude/settings.local.jsonOne projectNo, it stays gitignored
Managed configurationThe whole organizationYes, controlled by the admin
A plugin, in hooks/hooks.jsonWherever the plugin is activeYes, alongside the plugin
A skill's frontmatterThe rest of the session, after the skill is invokedYes, inside the skill's file
A subagent's frontmatterWhile that subagent runsYes, inside the subagent's file

The last two rows are the least known and the most interesting.

Claude Code registers a hook declared in a skill's frontmatter the moment something invokes the skill, and it keeps applying for the rest of the session. That lets you package policy alongside the procedure: the skill doing the deploy can bring the hook that blocks writes to production.

A hook declared in a subagent's frontmatter applies while it runs and no longer. It is the way to give a subagent a restriction the main session does not carry, and the documentation's example validates a read-only query before letting the subagent touch the database.

To switch everything off, "disableAllHooks": true in the settings. Two caveats: configuration precedence applies, so the project file can override yours; and hooks from managed configuration keep running unless the flag sits there too. That fits the rule that a hook tightens and never loosens.

/hooks lists everything configured, grouped by event, and it is where to start when something fires and you cannot tell where it came from.

Three hooks worth the effort

1. Filter verbose output before it becomes context. The highest-return case. Instead of the agent reading a 10,000-line log to find the errors, the hook filters and returns what matters:

bash
#!/bin/bash
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command')

if [[ "$cmd" =~ ^(npm test|pytest|go test) ]]; then
  filtered="$cmd 2>&1 | grep -A 5 -E '(FAIL|ERROR|error:)' | head -100"
  echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"updatedInput\":{\"command\":\"$filtered\"}}}"
else
  echo "{}"
fi

2. Block a protected path. A rule that depends on no agent remembering it:

json
{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Edit|Write",
      "hooks": [{
        "type": "command",
        "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-protected.sh"
      }]
    }]
  }
}

3. Reinject context after compaction. A PostCompact solves the problem of an instruction vanishing mid-session, since nested CLAUDE.md files and paths rules never reinject themselves.

To check whether a hook is active, /hooks shows whether it appears under the right event. And claude --debug shows modified tool input keys when a hook rewrites the command.

When to skip the hook

When the decision needs conversation context. A command hook communicates through stdout, stderr and an exit code alone. It sees none of the conversation.

When you need to undo. PostToolUse runs after the tool already executed. No rollback exists.

When "always" is not always. Stop fires every time Claude finishes answering, and not when the task finishes. And it does not fire on a user interruption. An API error fires StopFailure, a different event.

When the latency cost outweighs it. The timeouts vary: command, http and mcp_tool get 10 minutes, while UserPromptSubmit drops to 30 seconds and MessageDisplay to 10. A prompt hook gets 30 seconds and an agent one 60. The SessionEnd hooks share a budget of 1.5 seconds.

And one debugging note that saves time: matchers are case-sensitive. Half of the "my hook does not fire" reports are a tool name in the wrong case.

Worth remembering that a hook can arrive from outside: an installed plugin can register its own. Plugins and marketplaces covers what to check first.

Frequently asked questions

Is a hook better than an instruction in CLAUDE.md?

They are different layers. CLAUDE.md guides behavior and the agent can skip it. A hook executes at a fixed point in the cycle, whatever the model does. Use CLAUDE.md for conventions and a hook for what needs a guarantee.

Can I stop someone disabling my hooks by changing the permission mode?

Yes, and that is the central property. PreToolUse fires before the mode check, so a deny holds even under bypassPermissions and with --dangerously-skip-permissions. The inverse path does not exist: a hook cannot loosen a settings restriction.

My hook does not fire. Where do I start?

Three checks, in this order: /hooks confirms it appears under the right event; the matcher has to match the tool name character for character, in the same case; and the event has to be the right one, PreToolUse before and PostToolUse after.

Does a prompt hook make the mechanism non-deterministic?

It does, and that is intentional. The prompt and agent types exist for decisions needing judgment. For a deterministic guarantee, the type is command with an exit code, and that is what the documentation recommends for production flows.

Sources

Verified on 20 August 2026.

Review trigger: revisit when (a) agent hooks leave the experimental stage, (b) the list of 30 events or the per-type timeouts change, or (c) the precedence between a hook and the permission mode changes, since that is what holds up the use of hooks as a policy layer today.