Preflight Checklist
What's Wrong?
When a PreToolUse hook blocks a tool call by exiting with code 2, Claude stops responding and waits for user input instead of acting on the error feedback provided via stderr.
Per the hooks reference, exit code 2 on PreToolUse "blocks the tool call" and "stderr text is fed back to Claude as an error message." The expected behavior is that Claude receives the error message and can reason about it — for example, fixing lint errors and retrying a commit. Instead, Claude treats the blocked tool call as a full stop and goes idle, requiring the user to manually type "continue" or similar to resume.
This behavior is intermittent — sometimes Claude does act on the feedback correctly, but increasingly it stops. The inconsistency suggests the model is interpreting the "blocked tool call" signal as a reason to defer to the user, rather than as actionable feedback.
Context that should prevent stopping
The stderr output includes explicit directives telling Claude to continue:
<claude-directive>
This is an AUTOMATED QUALITY GATE, not a user denial or permission rejection.
You MUST continue your current workflow autonomously:
1. Fix every error listed above in the source files
2. Run: git add -v .
3. Retry the exact same commit command
Do NOT stop, do NOT end your turn, do NOT ask the user to continue.
</claude-directive>
Additionally, the workflow is injected via a UserPromptSubmit hook with an <error-recovery> section containing:
**PreToolUse hook blocks are automated quality checks, NOT user denials.**
Critical directives:
- Do NOT ask the user for permission to fix lint/format errors — fix them autonomously
- Do NOT stop or end your turn when a commit is blocked by a hook
- Do NOT treat a hook block as a user denial — it is a machine-generated quality gate
Despite both of these, Claude frequently stops and waits for user input.
What Should Happen?
When a PreToolUse hook exits with code 2 and provides error details via stderr, Claude should:
- Receive the stderr content as actionable feedback (this part works)
- Continue its turn and act on that feedback — e.g., fix the reported errors and retry the tool call
- NOT treat the hook block as equivalent to a user denial or permission rejection
The key distinction: a user denial (clicking "deny" on a permission prompt) means "don't do this." A hook exit code 2 means "this action was blocked for a specific reason" — Claude should read the reason and adapt, not stop.
Error Messages/Logs
PreToolUse:Bash hook error: [bash "$HOME/.claude/hooks/validate-code-before-commit/commit-quality-check.sh"]: Running code quality checks before commit...
=== Python Checks ===
Running Ruff linter...
Running Ruff formatter...
Running Mypy type check...
Mypy type check failed:
Summary: 1 errors across 1 file(s)
Types: 1 code
→ 8 share root cause: [annotation-unchecked]
[CODE] tests/test_fix_verify.py:367: error: Need type annotation for "state" [var-annotated]
Context:
366│ # Create a staged state with fix and testing blocks populated
367│ state = {
368│ "schema_version": "1.0.0",
Found 1 error in 1 file (checked 56 source files)
Environment:
Python: 3.14.1
mypy: 1.19.0
═══════════════════════════════════════════════════════════════════
COMMIT BLOCKED by commit-quality: Mypy type check failed
═══════════════════════════════════════════════════════════════════
───────────────────────────────────────────────────────────────────
WHAT TO DO: Fix ALL errors shown above, then retry the commit.
───────────────────────────────────────────────────────────────────
<claude-directive>
This is an AUTOMATED QUALITY GATE, not a user denial or permission rejection.
You MUST continue your current workflow autonomously:
1. Fix every error listed above in the source files
2. Run: git add -v .
3. Retry the exact same commit command
Do NOT stop, do NOT end your turn, do NOT ask the user to continue.
Do NOT treat this as a blocked tool call — it is a fixable quality check.
Retry up to 3 times. Only report to the user if errors persist after 3 attempts.
</claude-directive>
After this output, Claude stops and waits for user input. The user must type "continue" or similar to get Claude to act on the errors.
Steps to Reproduce
- Create a
PreToolUse hook on Bash that runs quality checks before git commit:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/my-quality-check.sh",
"timeout": 300
}
]
}
]
}
}
- The hook script detects
git commit commands, runs linting/type-checking, and exits with code 2 when errors are found:
#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
# Only check git commit commands
if ! echo "$COMMAND" | grep -qE '\bgit\s+commit'; then
exit 0
fi
# Run type checker
OUTPUT=$(mypy . 2>&1)
if [[ $? -ne 0 ]]; then
echo "Type check failed. Fix these errors and retry:" >&2
echo "$OUTPUT" >&2
exit 2 # Block the commit
fi
exit 0
- Ask Claude to commit changes in a project with a type error
- The hook blocks the commit and feeds the error to Claude via stderr
- Observe: Claude stops and waits for user input instead of fixing the type error and retrying
Claude Model
Is this a regression?
Yes, this worked in a previous version
Last Working Version
Not certain of exact version. The behavior change correlates with the introduction of Claude Opus 4.6 (v2.1.32), first noticed around early February 2026. It may also be a model-level behavioral change rather than a Claude Code client change.
Claude Code Version
2.1.37 (Claude Code)
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
iTerm2
Additional Information
Observations
- Intermittent: Claude sometimes does handle the hook block correctly and fixes/retries. Other times it stops. This inconsistency suggests a model interpretation issue rather than a deterministic client bug.
- Timing: First noticed after Opus 4.6 became available (v2.1.32). The behavior was reliable with earlier Opus versions.
- Bypass permissions mode: Running with
--dangerously-skip-permissions does not change the behavior. The hook block is processed correctly by the client — the issue is how the model responds to the "blocked tool call" signal.
Analysis
The root cause appears to be how the model interprets the "tool call was blocked" feedback. When a PreToolUse hook exits with code 2, Claude Code presents this to the model as a blocked/denied action. The model's trained behavior treats blocked actions conservatively — it stops and defers to the user, similar to how it would respond to a user clicking "deny" on a permission prompt.
The distinction between "user denied this action" and "automated hook blocked this action with fixable feedback" is not apparent to the model from the signal it receives.
Possible improvements
-
Differentiate hook blocks from user denials in the model's prompt: When a PreToolUse hook exits with code 2, the message to the model could explicitly indicate this is an automated hook (not a user denial) and that the model should act on the feedback.
-
Add a hook output field like expectContinuation: true: Allow hooks to signal that the block is expected to be resolved by the model, not by the user.
-
Surface the <claude-directive> or additionalContext from stderr: Currently exit code 2 causes stdout/JSON to be ignored. If the stderr contains structured directives (like <claude-directive>), Claude Code could parse and present them as system instructions rather than just error text.
Workaround attempted
Embedding <claude-directive> tags in stderr with explicit instructions to continue. This works sometimes but not reliably — the "blocked tool call" signal appears to override embedded directives.
Preflight Checklist
What's Wrong?
When a
PreToolUsehook blocks a tool call by exiting with code 2, Claude stops responding and waits for user input instead of acting on the error feedback provided via stderr.Per the hooks reference, exit code 2 on
PreToolUse"blocks the tool call" and "stderr text is fed back to Claude as an error message." The expected behavior is that Claude receives the error message and can reason about it — for example, fixing lint errors and retrying a commit. Instead, Claude treats the blocked tool call as a full stop and goes idle, requiring the user to manually type "continue" or similar to resume.This behavior is intermittent — sometimes Claude does act on the feedback correctly, but increasingly it stops. The inconsistency suggests the model is interpreting the "blocked tool call" signal as a reason to defer to the user, rather than as actionable feedback.
Context that should prevent stopping
The stderr output includes explicit directives telling Claude to continue:
Additionally, the workflow is injected via a
UserPromptSubmithook with an<error-recovery>section containing:Despite both of these, Claude frequently stops and waits for user input.
What Should Happen?
When a
PreToolUsehook exits with code 2 and provides error details via stderr, Claude should:The key distinction: a user denial (clicking "deny" on a permission prompt) means "don't do this." A hook exit code 2 means "this action was blocked for a specific reason" — Claude should read the reason and adapt, not stop.
Error Messages/Logs
After this output, Claude stops and waits for user input. The user must type "continue" or similar to get Claude to act on the errors.
Steps to Reproduce
PreToolUsehook onBashthat runs quality checks beforegit commit:{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "$HOME/.claude/hooks/my-quality-check.sh", "timeout": 300 } ] } ] } }git commitcommands, runs linting/type-checking, and exits with code 2 when errors are found:Claude Model
Is this a regression?
Yes, this worked in a previous version
Last Working Version
Not certain of exact version. The behavior change correlates with the introduction of Claude Opus 4.6 (v2.1.32), first noticed around early February 2026. It may also be a model-level behavioral change rather than a Claude Code client change.
Claude Code Version
2.1.37 (Claude Code)
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
iTerm2
Additional Information
Observations
--dangerously-skip-permissionsdoes not change the behavior. The hook block is processed correctly by the client — the issue is how the model responds to the "blocked tool call" signal.Analysis
The root cause appears to be how the model interprets the "tool call was blocked" feedback. When a
PreToolUsehook exits with code 2, Claude Code presents this to the model as a blocked/denied action. The model's trained behavior treats blocked actions conservatively — it stops and defers to the user, similar to how it would respond to a user clicking "deny" on a permission prompt.The distinction between "user denied this action" and "automated hook blocked this action with fixable feedback" is not apparent to the model from the signal it receives.
Possible improvements
Differentiate hook blocks from user denials in the model's prompt: When a PreToolUse hook exits with code 2, the message to the model could explicitly indicate this is an automated hook (not a user denial) and that the model should act on the feedback.
Add a hook output field like
expectContinuation: true: Allow hooks to signal that the block is expected to be resolved by the model, not by the user.Surface the
<claude-directive>oradditionalContextfrom stderr: Currently exit code 2 causes stdout/JSON to be ignored. If the stderr contains structured directives (like<claude-directive>), Claude Code could parse and present them as system instructions rather than just error text.Workaround attempted
Embedding
<claude-directive>tags in stderr with explicit instructions to continue. This works sometimes but not reliably — the "blocked tool call" signal appears to override embedded directives.