Magic Tools
Hands-OnBy CooconAugust 6, 2026484 views3 min read

The Claude Code Hooks Stdin Trap: Python Heredocs Eat Your Hook JSON

What the official docs say

The Claude Code hooks mechanism is straightforward: register a command in settings.json, and when an event fires (say PostToolUse), Claude Code launches that command and passes the event data as JSON via stdin. The docs are concise — a hook reads JSON from stdin, does its work, and communicates back through exit codes.

Standard Unix piping. Building a "log every failed command" hook should take ten minutes.

What actually happened

The goal: whenever a Bash command fails, append it to a tasks/lessons-inbox.md inbox file (with dedup and benign-failure filtering). The logic is not complex, but it is verbose in shell, so the natural choice was to hand it to python. The first version of the hook looked like this:

#!/bin/sh
python3 - <<'PYEOF' 2>/dev/null
import json, sys

data = json.load(sys.stdin)   # read the hook JSON — or so you think
cmd = data.get("tool_input", {}).get("command", "")
# ... detect failure, append to inbox ...
PYEOF
exit 0

It ran without a single error. I then deliberately triggered several failing commands — the inbox file stayed empty. No error, no log output, the settings.json config triple-checked and fine. The hook simply "did not work".

The trap: the heredoc redirects stdin

Trace the data flow of that command and the bug becomes obvious:

  1. Claude Code launches the hook process with the event JSON attached to the process's stdin
  2. python3 - means "read the program itself from stdin"
  3. The <<'PYEOF' heredoc redirects python's stdin to the heredoc content — the python script

So python's stdin is fully occupied by the script text; once the program is read, the stream is at EOF. The json.load(sys.stdin) inside reads empty input and raises JSONDecodeError — which is then completely swallowed by 2>/dev/null and the "hooks must never block" fault-tolerant design. On the Claude Code side, the hook exits 0. Everything looks "successful".

And the real hook JSON? It is still sitting on the outer sh process's stdin, but once the heredoc takes effect, python can never reach it.

What makes this trap nasty is three layers of silence stacked together: the heredoc is perfectly legal shell (no shell error), the python exception is discarded by the redirect (no runtime error), and the hook exits 0 per best practice (no Claude Code error). Each layer is correct design on its own; combined, they form a soundless black hole.

The fix: spool to disk first, pass a path

The fix is two lines — before python starts, consume the outer process's stdin with cat into a temp file, then hand the file path in through an environment variable:

#!/bin/sh
TMP_IN="$(mktemp)" || exit 0
cat > "$TMP_IN" 2>/dev/null || true          # catch the hook JSON off stdin first
CL_HOOK_INPUT="$TMP_IN" python3 - <<'PYEOF' 2>/dev/null
import json, os, sys

try:
    data = json.load(open(os.environ["CL_HOOK_INPUT"], encoding="utf-8"))
except Exception:
    sys.exit(0)

if data.get("tool_name") != "Bash":
    sys.exit(0)
# ... failure detection, benign filtering, dedup by command hash, append to inbox ...
PYEOF
rm -f "$TMP_IN"
exit 0

It worked immediately: failed commands started landing in the inbox one by one, with dedup and filtering behaving as intended.

A few design points from the full version of this hook worth copying (all validated by real usage over time):

  • Never block: every exceptional path ends in exit 0, including a failed mktemp — a broken hook must not drag down the main loop
  • Benign-failure filtering: non-zero exits from grep / rg / diff / test are normal semantics, not worth recording
  • Dedup by command hash: the same command failing repeatedly is recorded once, so the inbox never bloats

Scope and boundaries

  • This only affects the pattern of "feeding an interpreter its script via heredoc inside a hook" — python3 - <<EOF and node - <<EOF fail identically; if your python code lives in a separate file (python3 hook.py), stdin passes through untouched and there is no issue
  • python3 -c 'one-liner' does not occupy stdin, so short logic can dodge the trap that way; beyond a few lines it becomes unmaintainable, and the spool-to-disk approach is sturdier
  • Verified in the environment stated at the top of this page; hooks receiving JSON via stdin is a documented, stable contract, and this trap comes from shell semantics rather than Claude Code version behavior, so it should hold long-term

The general lesson

