●MCP — Support for the 2026-07-28 spec is rolling out across Claude. The protocol moves from bidirectional and stateful to request/response, so MCP servers can now live on serverless and edge infrastructure●EXTENSIONS — Three official extensions have landed: MCP Apps for server-rendered UI, Tasks for async and long-running work, and Enterprise Managed Auth for IdP-based org-wide provisioning●ADOPTION — MCP passed 400 million monthly SDK downloads, roughly 4x growth this year, settling into its role as the standard way to connect agents to applications●QUOTA — Today, August 19, is the last day of the 50 percent weekly usage boost for Claude Code subscribers. If you have long agent runs queued, this is the window●PRICING — Claude Sonnet 5's introductory rate of $2 per million input tokens and $10 output ends August 31; standard pricing of $3 and $15 takes over on September 1, twelve days out●FIX — A bug where MCP v2 connections endlessly reopened subscriptions against servers with fixed timeouts is resolved, and a forward_user_identity setting was added for user attribution●MCP — Support for the 2026-07-28 spec is rolling out across Claude. The protocol moves from bidirectional and stateful to request/response, so MCP servers can now live on serverless and edge infrastructure●EXTENSIONS — Three official extensions have landed: MCP Apps for server-rendered UI, Tasks for async and long-running work, and Enterprise Managed Auth for IdP-based org-wide provisioning●ADOPTION — MCP passed 400 million monthly SDK downloads, roughly 4x growth this year, settling into its role as the standard way to connect agents to applications●QUOTA — Today, August 19, is the last day of the 50 percent weekly usage boost for Claude Code subscribers. If you have long agent runs queued, this is the window●PRICING — Claude Sonnet 5's introductory rate of $2 per million input tokens and $10 output ends August 31; standard pricing of $3 and $15 takes over on September 1, twelve days out●FIX — A bug where MCP v2 connections endlessly reopened subscriptions against servers with fixed timeouts is resolved, and a forward_user_identity setting was added for user attribution
The Line That Disappears at 100K: Measuring What Tool-Output Spill Actually Keeps
When agent tool output passes 100,000 characters, the full text spills to a file and the model sees only a head-truncated preview. Here are measured survival rates from a real repository, and the output envelope I built to push decision-relevant lines to the front.
I asked an agent a plain question about my own content repository: which MDX file here is the largest?
The answer it gave was wrong.
Checking by hand with find and wc -c, the real answer is a 53,595-byte article. The agent named something noticeably smaller. The tool call itself had succeeded. No error, no warning, no retry.
It took me a while to find the cause. The tool output had crossed the 100,000-character boundary.
What happens past 100,000 characters
In Managed Agents, output from agent_toolset and MCP tools that exceeds 100,000 characters — roughly 25K tokens — is automatically written to a file inside the sandbox. What reaches the model is a truncated preview plus the path to that file. The full content can be read back on demand.
This is a much better failure mode than blowing out the context window mid-task. Jobs that handle build logs or bulk exports now run without me thinking about size at all.
What the spec does not tell you is which part gets truncated. I misunderstood the situation until I pushed real output across the boundary and measured what survived.
Measuring which commands cross the line
The subject is a real content repository holding 791 Japanese and 791 English MDX articles. I ran the commands I hand to agents every day and counted bytes.
Three of four are already over. None of these is a reckless command. They are the first things anyone teaches an agent to do: list the files, search across them.
Once a repository grows, ordinary operations cross the boundary on their own.
✦
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
✦The boundary counts characters while wc -c counts bytes: corrected survival rates (10.7% to 65.7%) and the 1.29x gap on multibyte output
✦The envelope defect that drops the conclusion line precisely when a log is error-heavy, with the measured 220-240 character threshold and a corrected implementation
✦Where an 822,582-character JSON payload breaks under a head cut, and an envelope that folds it to 0.69% while staying parseable
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 lines that survive, and the one that never does
Next I counted how many lines fit inside the first 100,000 characters.
Command
Total lines
Lines inside head-100K
Survival
find ... -exec wc -c {} +
1,654
1,105
66.8%
grep -rn 'claude' ...
5,595
466
8.3%
Survival ranges from 8.3% to 66.8%. That much I expected.
The problem is that these numbers barely matter in practice.
Line 1,654 — the last line of the find output — reads:
658964 total
That line, plus the largest few files by size, was the entire reason I ran the command. And by definition, the last line is the first casualty of a head cut.
Here is the final line that actually made it into the preview:
An unremarkable file sitting alphabetically around en/claude-code/. That is where the preview stops.
This is where my expectation was exactly backwards. I had understood "large output means the model only sees part of it." The reality is closer to: large output means the model only sees the least useful part.
Command-line tools put their verdict at the end. total from wc. N files changed from git. N passing, M failing from a test runner. Whether detail survives at 8% or 67%, the summary line survived at 0% in both cases. A head cut structurally targets the single densest line in the output.
The agent naming the wrong file was not a hallucination. It gave the most plausible answer available in the material it was handed.
I had the units wrong: the boundary counts characters, wc -c counts bytes
Rebuilding the two tables above after publishing, I noticed the counts were not measured the same way.
The grep, find, and ls -R rows came from wc -c, which returns bytes. The JSON row came from Python's len(raw), which returns characters. And the spill boundary is drawn at 100,000 characters, not bytes.
For output containing Japanese text, those two numbers do not agree. I counted both against the same repository (it has grown from 791 to 803 Japanese articles since publication, so absolute values shift slightly).
find and ls -R emit nothing but file paths, so bytes and characters match exactly. The gap appears in grep, which carries article prose, and in the JSON, which carries Japanese titles and descriptions. A Japanese character is three bytes in UTF-8, so the share of Japanese text lands directly in the ratio.
The consequences came in two layers.
First, the ratios. I described the grep output as 12.01x over the boundary. Measured in the same unit the boundary uses, it is 9.45x. I had overstated it by about 29%.
Second, and more consequential, the survival rates. Cutting at the first 100,000 bytes leaves 466 lines. Cutting at the first 100,000 characters leaves 607. The second one is what actually reaches the model.
Command
Total lines
Inside head-100K bytes
Inside head-100K chars
Corrected survival
find ... -exec wc -c {} +
1,678
1,103
1,103
65.7%
grep -rn 'claude' ...
5,671
466
607
10.7%
The 8.3% figure I published for grep should have been 10.7%. The find row is pure ASCII, so its 66.8% (65.7% on re-measurement) is unaffected.
The error direction is at least the safe one. Counting bytes makes output look closer to the boundary than it is, so nothing that has already crossed goes unnoticed. It flips the other way only when you reason from the safe side — "still only 0.9x, we're fine". The more multibyte text an output carries, the further short of the real position a byte count lands.
The fix is one flag.
# Wrong: bytes. Runs high on any output carrying multibyte textgrep -rn 'claude' content/articles/ja --include='*.mdx' | wc -c # 1218722# Right: characters, the same unit the spill boundary usesgrep -rn 'claude' content/articles/ja --include='*.mdx' | wc -m # 945213
One caveat: wc -m is locale-dependent and returns the byte count under LC_ALL=C. These measurements ran under LANG=C.UTF-8. In CI or a bare container, check LANG before trusting wc -m, or count with Python's len() instead.
The envelope header further down inherits the same mistake. Its "bytes": len(raw) key actually holds a character count. The revised version below reports chars and bytes separately and compares chars against the boundary.
A character cut destroys structure
I tried one payload that is not line-oriented: articles.json, the article metadata index — 822,582 characters, or 978,702 bytes, in the units established above.
Take the first 100,000 characters and try to parse them.
import json, pathlibraw = pathlib.Path("src/data/articles.json").read_text()print(f"bytes={len(raw)} ({len(raw)/100_000:.2f}x)") # bytes=822582 (8.23x)cut = raw[:100_000]try: json.loads(cut) print("head-100K only: parsed")except json.JSONDecodeError as e: print(f"head-100K only: failed -> {e}") print(f"last 40 chars before the cut: ...{cut[-40:]!r}")
Result:
bytes=822582 (8.23x)
head-100K only: failed -> Expecting property name enclosed in double quotes: line 3087 column 6
last 40 chars before the cut: ...'ull,\n "author": "Claude Lab",\n '
The cut lands on a character index — not a line, not a record. It sliced an object open between two keys.
Line-oriented text degrades gracefully. Those 466 surviving grep lines each keep their full meaning. Structured data has no such property. It is not that 12% of the payload survived; it is that a broken string survived.
If you hand an agent a tool that emits raw JSON, the safe assumption is that crossing the boundary is a total loss, not a partial one.
The boundary moves on its own
Look at the last row again: ls -R content at 87,135 bytes, or 0.87x. No spill today.
Each article adds about 110.2 bytes to that particular output.
# current value, and how many more articles until the boundarynow=$(ls -R content | wc -c)ja=$(find content/articles/ja -name '*.mdx' | wc -l)python3 -c "n, a = $now, $japer = n / aneed = (100_000 - n) / perprint(f'now {n} bytes / {a} articles / {per:.1f} bytes per article')print(f'{need:.0f} more articles to cross (~{need/2:.0f} days at 2 per day)')"
now 87135 bytes / 791 articles / 110.2 bytes per article
117 more articles to cross (~58 days at 2 per day)
In roughly two months this command quietly moves to the other side. Not one line of prompt, tool definition, or code will have changed, yet the nature of what the agent receives will have.
This is the failure mode I find hardest to defend against. Regression tests pass. The tool call returns success. The only thing that changes is whether the answer is right. And the change is not gradual — it arrives all at once, on the day the threshold is crossed.
Eleven days on from publication, I checked the estimate against reality.
Date
ls -R content in characters
JA articles
2026-07-28 (published)
87,135
791
2026-08-08 (re-measured)
88,611
803
Twelve articles added 1,476 characters. Projecting from the 110.2 characters-per-article average gives 88,457, off by 154 characters — 0.17%. Eleven days is a short window, but it confirms a linear estimate is good enough for this purpose.
The marginal rate tells a slightly different story: 123.0 characters per article, 12% above the average. The average is diluted by the early period when the directory lines existed and the articles did not. For distance-to-boundary, dividing by the recent marginal rate is the more conservative choice.
Rate used
Per article
Articles to boundary
Days at 2 per day
Average
110.2 chars
103
~52
Recent marginal
123.0 chars
93
~46
That puts the crossing in late September on the marginal rate, end of September on the average. The original "about 58 days" pointed at late September too, so eleven days of real data barely moved the landing point. Having an estimate at all mattered more than its precision.
The output envelope: push decisions to the front
The fix turned out to be simple once I stopped fighting the truncation. Assume the head cut, and rebuild the output around it.
Put what a decision needs at the front, let detail flow behind it, and always write the full text to a file yourself so the path travels with the preview. An envelope between the tool and the model.
#!/usr/bin/env python3"""outbox.py — repack tool output so decision material sits at the front. usage: <command> | python3 outbox.py --label build --tail 20 Reads stdin, emits header + signal lines + tail + head excerpt on stdout. The full text always goes to a file, whose path is carried in the header."""import sys, os, re, json, hashlib, pathlib, argparseSPILL_LIMIT = 100_000 # threshold at which full output spills to a fileHEADER_BUDGET = 2_000 # characters reserved for the decision headerEXCERPT_BUDGET = 12_000 # characters reserved for the head excerpt# Default signal set. Override with --signal to match your test runner's vocabulary.ERROR_RE = re.compile(r"\b(error|failed|failure|fatal|panic|exception)\b", re.I)def main(): ap = argparse.ArgumentParser() ap.add_argument("--label", default="tool-output") ap.add_argument("--dir", default=os.environ.get("OUTBOX_DIR", "/tmp/outbox")) ap.add_argument("--signal", default=None, help="regex for important lines") ap.add_argument("--tail", type=int, default=20, help="lines always kept from the end") args = ap.parse_args() raw = sys.stdin.read() lines = raw.splitlines() # Persist first. Do this later and a mid-run crash leaves you with nothing. digest = hashlib.sha256(raw.encode("utf-8", "replace")).hexdigest()[:12] outdir = pathlib.Path(args.dir) outdir.mkdir(parents=True, exist_ok=True) full = outdir / f"{args.label}-{digest}.txt" full.write_text(raw, encoding="utf-8", errors="replace") pat = re.compile(args.signal, re.I) if args.signal else ERROR_RE hits = [(i, l) for i, l in enumerate(lines, 1) if pat.search(l)] header = { "label": args.label, "bytes": len(raw), "lines": len(lines), "spill_ratio": round(len(raw) / SPILL_LIMIT, 2), "signal_hits": len(hits), "full_output": str(full), # point the model here for the rest "tail_lines_included": min(args.tail, len(lines)), } parts = ["<<<OUTBOX-HEADER", json.dumps(header, ensure_ascii=False, indent=2), "OUTBOX-HEADER"] if hits: parts.append(f"--- signal lines (first 40 of {len(hits)}) ---") parts += [f"{i}: {l[:300]}" for i, l in hits[:40]] # Where CLI tools put their verdict. Drop this and the envelope is pointless. parts.append(f"--- tail {header['tail_lines_included']} lines ---") parts += lines[-args.tail:] body = "\n".join(parts) if len(body) > HEADER_BUDGET + EXCERPT_BUDGET: body = body[: HEADER_BUDGET + EXCERPT_BUDGET] + "\n--- excerpt truncated; read full_output ---" sys.stdout.write("\n".join([ body, f"--- head excerpt ({EXCERPT_BUDGET} chars max) ---", raw[:EXCERPT_BUDGET], ]))if __name__ == "__main__": main()
The same two commands, measured through the envelope:
Command
Raw
Enveloped
Ratio
Summary line kept
find ... -exec wc -c {} +
149,838
17,182
11.5%
yes
grep -rn 'claude' ...
1,201,154
31,279
2.6%
(none exists)
The find case folds to 11.5% and keeps 658964 total in the tail block. The agent can now answer with the total in hand.
The grep case mattered more operationally. An output 12x over the boundary came down to 31,279 bytes, so no spill happens at all. The read-the-file round trip disappears, while the full text stays one path away whenever it is genuinely needed.
When the envelope drops the conclusion: budget from the front, not the back
After running this envelope for a while, I found a defect in it. It discards the line I most wanted to protect, in exactly the situation where I most wanted it protected.
Follow the assembly order. Header, then signal lines (up to 40, each capped at 300 characters), then the tail block, all pushed onto parts — and the whole thing truncated at 14,000 characters at the end. Truncation cuts from the back. So the tail block, appended last and holding the only copy of the conclusion, is the first thing to go.
This bites when signal lines are numerous and long. An error-heavy production build or deployment log has precisely that shape.
I generated a 1,200-line synthetic build log ending in BUILD FAILED: 3 errors, 12 warnings in 48.2s, with 40 error lines, and varied only the length of the signal lines.
Signal line length
Envelope output (chars)
Tail block retained
Conclusion line
200
24,414
20 / 20
kept
220
25,594
20 / 20
kept
240
26,084
18 / 20
lost
260
26,084
13 / 20
lost
280
26,084
10 / 20
lost
300
26,084
6 / 20
lost
The threshold sits between 220 and 240. Past it, the tail block erodes quietly from the back. The more errors there are, the less visible the conclusion becomes.
Notice also that the output plateaus at 26,084 characters. The 14,000 I wrote as a budget only governs body. A 12,000-character head excerpt is appended unconditionally afterwards, so the real output runs to roughly 26,000.
So the envelope was trimming the conclusion at the tail while spending 12,000 characters on the head. An article arguing that head truncation structurally targets the densest line in the output had rebuilt that same structure inside its own fix.
The correction is to reserve budget up front rather than trim from the back.
#!/usr/bin/env python3"""outbox2.py — reserve the tail block first, then distribute what is left."""import sys, os, re, json, hashlib, pathlib, argparseSPILL_LIMIT = 100_000TOTAL_BUDGET = 14_000 # ceiling for the whole output, head excerpt includedTAIL_BUDGET = 3_000 # set aside for the tail block before anything elsedef main(): ap = argparse.ArgumentParser() ap.add_argument("--label", default="tool-output") ap.add_argument("--dir", default=os.environ.get("OUTBOX_DIR", "/tmp/outbox")) ap.add_argument("--signal", default=None) ap.add_argument("--tail", type=int, default=20) args = ap.parse_args() raw = sys.stdin.read() lines = raw.splitlines() digest = hashlib.sha256(raw.encode("utf-8", "replace")).hexdigest()[:12] outdir = pathlib.Path(args.dir) outdir.mkdir(parents=True, exist_ok=True) full = outdir / f"{args.label}-{digest}.txt" full.write_text(raw, encoding="utf-8", errors="replace") pat = re.compile(args.signal, re.I) if args.signal else re.compile( r"\b(error|failed|failure|fatal|panic|exception)\b", re.I) hits = [(i, l) for i, l in enumerate(lines, 1) if pat.search(l)] # 1. Reserve the tail block first. If this gets cut, the envelope has no point tail_lines, used = [], 0 for l in reversed(lines[-args.tail:]): if used + len(l) + 1 > TAIL_BUDGET: break tail_lines.insert(0, l) used += len(l) + 1 tail_block = [f"--- tail {len(tail_lines)}/{min(args.tail, len(lines))} lines ---"] + tail_lines header = { "label": args.label, "chars": len(raw), # same unit as the boundary; compare on this "bytes": len(raw.encode("utf-8")), "lines": len(lines), "spill_ratio_chars": round(len(raw) / SPILL_LIMIT, 2), "signal_hits": len(hits), "full_output": str(full), "tail_lines_included": len(tail_lines), "tail_lines_dropped": min(args.tail, len(lines)) - len(tail_lines), } head_block = ["<<<OUTBOX-HEADER", json.dumps(header, ensure_ascii=False, indent=2), "OUTBOX-HEADER"] # 2. Spend the remainder on signal lines; report the overflow as a count fixed = len("\n".join(head_block)) + len("\n".join(tail_block)) + 2 sig_budget = max(0, TOTAL_BUDGET - fixed) sig, used, shown = [], 0, 0 for i, l in hits: e = f"{i}: {l[:300]}" if used + len(e) + 1 > sig_budget: break sig.append(e) used += len(e) + 1 shown += 1 sig_block = ([f"--- signal lines ({shown} of {len(hits)} shown) ---"] + sig) if hits else [] if hits and shown < len(hits): sig_block.append(f"--- {len(hits) - shown} more signal lines omitted; read full_output ---") # 3. Head excerpt last, and only if there is room left body = "\n".join(head_block + sig_block + tail_block) room = max(0, TOTAL_BUDGET - len(body)) out = [body] if room > 500: out += [f"--- head excerpt ({room} chars) ---", raw[:room]] sys.stdout.write("\n".join(out))if __name__ == "__main__": main()
The same inputs through both versions:
Signal line length
Old: output
Old: conclusion
New: output
New: conclusion
New: signal lines shown
120
19,694
kept
14,035
kept
41 / 41
240
26,084
lost
14,034
kept
41 / 41
280
26,084
lost
13,813
kept
37 / 41
300
26,084
lost
13,839
kept
35 / 41
The conclusion line survives in every case and the output settles around 14,000 characters. When signal lines do not fit, the envelope says "35 of 41 shown" and leaves the rest behind full_output. Not letting the model believe it has seen everything turned out to matter about as much as preserving the tail.
Against real output:
Command
Raw
Old envelope
New envelope
total line
find ... -exec wc -c {} +
152,216
17,888
14,035
kept by both
grep -rn 'claude' ...
945,213
23,240
13,924
(none exists)
Neither of these two loses the conclusion under the old envelope. That is likely why the defect escaped me at publication: it never showed up in the commands I had been measuring. When you test an envelope, do not feed it your ordinary output — feed it the log from the day something broke, dense with signal lines and long ones at that. A safety net verified only on the happy path tends to be missing on the unhappy one.
Structured data needs a different envelope
The text envelope does not work on JSON. Appending 20 trailing lines does not produce a parseable document.
For structured data the summary has to describe shape rather than values: item count, key inventory, and a small set of samples at full fidelity. And the envelope should verify its own output parses before it returns.
#!/usr/bin/env python3"""json_outbox.py — fold a large JSON payload while staying valid JSON."""import json, sys, hashlib, pathlib, collectionsdef envelope(path, keep=8, outdir="/tmp/outbox"): raw = pathlib.Path(path).read_text() data = json.loads(raw) # Top level may be an object; take the first array we find inside it. items = data if isinstance(data, list) else next( (v for v in data.values() if isinstance(v, list)), []) digest = hashlib.sha256(raw.encode()).hexdigest()[:12] outdir = pathlib.Path(outdir) outdir.mkdir(parents=True, exist_ok=True) full = outdir / f"json-{digest}.json" full.write_text(raw) # Scanning every item is needlessly slow on large files; 2000 was enough # to capture the full key inventory in every payload I tested. keys = collections.Counter() for it in items[:2000]: if isinstance(it, dict): keys.update(it.keys()) body = { "header": { "source": str(path), "bytes": len(raw), "item_count": len(items), "keys": [k for k, _ in keys.most_common()], "full_output": str(full), "sample_policy": f"first {keep} items, full fidelity", }, "sample": items[:keep], } return json.dumps(body, ensure_ascii=False, indent=1), len(raw)if __name__ == "__main__": out, n = envelope(sys.argv[1]) json.loads(out) # self-check: the envelope must always be parseable print(f"raw={n} envelope={len(out)} ratio={len(out)/n*100:.2f}% parse=OK")
raw=822582 envelope=5688 ratio=0.69% parse=OK
822,582 characters down to 5,688 — 0.69% — and the result is valid JSON, in contrast to the raw head cut returning JSONDecodeError.
Here I hit a second expectation that turned out to be wrong.
I started with roughly 50 sample items, on the assumption that fewer samples meant less information. In use, 8 and 50 produced no difference in answer quality. What the agent draws from this envelope is which keys exist and how many records there are — not the contents of any individual record.
The sample exists to show shape, not to convey content. Cutting it to 8 freed budget that was better spent on making the key inventory complete.
Deciding where to apply it
Not every tool needs an envelope. My rule of thumb:
Wrap it when output size scales with the size of the thing being inspected: grep, find, git log, log retrieval, list endpoints. Something at 0.5x today will pass 2x within a year.
Skip it when output is constant-size: reading a config value, fetching one record, checking status. The header ends up larger than the payload and makes things harder to read.
Raise --tail when order carries meaning. For time-ordered logs the end is the present. The default 20 was too thin for deployment logs, where I moved to 60.
One more note on spill_ratio in the header: it exists to make distance-to-boundary visible during normal operation. Any value above 0.9 in the logs means that tool is about to cross. Learning that two months early is far easier than discovering it after the fact.
Running automation as an indie developer, every one of these quiet-failure paths I close visibly widens what I can hand off. Bugs that fail loudly have cost me far less time than bugs that return success while being wrong.
If you want to try this, start by running your own tool set as-is and counting characters.
for c in "grep -rn TODO ." "find . -type f -exec wc -c {} +" "git log --stat -50"; do n=$(eval "$c" 2>/dev/null | wc -m) # -m, not -c: the boundary is drawn in characters printf "%-40s %10d %5.2fx\n" "${c:0:38}" "$n" "$(python3 -c "print($n/100000)")"done
Anything above 1.0 has already lost its summary line. Anything below it is a question of when, and that is worth estimating today.
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.