●TOOLSWAP — Mid-conversation tool changes are in beta: add or remove tools between turns while keeping the prompt cache intact, on Fable 5, Mythos 5, Opus 4.8, and Opus 5●FALLBACK — The fallbacks parameter gained a default mode that applies Anthropic's recommended fallback models per refusal category, with server-side fallback also in beta●ADDDIR — A new DirectoryAdded hook fires right after /add-dir or the SDK register_repo_root request registers a working directory mid-session●MCPERR — Entries skipped by --mcp-config validation now surface as mcp_server_errors in the headless stream-json init event, and terminal runs print a startup warning●FANOUT — Concurrently running subagents are now capped at 20 by default, and hitting --max-budget-usd denies new spawns while halting the background agents already running●OPUS5 — Claude Opus 5 ships with a 1M-token context window, 128K max output, thinking on by default, and the same pricing as Opus 4.8●TOOLSWAP — Mid-conversation tool changes are in beta: add or remove tools between turns while keeping the prompt cache intact, on Fable 5, Mythos 5, Opus 4.8, and Opus 5●FALLBACK — The fallbacks parameter gained a default mode that applies Anthropic's recommended fallback models per refusal category, with server-side fallback also in beta●ADDDIR — A new DirectoryAdded hook fires right after /add-dir or the SDK register_repo_root request registers a working directory mid-session●MCPERR — Entries skipped by --mcp-config validation now surface as mcp_server_errors in the headless stream-json init event, and terminal runs print a startup warning●FANOUT — Concurrently running subagents are now capped at 20 by default, and hitting --max-budget-usd denies new spawns while halting the background agents already running●OPUS5 — Claude Opus 5 ships with a 1M-token context window, 128K max output, thinking on by default, and the same pricing as Opus 4.8
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
✦Measured output sizes for 4 everyday commands, with head-100K line survival rates from 8.3% to 66.8%
✦The exact position where an 822,582-byte JSON payload breaks under a character cut, and an envelope that stays parseable
✦A working outbox script that folds 149,838 bytes into 17,182 (11.5%) while preserving the total line
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.
A character cut destroys structure
I tried one payload that is not line-oriented: articles.json, the article metadata index, at 822,582 bytes.
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.
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.
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 bytes 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.
Where to measure next
If you want to try this, start by running your own tool set as-is and counting bytes.
for c in "grep -rn TODO ." "find . -type f -exec wc -c {} +" "git log --stat -50"; do n=$(eval "$c" 2>/dev/null | wc -c) 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.