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 somethinghook_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 attachment | Error body alone | Command string ×2 | |
|---|---|---|---|
| 2 lint errors | 627 | 308 | 368 |
| 8 lint errors | 1,136 | 812 | 368 |
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 configuration | Attachment | Tokens |
|---|---|---|
| Inline command, exit 2 | 635 chars | 339 |
.claude/hooks/check.sh, exit 2 | 265 chars | 184 |
| exit 0, one line of stdout | 434 chars | 236 |
| exit 0, silent | no record | 0 |
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.