Skip to content

Claude Code

In Claude Code hooks, exit 1 blocks nothing

9 min read

Written by an AI model from the linked sources and checked against them by a second model. How we work

All about: Claude Code versions and changes →
Image: blakecrosley.com

A Claude Code policy hook that exits with code 1, the usual Unix failure code, blocks nothing. Claude Code logs a hook error and runs the tool call anyway. Only exit 2 stops it, and only on the events that can be blocked at all. That is the main warning in an explainer published on Blakecrosley on September 23, which checks every API detail against Anthropic’s hooks reference and guide.

The scale of the system is the other surprise. As of September 6, 2026, the reference documents 33 hook events, but the explainer says nearly every production setup is built from five: PreToolUse, PostToolUse, UserPromptSubmit, SessionStart and Stop. The API changed a lot across the v2.1.x releases, so the author gives a rule for any conflict between the post and the docs: the reference wins.

Exit 2, and only on some events

A command hook reads JSON on stdin and answers with an exit code, stdout and stderr. An HTTP hook gets the same JSON as a POST body and answers in the response body. Every event carries a common envelope of session_id, transcript_path, cwd and hook_event_name, with permission_mode on most events, plus fields specific to the event. A PreToolUse hook on Bash gets tool_name and tool_input.command. Stop gets stop_hook_active and last_assistant_message. Hooks that fire inside subagents also get agent_id and agent_type.

The exit codes mean three things. Exit 0 is success, and Claude Code parses stdout for JSON. For most events plain stdout only goes to the debug log, but for UserPromptSubmit, UserPromptExpansion and SessionStart it becomes context the model can see. Exit 2 is a blocking error: stdout is thrown away, JSON included, and stderr goes back to Claude as the error message. Any other code is a non-blocking error. The transcript shows a hook error notice and execution carries on. The docs flag the exit 1 trap explicitly, and that warning is the only protection a hook author gets against a reflex every shell script has.

What “block” means depends on the event. On PreToolUse it cancels the tool call. On PermissionRequest it denies the permission. On UserPromptSubmit it stops processing and erases the prompt. On Stop and SubagentStop it keeps the conversation going. PreCompact can refuse compaction, and PostToolBatch halts the agentic loop before the next model call. For WorktreeCreate, any non-zero exit aborts. Everything else cannot block. PostToolUse shows stderr to Claude after the tool has already run. SessionStart, Notification and several others show it only to the user. StopFailure, InstructionsLoaded, MessageDisplay and PermissionDenied ignore the exit code entirely.

For anything finer than block-or-allow, the hook exits 0 and prints one JSON object. You use exit codes or JSON, never both, since JSON is only read on exit 0. PreToolUse breaks the pattern most events follow. Its old top-level decision/reason fields are deprecated, and the current form is hookSpecificOutput.permissionDecision with "allow", "deny", "ask" or "defer", plus updatedInput to rewrite the tool’s arguments before they run. When several PreToolUse hooks disagree, the most restrictive answer wins, in the order deny, defer, ask, allow. The explainer still recommends giving each decision to a single hook instead of leaning on the tie-break.

Above the permission system

PreToolUse hooks fire before any permission-mode check. A hook that returns permissionDecision: "deny" therefore blocks the tool even in bypassPermissions mode or under --dangerously-skip-permissions. It does not work the other way: a hook’s "allow" cannot loosen a deny rule in settings. Hooks can make policy stricter than permissions, never looser. The official guide gives the reason they exist as “deterministic control: certain actions always happen rather than relying on the LLM to choose to run them.”

That puts hooks on a different footing from CLAUDE.md and skills, which the model usually follows and sometimes skips, for example when a change looks trivial or the context runs long. The explainer’s test for choosing between them is the cost of the model ignoring a rule once. If the cost is annoyance, it goes in CLAUDE.md. If it is an incident, it goes in a hook. Permission rules still cover whatever a static pattern can express. A hook earns its place when the decision needs logic, outside state or rewritten input.

Configuration has three levels: an event, a matcher group that filters when it fires, and one or more handlers. Scope follows the usual settings files: ~/.claude/settings.json for all projects, .claude/settings.json committed with the project, .claude/settings.local.json gitignored, and managed policy above all of them. Enterprise admins can enforce managed hooks that users cannot override. Hooks can also ship in plugins and in skill or agent frontmatter.

Matchers trip people up. A value made only of letters, digits, _, -, spaces, commas and | is an exact name or list. Anything else is an unanchored JavaScript regex, so Edit.* also catches NotebookEdit, and ^Edit$ is the way to mean one tool. Matching is case-sensitive, so bash never matches Bash. The per-handler if field accepts a permission rule such as Bash(git *), but it is best-effort and fails open when it cannot parse a command. Hard guarantees belong in permission rules.

