What Background Agent Status Summaries Cost: Attributing Model Calls by query_source

A gist that did the rounds on Hacker News at the end of October reports that Claude Code sends each actively working background agent’s transcript back to the model roughly every 30 seconds in interactive mode to produce a three-to-five-word status line, and that in the author’s own data those calls came to 16.4% of estimated spend and 19.7% of API requests over a week. It is one person’s measurement from their own OpenTelemetry export; the tested versions of the interception method span 2.1.257 to 2.1.312. The result needs verification before being applied to other versions or setups.

To estimate this overhead in a workflow, classify model requests by purpose and calculate request counts, tokens and estimated cost separately, then compare them with billing where available. Shares from different periods need comparable workloads before they can be interpreted as a change in cost. The synthetic log below demonstrates the arithmetic and the effect of changing the denominator.

Two earlier posts here, Estimating AI prototypes in two stages and Counting the cost of AI code review, are cited as discussions of total AI workflow cost; their contents could not be retrieved and need verification. This post splits the total by purpose.

1. What the public measurement says

The gist is titled “Claude Code burns ~15–20% of your tokens on a 3-word status line”, by flamerix-ai, tested on Linux with Claude Code 2.1.257 to 2.1.312. The behaviour described: about every 30 seconds, each actively working background agent in interactive mode is asked to “describe your most recent action in 3-5 words”, and each call sends that agent’s full transcript to the model the agent itself uses. The author describes the answer as only updating the status line in the agents panel; whether other workflows also consume it needs verification. The author says it has been on by default for actively working background agents in interactive mode since 2.1.232, that the interval is tunable from the server side within 30 to 240 seconds, and that the default is already the minimum.

The numbers are a before-and-after table, “the week before the hook” against “2 days after”:

  • Share of all Claude Code estimated spend: 16.4% before (the main conversation was 15.7%), 0% after.
  • Share of all API requests: 19.7% before, 0% after.
  • Input to output ratio on those calls about 9,000 to 1, with 96% of the spend on Opus.
  • Weekly share over time: 3.5% to 7% in July, 7.3% and then 11.8% after picking up 2.1.233, about 16% by mid-October.

The report has several limits. Spend is Claude Code’s own per-request estimate, the cost_usd field, not an invoice; the effect on subscription limits and actual spending depends on the plan. The author also reports finding these requests in the OpenTelemetry export but not in the on-disk session logs. The author found them by grouping claude_code.api_request events by query_source, at which point agent_summary turned out to cost more than the main conversation.

The gist proposes a Bun --preload script that wraps only the global setTimeout. If a callback’s source text contains an anchor such as “[AgentSummary] Timer fired”, it returns an inert timer with a delay of 2³¹−1 ms; every other timer passes through. The Claude Code binary is not modified. Installation is BUN_OPTIONS=--preload=<path> followed by claude daemon stop --any. The author lists the failure mode plainly: it fails open. Matching depends on source text, so a renamed log line, a moved function or a timer that no longer goes through the global setTimeout brings the summaries back silently, which is why the gist insists on a verification step. This is an unofficial interception method. The author reported no dedicated setting at the time; the current behaviour needs verification. The author also says CLAUDE_CODE_FORK_SUBAGENT=0 and CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1 stop it, at the price of losing fork subagents or background agents altogether.

The SDK path is handled separately in the gist. Through the SDK, summaries can be enabled with agentProgressSummaries: true or the CLAUDE_CODE_AGENT_PROGRESS_SUMMARIES environment variable, and they then arrive as task_progress stream-json events. If anything in your automation consumes that event, find out before switching summaries off. The author did not test ACP adapters; the current SDK behaviour also needs verification.

The gist points to GitHub issue #86879 as collecting four independent measurements in the 18% to 26% range. Opening the issue, the body describes one four-hour session on 2.1.233 on macOS, 27 subagents, 1,496 requests, with agent_summary at 22.7% of requests, 22.2% of raw tokens and 18.0% of tokens weighted by rate. A comment below it reports a 1 hour 45 minute interactive session on 2.1.276 on Linux, and its figure is “26% of the session’s cache-read tokens”. So the two ends of that range use different denominators, weighted tokens at one end and cache-read tokens at the other, with a request share in between, and different versions and session lengths as well. These measures cannot be combined into a general cost range.

