PreToolUse hook shows 'error' label even for successful (exit 0) hook runs
Status Open
Maintainer reply None cached
Workaround ✓ Mentioned in thread ↓
Activity 12 comments · opened Jan 9, 2026
Description
When a PreToolUse hook runs and exits successfully with code 0, Claude Code displays the output with a PreToolUse:Bash hook error prefix, which is misleading since no error occurred.
Steps to Reproduce
- Create a PreToolUse hook in
.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/my-hook.py"
}
]
}
]
}
}
- Create a hook script that exits 0 (success) without output:
#!/usr/bin/env python3
import json
import sys
input_data = json.load(sys.stdin)
# Allow all commands
sys.exit(0)
- Run any Bash command in Claude Code
Expected Behavior
When the hook exits with code 0 and produces no output, there should be no "error" label shown, or it should show something like "PreToolUse:Bash hook" (without "error").
Actual Behavior
Every Bash command shows PreToolUse:Bash hook error in the output, even though the hook succeeded and the command was allowed:
⏺ Bash(git status)
⎿ PreToolUse:Bash hook error
⎿ On branch main
...
Impact
This is cosmetic but confusing - users may think their hooks are failing when they're working correctly.
12 Comments
Found 3 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
The only issue that is still open https://github.com/anthropics/claude-code/issues/16950 doesn't have steps to reproduce. So I think this one here could be more helpful.
Additional confirmation and debugging findings
I've done extensive investigation into this issue while debugging 141 hooks in a plugin system. Here's what I found:
Confirmed behavior
Example of a working hook that shows "error":
This outputs valid CC 2.1.7 compliant JSON, exits 0, yet displays "PostToolUse:Bash hook error" in the UI.
Verification method
Related observations
Environment
*matcher (global hooks)Impact
Would be great to either:
This issue has the most detailed reproduction steps of all the duplicates. I've also added reproduction steps to #16950 (the only remaining open issue).
Note that #10936 and #16051 were closed without resolution - the bug still exists in CC 2.1.11.
Suggesting to either:
The bug is still actively affecting users.
Update: Comprehensive Hook Modernization Completed
We've completed a comprehensive audit and modernization of our 141 hooks to utilize all CC 2.1.x features properly. Sharing findings that may help the CC team understand hook usage patterns.
CC 2.1.x Feature Utilization Audit
| Feature | CC Version | Usage Count | Status |
|---------|------------|-------------|--------|
|
once: true| 2.1.0 | 3 hooks | ✅ Used correctly || Agent-scoped hooks | 2.1.0 | 11 agents | ✅ Implemented |
| Skill-scoped hooks | 2.1.0 | 0 skills | 🔄 Evaluating |
|
additionalContext| 2.1.9 | 82 hooks | ✅ Heavy usage ||
${CLAUDE_SESSION_ID}| 2.1.9 | 22 hooks | ✅ Used || Setup hooks | 2.1.11 | 4 hooks | ✅ With --init/--maintenance |
|
context: fork| 2.1.0 | 142 skills | ✅ Isolated contexts || Bash 5.3 builtins | N/A | All hooks | ✅ Modernized |
Hook Count by Event Type
The Bug Impact
Every
Bashtool call triggers:Running PostToolUse hooks… (1/10 done)PostToolUse:Bash hook error← FALSE POSITIVEAll hooks exit 0 with valid JSON. The "error" label is purely cosmetic but causes significant confusion during development.
Workaround Attempted
We considered consolidating hooks into a single dispatcher to reduce the "1/N done" count, but this would lose CC's native parallel execution benefits and make debugging harder.
Request
Please prioritize fixing the false "error" label. It's the #1 source of confusion for hook developers and has been reported in multiple issues (#10936, #16051, #16950, #17088).
Workaround: Use Modern Hook Output Format
I've been able to reproduce this issue and found a workaround. The phantom "PreToolUse:X hook error" label appears when hooks use the legacy output format.
Cause: Hooks outputting {"decision": "allow"} trigger the error label even though the hook succeeds.
Fix: Use the modern hookSpecificOutput format instead:
# Legacy format (causes phantom error)
print(json.dumps({"decision": "allow"}))
# Modern format (no phantom error)
print(json.dumps({
"continue": True,
"suppressOutput": False,
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": ""
}
}))
For bash hooks:
# Legacy
echo '{"decision": "allow"}'
# Modern
echo '{"continue":true,"suppressOutput":false,"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":""}}'
Verified: Tested by switching between formats on the same hook - legacy format shows the error label, modern format does not.
This suggests the issue is in how Claude Code parses hook output rather than a pure UI bug. The legacy format may not be fully recognized, causing it to be treated as an error condition for display purposes.
I've been struggling with this error for a long time, I hope this fix workaround works for everyone, and that anthropic can improve the error message!
Also note I believe this is not an area:tui bug
I have filed a comprehensive feature request to make hook errors more understandable to both Claude and the User #20157. Please upvote this issue if you would like hook errors to be more clear to both Claude and the user.
Also facing this issue
SessionStart workaround: /dev/tty for visual feedback (CC 2.1.96)
I run 3 SessionStart hooks that I would like visual confirmation it worked correctly. The hooks already use the modern hookSpecificOutput format and the JSON reaches Claude's context fine. But stdout is never displayed in terminal for SessionStart hooks (related: #24425, #23758).
Workaround: Single-line printf to /dev/tty from each hook script:
printf "Projects: synced 20s ago (22 projects)\n" > /dev/tty 2>/dev/null || trueCombined with the JSON output for Claude's context:
echo '{"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": "..."}}'Tradeoff: These /dev/tty lines writes its output on top of Claude Code's status line, partially overwriting it. Cosmetic only, but not fixable from hook scripts. See image below for example.
<img width="683" height="187" alt="Image" src="https://github.com/user-attachments/assets/572d2efd-93e2-4b6a-bbdd-e89cf1b4e4f4" />
This gives users visual confirmation hooks ran, while the actual data still flows through JSON. I'd prefer to drop the /dev/tty hack once stdout display and the error label are fixed.
Edit: Better screenshot
Related to #18424 (distinct
blockedvserrorstyling) and #38422 (informational block exit code) — adding a behavioral case where this framing is not just cosmetic.---
Additional repro / related case: this also affects
PreToolUseoperation-substitution hooks, not just successful allow/pass-through hooks.I reproduced this on Claude Code
2.1.185with aPreToolUsehook that intentionally performs the operation itself, then suppresses the native tool call only to avoid double-execution.In this pattern, the hook exits successfully and returns valid JSON:
The operation succeeds, but the model receives the reason as an error-framed tool result:
I also tested
suppressOutput: true; it produced byte-identical model-facing output. That makes sense becausesuppressOutputcontrols whether hook stdout appears in the transcript, whilepermissionDecisionReasontravels on its own channel and is still returned to Claude as the tool error.For comparison, using exit code 2 plus stderr is even more strongly error-framed:
So there seem to be two related cases:
The second case is more than cosmetic. Because the model sees the hook-provided success message inside an error-framed channel, it can re-read, retry, or otherwise re-verify an operation that already succeeded.
Minimal fix: intentional hook denials from a hook that exits successfully with valid JSON should be surfaced as
denied/blockedcontext rather than a hook error.More complete fix: add a
PreToolUseresult such ashandled/intercept, or a replacementtoolResult, so operation-substitution hooks can cancel the native tool while returning hook-provided output as a success-framed result. This would also make hooks more consistent with the MCP tool-result model, where a handled operation can return a normal result while separately indicating tool execution failure withisError.+1, its just cosmetic but its annoying.