preflight
Adding a third verifier lens is a one-line diff and a 50% cost increase.
Runtime guards stop a run that is already spending. Nothing tells you what a workflow will cost before you merge the change that made it expensive — which is when it is cheap to fix.
- alpha
- 82 tests passing
- GitHub Action
- MIT
The estimate
Where the money actually goes
$ preflight estimate audit.graph.json
route-auth-audit estimated before running
agents 114 (66–222)
cost $0.974 ($0.687–$1.62)
tokens 588k in 70k out
budget $12.00 ✓ under
where it goes
verify █████████████░░░░░░░░░░░░░░░ $0.435 45%
fan-out █████████░░░░░░░░░░░░░░░░░░░ $0.299 31%
synthesis ██████░░░░░░░░░░░░░░░░░░░░░░ $0.210 22%
scope █░░░░░░░░░░░░░░░░░░░░░░░░░░░ $0.029 3%
! verification is 45% of this run. findings × lenses, not fan-out width, is usually the growth term.
biggest levers
$0.435 verify (verifier)
drop a lens, or replace one with an executable oracle
$0.299 inspect (worker)
narrow the fan-out, or add a zero-token prefilter
The shape is the useful part. Most people size their graph by fan-out width and are surprised that verification — findings × lenses — is the term that actually grows.
The model
Four terms, one that bites
cost ≈ Σ_nodes (fanout × tier_rate)
+ Σ_findings (findings × lenses × verify_rate) ← the term that bites
+ retries × schema_mismatch_rate
+ rounds (if the graph has a cycle)
Every assumption is printed
The output states the token profile per node kind, the findings-per-unit ratio, the retry rate and the cache multipliers. A number with hidden assumptions is a number nobody can argue with — which means nobody can trust it either.
It reports a range
Fan-out width and finding counts are unknown until the run. Emitting $12.40 implies a precision the input does not contain, so you get low/expected/high and every assumed count is marked ~.
Prompt caching is modelled
A fan-out shares a prefix: the first call writes the cache at 1.25×, the rest read at 0.1×. Ignoring that overstates a wide fan-out substantially.
Prices are a cache, and say so
The table carries its verification date, the CLI prints it, and CI fails when it is over 120 days old. Sonnet 5's intro rate expires 2026-08-31 — --as-of prices past that at the standard rate rather than the promotional one.
Action
One comment, updated in place
name: preflight
on: pull_request
permissions:
contents: read
pull-requests: write
jobs:
cost:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: Unchained-Labs/preflight@v0
with:
paths: |
.claude/workflows/**
**/*.graph.json
max-usd: "25"
It posts one comment and updates it in place on later pushes rather than adding a new one each time, and outputs usd and agents for downstream steps.
The action is dependency-free — no @actions/core, no @actions/github. A cost bot that needs a 40MB install to post one comment is a cost bot nobody adopts.
Cache
A cache write is billed by how long it lives
Writing the shared prefix costs 1.25x the input rate for the API's five-minute default and 2x for the one-hour tier. On a workload that is mostly cached that difference is about a third of the bill, which is too much to leave at whatever the default happens to be.
preflight shipped a single 1.25x multiplier until localflow checked it against an oracle. claude -p --output-format json reports total_cost_usd for the run it just did, so the arithmetic either lands on it or it does not:
531 input + 22188 cache-read + 3026 cache-write(1h) + 51 output, haiku
1.25x -> $0.0067873000
2.00x -> $0.0090568000
CLI -> $0.0090568000
Set "cacheTtl": "1h" in preflight.json for a graph running on Claude Code or the extended-TTL beta. The default stays "5m", because that is the API default and guessing the more expensive tier would over-report every ordinary run.
An existing config is not silently repriced. A preflight.json that still sets the old single cacheWriteMultiplier is honoured exactly as written. A number somebody deliberately pinned should not move because the tool learned something.
Calibration
The defaults are a guess. Replace them with your own numbers.
This tool's honest weakness has always been that its token profiles are generic — 8k in and 800 out per fan-out call is a plausible shape, not a measurement of your workload. The README has said so since the first commit, along with the fix: run one real workflow, read the counts out of your spans, write them into preflight.json.
preflight calibrate is that step, automated. Most orchestrators already record per-call token usage; feed it those rows and it writes the profile. It reads a JSON array, a single object, or JSONL, because which of those you end up with depends on whether you remembered jq -s.
# Otter records this per job at GET /v1/jobs/{id}/usage
for id in $(cat job-ids); do curl -s "$OTTER/v1/jobs/$id/usage"; done \
| jq -s . | preflight calibrate - --kind worker --out preflight.json
calibrating the worker profile from 23 measured call(s)
assumed measured change p10–p90
input 8000 → 16024 ×2.00 9845–35458
output 800 → 567 ×0.71 382–969
cacheHitRate 0.7 (unchanged)
not measurable from usage rows — nothing in them reports cache reads
What it refuses to do
A number that looks measured and is not is worse than an assumption that admits it, so three refusals are built in.
No invented cache rate
Usage rows do not report cache reads, so there is nothing to derive one from. The existing value is carried through and the report says so — it is the most tempting number to fabricate, since it moves the total materially and nobody would check. Where a source does report it, measure it: localflow reads it out of Claude Code transcripts and writes it here.
No guessed node kind
A single-prompt job has no worker/verifier distinction to read. You name the kind. The default is worker, because a prompt in and a result out is the worker shape — stated, not inferred.
No calibrating from five runs
Below five samples it exits 1 and writes nothing. Two runs produce a number with the authority of a measurement and the accuracy of a guess, and half-calibrating is worse than not starting.
It writes the median, not the mean. Token distributions are right-skewed, and one run that filled a 400k context should not set your profile. The p10–p90 spread is reported separately, because the tail is what blows a budget.
And it merges rather than replaces. A real preflight.json also carries pricing overrides and fan-out assumptions; overwriting them would be a silent regression in someone's cost model. The write adds a $calibration block with the sample size, the models in it, and the date — a calibrated config with no date will be trusted long after it stopped being true.
Trust
Two input shapes, honestly different accuracy
| Input | Accuracy |
|---|---|
Declarative spec (*.graph.json) |
Tight. Width, tier and lens count are data, so the estimate is arithmetic on numbers you wrote down. |
Script (agent(), parallel()) |
Wide. Those numbers are runtime values. We recover what is statically visible and mark the rest assumed. |
This is a property of the input, not something to engineer away. If you want a tight number, write the spec.
It is an estimate, not a quote. It cannot know how many findings your scan will produce or how wide a runtime fan-out gets.
Defaults are generic. Uncalibrated, treat the shape — which stage dominates — as far more reliable than the absolute figure. Run one real workflow, read the token counts out of your spans, and put them in preflight.json.
It does not track actual spend. It prices the spec, not the run.