Claude Code workflows log when every subagent starts and stops. By timing each stage from those transcripts, an agent can estimate your next piece of work in hours instead of imaginary engineering weeks.
Not long ago, I asked Claude how long a new feature plan would take. It answered “2 to 3 engineering weeks.” Anyone who builds with coding agents knows that calibration is broken. Even our largest specs take agents 9 to 22 hours of working time. Once I pointed Claude at the logs from our own past runs, it started answering in hours, broken down by stage:
| Stage of work | Estimate |
|---|---|
| Agents build the change and integrate their work | 7 hours |
| Agents verify the build and fix what they find | 5 to 6 hours |
| Release to production | 45 minutes |
I call each backlog item a phase. To produce an estimate like this, Claude compares the new phase’s spec against completed phases of similar scope, then adds the median duration of our CI release pipeline. Over the last few weeks, its estimates have landed within about 10% of actual working time.
I run every phase as a Claude Code workflow, a script that spawns subagents and coordinates work between them. To time and estimate work this way, you only need three things:
- Claude Code with workflows enabled.
- A timing script. I wrote
phase-timings.py(220 lines of standard-library Python) for this post. - A consistent pipeline across runs. Estimation works by comparing new specs against historical runs, so your workflows need to run the same stages from one task to the next.
All of the data in this post comes from two products I built almost entirely with Claude Code workflows:
| Project | What it does | Workflow runs | Agents spawned |
|---|---|---|---|
| RunStory | Hosted service that runs the software in a commit and returns a verification verdict to the agent that wrote it | 462 | 3,409 |
| Tortie | macOS app that organizes all of my projects and coding-agent sessions in a single window | 600 | 4,300 |
What a Claude Code workflow is
In Claude Code, a dynamic workflow is a JavaScript file executed by the built-in Workflow tool. Rather than relying on a single long chat session to remember every step, the script spawns isolated subagents, passes prompts and structured outputs between them, and uses ordinary JavaScript control flow for loops, branches, and retries. You can also pass a JSON Schema to any agent call so its response is validated before the next step runs.
You don’t write workflow scripts by hand; Claude generates them from your prompt. Most of my workflows follow this basic build-verify-commit shape:
export const meta = {
name: 'small-fix',
description: 'One builder, one verifier, one commit',
phases: [{ title: 'Build' }, { title: 'Verify' }, { title: 'Commit' }],
}
const VERDICT = {
type: 'object',
properties: { verdict: { type: 'string', enum: ['pass', 'needs_work'] }, problems: { type: 'array', items: { type: 'string' } } },
required: ['verdict', 'problems'],
}
phase('Build')
const build = await agent(`Fix ${args.issue}. Edit only ${args.files}. Run its tests.`, { label: 'build' })
phase('Verify')
const check = await agent(`You did not write this change. Run it and try to break it. The builder said ${build}`, { label: 'verify', schema: VERDICT })
if (check.verdict !== 'pass') return { state: 'needs work', problems: check.problems }
phase('Commit')
return await agent('Make one commit with a conventional subject.', { label: 'commit' })Launch arguments arrive in args, and the script body runs in an async context where top-level await and return work out of the box. Each phase() call groups the agents that follow under a single heading. (Claude Code calls that grouping a “phase”; since my backlog already uses “phase” for a feature or bug fix, I’ll call the steps inside a workflow stages to keep the two distinct.)
While the workflow runs, the main Claude Code session orchestrates the script and automatically writes a run journal alongside an individual transcript for every subagent:
Turning workflows on
If workflows aren’t enabled by default on your plan, toggle Dynamic workflows in /config or set enableWorkflows to true in ~/.claude/settings.json. To make workflows the default for every non-trivial task, enable Ultracode: either per session with /effort ultracode (or claude --effort ultracode), globally via "ultracode": true in your settings, or for a single prompt by including the word ultracode.
Because every subagent runs in its own context window, workflows consume more tokens than a single chat session. A small 6-agent bug fix (Phase 256) used 1.49M tokens, so expect to hit usage limits more frequently on multi-agent runs.
What Claude Code logs on every run
Without any custom instrumentation, Claude Code (tested on versions 2.1.226 through 2.1.301) writes four sets of files under ~/.claude/projects/<project>/<session>/:
| Record | Contents | Path |
|---|---|---|
| Workflow script | The JavaScript file executed for the run | workflows/ (if generated in chat; scripts launched by path stay in place) |
| Run summary | Status, start time, duration, token count, and script source | workflows/ |
| Run journal | One started line and one result line per agent, with its label and stage | subagents/ |
| Agent transcripts | Every message and tool call for each subagent, with ISO timestamps | subagents/ |
Here is an entry from journal.jsonl (with the prompt-hash key truncated):
{"type":"started","key":"v2:15a7371483e0...","agentId":"aa3cd8997f594219b","label":"build","phase":"Build"}Notice that journal.jsonl maps each agentId to its label and stage, but doesn’t include timestamps. The timestamps live inside each agent’s agent-<id>.jsonl transcript. For the builder above, the transcript runs from 21:06:48Z to 21:32:44Z, or 26 minutes of work.
How I run a phase
Logs only become useful for estimation when your workflows follow a repeatable structure. In both RunStory and Tortie, every phase runs in an isolated git worktree as a single build workflow that ends in a single commit.
The rules for every agent live in AGENTS.md, which CLAUDE.md pulls into every session via @AGENTS.md. Each backlog item is assigned a verification depth from Tier 1 (lightest) to Tier 3 (deepest). A small Tier 2 fix might use 1 builder and 1 verifier; a large Tier 3 feature uses 7 or 8 parallel builders (split into 1 or 2 waves) and 3 independent verifiers. If any verifier finds a serious issue, the script runs up to 3 fix-and-reverify rounds before committing:
Strict boundaries keep stages from stepping on each other:
| Stage | What it owns | What it never does |
|---|---|---|
| Spec | spec.md and the per-builder file assignments | Writes product code |
| Base | Creating the git worktree | Edits files |
| Build (1–8 in parallel) | Only the disjoint files assigned to it in the spec | Touches another builder’s files |
| Integrate | Merging all builders’ changes cleanly | Commits |
| Verify (1–3 in parallel) | Independent runtime proof | Trusts a builder’s self-report |
| Fix | Resolving issues flagged by verifiers | Commits |
| Commit | A single commit after make gates passes all 37 checks | Pushes or deploys |
The Spec agent returns its builder breakdown as JSON, and the workflow launches all builders in a wave concurrently with parallel(). You can save reusable scripts in .claude/workflows/; I keep a copy of my build script alongside each phase’s launch arguments in an ignored scratch/phase<N>/build/ directory.
Resuming interrupted runs
Multi-hour workflows inevitably hit rate limits or manual pauses. When you resume a stopped workflow in the same session (passing the original script, identical args, and resumeFromRunId), Claude Code replays the cached results of every completed subagent immediately. Any subagent that was mid-flight when the run stopped starts over from scratch.
To make resuming and timing reliable, I keep a small run.json file in each phase’s folder with its run ID, transcript directory, and stop history:
{
"workflow": "scratch/phase252/build/workflow.js",
"args_file": "scratch/phase252/build/launch-args.json",
"run_id": "wf_4b9ab0eb-3cd",
"transcript_dir": "~/.claude/projects/<project>/<session>/subagents/workflows/wf_4b9ab0eb-3cd",
"launched_at": "2026-10-17T03:40Z",
"resume": "Workflow({scriptPath: workflow, resumeFromRunId: run_id, args: <the exact content of args_file>}). Stop the live run first with TaskStop.",
"history": [
{ "at": "2026-10-17T17:41:38Z", "event": "TaskStop during reverify2 to ship an earlier release" }
]
}The release train
The final piece of every estimate is the release itself. I ship finished phases on a release train via a single command, make release, which runs 20 numbered checkpoints and logs a JSON timestamp as each one completes.
All durations in the tables below use H:MM format (so 4:52 is 4 hours 52 minutes). Here is a typical release breakdown:
| Checkpoints | What happens | Duration |
|---|---|---|
| 01 to 03 | Build the release branch in a clean checkout | 0:01 |
| 04 | Run full CI on the exact commit | 0:25 |
| 05 to 07 | Build sandbox images, deploy a preview environment, and run an end-to-end smoke check | 0:15 |
| 08 to 20 | Acquire the release lock, deploy to production, push main, and clean up | 0:02 |
| Total | 0:43 |
Across nine recent releases, trains took between 29 and 45 minutes (median 41 minutes), with CI accounting for the bulk of the time. If your release process is scripted, having each step log a completion timestamp lets an agent include deployment time in its estimates automatically.
How I time each stage
To turn raw subagent transcripts into stage durations, phase-timings.py applies four rules:
- Agent span: An agent starts at the first timestamp in its transcript and ends at the last.
- Parallel union: A stage’s wall time is the time during which at least one of its agents was running, so parallel builders or verifiers aren’t double-counted.
- Idle gaps: My prompts instruct agents to cap blocking tool calls at 10 minutes, meaning an active agent writes to its transcript at least every ~10 minutes. Any silence longer than 20 minutes is treated as an inactive gap rather than working time.
- Rate limits vs. pauses: If an agent logs a
rate_limiterror within the 10 minutes before a gap, the gap is classified as a usage limit; otherwise it’s marked as a manual stop or stall.
Because the script only inspects timestamps and metadata without printing prompt or message text, its output is safe to paste or feed straight back to an agent. (One caveat: if an agent spends hours actively polling on an external job, the script will see regular transcript lines and count that polling as work. I exclude pure wait stages, such as a Base stage waiting for an earlier phase to finish, when comparing working times.)
Here is the script’s output for a small Tier 2 bug fix (Phase 256):
$ python3 phase-timings.py ~/.claude/projects/<project>/<session>/subagents/workflows/wf_e3c484d2-242
runs: wf_e3c484d2-242
stage agents lost start UTC end UTC wall working agent time
Build 1 0 10-17 21:06 10-17 21:32 0:26 0:26 0:26
Verify 2 0 10-17 21:32 10-17 22:49 1:16 1:16 1:42
Fix 2 0 10-17 22:49 10-17 23:30 0:41 0:41 0:41
Commit 1 0 10-17 23:30 10-17 23:50 0:20 0:20 0:20
elapsed, first start to last end 2:43
working, some agent at work 2:43
agent time, all agents added up 3:09
gaps over 20 minutes: 0
Claude Code's record of wf_e3c484d2-242, last launch only: phase256-closed-run-narrow-host, 2:43, 6 agents, 1,491,785 tokens| Column | What it measures |
|---|---|
lost | Subagents that were interrupted mid-run (for example, by a rate limit or manual stop) and had to restart |
wall | Union of time intervals where at least one agent in the stage was active |
working | Stage wall time minus any internal silent gaps longer than 20 minutes |
agent time | Sum of all individual agents’ working times (shows total compute effort across parallel agents) |
elapsed | End-to-end wall clock time from the first agent’s start to the last agent’s completion, including all gaps |
In Phase 256 above, Verify ran two agents concurrently (an independent verifier and a gates runner) for a combined 1:42 of agent time inside 1:16 of wall time.
By default, phase-timings.py groups agents by the stage title passed to phase(). Passing --by label groups them by agent label prefix instead (stripping suffixes like :B3 from build1:B3), which breaks out individual fix and reverify rounds. Here is a large Tier 3 feature (Phase 252) that went through 3 fix rounds:
$ python3 phase-timings.py .../subagents/workflows/wf_4b9ab0eb-3cd --by label
runs: wf_4b9ab0eb-3cd
stage agents lost start UTC end UTC wall working agent time
spec 1 0 10-17 03:43 10-17 04:35 0:52 0:52 0:52
base 2 0 10-17 04:35 10-17 18:31 0:06 0:06 0:06
build1 9 2 10-17 04:37 10-17 09:20 3:39 3:39 11:57
integrate1 1 0 10-17 09:20 10-17 10:48 1:28 1:28 1:28
verify 3 0 10-17 10:48 10-17 12:49 2:01 2:01 3:54
fix1 1 0 10-17 12:49 10-17 14:44 1:55 1:55 1:55
reverify1 3 0 10-17 14:44 10-17 15:37 0:53 0:53 2:24
fix2 1 0 10-17 15:37 10-17 16:42 1:05 1:05 1:05
reverify2 3 0 10-17 16:42 10-17 17:38 0:55 0:55 2:14
fix3 2 1 10-17 17:38 10-17 19:23 0:53 0:53 0:53
reverify3 3 0 10-17 19:23 10-17 20:07 0:45 0:45 1:59
commit 1 0 10-17 20:07 10-17 21:43 1:36 1:36 1:36
elapsed, first start to last end 18:00
working, some agent at work 16:08
agent time, all agents added up 30:24
gaps over 20 minutes: 2
10-17 06:26 to 10-17 07:30 UTC 1:04 usage limit
10-17 17:41 to 10-17 18:27 UTC 0:47 no agent at work: a stop, a stall or a wait
Claude Code's record of wf_4b9ab0eb-3cd, last launch only: phase-build-queue, 3:11, 26 agents, 2,008,447 tokensNotice the bottom line: Claude Code’s built-in summary only recorded the final resumed launch (3:11). Reading the transcripts recovers the full 16:08 of working time across both interruptions (a 64-minute rate limit during build1 and a 47-minute pause before fix3 to ship another release).
How to estimate a future phase
Once you have stage timings for your past runs, estimating a new phase is straightforward reference-class forecasting: count the scope inputs in the new spec, find the closest completed phases, and sum their stage durations.
Here is the working-time breakdown across seven recent phases: five large Tier 3 features (Phases 247–253) and two small Tier 2 bug fixes (Phases 256–257), excluding Base worktree setup:
| Phase | Builders | Fix rounds | Spec | Build and integrate | Verify and fix | Commit | Working time | Release |
|---|---|---|---|---|---|---|---|---|
| 247 | 8 in 2 waves | 3 | 0:26 | 10:25 | 9:59 | 1:36 | 22:25 | 0:44 |
| 248 | 7 in 2 waves | 3 | 0:35 | 7:45 | 9:01 | 1:34 | 18:55 | 0:42 |
| 249 | 7 | 1 | 1:21 | 5:19 | 4:32 | 1:40 | 12:52 | 0:45 |
| 252 | 7 | 3 | 0:52 | 5:07 | 8:27 | 1:36 | 16:02 | 0:41 |
| 253 | 8 | 1 | 1:01 | 3:07 | 3:39 | 1:26 | 9:13 | 0:41 |
| 256 | 1 | 1 | none | 0:26 | 1:57 | 0:20 | 2:43 | 0:35 |
| 257 | 1 | 1 | none | 1:35 | 2:02 | 0:19 | 3:55 | 0:43 |
When comparing a new Tier 3 spec against those five large runs, I look at five scope signals:
| Scope signal | Range across the five Tier 3 phases |
|---|---|
| Spec length | 433 to 661 lines |
| Builders and waves | 7 or 8 builders across 1 or 2 waves |
| Verification proofs (scenarios verifiers must run) | 24 to 49 in the initial verify round |
| Files touched in the final commit | 155 to 397 |
| Fix rounds required | 1 to 3 |
The first three signals can be read directly off a new spec (along with the number of files assigned to builders); the expected number of fix rounds is inferred from whichever past phases are closest in complexity.
Breaking the five Tier 3 phases down by individual stage reveals where the time actually goes:
| Stage | Median | Range | Samples |
|---|---|---|---|
| Spec | 0:52 | 0:26 to 1:06 | 5 |
| One build wave + integrator | 4:52 | 3:07 to 5:34 | 7 |
| Initial verify round (3 verifiers in parallel) | 1:30 | 0:59 to 2:01 | 5 |
| First fix round (fix + reverify) | 2:48 | 2:38 to 3:54 | 5 |
| Each subsequent fix round | 2:17 | 1:38 to 2:27 | 6 |
Commit (including make gates) | 1:36 | 1:26 to 1:40 | 5 |
| Release train | 0:41 | 0:29 to 0:45 | 9 |
Two variables dominate total runtime: whether the build requires a second wave (adding 3:24 to 4:52) and how many fix rounds it takes (adding 1:38 to 3:54 per round). Surprisingly, the raw number of verification proofs barely moved the initial verify stage: Phase 247 ran 24 proofs in 1:30, while Phase 249 ran 49 proofs in 1:14.
Suppose a new Tier 3 spec calls for a single wave of 7 builders, and comparable specs needed 2 fix rounds. Summing the stage medians produces the estimate:
| Part | Stages included | Median estimate | Stage-extremes range |
|---|---|---|---|
| Build and integrate | Spec + 1 build wave | 5:43 | 3:33 to 6:40 |
| Verify and fix | Initial verify + 2 fix rounds + commit | 8:11 | 6:40 to 10:03 |
| Release | 1 release train | 0:41 | 0:29 to 0:45 |
| Total working time | Launch to production (excluding idle gaps) | 14:36 | 10:42 to 17:27 |
Notice that 13:54 of pre-release working time sits right between the two closest historical runs: Phase 249 (7 builders, 1 fix round, 12:52) and Phase 252 (7 builders, 3 fix rounds, 16:02). Adding every stage’s minimum and maximum gives a wide theoretical envelope (10:42 to 17:27); anchoring on the closest comparable phases gives a much tighter practical range.
Always ask for working time and elapsed wall-clock time separately. Across those seven phases, idle gaps over 20 minutes added anywhere from 0:00 to 22:29 on top of working time, almost entirely from rate limits. Phase 247 hit 7 rate limits and one 5-hour stall, turning 22:25 of active work into 45:07 on the wall clock.
Here is the prompt I use to have Claude generate an estimate from our logs (using the directory layout from Getting started):
Estimate how long the work in work/<name>/spec.md will take, from launch to release.
1. Read every work/*/run.json. Each names a past run and its transcript folder.
2. Run python3 tools/phase-timings.py --csv on each transcript folder, one run per
call. Make one table with a row per run and a column per stage.
3. Add each past run's inputs from its spec and its commit: spec lines, builders,
files the commit touched, and fix rounds.
4. If work/releases.csv exists, give the median and range of its release times.
5. Measure the same inputs for the new spec. Name the closest past runs and say why.
6. Answer in three parts, each with a range: build and integrate, then verify and
fix, then release. Then give the total working time.
7. Give the elapsed time separately, with the stops you expect, e.g. usage limits.
8. Say what would move the estimate most, and how sure you are.
9. Save the answer to work/<name>/estimate.md.Even before I extracted phase-timings.py into a standalone tool, my main session was already making informal estimates in its status updates by comparing in-flight work against earlier phases (“If it goes like 247…”). Checking four of those estimates against the transcript logs shows how quickly the calibration converges:
| Timestamp (UTC) | Claude’s estimate | Actual duration in logs |
|---|---|---|
| Oct 16, 02:29 | Release 0.42.1 “should take about an hour” | 60 minutes |
| Oct 16, 03:49 | Phase 248’s remaining stages will “take 8 to 15 more working hours” | 13h 13m to commit (13h 56m including release), all continuous working time |
| Oct 16, 16:43 | Phase 248’s final gate checks need “about 15 to 20 minutes” | 19 minutes |
| Oct 16, 16:43 | Release 0.43.0 takes “about 30 to 40 minutes” | 42 minutes |
Getting started
You can set up the pipeline and timing script in an afternoon; after about 5 completed runs, you’ll have enough baseline data for your first comparative estimate.
- Enable Ultracode. Run
/effort ultracodein your session, or prefix your task prompt withultracode. Standardize your workflow stages in
CLAUDE.md. Subagents readCLAUDE.mdon every run, ensuring stage names stay consistent across sessions:Add to CLAUDE.md that each piece of work runs as one workflow with these stages, named exactly so in phase(): Spec, Build, Verify, Fix, Commit. The spec goes in work/<name>/spec.md and gives each builder its own files. One verifier who did not build the change runs the code. Allow at most one fix round. Only the committer commits. At launch, write work/<name>/run.json with the run id, the transcript folder and the launch time. Save the args beside it and never edit them. Add a line to its history at every stop, with the cause.Run a small workflow with a JSON schema verdict. Replace the issue and file path with one from your own repo:
ultracode. Fix issue 12 as a workflow. One builder edits only src/parser.ts and its test. One verifier runs the tests and returns JSON with a verdict of pass or needs_work and a list of problems. Then one committer.- Save the workflow script. Once your first run succeeds, ask Claude to save the script in
.claude/workflows/and reuse it for future tasks so stage names never drift. Time the run. Drop
phase-timings.pyintotools/phase-timings.pyand ask Claude:Give me the transcript folder of that workflow, then run python3 tools/phase-timings.py on it and show me the table.- Estimate your next task after ~5 runs. Run the estimation prompt from the previous section to generate
work/<name>/estimate.mdbefore launching the workflow. Audit the estimate when the run finishes. Close the loop so future estimates learn from any misses:
Compare work/<name>/estimate.md with what the transcripts show, stage by stage.
What this changes for planning
An estimate stops being a guess when it cites the specific historical runs it came from. Before launching a phase in my backlog, I know whether it’s a 3-hour bug fix or a 16-hour overnight build. And when an in-flight stage runs past its historical range, I can spot the outlier early and investigate why.
None of this required building custom telemetry into Claude Code; the transcripts were already sitting on disk. What made the logs predictive was standardizing the shape of the work so every phase could be measured against the ones before it. When you introduce a genuinely new kind of workflow with unfamiliar stages, the first run won’t have a baseline; its transcript becomes the first row for the runs that follow.