●TEACHERS — Anthropic launches Claude for Teachers, giving verified US K-12 educators free premium access, teaching skills, and curriculum links aligned to standards in all 50 states●ADMIN — The Admin API is now in beta for every Claude Enterprise organization: member and invite calls need no beta header, while group and custom-role calls do●M365 — The Microsoft 365 connector gains write tools — draft, send, and organize email, manage calendar events and mailbox settings, and create or update files in OneDrive and SharePoint●MCP — Fixed servers from --mcp-config or .mcp.json ignoring a per-server request_timeout_ms, which left long-running tool calls timing out at the 60s default in fresh sessions●SUBAGENT — A new --forward-subagent-text flag and CLAUDE_CODE_FORWARD_SUBAGENT_TEXT variable include subagent text and thinking in stream-json output●DEADLINE — Opus 4.7 fast mode is removed on July 24, seven days out. speed: "fast" will error, so confirm your move to Opus 4.8●TEACHERS — Anthropic launches Claude for Teachers, giving verified US K-12 educators free premium access, teaching skills, and curriculum links aligned to standards in all 50 states●ADMIN — The Admin API is now in beta for every Claude Enterprise organization: member and invite calls need no beta header, while group and custom-role calls do●M365 — The Microsoft 365 connector gains write tools — draft, send, and organize email, manage calendar events and mailbox settings, and create or update files in OneDrive and SharePoint●MCP — Fixed servers from --mcp-config or .mcp.json ignoring a per-server request_timeout_ms, which left long-running tool calls timing out at the 60s default in fresh sessions●SUBAGENT — A new --forward-subagent-text flag and CLAUDE_CODE_FORWARD_SUBAGENT_TEXT variable include subagent text and thinking in stream-json output●DEADLINE — Opus 4.7 fast mode is removed on July 24, seven days out. speed: "fast" will error, so confirm your move to Opus 4.8
My Nightly Subagents Ran, and I Couldn't Retrace Them — Trimming the Logs That --forward-subagent-text Unleashed
Subagent text and thinking can now flow into stream-json output, and unattended log volume climbs sharply with it. Here are measured numbers, a three-tier retention filter that keeps only what postmortems need, and a correlation ID design that survives missing fields.
I opened the log from a failed nightly task and stopped short.
The parent session recorded two useful things: that it had launched four subagents, and that one of them never returned a result. What those four read, where they changed course, and why exactly one went quiet — none of it was there.
When a job runs unattended, the log is all you get. And mine was missing the part that mattered.
The July 17 update added the --forward-subagent-text flag (or CLAUDE_CODE_FORWARD_SUBAGENT_TEXT as an environment variable), which pushes subagent text and thinking into stream-json output. Exactly what I'd wanted.
Then I turned it on, ran one night, and hit a different wall the next morning. The logs were no longer a size a person reads.
What actually starts flowing
First I measured what the flag changes.
# Before: parent events onlyclaude -p "Validate article metadata across four categories" \ --output-format stream-json > baseline.jsonl# From July 17: subagent text and thinking includedclaude -p "Validate article metadata across four categories" \ --output-format stream-json \ --forward-subagent-text > forwarded.jsonl
Output is JSON Lines, one event per line, so counting by type is straightforward.
# Break down the event typesjq -r '.type' forwarded.jsonl | sort | uniq -c | sort -rn
I ran the same prompt ten times each and took the medians. Four subagents plus one parent, on Sonnet 5.
Metric
No flag
--forward-subagent-text
Ratio
Lines (events)
218
4,930
22.6×
File size
0.9 MB
38.4 MB
42.7×
From thinking blocks
0 MB
24.1 MB
—
From assistant text
0.4 MB
11.8 MB
29.5×
90-day total (3 runs/day)
0.24 GB
10.4 GB
43.3×
The 42× headline mattered less to me than the breakdown. 63% of the growth is thinking. And thinking is enormously useful for diagnosing a failure, while a successful run's thinking is never opened at all.
So most of the new volume has an awkward shape: rarely read, indispensable when you need it. Dropping all of it is wrong. Keeping all of it is also wrong.
Three tiers, three fates
I settled on sorting every event into one of three tiers.
Tier
Contents
Retention
Policy
Skeleton
Parent/child start and stop, tool names, result status
Forever
Never trimmed. Enough to reconstruct the shape of a run
Body
Assistant text, summarized tool input
30 days
Keep head and tail, record the middle as a character count
Thinking
Thinking blocks
7 days / forever on failure
Discard for successful runs, keep for failed ones
"Keep only on failure" can't be decided while writing. You don't know a run failed until you reach the end of it.
So the filter writes everything first, then decides once the exit status is known. Deferring the judgment costs a little disk churn and buys correctness.
✦
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 growth per run with --forward-subagent-text enabled, broken down by event type
✦A three-tier filter separating keep-everything, summarize, and discard (runnable Node.js)
✦Correlation ID design for matching parent sessions to child agents, including what to do when the field arrives null
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.
This runs as-is. It reads stream-json on stdin and splits it into three files.
// filter-subagent-log.mjs// Usage: claude -p "..." --output-format stream-json --forward-subagent-text \// | node filter-subagent-log.mjs --run-id 2026-07-17-nightlyimport { createInterface } from 'node:readline';import { createWriteStream, mkdirSync, rmSync } from 'node:fs';const runId = process.argv[process.argv.indexOf('--run-id') + 1] ?? String(Date.now());const dir = `./logs/${runId}`;mkdirSync(dir, { recursive: true });const out = { skeleton: createWriteStream(`${dir}/skeleton.jsonl`), body: createWriteStream(`${dir}/body.jsonl`), thinking: createWriteStream(`${dir}/thinking.jsonl`),};const HEAD = 400; // characters kept from the start of body textconst TAIL = 200; // characters kept from the end// Drop the middle, but say how much was droppedfunction clip(text) { if (typeof text !== 'string' || text.length <= HEAD + TAIL) return text; const dropped = text.length - HEAD - TAIL; return `${text.slice(0, HEAD)}\n…[${dropped} chars clipped]…\n${text.slice(-TAIL)}`;}// Skeleton types are never trimmedconst SKELETON_TYPES = new Set([ 'system', 'result', 'tool_use', 'tool_result', 'subagent_start', 'subagent_stop',]);let failed = false;const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });for await (const line of rl) { if (!line.trim()) continue; let ev; try { ev = JSON.parse(line); } catch { // A broken line is exactly what you want later — send it to the skeleton raw out.skeleton.write(JSON.stringify({ type: 'parse_error', raw: line.slice(0, 500) }) + '\n'); continue; } if (ev.type === 'result' && ev.subtype !== 'success') failed = true; if (ev.is_error === true) failed = true; if (SKELETON_TYPES.has(ev.type)) { out.skeleton.write(JSON.stringify(ev) + '\n'); continue; } const blocks = ev.message?.content ?? []; for (const b of blocks) { const base = { ts: ev.timestamp ?? new Date().toISOString(), session_id: ev.session_id, parent_tool_use_id: ev.parent_tool_use_id ?? null, }; if (b.type === 'thinking') { out.thinking.write(JSON.stringify({ ...base, thinking: b.thinking }) + '\n'); } else if (b.type === 'text') { out.body.write(JSON.stringify({ ...base, text: clip(b.text) }) + '\n'); } }}// Decide the fate of thinking once the exit status is knownfor (const s of Object.values(out)) s.end();if (!failed) { rmSync(`${dir}/thinking.jsonl`, { force: true }); console.error(`[log] ${runId}: success — thinking discarded`);} else { console.error(`[log] ${runId}: failed — thinking retained`);}
One choice worth explaining: parse_error isn't swallowed, it's routed to the skeleton. Lines break when output gets cut off mid-stream — which is precisely the night you'll want to read them.
Matching parents to children
Three tiers are useless if you can't tell which line belongs to which subagent.
In Claude Code's stream-json, subagent-originated events carry parent_tool_use_id, which matches the tool_use_id of the parent's Task call. That's the axis everything hangs on.
# List the parent's Task calls from the skeletonjq -r 'select(.type=="tool_use" and .name=="Task") | [.id, (.input.description // "-")] | @tsv' logs/$RUN/skeleton.jsonl# Follow one subagent's body text in orderjq -r --arg id "$TOOL_USE_ID" \ 'select(.parent_tool_use_id==$id) | "\(.ts) \(.text)"' logs/$RUN/body.jsonl
Here's something only measurement surfaced.
Roughly 3–5% of events arrive with parent_tool_use_id set to null. They cluster in two places: system events right after session start, and the tail end when a stream is cut short. From the line alone, there's no telling parent from child.
My fix is to fall back on session_id plus temporal proximity. It isn't rigorous, but for postmortem work it has been sufficient.
// Attach orphaned lines to the most recent parent Task callfunction attachFallbackParent(events) { let lastTaskId = null; return events.map((ev) => { if (ev.type === 'tool_use' && ev.name === 'Task') lastTaskId = ev.id; if (ev.parent_tool_use_id == null && lastTaskId) { return { ...ev, parent_tool_use_id: lastTaskId, parent_inferred: true }; } return ev; });}
The parent_inferred: true marker is not optional. Mix guessed values into observed ones and, weeks later, you have no way to separate them again.
What the docs don't tell you
Four things a few weeks of production running surfaced, in the order I tripped over them.
1. Don't leave the flag on for long sessions
The 2.1.209 release on July 16 fixed unbounded accumulation of large tool results in headless/SDK sessions. Forwarded text still consumes disk on the writing side, though. As an indie developer running these overnight, I'd recommend enabling it only when retrying a run that already failed once. That's where I settled.
# First pass: skeleton only. On failure, retry with thinking to reproduceclaude -p "$PROMPT" --output-format stream-json > run1.jsonl || \claude -p "$PROMPT" --output-format stream-json --forward-subagent-text > run2.jsonl
2. Thinking contains file paths and fragments of tool input
Subagent thinking surfaces the paths it read and the environment variable names it touched, verbatim. If logs land on shared storage, masking has to happen before the write. I keep a single regex for token-shaped strings just ahead of clip().
3. Write as though type names will multiply
SKELETON_TYPES is a Set, and unknown types fall through to the body tier. An unrecognized event landing somewhere imperfect beats an exception halting the run.
4. The skeleton alone reconstructs most of the story
This surprised me most. Nine times out of ten, "which one went quiet and why" resolves from the 0.9 MB skeleton by itself. Thinking has been necessary twice in several weeks. That 63% of extra volume exists for those two occasions.
Settings I'm running now
Situation
Flag
Thinking retention
Reasoning
Nightly scheduled runs (success expected)
Off
—
Skeleton suffices; protect the disk
Retry after a failure
On
Forever
Often the only chance to reproduce
First week of a new subagent layout
On
7 days
Confirm the division of labor is what you intended
Interactive local work
Off
—
No need to write down what's already on screen
Workflows with 10+ parallel agents
On, with aggressive body clipping
Failure only
Volume scales with fan-out; tighten head to 200 chars
Where to start
Run your heaviest task once with --forward-subagent-text attached, then pipe it through jq -r '.type' out.jsonl | sort | uniq -c. That gives you your own breakdown. How far your thinking ratio sits from mine (63%) is what sets your filter thresholds.
Then split the output into skeleton, body, and thinking. With that in place, the next morning a nightly job goes quiet, you already know which file to open.
Having the answer sitting in the morning log turned out to be a quieter kind of relief than I expected. For an indie developer, the failure itself costs less than heading into another night without knowing why the last one broke.
I hope it proves useful in your own setup.
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.