●2.1.273 — A round of connection work landed together: five opt-in headers for LLM gateways, and a notice when Claude Code stops trying to reconnect an MCP server●09/29 — The date beside claude-sonnet-4-5 is 12 days out, but it is an earliest-possible estimate. The model is still Active, and public retirements get at least 60 days notice●MCP — People keep asking to reconnect a dropped server without ending the session. The disconnect is now announced, but reattaching is still something you do by hand●NEW — A scheduled task ran some days and not others. The cause was that only one folder had been bound to it●WINDOWS — When Cowork fails on its very first task, check developer mode and the setup state before going looking for a cause●HANDOFF — Before a long draft gets too heavy for one chat, decide on the three things the summary must carry into the next one●2.1.273 — A round of connection work landed together: five opt-in headers for LLM gateways, and a notice when Claude Code stops trying to reconnect an MCP server●09/29 — The date beside claude-sonnet-4-5 is 12 days out, but it is an earliest-possible estimate. The model is still Active, and public retirements get at least 60 days notice●MCP — People keep asking to reconnect a dropped server without ending the session. The disconnect is now announced, but reattaching is still something you do by hand●NEW — A scheduled task ran some days and not others. The cause was that only one folder had been bound to it●WINDOWS — When Cowork fails on its very first task, check developer mode and the setup state before going looking for a cause●HANDOFF — Before a long draft gets too heavy for one chat, decide on the three things the summary must carry into the next one
Don't let your verification script's full output flow back through a hook
The check was working the whole time. The conversation was what ran out of room. Measured side by side: 58 bytes from one scan of 833 files, 42KB from another. Which paths actually reach the model, and how to enforce a budget on them.
The check ran correctly to the very end. The conversation was the thing that broke.
I had added a hook that ran a static check after every edit. Working alone as an indie developer across several repositories, I have found that checks tied to the edit itself hold up far better than checks I have to remember to run. The check worked exactly as intended and caught the violations it was supposed to catch. But somewhere after a few dozen edits, responses got noticeably sluggish, and eventually long prompts stopped going through at all.
I spent a while staring at the check script looking for the problem. Wasteful loops. Slow regular expressions. But it was never about processing time. The strings the check was returning had been piling up inside the conversation, one edit at a time.
What lands in context is what you returned, not what you scanned
Here are numbers I measured on my own machine. Against the same repository (833 MDX files), I ran several checks with different personalities and recorded the bytes and lines each one wrote to standard output.
Process
Scope scanned
Output bytes
Lines
Approx. tokens
Frontmatter integrity check (pass)
833 files
58 B
1
~26
Verbatim-duplication scan (pass)
833 files
109 B
1
~50
Redirect integrity check (pass)
whole repo
126 B
1
~57
Single-file check (violations found)
2 files
893 B
18
~406
Enumerating grep
833 files
42,280 B
398
~19,218
Token figures are rough, based on roughly 2.2 bytes per token for mixed Japanese and English text. Your tokenizer will give different numbers.
The gap between the top three rows and the last one is the part worth sitting with. The same 833 files, scanned by both — one returns 58 bytes, the other 42,280. Roughly 728 times more. The size of what you scan does not determine the size of what you return. The only thing that determines it is what the author decided to print.
Until I built that table, I had been assuming that heavier checks produce heavier output. The opposite tends to be true. A well-designed full-repository check returns one line when everything passes. A hastily written grep -rn prints every matching line it finds. The second one is far cheaper to run and three orders of magnitude more expensive to keep.
Does that output actually reach the conversation?
Here I have to walk something back. For a long time I assumed that anything a hook printed went into the conversation. Re-reading the official hooks reference after I had finished the first draft of this piece, I found that my mental model had been too coarse.
The path decides.
How you return it
Does Claude see it?
Notes
stdout on exit 0 (most events)
No
Written to the debug log; it does not appear in the transcript
stdout on exit 0 (SessionStart, UserPromptSubmit, UserPromptExpansion, PostModelSwitch)
Yes
Plain text is added to context as-is
stderr on exit 0
No
Debug log only, unless you enable debug logging yourself
stderr on exit 2 (e.g. PostToolUse)
Yes
The tool has already run; the text is shown to Claude
hookSpecificOutput.additionalContext
Yes
Inserted into the conversation as a system reminder
The paths that do reach Claude have a ceiling. additionalContext, systemMessage, and plain stdout are all capped at 10,000 characters, with anything beyond that written to a file and replaced by a preview plus a path. So my original line about one misplaced cat dumping 5.7MB into the conversation was not accurate. A single blowout never gets that far.
That does not make it safe. What actually hurts is not one large payload but a small one, on a path that reaches the model, repeated forever. A hook that fires on every edit and returns 400 bytes costs 120,000 bytes across 300 edits, without ever touching the per-call limit once. That is the version I lived through.
Exit status has a similar trap. exit 2 is what enforces a policy, and exit 1 without valid JSON on stdout is treated as a non-blocking error — the action simply proceeds. Follow the Unix convention and you get a check that finds violations and stops nothing.
Output volume and exit status are independent axes, and all four combinations exist.
Short output
Long output
exit 0
A passing check. Make this the default
Suspicious. It passed and, on a reaching path, still spent your context
exit 2
A failure you can state in one line. Ideal
Fine within budget. Over budget, summarize and offload
The quadrant people miss is the top right: passing, and long. Nothing failed, so nobody investigates. No error appears anywhere. And it quietly removes a slice of the context window on every run. I lost real time reading failure logs for exactly this reason. The culprit had never failed once.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will see why one scan of 833 files returns 58 bytes and another returns 42,280, and be able to estimate output before wiring a check into a hook
✦You will know which of stdout on exit 0, stderr on exit 2, and additionalContext actually reaches Claude
✦You will be able to write a budget wrapper that avoids the head -c mojibake and the non-blocking exit 1 trap
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The best thing a passing check can say is almost nothing
Which makes the design rule fairly blunt:
On success, the fact that it passed — or nothing at all
On failure, the smallest thing that lets someone fix it
Full logs, complete listings, and raw traces go to a file, never to the conversation
The top three rows of that table are exactly this shape. Inspect 833 files, print "clean," stop. Nothing about listing which files passed would change anyone's next move.
Failure is a different negotiation. That 893-byte, 18-line output contains the violated rule names and where they occurred. For something you are about to fix on the spot, that trade is fair.
Why "show everything, just in case" backfires
More information feels safer. For logs a human reads, that instinct is usually right.
But when the reader has a finite context window, the total volume of available evidence has a ceiling. Fill the ceiling and there is nowhere left for the information that actually matters later. Worse, most of what filled it was the names of files that turned out to be fine.
Information that does not change a decision becomes pure debt the moment you include it. That holds well beyond checks — it is a reasonable default for anything a hook hands back.
Decide the output budget before you write the body
In practice, the reliable move was to fix a byte budget per hook up front. I first tried to manage this by feel and it did not hold, so it became a number.
Rough values I use now:
Hook character
Budget on success
Budget on failure
Runs on every edit (high frequency)
0 B
1,500 B
Runs at checkpoints, e.g. pre-commit
0–200 B
4,000 B
Runs once at session start
up to 2,000 B
4,000 B
The one rule that matters is that higher-frequency hooks get tighter success budgets. Adjust the specific numbers to your workflow.
Put a wrapper in front that enforces the budget
Deciding on a policy is not enough, because check scripts drift toward being chatty. Swap a tool, or watch --verbose become the default, and your assumption breaks without a word. So the budget is enforced by a wrapper, not by the good intentions of the script.
#!/usr/bin/env bash# hook-guard.sh — keep a check's output inside a budget, on the path that reaches Claude# usage: hook-guard.sh <budget_bytes> <evidence_dir> -- <check command...>set -uo pipefailBUDGET="$1"; shiftEVIDENCE_DIR="$1"; shift[ "${1:-}" = "--" ] && shiftmkdir -p "$EVIDENCE_DIR"EVIDENCE="${EVIDENCE_DIR}/$(date +%Y%m%d-%H%M%S)-$$.log"# stdout and stderr both land in the evidence file; nothing reaches the conversation yet"$@" > "$EVIDENCE" 2>&1STATUS=$?TOTAL_BYTES=$(wc -c < "$EVIDENCE" | tr -d ' ')TOTAL_LINES=$(awk 'END{print NR}' "$EVIDENCE") # wc -l undercounts a file with no trailing newline# On success, return nothing at all[ "$STATUS" -eq 0 ] && exit 0NOTICE=$(printf '\n--- %s of %s bytes, %s lines total / full log: %s ---\n' \ "$BUDGET" "$TOTAL_BYTES" "$TOTAL_LINES" "$EVIDENCE")NOTICE_BYTES=$(printf '%s' "$NOTICE" | wc -c | tr -d ' ')BODY_BUDGET=$(( BUDGET - NOTICE_BYTES ))[ "$BODY_BUDGET" -lt 0 ] && BODY_BUDGET=0# Never cut mid-line, which means never cut mid-character. LC_ALL=C makes length() count bytesLC_ALL=C awk -v max="$BODY_BUDGET" ' { n = length($0) + 1; if (used + n > max) exit; used += n; print }' "$EVIDENCE" >&2printf '%s' "$NOTICE" >&2exit 2 # 1 would not stop anything. Enforcing a policy takes 2
The call site looks like this:
# command invoked from a hook entry in settings.json~/bin/hook-guard.sh 1500 ~/.cache/hook-evidence -- \ python3 tools/frontmatter_check.py "$CLAUDE_FILE_PATH"
I re-measured my earlier version and found three things wrong with it
This wrapper used to be written differently. I ran both versions again against a check that emits 60 lines of Japanese violation messages (7,302 bytes), with a 300-byte budget.
Aspect
Earlier version
Current version
Exit status
1 (non-blocking; the action proceeds)
2 (blocks; stderr reaches Claude)
Destination
stdout (invisible on most events)
stderr (visible when exiting 2)
Bytes actually returned
412 B (1.37x the 300 B budget)
215 B (inside budget)
Valid UTF-8?
No — an orphaned 0xE3 at byte 299
Yes
Output on success
30 B on every run
0 B
Rows three and four were the ones that stung. head -c cuts on bytes, so the blade lands inside a three-byte Japanese character; in the measurement a lone 0xE3 survived at byte 299 and decoding failed right there. And a wrapper that announces a 300-byte budget actually sent 412 bytes, because the truncation notice was added afterwards. A budget has to count everything you return, not just the body, or the declaration means nothing.
The current version accumulates whole lines and stops when the budget is reached. If you never split a line, you never split a character. An 800-byte budget produced 696 bytes across 6 lines; a 300-byte budget produced 215 bytes across 2. The notice itself runs about 94 bytes, varying with the evidence path length, so a budget under 100 bytes is not meaningful — worth picking a floor and living with it.
Why it is written this way
Output goes to a file before any decision is made. Truncating through a pipe kills the writing process with SIGPIPE, and the exit status stops matching reality — a passing check reported as failed, or the reverse. That confusion cost me real time, so the wrapper takes the plain route and writes to disk once.
On success, not even the evidence path is printed. It used to be. It felt helpful. But on a high-frequency hook, that one line of path accumulates on every single run, and since the check passed, nobody ever opens the file. Returning something you know will never be read is just a subscription to debt.
When output is truncated, the totals always come along. A bare cut leaves the reader unable to tell whether they are looking at everything or at a fragment — and a fragment mistaken for the whole leads somewhere wrong. "300 of 7302 bytes, 60 lines total" removes the ambiguity entirely. Truncating is fine. Truncating silently is not.
What to keep and what to drop, in order
When the budget is exceeded, what goes first? Deciding this in advance keeps the implementation from wandering. My order:
Total violation count — one versus two hundred changes the entire response
The first three to five representative violations — enough to see the shape of the fix
Breakdown by violation type — distinguishes "one cause, 200 times" from "200 different problems"
Path to the evidence file — the door back to the full listing
Everything else — not returned
Items 1 and 2 usually settle the next move on their own. Item 3 earns its place when the count is high and you want a single sweeping fix.
Put the other way around: a hook that returns item 5 is almost certainly wasting its budget. If 200 violations share one cause, 199 of them say nothing the first one did not already say.
Measure your own hooks for one week
Before changing any design, it helps enormously to turn the present state into numbers. Here is the smallest instrument that does it.
#!/usr/bin/env bash# hook-meter.sh — wrap an existing hook and record only its output volume# usage: hook-meter.sh <hook_name> -- <original command...>set -uo pipefailNAME="$1"; shift[ "${1:-}" = "--" ] && shiftLEDGER="$HOME/.cache/hook-meter.tsv"mkdir -p "$(dirname "$LEDGER")"# Command substitution drops the trailing newline, so it is used neither to measure nor to pass throughTMP="$(mktemp)""$@" > "$TMP" 2>&1STATUS=$?printf '%s\t%s\t%s\t%s\t%s\n' \ "$(date +%Y-%m-%dT%H:%M:%S)" \ "$NAME" \ "$STATUS" \ "$(wc -c < "$TMP" | tr -d ' ')" \ "$(awk 'END{print NR}' "$TMP")" \ >> "$LEDGER"cat "$TMP"rm -f "$TMP"exit "$STATUS"
For a while my meter was built on OUT="$("$@" 2>&1)", and re-measuring it is what got it replaced. Feed it a check that returns 31 bytes on 1 line and the ledger records 30 bytes, 0 lines. Command substitution eats the trailing newline, and wc -l counts newline characters, so a one-line hook reads as zero lines. A 6-byte, 3-line check logged as 5 bytes and 2 lines. The instrument built to show me totals was losing a byte and a line on every single run — and the passed-through output lost its trailing newline too, which changes how everything downstream renders.
Read the total column, not the average. Two hundred bytes per run becomes sixty thousand if it fires three hundred times a day. Two thousand bytes once per session is not worth a thought. Frequency multiplied by volume is what your conversation is actually paying.
In my case the top of that list was the check I had been calling "the light one." I had been looking only at the per-run number and feeling reassured by it.
One next step
Just one thing is enough. Take the hook that fires most often, and make its success output empty.
The question that decides what can go: does this line change the next action anyone takes? If it does not, it does not belong in the conversation.
I have spent a lot of time making the net finer. But a fine net attached to a verbose report ends up slowing the very judgment it was meant to sharpen. The checks worth keeping are the ones that can stay silent when they pass. Re-measuring my own published scripts and finding three things wrong with them came out of the same instinct.
Thank you for reading. If you measure your hooks and the ranking surprises you, that surprise is where to start.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.