PreToolUse hook shows 'error' label even for successful (exit 0) hook runs

Status Open
Maintainer reply None cached
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

  1. Create a PreToolUse hook in .claude/settings.json:
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/my-hook.py"
          }
        ]
      }
    ]
  }
}
  1. 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)
  1. 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.

View original on GitHub ↗

12 Comments

github-actions[bot] · 7 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/16051
  2. https://github.com/anthropics/claude-code/issues/16950
  3. https://github.com/anthropics/claude-code/issues/10936

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

rcaferraz · 7 months ago

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.

yonatangross · 7 months ago

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

  • Exit code 0 + valid JSON output = "error" label displayed
  • The label appears on PostToolUse hooks regardless of actual success
  • Hook execution completes correctly, side effects work, JSON is processed

Example of a working hook that shows "error":

#!/bin/bash
set -euo pipefail
_HOOK_INPUT=$(cat)
echo '{"continue":true,"suppressOutput":true}'
exit 0

This outputs valid CC 2.1.7 compliant JSON, exits 0, yet displays "PostToolUse:Bash hook error" in the UI.

Verification method

# Test hook JSON validity
echo '{"tool_name":"Bash","command":"ls"}' | bash hook.sh 2>/dev/null | jq -e .
# Returns valid JSON ✅

# Check exit code
echo '{"tool_name":"Bash","command":"ls"}' | bash hook.sh; echo "Exit: $?"
# Exit: 0 ✅

Related observations

  1. The "Running hooks... (1/N done)" aggregation message appears for ALL hook runs
  2. The "error" label seems to be triggered by something other than exit code or JSON validity
  3. Multiple hooks running in parallel (CC 2.1.7) all show the label even when all succeed

Environment

  • Claude Code: 2.1.11+
  • macOS with Bash 5.3 (via Homebrew)
  • Hook type: PostToolUse with * matcher (global hooks)

Impact

  • Causes confusion when debugging hooks (spent hours thinking hooks were broken)
  • Makes it difficult to identify actual hook failures vs UI false positives
  • Users may disable working hooks thinking they're broken

Would be great to either:

  1. Only show "error" when exit code != 0 or JSON is invalid
  2. Or provide a way to see actual hook output/errors for debugging
yonatangross · 7 months ago

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:

  1. Keep this issue open as the canonical one (best reproduction steps)
  2. Or reopen #10936 with additional details

The bug is still actively affecting users.

yonatangross · 7 months ago

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

PreToolUse Bash:      18 hooks
PreToolUse Write:     11 hooks  
PostToolUse (*):       4 hooks  ← Triggers "1/4 done" message
PostToolUse Write:    12 hooks
PostToolUse Bash:      6 hooks  ← Triggers "hook error" label (this bug)
UserPromptSubmit:      8 hooks
SessionStart:         10 hooks
Stop:                 26 hooks
SubagentStart:         4 hooks
SubagentStop:          9 hooks
Setup:                 3 hooks

The Bug Impact

Every Bash tool call triggers:

  1. 18 PreToolUse:Bash hooks (no error label shown)
  2. 4 PostToolUse:* hooks + 6 PostToolUse:Bash hooks
  3. UI shows: Running PostToolUse hooks… (1/10 done)
  4. UI shows: PostToolUse:Bash hook errorFALSE POSITIVE

All 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).

kitaekatt · 7 months ago

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!

kitaekatt · 7 months ago

Also note I believe this is not an area:tui bug

kitaekatt · 7 months ago

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.

benjamin-johnston-work · 6 months ago

Also facing this issue

sanisideup · 4 months ago

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 || true

Combined 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

kigenst · 2 months ago

Related to #18424 (distinct blocked vs error styling) and #38422 (informational block exit code) — adding a behavioral case where this framing is not just cosmetic.

---

Additional repro / related case: this also affects PreToolUse operation-substitution hooks, not just successful allow/pass-through hooks.

I reproduced this on Claude Code 2.1.185 with a PreToolUse hook 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:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "HOOK_SUBSTITUTION_OK: operation already performed by the hook; native tool suppressed to avoid double-execution. This is a SUCCESS, not a failure."
  }
}

The operation succeeds, but the model receives the reason as an error-framed tool result:

<error>HOOK_SUBSTITUTION_OK: operation already performed by the hook; native tool
suppressed to avoid double-execution. This is a SUCCESS, not a failure.</error>

I also tested suppressOutput: true; it produced byte-identical model-facing output. That makes sense because suppressOutput controls whether hook stdout appears in the transcript, while permissionDecisionReason travels 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:

<error>PreToolUse:Bash hook error: [node intercept.cjs]:
HOOK_SUBSTITUTION_OK_EXIT2: operation already performed by the hook; native tool
suppressed to avoid double-execution. This is a SUCCESS, not a failure.</error>

So there seem to be two related cases:

  1. Successful/pass-through hooks can be mislabeled as hook errors.
  2. Intentional substitution hooks have no non-error way to say: "I handled the operation; suppress the native tool; return this result as success."

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 / blocked context rather than a hook error.

More complete fix: add a PreToolUse result such as handled / intercept, or a replacement toolResult, 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 with isError.

italocjs · 1 month ago

+1, its just cosmetic but its annoying.