Unchained Labs

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
Real output. Every assumption printed, prices dated.

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       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.