Subagent transcripts: `message.usage.output_tokens` differs across the block records of one response; the first record carries a partial count
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
Claude Code writes one assistant record per content block for each API response, and the records of one response share requestId and message.id. In the main session transcript every record of a response carries the same, final message.usage, so a reader can count usage once per message.id.
In subagent transcripts (<session-id>/subagents/agent-*.jsonl, and subagents/workflows/<runId>/agent-*.jsonl for Workflow agents) that is not the case: message.usage.output_tokens is different on each record of the same response. It looks like the running count at the moment each block was written, so the first record (typically a thinking or text block) shows 2–8 output tokens while the last record shows the real total (hundreds or thousands). The input and cache fields repeat on every record as they do in the main transcript.
The effect is that any tool reading the transcript the way the main-session shape suggests (usage once per response, first record wins) badly under-counts subagent output tokens. On our side this was one of the two causes of an 8-hour, subagent-heavy session being reported at $21.83 when it had cost $322.36 (the other was Workflow agents living under subagents/workflows/, which a reader that only knows subagents/agent-*.jsonl never sees).
What Should Happen?
Either every record of a response carries the same final usage (as the main transcript does), or the format documents that usage on split records is a running count and the last record for a message.id is authoritative. Consistency between main and subagent transcripts would be the simplest fix.
Error Messages/Logs
None; nothing errors. The numbers are just inconsistent between records.
Steps to Reproduce
- Run a session that launches a subagent (the
Agenttool or aWorkflow) that produces a multi-block response (thinking + text + tool_use is enough). - Open
~/.claude/projects/<project>/<session-id>/subagents/agent-<id>.jsonl. - Group the
assistantrecords bymessage.idand comparemessage.usage.output_tokensacross the group. - Observe that the values differ and that the first one is a small partial count; repeat step 3 on the main
<session-id>.jsonland observe that there the values are identical.
The script below does step 3 for every subagent transcript under ~/.claude/projects and prints ids and usage numbers only (no prompts or content):
// usage-check.mjs — node usage-check.mjs [projects-dir]
import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os';
const root = process.argv[2] || path.join(os.homedir(), '.claude', 'projects');
const files = [];
(function walk(d) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else if (/agent-.*\.jsonl$/.test(e.name)) files.push(p); } })(root);
let responses = 0, varying = 0, shown = 0;
for (const f of files) {
const by = new Map();
for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
let r; try { r = JSON.parse(line); } catch { continue; }
if (r.type !== 'assistant' || !r.message?.usage) continue;
const k = r.message.id; if (!by.has(k)) by.set(k, []);
by.get(k).push({ block: r.apiBlockIndex, out: r.message.usage.output_tokens, in: r.message.usage.input_tokens, cacheRead: r.message.usage.cache_read_input_tokens });
}
for (const [id, recs] of by) {
responses++;
if (new Set(recs.map((x) => x.out)).size > 1) { varying++; if (shown++ < 5) console.log(path.relative(root, f), id, JSON.stringify(recs)); }
}
}
console.log(`${files.length} subagent files, ${responses} responses, ${varying} with differing output_tokens across their records`);
Example output shape:
-home-me-proj/abc…/subagents/agent-a1b2.jsonl msg_01X… [{"block":0,"out":4,…},{"block":1,"out":4,…},{"block":2,"out":1800,…}]
31 subagent files, 212 responses, 187 with differing output_tokens across their records
Claude Model
Other
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.241.
Platform
Anthropic API
Operating System
Windows
Terminal/Shell
Windows Terminal
Additional Information
Found while auditing 33 real sessions for Glassbox, an open-source local session checker. The workaround shipped in 0.4.2 keeps the largest value seen per usage field across a response's records: https://github.com/aldohushi1-stack/glassbox/blob/main/CHANGELOG.md (0.4.2, "Streamed usage") and maxUsage() in https://github.com/aldohushi1-stack/glassbox/blob/main/src/trace-core.js. Happy to share a redacted subagent transcript (usage fields and ids only) if that helps.
Two related observations I have not filed separately (say if you'd like them as their own issues): a frame-link record written weeks after the session carries a current timestamp, so a reader that bounds the session by first/last record timestamps sees a multi-week session; and Workflow agent transcripts live under subagents/workflows/<runId>/ rather than subagents/, with a journal.jsonl beside them.
4 Comments
Related: #76484 (closed by the stale bot on 14 Sep) names the child transcripts under
subagents/as the fallback for per-child usage, saying they have full per-turn usage. They do, but only on the last block record of each response. Earlier records with the samemessage.idcarry a partialoutput_tokens, so a reader that joins children back byagentIdand counts once per response still under-counts unless it keeps the largest value per field.Workflow agents also sit one level deeper, in
subagents/workflows/<runId>/. The two together turned $322.36 into $21.83 on the session described above.Additional data from another Windows machine. It confirms this report, and adds one point: for most subagent responses on recent versions, no record of the response ever carries the final usage. Keeping the largest value per
message.id(as suggested in the comment above) does not recover those.Counts
All transcripts under
~/.claude/projectson this machine (Claude Code 2.1.247 – 2.1.283, Windows 11). Records are grouped bymessage.id. Synthetic records (all input-side tokens 0) are skipped.| | responses |
output_tokensdiffers between records | last record hasstop_reasonand final usage | last record is still partial ||---|---|---|---|---|
| main
<session-id>.jsonl(159 files) | 21,094 | 0 | 21,091 | 3 ||
subagents/agent-*.jsonl(79 files) | 2,319 | 685 | 895 | 1,424 (61%) |A partial last record looks like this:
stop_reasonisnullusagehas noiterations,output_tokens_details,server_tool_useorspeedkeys (the final usage on the main transcript always has them)output_tokensis a small running count (average 9)Responses whose last record is final average 1,985 output tokens. In 1,408 of the 1,424 partial cases the last record is a
tool_useblock. Among subagent responses that end intool_use, 826 have a final record (stop_reason: "tool_use") and 1,408 do not.The input and cache fields (
input_tokens,cache_read_input_tokens,cache_creation_input_tokens) are the same on every record of a response, in both main and subagent transcripts.By version (subagent responses)
| version | last record partial | last record final |
|---|---|---|
| 2.1.266 | 63 | 25 |
| 2.1.267 | 141 | 23 |
| 2.1.268 | 14 | 37 |
| 2.1.270 | 3 | 93 |
| 2.1.272 | 0 | 33 |
| 2.1.273 | 0 | 12 |
| 2.1.274 | 13 | 288 |
| 2.1.278 | 314 | 13 |
| 2.1.280 | 654 | 302 |
| 2.1.282 | 66 | 15 |
| 2.1.283 | 152 | 51 |
2.1.270 – 2.1.274 mostly wrote the final record. 2.1.278 and later mostly do not. Every subagent here was launched in the background (
requestShape: "background"inagent-*.meta.json; agent types Explore, fork, general-purpose, claude-code-guide). I did not find a split by agent type or by the number of tool calls in the response.Effect
The transcripts under-count subagent cost, and the missing part cannot be recovered from any file. On this machine, sessions that used subagents came out 6–28% below the session cost Claude Code reports to the status line (
total_cost_usd), even when counting the last record of each response. Sessions without subagents are within 0–5% (a smaller gap with other causes). On a per-day basis the gap reached 39%. The status line total suggests Claude Code does receive the final usage; it just does not reach the subagent transcript.Check
Same idea as the script in the report. It prints counts only, and marks a response as final when its last record has a
stop_reason:Expected
Every subagent response should end with a record that carries the final usage and
stop_reason, as the main transcript does.🤖 AI-generated reply, posted by Claude Code under
depristo's account.Regression in 2.1.281: many subagent responses now have no record with the final
usageat all.The workaround above ("the last record for a
message.idis authoritative") no longer works on 2.1.281. For most subagent responses, every record hasstop_reason: nulland a partialoutput_tokens, so no record in the transcript carries the response's real output count.Measured 2026-09-28 across one macOS machine's
~/.claude/projects(subagent transcripts are<session-id>/subagents/agent-*.jsonl; a response counts as "no final record" when none of its records has a non-nullstop_reason):| Client version | Transcript | Responses | No final record |
|---|---|---:|---:|
| 2.1.219 | subagent | 2,543 | 282 (11%) |
| 2.1.280 | subagent | 2,581 | 12 (0.5%) |
| 2.1.281 | subagent | 7,409 | 4,974 (67%) |
| 2.1.281 | main | 8,732 | 0 |
Characteristics of the affected responses:
end_turn); this is not interruption.tool_useblock, and the rate rises with parallel tool calls (2% of one-tool responses, 7–8% of two- or three-tool responses, pooled over all versions and both transcript kinds). Example: one response issued 5 tool calls and every one of its 5 records reportsoutput_tokens: 37withstop_reason: null.input_tokens,cache_creation_input_tokens,cache_read_input_tokensand thecache_creationTTL split are unaffected. On responses that do have a final record, all 13,041 partial records matched the final record's input-side fields exactly. Only output is short.usagedoes not recover it; it matches the subagent's last response only.Impact: output tokens for those responses are simply absent from disk. As an estimate of the size: on 4,949 finished subagent responses where both figures exist, the first record's partial count was 11% of the final output, and pricing with it understated cost by 15%. Any tool that computes subagent cost from transcripts can now only report a lower bound.
Expected: each response's final record (with its
stop_reasonand finalusage) is written to the subagent transcript, as 2.1.280 did, or every record carries the finalusageas the main transcript does.Counting script (prints counts only, no content):
Environment: Claude Code desktop app (Code tab) on macOS 26 (Darwin 25.6.0), client 2.1.281, subagents launched via the
Agenttool onclaude-opus-5-5.Thanks @roziko and @depristo. That moves this from "the first record is partial" to "for most subagent responses there is no final record at all", which is the worse problem: the largest-value-per-
message.idworkaround I suggested can't recover output that was never written.A third machine for the table: Windows 11, a copy of
~/.claude/projectstaken in early September, so it predates the 2.1.278+ jump. Same rule as the script above (a response has no final record when none of its records has a non-nullstop_reason), subagent and Workflow-agent transcripts only:| Client version | Subagent responses | No final record |
|---|---:|---:|
| 2.1.237 | 2,054 | 69 (3%) |
| 2.1.241 | 101 | 3 (3%) |
| 2.1.266 | 112 | 35 (31%) |
So it was there at a low rate before, and the rise lines up with what you both measured.
On the tooling side, Glassbox 0.10.1 (published today) no longer presents a floor as the bill: it counts these responses, shows subagent cost as
≥ $X, and estimates the gap from the median of the session's own complete subagent tool-use responses, labelled as an estimate (rulemissing-final-usage, info level so it doesn't fail CI). That is a reporting workaround only. The fix still has to be here: write each subagent response's final record, with itsstop_reasonand finalusage, as 2.1.280 did, or carry the finalusageon every record as the main transcript does.