What moved in v2.1.x

Two v2.1.214 changes can quietly break older configs. SessionStart gained a fifth source value, fork, alongside startup, resume, clear and compact, so a hook matching on the old four-value list misses forked sessions. And a single-segment path pattern in if, such as Edit(src/**), now matches only a top-level src under the working directory. Nested paths like packages/app/src/ stop matching, and Edit(**/src/**) restores the old any-depth behavior. DirectoryAdded, which fires when a repo joins the session through /add-dir or the SDK’s register_repo_root, needs v2.1.219 or later.

Argument rewriting has been around longer. Claude Code’s changelog for 2.0.10 reads “PreToolUse hooks can now modify tool inputs”. Handlers now come in five types: command, http, mcp_tool, prompt for a single-turn model check, and agent, an experimental subagent with Read, Grep and Glob. Default timeouts are 600 seconds for command, http and mcp_tool, cut to 30 for UserPromptSubmit and 10 for MessageDisplay, with 30 for prompt and 60 for agent. All matching hooks run in parallel, and identical handlers are deduplicated.

The hooks reference now also describes plugins that register hooks as JavaScript functions running inside Claude Code’s own process, which Anthropic calls Claude Mods. The shell and HTTP hooks covered in the explainer keep working alongside them, and the same events fire in the terminal, IDE extensions, the Desktop app and cloud sessions.

The five that do the work

The explainer’s patterns are short. Auto-formatting is a PostToolUse hook on Edit|Write that pipes .tool_input.file_path through jq into npx prettier --write, and the same shape works with ruff, gofmt or swiftformat. The command guard is a Bash script on PreToolUse that matches patterns like rm -rf, git push --force and DROP TABLE, writes a reason to stderr and exits 2, so Claude reads the reason and changes course. Session context is a SessionStart script that echoes the current branch, the last five commits and the count of uncommitted files. No JSON is needed there, since plain stdout becomes context.

The completion gate on Stop is the one with a trap. It runs the tests and returns decision: "block" with a reason when they fail, which keeps Claude working. Claude Code caps a Stop hook at 8 consecutive blocks by default, adjustable through CLAUDE_CODE_STOP_HOOK_BLOCK_CAP. A gate that never checks stop_hook_active will run straight through that cap. Stop also fires at the end of every response, not only when a task is done, and never on user interrupts, so gate logic has to cope with mid-task stops. For softer steering, additionalContext continues the conversation without counting as a hook error. The built-in /goal command works as a session-scoped, prompt-based Stop hook with no configuration.

The fifth pattern is a dispatcher: one registered script per event that runs every executable file in .claude/hooks/<EventName>/ and passes on the first exit 2. Adding a guard then means chmod +x on a new file, and settings.json stays the same. It does run in series what Claude Code would run in parallel, and hooks that emit JSON should stay standalone, because stdout must hold exactly one object.

Where hooks stop

PostToolUse cannot undo anything, because the tool has already run. PreToolUse never sees files pulled in with @ in a prompt, since no tool call happens there, so those paths need Read deny rules. PermissionRequest does not fire in plain headless -p runs. It does fire when an Agent SDK canUseTool callback supplies the prompt, and for background-subagent tool calls. Everywhere else, automated decisions belong in PreToolUse. When several hooks rewrite the same tool’s arguments with updatedInput, only one rewrite survives, and you can’t choose which. A gate that times out simply didn’t run, and output over 10,000 characters is written to a file and replaced with a preview.

The Agent SDK fires the same events as callbacks with the same JSON output shape, but coverage differs by language. PreToolUse, PostToolUse, Stop, PermissionRequest and a handful of others exist in both SDKs. SessionStart, SessionEnd, PostToolBatch and the compaction, model-switch, task, worktree and file-watch events are TypeScript-only for now. Shell hooks from settings files still run inside an SDK app whenever the matching settingSources entry is enabled, and the default query() options enable it.

Hooks also run with your full user permissions. The reference warns that they “can modify, delete, or access any files your user account can access. Review and test all hook commands before adding them to your configuration.” A common bug is a shell profile that prints something on startup and corrupts the hook’s JSON. When a hook runs but blocks nothing, the explainer’s fix is short: confirm it is registered with /hooks, pipe sample event JSON into the script, and check echo $? for a 2.

No comments yet. Be the first.

Join the conversation

Sign in to leave a comment. It appears after moderation.