p4ni.

Research

A Blocking Claude Code Hook Sends Its Own Shell Script Back — Twice

· 6 min read

On this page

I put a PostToolUse hook in this repository last week. It runs a content linter after any edit under src/content/blog/, and if the linter fails it writes the errors to stderr and exits 2, which hands them back to the agent instead of letting the turn end. The point was never token economy — it was that I kept forgetting to run the linter. But “hooks reduce token usage” is a claim that shows up constantly, and I had already taken the startup prefill apart and metered MCP tool loading, so the same method applies here.

The answer is that hooks do cut tokens, but not where the pitch says they do, and a failing hook has a fixed overhead that scales with how long you made the command.

The transcript keeps the receipts

Claude Code writes every session to ~/.claude/projects/<slug>/<session-id>.jsonl, one JSON object per line. Hook results land there as attachment records, and there are two kinds:

  • hook_success — the hook exited 0 and printed something
  • hook_blocking_error — the hook exited 2

A hook that exits 0 and prints nothing produces no record at all. That is the first real finding, and it is worth stating plainly: the successful path is free only when it is silent.

Here is an actual hook_success from a scratch project, where the hook printed a single 94-character line:

{"type": "hook_success", "hookName": "PostToolUse:Write",
 "toolUseID": "toolu_018SGuBQ9VnChXH3tDJjn3pg", "hookEvent": "PostToolUse",
 "content": "check passed: 62 posts, 4 scheduled, 0 errors. This line is stdout on a successful hook run.",
 "stdout": "check passed: 62 posts, 4 scheduled, 0 errors. This line is stdout on a successful hook run.\n",
 "stderr": "", "exitCode": 0, "command": ".claude/hooks/ok-loud.sh", "durationMs": 421}

The line appears in content and again in stdout. One echo in a hook that fires on every edit is billed twice per edit.

How I counted

Token counts come from prefill deltas. In an empty directory with --strict-mcp-config and an empty MCP config, claude -p "hi" --output-format json --model haiku reports its own usage; summing input_tokens, cache_creation_input_tokens and cache_read_input_tokens gives a prefill of 27,833 with an empty CLAUDE.md. Dropping a text into CLAUDE.md and rerunning gives the delta for that text. The baseline reproduced exactly across five runs spread over the session, so the deltas below are stable to the token.

Two caveats I would rather state than bury. The texts travel through CLAUDE.md here, not through the attachment channel they came from, so this measures the text and not the exact wire format around it. And measuring a text in halves does not sum to measuring it whole — tokenizers do not split on your section boundaries — so the breakdowns are approximations of each other, not an accounting identity.

Claude Code 2.1.235, macOS, pnpm project.

A rejection carries the command string twice

This is my actual hook’s rejection, from a session on 19 August, trimmed in the middle:

{"type": "hook_blocking_error", "hookName": "PostToolUse:Bash",
 "blockingError": {
   "blockingError": "[p=$(jq -r '[.tool_input.file_path, .tool_response.filePath, .tool_input.command] | map(select(type == \"string\")) | join(\" \")'); case \"$p\" in *src/content/blog/*) o=$(cd \"${CLAUDE_PROJECT_DIR:-.}\" && pnpm -s check 2>&1) || { printf '%s\\n' \"$o\" >&2; exit 2; };; esac]: error: ...eight lines of lint errors...",
   "command": "p=$(jq -r '[.tool_input.file_path, ... ;; esac"}}

The command string is prefixed to the error body in square brackets, and then repeated verbatim in its own field. My command is 264 characters, which the prefill probe prices at 184 tokens. So every rejection pays 368 tokens before a single line of lint output.

The two rejections this repository has recorded so far:

Whole attachmentError body aloneCommand string ×2
2 lint errors627308368
8 lint errors1,136812368

On the two-error rejection, the hook’s own source code costs more than the message it was trying to deliver.

The control experiment

The repository numbers mix a real linter’s output with the overhead, so I built the smallest version that isolates it: a scratch project, a PostToolUse hook matching Write, and a hook that does nothing but print error: demo.md:1 boom — 22 characters — and exit 2. Then the same run with the command moved into a file.

# version A: inline in settings.json (200 characters)
p=$(jq -r '[.tool_input.file_path, .tool_response.filePath, .tool_input.command] | map(select(type == "string")) | join(" ")'); case "$p" in *demo*) printf 'error: demo.md:1 boom\n' >&2; exit 2;; esac

# version B: same behaviour, in a file
.claude/hooks/check.sh

Identical error, identical exit code:

Hook configurationAttachmentTokens
Inline command, exit 2635 chars339
.claude/hooks/check.sh, exit 2265 chars184
exit 0, one line of stdout434 chars236
exit 0, silentno record0

Moving the command into a file cut the cost of a rejection by 46% without changing what the hook does. The command still appears twice — as [.claude/hooks/check.sh]: and as "command" — but twice a short path is cheap, while twice a jq pipeline is not.

What the hook actually replaced

The interesting part is what the before-and-after looks like, because it is not what I expected.

Before the hook, on 17 August, I wrote two posts in English and Japanese in one session: 41 tool calls touched src/content/blog/, and the linter was invoked 12 times by hand. Those 12 calls — command inputs plus their results — come to 10,694 characters, 5,660 tokens.

But only 2 of the 12 were standalone linter runs. The other 10 were bolted onto commands the session was running anyway (wc -w post.mdx && pnpm check, a Python rewrite script piped into another check), and almost all of them were truncated on the way in: | tail -20, | grep '^error'. The round trips were already amortised, and the output was already being trimmed by hand.

After the hook, across two sessions on 19 August, 31 edits fired it and exactly one was rejected. Total cost to the context: 627 tokens.

So the hook did not remove round trips — there were barely any dedicated ones to remove. It removed the successful output, which is the bulk of it, by exiting 0 and saying nothing 30 times out of 31. That reframes the advice. A hook saves tokens in proportion to how often it passes, and a hook wrapped around a check that usually fails will cost you more than running the check yourself, because you cannot pipe its output through tail.

What I would change

Three things, in order of how much they return.

Exit 0 without printing. Status lines, ”✓ all good”, counts of what was scanned — each one is billed twice on every fire. Mine prints nothing on success, which is the only reason the 31-fire session cost 627 tokens instead of several thousand.

Put the command in a file. A one-line command field pointing at .claude/hooks/check.sh behaves identically and cuts 46% off every rejection. This is the one I got wrong: my jq pipeline is inlined in settings.json, and I have been paying 368 tokens of it per rejection.

Trim the failure path, not just the success path. My linter prints a scheduled-post table and per-language counts on every run, including failures — 460 characters, 291 tokens, attached to every rejection whether or not it relates to the violation. The agent needs the error lines. It does not need the publishing calendar to fix a broken emphasis marker.

None of this makes hooks a bad trade. Thirty-one automatic verifications for 627 tokens is a price I would pay again, and the reason I added the hook — that a check I have to remember is a check that eventually doesn’t run — is untouched by any of these numbers. But the savings live entirely in the silent path, and the failure path has a floor you set yourself, in characters, when you paste a shell one-liner into settings.json.