Supporting-cast code like hooks is usually required to fail silently — rightly so, but during development, take the 2>/dev/null off first. Had I seen JSONDecodeError: Expecting value early, locating the bug would have taken ten minutes; debugging through a full stack of silence cost an order of magnitude more. Fault tolerance is for production, not for troubleshooting.

Get field notes like this every Saturday

Subscribe to Dev Breakfast: daily AI coding picks at 8:00, plus a Saturday roundup of this week's hands-on tests with Claude Code / Codex / local models. Written in Chinese.

Related Articles

Blocking rm -rf in Claude Code: A Deny Rule Stopped 14 Deletes in Two Weeks, 7 Other Spellings Still Get Through, and a Hook That Closes the Gap

To keep Claude Code from deleting things by accident, I added a deny rule, Bash(rm -rf *), to my global settings. Across my sessions from October 7 to 21 it blocked 14 deletes. After each denial the model picked a new directory name 8 times and dropped the delete 6 times. It never tried another spelling. Then I replayed 22 delete commands verbatim against Claude Code 2.1.285 through a stub. rm -fr, rm -r -f, rm -Rf and rm -r deleted the directory in manual, auto and bypass mode alike. /bin/rm, find -delete and bash -c deleted it in manual and bypass mode. In auto mode, rm -fr inside the working directory skips the classifier because it 'would be allowed in acceptEdits mode'. A second, built-in dangerous-rm check fires on targets like $D/*, even in bypass mode, but loses track of a variable assignment when a heredoc comes earlier in the same command. The post ends with a PreToolUse hook that blocks every recursive spelling in all three modes and tells the model what to do instead.

claude-codehooks+5
pitfallsOct 21, 20268 min
315

Claude Code Auto Mode with DeepSeek, GLM or Kimi: What Each "temporarily unavailable" Reason Means and When Retrying Is Pointless (Fault-Injection Test)

On a third-party model, Claude Code auto mode sends one extra classifier request per side-effecting action, from your session model to the same backend: a 132,470-character system prompt, about 29.7K input tokens per check. When it fails, you see "xxx is temporarily unavailable, so auto mode cannot determine the safety of Bash". We injected faults and recorded each message: 500 (server error), 429 (rate-limited), 529 (overloaded) and connection resets (connection failed) are retried 5 times; no reply in 60 s gives (timed out). **No parentheses means a 4xx rejection: tried once, and retrying never helps.** A reply with no verdict gives "could not evaluate"; three in a row bring back manual approval. CLAUDE_CODE_AUTO_MODE_MODEL has no effect. Claude Code 2.1.285, real DeepSeek v4-pro and MiMo v2.5 backends.

claude-codedeepseek+7
pitfallsOct 11, 202612 min
460

Claude Code "Prompt is too long" vs "maximum context length": Why Auto-Compact Works for One and Not the Other (Tested)

Claude Code decides a request was too long by matching the error text. If the backend says prompt is too long or input is too long for requested model, it auto-compacts the conversation and retries — invisible to you. DeepSeek and OpenAI-style gateways say This model's maximum context length is …, which it doesn't recognize: you get API Error: 400 on every turn. Manual /compact works; the better fix is to declare the real window with CLAUDE_CODE_MAX_CONTEXT_TOKENS (non-claude- model IDs) or CLAUDE_CODE_AUTO_COMPACT_WINDOW (claude- IDs) so it compacts before hitting the limit. Tested on Claude Code 2.1.285 against a local stub.

claude-codedeepseek+6
pitfallsOct 5, 20267 min
474

Claude Code Stuck on a Spinner With No Response: It Waits 6 Minutes per Attempt, and 10 Retries Can Hang It for Over an Hour (Tested)

When Claude Code spins without output or sits at Retrying in 0s, the backend is usually not sending anything. Tested on 2.1.285 (API key + ANTHROPIC_BASE_URL): with no response headers it waits 6 minutes (360 s) per attempt before timing out — setting API_TIMEOUT_MS to 600000 or 900000 doesn't change that; only lower values work. With the default 10 retries it can hang for over an hour. A gateway that buffers the reply until generation finishes will never deliver a reply that takes over 6 minutes. Press Esc to interrupt. All tested against a local stub.

claude-codetroubleshooting+5
pitfallsOct 5, 20269 min
407