The HN snapshot used here records 562 points and 227 comments, but its capture time needs verification. One commenter interprets the code as making a temporary fork of the conversation, allowing prompt-cache use, every 30 seconds. Another, on a 20x Max plan, estimated a share around 16% using the 30-second timer. These figures are user reports; whether they reconcile with billing needs verification.

2. Settle the unit of account first

The first decision when measuring this yourself is what counts as one unit. The sensible unit is one model request, one claude_code.api_request event, classified by what it was for. Tool executions and messages between agents do not map one-to-one onto requests, so mixing tool-call or message counts with request counts makes the units inconsistent and may double-count activity.

The second decision is to keep four quantities apart and never substitute one for another:

  • Request count measures frequency. A summary that fires every 30 seconds may account for many requests, depending on how often the rest of the workload calls the model.
  • Token count splits further into input_tokens, output_tokens, cache_read_tokens and cache_creation_tokens. If a summary sends the full transcript and largely hits the cache, its raw token share may be high while its cost share is lower. That depends on cache use and the applicable rates.
  • Estimated cost, the cost_usd field, is the tool’s estimate at model rates. Different models and cache hits or misses pull it away from the token count.
  • Actual spending depends on the payment model. Usage-based API charges and subscription limits cannot be inferred directly from the same estimated-cost share.

The issue numbers above are the worked example: 22.7% of requests, 22.2% of raw tokens, 18.0% of weighted tokens, reported for the same session, with different denominators.

Decide how failures and retries are counted as well. Whether a retry is one request or two, and whether api_error events are folded in, has to be fixed before grouping, or two measurements taken a week apart will not reconcile.

3. How to measure

The Claude Code monitoring documentation describes enabling OpenTelemetry with CLAUDE_CODE_ENABLE_TELEMETRY=1 and choosing exporters with OTEL_LOGS_EXPORTER and OTEL_METRICS_EXPORTER. Events are named claude_code.<event>; the relevant one here is api_request, whose documented attributes include model, cost_usd, cost_usd_micros, duration_ms, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, request_id, speed, effort and query_source, for which the documentation gives the examples "repl_main_thread", "compact" or a subagent name. There are aggregate metrics too: claude_code.token.usage carries a type attribute (input, output, cacheRead, cacheCreation) and model, and claude_code.cost.usage carries model; both also carry query_source, but the currently documented metric categories are too broad to identify summary calls directly. This post uses individual events for attribution; check the category granularity before using metrics.

The documentation defines input_tokens as excluding cache reads and cache creation. Adding all four fields gives the total input and output token count for the request; it includes output and should not be described as the amount sent to the model. Counting input_tokens alone omits cached input.

Without a query_source field, on an older version or a different tool, you can look for candidates by shape, a fixed interval and a prompt that keeps growing. That is a lead, nothing more. The gist author only became certain after grouping by query_source. A set of requests picked out by pattern has unknown contents, and any cost computed from it should be labelled as suspected. Requests you cannot classify go on a separate “unknown” line rather than being forced into a category.

What follows is a demonstration on synthetic data. The field names follow the documented api_request event, but the record format is simplified and the values and rates are made up. A real telemetry export cannot be fed directly into this example. The script produces seven days of records: a main thread active four hours a day at roughly one request a minute; zero to six background agents a day, each running an hour at roughly one request every 45 seconds; and for each agent one agent_summary row every 30 seconds with tiny input and output and a cache_read_tokens value that grows with the transcript. The seed is fixed at 42.

# Synthetic api_request records (field names follow the Claude Code OTel docs), written as JSONL
import json, random
random.seed(42)
RATE = {"in": 5.0, "out": 25.0, "cr": 0.5, "cc": 6.25}   # USD per 1M tokens, made-up rates, not list price
DAY = 86400
PLAN = [3, 1, 1, 0, 2, 6, 5]   # seven days; background agents started per day (1 hour each)

def req(t, source, inp, out, cr, cc):
    cost = (inp*RATE["in"] + out*RATE["out"] + cr*RATE["cr"] + cc*RATE["cc"]) / 1e6
    return {"event.name": "api_request", "event.timestamp": t, "model": "synthetic-model",
            "query_source": source, "input_tokens": inp, "output_tokens": out,
            "cache_read_tokens": cr, "cache_creation_tokens": cc, "cost_usd": round(cost, 6)}

