Quick answer
Launch your subagent once. Log input + cache_creation + output + finish_reason. Repeat the exact same call. If the second run doesn't reuse cache, the entrance is the bug — not the prompt.
Only after that baseline exists should you start bounding turns and dollars per profile behind a single wrapper.
TL;DR: I audited a week of sdk-cli subagent launches. Most launches reused no cache. Several baselines burned tens of thousands of input + cache_creation tokens before the agent had touched a file. I stopped trying to claim a reduction rate from single-shot numbers and made the entrance one wrapper with two profiles, two mandatory stop conditions (--max-turns, --max-budget-usd), and a pre-execution ledger — because none of the single-shot deltas I had were controlled A/B measurements.
What actually happened
I was touching subagents and hit a scene I did not expect: a single short request could show tens of thousands of tokens in input + cache_creation. When I audited about a week of launches, most of them showed no cache reuse.
The first thing you want to do at that point is to publish a reduction rate. Line up numbers from before and after wrapping, subtract, say "N% saved." That reduction rate does not hold. The conditions on either side of the subtraction are different: the model, the size of the request, the tools loaded, the hook state. A subtraction across single-shot numbers is neither the wrapper's effect nor the design's effect.
So this article is not about reduction. It is about audit over reduction — making the entrance the kind of thing where "why was this one call heavy?" can be answered later.
Written in collaboration with AI. Fresh execution logs at the time of the audit were the primary source; official docs were checked for the CLI behavior. Observed facts, interpretation, and unproven claims are kept separate. Final responsibility is mine.
Author
A subagent, here, is a worker launched by a main AI to delegate part of the job. Combining several of them lets you split work between roles — investigation and implementation, drafting and review. What I looked at was: for one launch, what gets loaded, where the caps sit, what actually gets recorded.
Bottom line up front. The wrapper (here, claude-worker.sh) is a guardrail that pins down profile, turn cap, budget cap, and the sanctioned launch path. The goal is to make one launch's conditions recoverable after the fact, not to save money on that launch. If you want to talk about reduction, you need a separate A/B measurement holding model, request, tools, and hooks fixed.
Why a short request still costs a lot at launch
A short request still ships a lot of context to be interpreted at launch: system instructions, project rules, hooks, any extra context the entrance loaded. A small body does not imply a small total.
The awkward part is that the visible reply is short, so the fixed cost is invisible from the user side. "Does this design look OK?" comes back as a few paragraphs and feels like a lightweight consultation. Internally, the worker read a large chunk of input and rebuilt an unreusable cache before writing a single sentence.
For Claude Code specifically, the official context-window docs note that the system prompt alone is roughly 4,200 tokens, and CLAUDE.md, skill descriptions, and MCP tool names load into context at the start of every session. Thousands to tens of thousands of tokens are already spent before you type your first line.
Kitchen metaphor: you asked for one cup of tea, and the kitchen was rebuilt for that cup. Halving the tea does very little to the cost of building the kitchen. Trimming a few lines of prompt is not meaningless, but if you skip the fixed launch cost first, you are aiming at the wrong thing.
There is no single reason cache reuse fails, either. Launch path, loaded config, environment variables, appended context, concurrent runs — all candidates. Because nothing in my week of data changed only one variable at a time, the low reuse rate I saw does not by itself identify the cause.
What you actually need to record to see the launch cost
The prompt text is not enough. To make a per-launch story recoverable you need to tie together, in the same execution:
- input tokens
- cache_creation tokens
- output tokens
- model
- tools
- working directory
- hook conditions active during the run
- finish_reason (stop / max_turns / error / auth failure / …)
- whether the request actually completed
The current ledger does not go that far — more on that below.
Splitting consult and impl profiles is a real design choice: they need different tools and different turn budgets. But comparing single-shot numbers across different jobs does not give you the profile's normal cost, and it does not give you a reduction rate.
Split "cheap" from "cheap-but-finished." A cheaper call that exits asking for missing info costs the fixed startup cost again on the retry.
Split "cheap" from "cheap-but-authenticated." I once added --bare to the impl profile to shave startup and got back Not logged in. Removing --bare fixed it. If I had judged by token count alone I would have kept it and quietly converted every impl launch into a retry loop.
The comparison unit is not "the cheapest one call." It is "one call that completed the same request" — same job, same model, same tools, same limits, successes and failures included. Only then does an entrance-level delta make sense.
Why I banned raw claude -p
Before this change, callers could pick their own flags each time they invoked claude -p. -p is print mode — non-interactive, prints output, exits. Great for automation. It also leaves discretion at every call site.
Discretion is convenient and unauditable. One day a caller sets a turn limit, the next day forgets. Some calls specify a budget, others don't. Some log, some don't. Later, when one launch is 3× everyone else, you cannot tell why this one was different.
So I took claude -p out of the normal path and routed everything through claude-worker.sh. A wrapper is a thin entrance around the real command that inserts shared configuration and recording.
How the wrapper is shaped — profiles and overrides
claude-worker.sh reads the profile's defaults and always appends --max-turns and --max-budget-usd to the real command. Concrete values live in the profile; I don't publish specific numbers because they should come from your own measurements.
claude-worker.sh consult "check my design concern"
claude-worker.sh impl "edit target and run tests"
The skeleton, as pseudocode (design intent, not the implementation):
# 1. Resolve profile (unknown name → stop)
case "$PROFILE" in
consult|impl|probe) ;;
*) echo "unknown profile" >&2; exit 2 ;;
esac
# 2. Accept only allow-listed overrides (unknown flags do not pass through)
while (($#)); do
case "$1" in
--model|--max-turns|--max-budget-usd|--cwd|--effort|--resume|--dry-run) ... ;;
*) echo "flag not allowed: $1" >&2; exit 2 ;;
esac
done
# 3. Always attach stop conditions (values overridable, existence not)
RESOLVED_MAX_TURNS="${OPT_MAX_TURNS:-$PROFILE_DEFAULT_MAX_TURNS}"
RESOLVED_MAX_BUDGET="${OPT_MAX_BUDGET:-$PROFILE_DEFAULT_MAX_BUDGET}"
# 4. Record pre-execution conditions (no result data)
append_ledger_pre "$PROFILE" "$RESOLVED_MODEL" "$RESOLVED_MAX_TURNS" "$CWD" "$PROMPT_HEAD" "$FRESH_ENV"
# 5. exec the real binary (exit code is transparent)
exec claude -p \
--model "$RESOLVED_MODEL" \
--max-turns "$RESOLVED_MAX_TURNS" \
--max-budget-usd "$RESOLVED_MAX_BUDGET" \
"$PROMPT"
The values of the two stop conditions can be tuned. Their existence on the command line cannot be removed. "Callers only pick a job type" would be inaccurate — the accurate phrasing is "the profile carries defaults; only allow-listed keys can be overridden."
consult and impl are not moods. They bundle the tools, turn cap, budget cap, and expected output shape for a job type. The caller picks the profile; the wrapper permits overrides only on allow-listed keys.
The guardrail is enforced by the mechanism, not the doc
The important part is that "please always use the wrapper" is not left as a guideline. In automation, when the rule and the physical path disagree, the physical path wins. Old examples get re-read (by another AI even) and the raw call pattern comes right back.
To make the single path real, raw claude -p calls stay blocked and the wrapper is registered as the sanctioned channel. This is guardrail work: the shape of the entrance is enforced by the mechanism, not by a document.
Once the entrance is one place, the place you change is also one place. Cache-reuse settings, a new profile, an added ledger field — you no longer hunt every call site.
Exit codes are transparent because the wrapper execs the real binary. Limit hits and auth failures do not get rewritten into success. Two environment variables mark the entrance so downstream hooks can identify it:
-
CLAUDE_CODE_ENTRYPOINT=sdk-cli— observed on headless runs; identifies the CLI/SDK-driven entry, not my wrapper specifically -
CLAUDE_WORKER=1— set by the wrapper so hooks can branch on "was this launched by my worker specifically"
Adding one env var sounds trivial, but it is what lets hooks quiet down only on the worker path without changing normal interactive behavior. Naming the entrance is not enough — downstream has to be able to identify it too.
One small warning on passing the prompt: if you rebuild the argv string in shell, quotes and newlines in the prompt can split into separate arguments. The wrapper's job is not to rewrite the prompt but to hand it safely to the real binary. Pass via arg, stdin, or a temp file — pick one and stick with it. Mixing methods per caller ruins your measurement conditions.
Why you need both --max-turns and --max-budget-usd
--max-turns caps the number of tool-using turns. The Claude Code CLI reference documents it as a print-mode (-p) flag with no default limit. If the cap is hit, the run exits with an error rather than pretending to succeed.
"No default limit" is not a claim that runs go forever. Normal jobs finish. But if you never say "this kind of consultation may take at most N turns," you have delegated the stop condition entirely to model judgment. When investigation branches or errors chain, a consultation-scoped call can grow to implementation scale before you notice.
--max-budget-usd is a spend cap on the API calls the execution may make. The reference documents it as another print-mode stop condition. This article deliberately avoids per-model pricing and JPY conversion — plans and contracts vary.
Turn cap alone is insufficient. If a single turn drags in a large input, few turns can still be heavy. Budget cap alone is insufficient. A run can stay under budget while dragging out too many turns for a "consult." Turn count and total spend are different axes. You need both.
Not with a single value everywhere. Consult and impl need different completion budgets. A consult cap on impl stops before verification. An impl cap on consult wastes headroom on a job that should have ended short. The caps live on the profile, not as one hard-coded value in the wrapper.
flowchart TD
A[caller] --> B[claude-worker.sh]
B --> C[resolve profile<br/>consult / impl / probe]
C --> D[allow-listed overrides<br/>model / cwd / effort ...]
D --> E["always attach stop conditions<br/>--max-turns / --max-budget-usd"]
E --> G[[pre-execution ledger<br/>7 fields per row]]
G --> H(((exec claude -p)))
H --> I[exit code<br/>transparent to caller]
How to read the diagram: the stop conditions are added at one entrance instead of being appended by every caller. The ledger (double outline) records only the pre-execution conditions; results (tokens, cost, exit code) do not go here. The separation is discussed in the next section.
Limits are not a punishment for distrusting the model. They are the boundary both sides agree on for "one call." With the boundary, small jobs stay small and large jobs get treated as impl from the start.
The current ledger records conditions, not usage
The fourth piece is the ledger. ~/.claude/logs/claude-worker-ledger.jsonl, one JSON object per line, appended per launch.
The current implementation stores only pre-execution fields — seven of them: timestamp, profile, model, max_turns, working directory, first 100 chars of the prompt, and a fresh_env flag. Token counts, cost, exit code, finish_reason, success/failure, response body — none of that is stored. The budget cap is not in the row either.
So the ledger cannot rank single-shot calls or produce a reduction rate. What it can tell you is: what conditions did this launch try to start with. Whether the execution finished and how much it consumed have to be joined from another source.
The next improvement is a paired result record. At minimum: execution ID, actual input / cache_creation / output tokens, cost, exit code, finish_reason, and a completion judgment. With those joined on execution ID, "was this run cheap because it succeeded, or cheap because it failed?" becomes answerable from the ledger.
The goal is not "store everything." Full prompts can contain secrets and PII. Scope the result record to fields that answer "why was this one expensive?" and "did the job complete?" — not raw payloads.
Monthly totals help with budget forecasting but hide per-run condition differences and failure reasons. A pre-execution-only ledger tells you what conditions were tried but not what happened. Different jobs.
Making the launch cheaper by breaking it
When you are trying to lower startup cost, "just load less at boot" sounds obvious. Sometimes it is right. Not always.
The --bare experiment is the counter-example. On paper, a cheaper launch. In practice, Not logged in. The public record only preserves the before-and-after; I do not claim to know which auth step got skipped.
By number, an execution that stops before authenticating looks cheap. But it did not finish anything. Whoever investigates, retries, and tries a different route pays the fixed startup cost again. Manufacturing cheap failures is more expensive across the run than doing one properly initialized launch.
Hooks are the same trap. Hooks run small pieces of code on lifecycle events like startup and shutdown. In headless mode you can often skip interactive-flow hooks. But those hooks are usually shared with the interactive session; blanket-disabling them changes normal work too.
For that reason the change identifies the entrance and branches, instead of turning everything off. The wrapper's guard was narrowed for worker launches and several older hook files were removed. Post-change verification: the hook test suite passed and a live headless call through the wrapper succeeded. Those verifications prove the wrapper and the guard work — they do not prove any reduction rate. Feature-health-check and same-conditions-effect-measurement are different tests.
Checklist: measure your own environment first
Do not copy someone else's wrapper. Measure your own baseline first — one time — then decide.
Pick one small consultation you would normally hand to a subagent. Not a benchmark string; the kind of thing you actually use. No file edits, no long research yet. You want the launch fixed cost, not the work.
For that one call, log input / cache_creation / output / finish_reason. If your platform exposes these values, use them. If not, figure out how to get them before you start optimizing. Do not estimate tokens from the reply's character count. Do not port other people's numbers into your environment's estimate.
Run the exact same call again through the exact same entrance — same cwd, same loaded config, same appended context, same profile. Compare cache reuse. If the second run does not reuse cache, chase what is varying at the entrance.
Only now decide a consult profile: the tools you actually need, plus --max-turns and --max-budget-usd set from your real numbers — not somebody else's.
Decide an impl profile the same way, separately. Impl reads, edits, tests; it will be larger. That is expected. Don't widen consult; make a different profile.
Finally, close the two profiles inside a wrapper and block calls outside the sanctioned path. In this order, you measure current work first, then draw the boundary — instead of drawing the boundary first and breaking the work.
Short checklist to keep with you:
- Pick one small everyday consultation.
- Measure input, cache_creation, output, finish_reason for real.
- Run it again through the same entrance and compare reuse.
- Split consult and impl and give each its own limits.
- Repeat under identical model / prompt / tools / cwd / hook conditions.
- Split cold cache vs warm cache, and compare only completed runs.
- Store usage / cost / finish_reason / completion in a result record, separate from the pre-execution row.
Order matters
If you decide caps before you measure, you will only see results shaped by your caps. If you build a wrapper before you measure, you lose the baseline you were going to compare against. The first pass does not need to be pretty. It just needs to preserve the current raw entrance long enough to compare later.
Don't declare success on one favorable delta. A consult can finish fast by luck. Cache can hit by luck. Build the state where you can repeat the measurement, then look at the normal range. The pre-change baseline is a starting point, not a permanent benchmark. When jobs or configs change, the normal range moves too.
When you keep a comparison snapshot, you don't have to publish the entire config file. A name that lets you re-derive the same conditions, plus usage and finish_reason, is enough to serve as a starting point. Don't throw away the whole record just to redact — separate the identifying info you need from the payload you shouldn't keep. Put that boundary in the wrapper too, so recording style doesn't drift per call site.
FAQ
Did the wrapper actually reduce token cost?
Not something I can claim from this data. Model, request, tools, and hook conditions all moved between the baseline launches and the follow-ups. A same-conditions repeated A/B measurement has not been run. The wrapper's proven value so far is auditability of the entrance (one call path, always-appended caps, one pre-execution row), not a reduction rate.
Why not drop --max-turns / --max-budget-usd if the job usually finishes early?
Because "usually finishes early" is not the same as "always finishes early." Without the caps, an unexpectedly branching consult (chained errors, expanded scope) can grow to impl-scale spend before you notice. The caps make the boundary explicit for both sides, not only the model.
Why both CLAUDE_CODE_ENTRYPOINT and CLAUDE_WORKER?
CLAUDE_CODE_ENTRYPOINT=sdk-cli is what I observed on headless runs — it marks the CLI/SDK-driven entry, not specifically my wrapper. CLAUDE_WORKER=1 is one I set in the wrapper so downstream hooks can branch on "was this launched by my worker specifically." Two signals, two scopes.
Can I get the wrapper?
Not as a portable script. The current one is tied to my profile names, allow-list, and ledger path. What is worth copying is the shape, not the code: one entrance, an allow-list of overrides, mandatory --max-turns + --max-budget-usd, one pre-execution ledger row per call, and an explicit block on raw claude -p at the caller layer.
Does this apply outside Claude Code?
The specific flags and env-var names in this article are Claude Code's. The shape — audit-first entrance, allow-listed overrides, mandatory stop conditions per profile, separate pre-execution and result records — is not tied to a specific vendor.
Takeaway: this was an audit problem, not a savings problem
Subagents are valuable because they let you delegate without watching every call. That value evaporates the moment you have to pick flags at each launch, hunt logs when something looks off, and hope you did not forget a limit.
The four pieces the change kept:
- Profiles that separate consult from impl. Job type is decided ahead of time as a bundle of settings, not improvised per call.
-
--max-turns. A per-job-type completion boundary on a mode that ships with no default limit. -
--max-budget-usd. A per-execution spend boundary that works even when turn count does not correlate with cost. - A pre-execution ledger. One row per launch, so the entrance conditions are auditable. A result record (tokens, exit code, completion) is the next thing to add.
All four routed through one wrapper (claude-worker.sh). The takeaway is not a number. It is a procedure: fix your comparison conditions and your completion conditions first, then look at the entrance.
If your AI-worker consumption feels off, don't start with "which prompt do I trim." Start with: one call, log input + cache_creation + output + finish_reason + completion. Then repeat under identical conditions. Then split cold cache vs warm cache. Only then have you earned the right to change the entrance.
Top comments (0)