●WORKFLOWS — Dynamic workflows are GA in Claude Code: Claude plans the work, runs hundreds of parallel subagents in one session, and verifies outputs before reporting back●SCALE — Paired with Opus 4.8, Claude Code can carry codebase-scale migrations across hundreds of thousands of lines from kickoff to merge●ULTRACODE — The ultracode setting, via the effort menu, sets effort to xhigh and lets Claude decide automatically when to use a workflow●EFFORT — claude.ai adds an effort control beside the model selector; pick extra (xhigh in Code) or max for harder, long-running tasks●WFSIZE — A Dynamic workflow size setting in /config controls how large Claude makes workflows (small, medium, or large agent counts)●OPUS48 — Claude Opus 4.8 remains generally available, improving on 4.7 across coding, agentic skills, reasoning, and knowledge work●WORKFLOWS — Dynamic workflows are GA in Claude Code: Claude plans the work, runs hundreds of parallel subagents in one session, and verifies outputs before reporting back●SCALE — Paired with Opus 4.8, Claude Code can carry codebase-scale migrations across hundreds of thousands of lines from kickoff to merge●ULTRACODE — The ultracode setting, via the effort menu, sets effort to xhigh and lets Claude decide automatically when to use a workflow●EFFORT — claude.ai adds an effort control beside the model selector; pick extra (xhigh in Code) or max for harder, long-running tasks●WFSIZE — A Dynamic workflow size setting in /config controls how large Claude makes workflows (small, medium, or large agent counts)●OPUS48 — Claude Opus 4.8 remains generally available, improving on 4.7 across coding, agentic skills, reasoning, and knowledge work
Choosing Dynamic Workflow Size and Effort From a Ledger, Not a Hunch
Dynamic Workflows went generally available, handing you the workflow size and effort dials. After a week of picking large and watching only the bill grow, here is how to turn Claude Code's OpenTelemetry console output into a ledger and assign size and effort per task type.
The moment I noticed a new line in /config labelled Dynamic workflow size, I set it to large. If the dial exists, bigger must be better — that was the whole of my reasoning.
A week later, the only thing that had grown was the usage graph in Console. The nightly research runs behind my four technical blogs were not visibly better than they had been on small. The spend kept stacking up anyway, and I could not have told you what had increased and what had not.
That is what it means for a dial to land in your hands. Decisions the default used to make quietly become yours. What follows is how I pried that decision loose from my gut and put it on a ledger.
The Two Dials You Just Inherited
Dynamic Workflows reached general availability on 2026-07-15, across the CLI, Desktop, the VS Code extension, and via the API, Bedrock, Vertex AI, and Foundry. Claude plans the work, runs many parallel subagents inside one session, and verifies its own output before reporting back. That shape is unchanged from the research preview.
What changed is that two things are now yours to hold.
Dial
Where
Direction it pushes
Dynamic workflow size
/config (small / medium / large)
Rough subagent count. Width of the search
effort
The effort menu (up to xhigh in Claude Code)
Tokens spent per judgement. Depth
Turning on the ultracode setting raises effort to xhigh and also lets Claude decide whether to reach for a workflow at all. Convenient — but raise both dials at once and you lose the ability to say which one the extra spend came from. That is precisely how I wasted a week.
Staring at a week of logs, a shape finally emerged. Adding parallel subagents buys you width of search: gathering candidates broadly, verifying from several independent angles, closing gaps. For that class of work, headcount converts directly into quality.
When the search is narrow to begin with, extra heads all walk to the same place. My article research touches four or five sources. Setting large on that task simply produced more subagents arriving at the same conclusion.
Anthropic reports that Opus 4.8 misses defects in its own code at roughly a quarter of the previous generation's rate, and has published a case of a 750,000-line migration completed in 11 days with a 99.8% test pass rate. That is exactly where large earns its keep: the changes are scattered across tens of thousands of files, so the width of the search is the job. My four blogs simply do not have a search surface that big.
So the real question was never "is bigger better." It was "how wide is this task's search surface" — and that is measurable.
✦
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
✦See exactly what pinning workflow size to large does and does not buy you, split across tokens, wall time, and a pass/fail column
✦Turn CLAUDE_CODE_ENABLE_TELEMETRY console output into a per-run cost and token ledger in under 100 lines
✦Get a sweep harness that runs one task across size x effort, plus the assignment table I derived from its output
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.
Claude Code exports metrics over OpenTelemetry. That sounds like standing up a monitoring stack, but if all you want is evidence for a decision, the console exporter is enough. I first tried to bring up Prometheus and burned half a day on setup alone. I would not recommend it now.
# For ~/.claude/settings.json, put these under the env block.# For a single trial session, the shell is plenty.export CLAUDE_CODE_ENABLE_TELEMETRY=1export OTEL_METRICS_EXPORTER=consoleexport OTEL_LOGS_EXPORTER=consoleexport OTEL_METRIC_EXPORT_INTERVAL=10000 # default is 60s; short runs get lost# Capture each run to its own file (it interleaves with stdout otherwise)mkdir -p ~/.wf-ledger/rawclaude -p "$(cat ~/.wf-ledger/task.md)" 2> ~/.wf-ledger/raw/$(date +%s).otel.jsonl
Leave OTEL_METRIC_EXPORT_INTERVAL at its default and any run shorter than 60 seconds finishes before the flush, leaving you with zero metrics. My first three attempts produced empty files for exactly this reason. Drop it to 10 seconds and nothing slips through.
The two metrics worth collecting are claude_code.token.usage, which carries a type attribute of input / output / cacheRead / cacheCreation, and claude_code.cost.usage. Note the word estimate on that second one. It is a number for comparing conditions against each other, not for reconciling against an invoice.
A Sweep Harness That Runs One Task Everywhere
Change one variable at a time and repeat the same task. It is unglamorous, but without it you never escape "large feels better."
#!/usr/bin/env bash# ~/.wf-ledger/sweep.sh — sweep one task across size x effortset -euo pipefailTASK_FILE="${1:?usage: sweep.sh <task.md> [repeats]}"REPEATS="${2:-3}"LEDGER="$HOME/.wf-ledger"mkdir -p "$LEDGER/raw"export CLAUDE_CODE_ENABLE_TELEMETRY=1export OTEL_METRICS_EXPORTER=consoleexport OTEL_METRIC_EXPORT_INTERVAL=10000for size in small medium large; do for effort in high xhigh; do for i in $(seq 1 "$REPEATS"); do run_id="${size}-${effort}-${i}-$(date +%s)" start=$(date +%s%3N) # Write size into settings before launching; the value matches /config. python3 - "$size" <<'PY'import json, pathlib, sysp = pathlib.Path.home() / ".claude" / "settings.json"cfg = json.loads(p.read_text()) if p.exists() else {}cfg["dynamicWorkflowSize"] = sys.argv[1]p.write_text(json.dumps(cfg, indent=2))PY claude -p "$(cat "$TASK_FILE")" \ --output-format json \ > "$LEDGER/raw/${run_id}.result.json" \ 2> "$LEDGER/raw/${run_id}.otel.jsonl" || echo "run failed: $run_id" >&2 end=$(date +%s%3N) echo "{\"run_id\":\"${run_id}\",\"size\":\"${size}\",\"effort\":\"${effort}\",\"elapsed_ms\":$((end - start))}" \ >> "$LEDGER/runs.jsonl" echo "done ${run_id} ($(( (end - start) / 1000 ))s)" >&2 done donedone
Three repeats, because one tells you nothing. Identical conditions still wobble: across three runs of the same setting I saw output tokens vary by as much as 18%. Back when I judged from a single run, I was reading that wobble as a difference between conditions.
Settings key names can differ between versions. Set the value once through /config, open ~/.claude/settings.json, and copy whatever key actually appears — that is safer than trusting my spelling of it.
It Only Becomes Comparable Once It Is Folded
Raw OTel output is unreadable as-is. Fold it into three columns: tokens, estimated cost, wall time.
#!/usr/bin/env node// ~/.wf-ledger/ledger.mjs — fold raw/*.otel.jsonl into a per-condition ledgerimport { readFileSync, readdirSync } from "node:fs";import { homedir } from "node:os";import { join } from "node:path";const DIR = join(homedir(), ".wf-ledger");const RAW = join(DIR, "raw");// The console exporter spreads one metric across several lines,// so scan by block rather than by line.function extractMetrics(text) { const out = []; const re = /"name"\s*:\s*"(claude_code\.[a-z._]+)"[\s\S]*?"value"\s*:\s*([\d.]+)([\s\S]*?"attributes"\s*:\s*\{([\s\S]*?)\})?/g; let m; while ((m = re.exec(text)) !== null) { out.push({ name: m[1], value: Number(m[2]), attrs: m[4] ?? "" }); } return out;}const runs = readFileSync(join(DIR, "runs.jsonl"), "utf8") .trim().split("\n").map((l) => JSON.parse(l));const rows = runs.map((run) => { const file = readdirSync(RAW).find((f) => f.startsWith(run.run_id) && f.endsWith(".otel.jsonl")); if (!file) return { ...run, tokens: null, cost: null, note: "otel missing" }; const text = readFileSync(join(RAW, file), "utf8"); const metrics = extractMetrics(text); const tokens = metrics .filter((x) => x.name === "claude_code.token.usage") .filter((x) => !/cacheRead/.test(x.attrs)) // cache reads are priced differently .reduce((s, x) => s + x.value, 0); const cost = metrics .filter((x) => x.name === "claude_code.cost.usage") .reduce((s, x) => s + x.value, 0); const result = JSON.parse(readFileSync(join(RAW, `${run.run_id}.result.json`), "utf8")); return { ...run, tokens, cost: Number(cost.toFixed(4)), passed: gradeOutput(result) };});// Grade against your own acceptance criteria. Replace this wholesale.function gradeOutput(result) { const text = typeof result === "string" ? result : (result.result ?? JSON.stringify(result)); const hasAllSections = ["## Context", "## Verification", "## Conclusion"].every((h) => text.includes(h)); const hasCitations = (text.match(/https?:\/\//g) ?? []).length >= 3; return hasAllSections && hasCitations;}const grouped = {};for (const r of rows) { const key = `${r.size}/${r.effort}`; (grouped[key] ??= []).push(r);}const avg = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length;console.table( Object.entries(grouped).map(([key, rs]) => ({ condition: key, runs: rs.length, tokens_avg: Math.round(avg(rs.map((r) => r.tokens ?? 0))), cost_avg: Number(avg(rs.map((r) => r.cost ?? 0)).toFixed(4)), elapsed_s: Math.round(avg(rs.map((r) => r.elapsed_ms)) / 1000), pass_rate: `${Math.round((rs.filter((r) => r.passed).length / rs.length) * 100)}%`, })),);
gradeOutput is the load-bearing part. Without a pass column this ledger is just a cost table — and since cheaper always wins a cost table, your decision collapses onto "the cheapest condition." Decide your acceptance criteria first, then express them in a form a machine can check. That is where the time is well spent.
Mine were crude: does the research output carry all three headings, and does it cite at least three source URLs. Crude, but a crude column still changes the decision.
The Table That Came Out, and What I Changed
Here is the sweep for one task — article research for Dolice Labs. Three-run averages, from my environment. These are not numbers to carry home; the procedure is.
Condition
Avg output tokens
Avg wall time
Pass rate
small / high
~41,000
3m12s
67%
small / xhigh
~68,000
5m01s
100%
medium / high
~96,000
4m48s
67%
medium / xhigh
~158,000
7m33s
100%
large / xhigh
~402,000
14m20s
100%
What moved the pass rate on this task was effort, not size. Leaving size at small and raising effort to xhigh reached 100%, at roughly one sixth the output tokens of large / xhigh. For a week, what I had been buying with large was a crowd of subagents all headed to the same destination.
A cross-site dead-link audit across all four blogs inverted the result. small misses links; medium and above hold steady. The search surface is genuinely wide there.
High cost of being wrong (production config proposals)
medium
xhigh
Width to catch misses, depth to justify
Repetitive, well-defined work
small
high
Consider skipping the workflow entirely
I keep this table in ~/.wf-ledger/policy.md and have my unattended runs read it. When in doubt I recommend starting at small / xhigh: insufficient width shows up in the pass rate, whereas insufficient depth quietly erodes quality without ever failing a check. I have not stopped letting ultracode make the call — I just prefer to hold my own answer before I delegate the question.
Where the Reading Goes Wrong
Even with a ledger in hand I misread it several times. Four of them, in the order I hit them.
Summing cache-read tokens
claude_code.token.usage with a cacheRead type is priced differently, so a naive sum inflates what size increases appear to cost. Split by the type attribute — that is why the script above filters cacheRead out.
Treating the estimate as billing
The docs call claude_code.cost.usage an estimate. Fine for comparing conditions, useless for monthly reconciliation. Console usage remains the source of truth, and confusing the two will quietly skew your production budgeting.
Judging on wall time alone
large stretches both time and tokens, but in unattended runs time is not necessarily cost. Nobody is waiting on my nightly jobs, so 14 minutes is fine; the spend is what hurts. In an interactive session the calculus flips. The same table yields different conclusions depending on when the run happens.
Relaxing the threshold after the fact
The nastiest one. When the cheap condition fails, the temptation to soften gradeOutput arrives immediately. The workaround is blunt: set the bar before the sweep and do not touch it once results are in. I moved my thresholds into a separate file and keep it read-only for the duration of a sweep. Obvious in principle; surprisingly slippery once your own wallet is involved.
Spend Thirty Minutes Measuring First
I am glad the dials exist. But what arrived is a choice, not an answer — the default used to absorb this decision, and now it sits with you.
The work is small. Pick one representative task, set OTEL_METRICS_EXPORTER=console, sweep size and effort, and add one pass column. That alone makes you a better decision-maker than I was during my week of vaguely choosing large. Thirty minutes, start to finish.
As an indie developer I never once questioned "bigger is better" until I measured it. If this spares even one person the same detour, it was worth writing.
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.