rows = []
for day, n_agents in enumerate(PLAN):
    t0 = day * DAY + 9 * 3600
    for k in range(240):                                   # main thread: roughly every 60 s for 4 hours
        rows.append(req(t0 + k*60 + random.randint(0, 20), "repl_main_thread",
                        random.randint(500, 3000), random.randint(100, 800),
                        100000 + 300*k, random.randint(0, 4000)))
    for a in range(n_agents):
        ta = t0 + 600 + a * 300
        ctx = 20000
        for k in range(80):                                # the agent itself: roughly every 45 s for 1 hour
            ctx += random.randint(1500, 4000)
            rows.append(req(ta + k*45, f"agent-{a}", random.randint(300, 2000),
                            random.randint(100, 600), ctx, random.randint(0, 3000)))
        for k in range(120):                               # status summary: every 30 s, reads the whole transcript
            rows.append(req(ta + k*30 + 5, "agent_summary", 60, random.randint(5, 20),
                            15000 + k * 1000, 0))
rows.sort(key=lambda r: r["event.timestamp"])
with open("requests.jsonl", "w") as f:
    for r in rows:
        f.write(json.dumps(r) + "\n")
print(len(rows), "requests written")
5280 requests written

Next, group by query_source, folding the agents’ own requests (agent-0, agent-1 and so on) into one class, and sum request counts, token kinds and estimated cost. The last block prints the three shares side by side:

# Group by query_source: requests, token kinds, estimated cost, and three shares side by side
import json, collections
agg = collections.defaultdict(lambda: collections.Counter())
for line in open("requests.jsonl"):
    r = json.loads(line)
    src = "subagent" if r["query_source"].startswith("agent-") else r["query_source"]
    c = agg[src]
    c["requests"] += 1
    c["input"] += r["input_tokens"]; c["output"] += r["output_tokens"]
    c["cache_read"] += r["cache_read_tokens"]; c["cache_creation"] += r["cache_creation_tokens"]
    c["cost_usd"] += r["cost_usd"]
total = collections.Counter()
for c in agg.values():
    total.update(c)
total["tokens"] = total["input"] + total["output"] + total["cache_read"] + total["cache_creation"]
print(f"{'query_source':17}{'requests':>9}{'input':>10}{'output':>9}{'cache_read':>13}{'cost_usd':>10}")
for src, c in sorted(agg.items(), key=lambda kv: -kv[1]["cost_usd"]):
    print(f"{src:17}{c['requests']:9}{c['input']:10}{c['output']:9}{c['cache_read']:13}{c['cost_usd']:10.2f}")
print()
for src, c in agg.items():
    tokens = c["input"] + c["output"] + c["cache_read"] + c["cache_creation"]
    print(f"{src:17} requests {c['requests']/total['requests']:6.1%}   "
          f"tokens {tokens/total['tokens']:6.1%}   cost_usd {c['cost_usd']/total['cost_usd']:6.1%}")
query_source      requests     input   output   cache_read  cost_usd
repl_main_thread      1680   2973883   748500    228228000    168.62
subagent              1440   1629914   506539    188531520    129.02
agent_summary         2160    129600    26923    160920000     81.78

repl_main_thread  requests  31.8%   tokens  39.9%   cost_usd  44.4%
subagent          requests  27.3%   tokens  32.7%   cost_usd  34.0%
agent_summary     requests  40.9%   tokens  27.3%   cost_usd  21.6%

The same request class accounts for 40.9%, 27.3% and 21.6%, depending on the denominator. In this synthetic dataset, summaries are frequent but carry fewer tokens per call, mostly cache reads, so their cost share is lower still. The figures illustrate the calculation and do not predict the overhead in another workflow.

4. A share with a different denominator is not comparable

The gist compares a week with two days and reports a summary share of 0% after interception. That supports the observation that no summary calls were recorded in that period; it does not establish a 16.4% reduction in total spending. The comparability of the workloads, agent use and cache behaviour needs verification.

Here is the same synthetic log cut three ways:

# Same log, different measurement windows: the agent_summary share moves a lot
import json, collections
DAY = 86400
rows = [json.loads(l) for l in open("requests.jsonl")]

def share(lo_day, hi_day):
    win = [r for r in rows if lo_day*DAY <= r["event.timestamp"] < hi_day*DAY]
    cost = sum(r["cost_usd"] for r in win)
    summ = sum(r["cost_usd"] for r in win if r["query_source"] == "agent_summary")
    agent_hours = sum(1 for r in win if r["query_source"].startswith("agent-")) * 45 / 3600
    return cost, summ, agent_hours

for name, lo, hi in [("days 1-7", 0, 7), ("days 3-4", 2, 4), ("days 6-7", 5, 7)]:
    cost, summ, ah = share(lo, hi)
    per_day = cost / (hi - lo)
    per_ah = summ / ah if ah else float("nan")
    print(f"{name:9} total {cost:7.2f}  per day {per_day:6.2f}  summary share {summ/cost:6.1%}"
          f"  agent-hours {ah:5.1f}  summary cost per agent-hour {per_ah:5.2f}")
days 1-7  total  379.43  per day  54.20  summary share  21.6%  agent-hours  18.0  summary cost per agent-hour  4.54
days 3-4  total   59.55  per day  29.77  summary share   7.6%  agent-hours   1.0  summary cost per agent-hour  4.54
days 6-7  total  176.83  per day  88.41  summary share  28.3%  agent-hours  11.0  summary cost per agent-hour  4.54

In this synthetic log, each agent produces a summary every 30 seconds. Yet days 3 to 4 give a share of 7.6%, days 6 to 7 give 28.3%, and the week gives 21.6%, because the amount of agent work varies. Even if summaries were disabled on day 3, comparing total spending on days 6 to 7 with the earlier period would not isolate the effect: the workloads were different already.

The right-hand column divides summary cost by background agent running hours. It is about 4.54 in each window because every synthetic agent follows the same summary frequency and token model. Whether that rate is useful in a real workflow needs verification; someone else’s 16.4% is no substitute for measuring it. Record concurrent agents and their running times, distinguish those from the total number created, and check when the tool’s timers actually run.

5. What to compare before switching it off

Even with a measurement in hand, a few things come before the decision:

  • Run several times under fixed conditions. Same task, same model, same tool version, a few rounds each way. A single rerun is dominated by prompt cache hit rate and differences in execution path.
  • Record completion quality and wall-clock time alongside cost. Whether summaries serve only the status display needs verification. They may leave the result unchanged if no other workflow depends on them, but the gist’s method alters a timer and its side effects need checking.
  • Find every consumer of the status first. The terminal panel is one; the SDK’s task_progress event is another, and an automation that depends on it may fail when the summaries stop.
  • Do not infer that a whole file was sent because an agent read it. A summary request sends the transcript; start by checking the api_request_body event with OTEL_LOG_RAW_API_BODIES enabled, as the issue author reports doing. The export can itself be truncated or redacted, so it cannot always establish the full payload.

The destination also needs checking against the actual configuration. The public report describes summaries going to the model API used by the agent. Even if the provider is unchanged, that alone does not settle the data-handling implications; the transcript sent, any truncation and the receiving endpoint still need verification.

Notes

  • The public measurement is by a single author whose interception tests cover versions 2.1.257 to 2.1.312: agent_summary at 16.4% of estimated cost and 19.7% of requests, with cost being the tool’s own cost_usd estimate rather than a bill.
  • The 18% to 26% in the GitHub issue are a weighted-token share and a cache-read-token share from different versions and session lengths, not one quantity.
  • Attribute by claude_code.api_request event and query_source; keep request count, tokens, estimated cost and the bill as four separate figures.
  • In the synthetic data the same request class shows 40.9%, 27.3% and 21.6% for request, token and cost share, because it fires often and its tokens are mostly cheap cache reads.
  • Changing the measurement window moves the share from 7.6% to 28.3%; summary cost per agent-hour is approximately fixed in this synthetic model, and its use elsewhere needs verification.
  • Before switching it off, rerun under fixed conditions, record quality and time, and check whether anything consumes the SDK’s task_progress events.

Further reading

Ideas and technical judgement by Sheng; drafted with Claude · topic chosen by Sheng; text, case notes and examples written by Claude, run on Linux with Python 3.13.18.