Reading cost outside spwn
spwn writes each session’s cost into a file in your repo, on the session’s own branch. Reading it doesn’t need spwn, only git and a TOML parser. A CI job can report what a PR cost to produce, a script can total a sprint’s branches, and a teammate who pulls the branch sees the same numbers you do.
The PR doc
Section titled “The PR doc”Each session has one PR doc:
.spwn/pr/<branch-slug>.mdIt lives in the session’s worktree and is committed on its branch by the per-turn
commit. The slug is the branch name with anything outside A-Z a-z 0-9 . _ - replaced
by -, so branch spwn/1a2b3c4d has its doc at .spwn/pr/spwn-1a2b3c4d.md.
The file is TOML front matter between +++ fences, followed by markdown prose:
+++problem = "checkout-latency"reviewer = "dana" # anything you like
[cost]files = 1model = "claude-opus-5"sessions = ["6b254ae6-c327-43f6-a071-e19fc5baad4d"]usd = 5.9289
[cost.tokens]cacheRead = 6120455cacheWrite = 188920cacheWrite1h = 170512cacheWrite5m = 18408input = 310output = 41877requests = 58+++
# Cache the tax lookup
The agent's description of the change…The fences are +++, not ---. Most tools read --- as YAML. The front matter is
free-form: spwn owns cost (plus title, pr, pr_url and pr_repo once a pull
request exists), and every other key is yours. When spwn writes a key, it rewrites only
that key and leaves your comments, key order, and prose unchanged. Every field of
cost is described in How cost is counted.
One file per branch keeps parallel PRs from conflicting, because no two sessions ever write the same path.
Reading one branch
Section titled “Reading one branch”With the branches fetched, you don’t need a checkout:
git show origin/spwn/1a2b3c4d:.spwn/pr/spwn-1a2b3c4d.mdFor one branch on its own, cost.usd and cost.tokens are the figures you want: the
whole conversation, including anything inherited from a fork.
Adding up several branches
Section titled “Adding up several branches”To total several branches, add up each one’s own spend, not its tokens. Three
rules cover every doc:
- If
ownis present, use it. The session is a fork, andownexcludes the conversation it inherited. - If
ownis absent, it equalstokens. The session inherited nothing. - If
ownUnknown = true, skip the session. spwn couldn’t measure what it inherited, and itstokensinclude spend that’s already counted on its parent.
This script applies those rules to every spwn/* branch on origin. It uses only
Python’s standard library (3.11 or later, for tomllib):
#!/usr/bin/env python3"""Total what every spwn branch on origin cost, from the PR docs alone."""import re, subprocess, tomllib
FIELDS = ("input", "output", "cacheRead", "cacheWrite", "requests")FRONT = re.compile(r"\A?\+\+\+\r?\n(.*?)^\+\+\+\r?$", re.S | re.M)
def slug(branch: str) -> str: """Same rule as spwn: anything outside [A-Za-z0-9._-] becomes '-'.""" out = "" for ch in branch: if ch == "." and out.endswith("."): continue if ch.isascii() and (ch.isalnum() or ch in "._-"): out += ch elif not out.endswith("-"): out += "-" return out.strip(".-")[:100]
def git(*args: str) -> subprocess.CompletedProcess: return subprocess.run(["git", *args], capture_output=True, text=True)
refs = git("for-each-ref", "--format=%(refname:short)", "refs/remotes/origin/spwn/").stdout.split()
total = dict.fromkeys(FIELDS, 0)usd = 0.0for ref in refs: branch = ref.removeprefix("origin/") doc = git("show", f"{ref}:.spwn/pr/{slug(branch)}.md") match = FRONT.match(doc.stdout) if doc.returncode == 0 else None cost = tomllib.loads(match.group(1)).get("cost") if match else None if not cost: print(f"{branch:32} no cost recorded") continue if cost.get("ownUnknown"): print(f"{branch:32} skipped: own share unknown") continue own = cost.get("own", cost["tokens"]) # absent own == tokens own_usd = cost.get("ownUsd") if "own" in cost else cost.get("usd") for k in FIELDS: total[k] += own.get(k, 0) usd += own_usd or 0.0 shown = f"${own_usd:,.2f}" if own_usd is not None else "unpriced" print(f"{branch:32} {shown:>10} {own['requests']:>6} req")
print(f"{'total':32} ${usd:>9,.2f} {total['requests']:>6} req " f"{total['output']:,} output tokens")In CI, fetch the session branches first. actions/checkout fetches one ref by default:
git fetch origin '+refs/heads/spwn/*:refs/remotes/origin/spwn/*'python3 scripts/spwn-cost.pyA few notes on the result:
- Tokens sum exactly. Dollars are an estimate. A fork’s
ownUsdis itsusdsplit by request share, andusditself comes from a rate table. If the table was emptied, there’s nousd, and the script reportsunpriced. - Only open branches are counted. A branch that already landed isn’t on the remote. Its doc is still in the base branch’s history, as described in the next section.
- Renamed branches. A hook can give a session a branch name other than
spwn/<id>. Change therefs/remotes/origin/spwn/pattern to match.
After a branch lands
Section titled “After a branch lands”A PR doc does its job while its branch is open. Once the branch lands, spwn removes the doc from the base branch, so the base doesn’t collect one file per merged session:
- Merging in spwn (Merge, or the land order) carries the doc into
the base with the merge. If the base branch is checked out, spwn then deletes the doc
in a separate commit,
spwn: prune N merged PR doc(s), while it still holds the landing lock. That commit stages only the doc paths, so any uncommitted work in your base checkout stays put. spwn also removes any other doc whose branch no longer exists and whose session is gone. - Merging on GitHub brings the doc into the base the same way, but spwn doesn’t commit to the base for you. The sweep happens on sessions instead: every turn, spwn removes any doc in the session’s worktree whose branch and session no longer exist, and that removal rides the turn’s commit. A doc that reached a session by syncing with the base disappears once its own branch and session have been deleted.
Deleting the file loses nothing. Every version of it is in git history. To read a landed branch’s final cost, find the commit that deleted the doc and read the file as it was one commit earlier:
doc=.spwn/pr/spwn-1a2b3c4d.mdsha=$(git log --diff-filter=D --format=%H -1 -- "$doc")git show "$sha^:$doc"Rollups in a workflow
Section titled “Rollups in a workflow”Inside spwn, a workflow can get a group total without going through
git. session.cost() returns spend across every session working the same problem
as the one you call it on:
export default async (spwn) => { const s = await spwn.sessions.find("TICKET-412"); if (!s) return; const { problem, sessions, total } = await s.cost();
console.log(`${problem.kind} ${problem.key}: $${total.usd ?? "?"} over ${total.requests} requests`); for (const row of sessions) { console.log(row.title, row.branch, row.own ? `${row.own.requests} req` : "not measured"); }};spwn groups sessions within the project by the first rule that applies:
problem.kind |
Groups sessions that… | problem.key |
|---|---|---|
declared |
have the same problem key in their PR doc’s front matter |
that value |
workflowKey |
were created by the same workflow with the same key (sessions.create({ key }) or agents.run({ key })) |
the key |
lineage |
descend from the same root session by forking | the root session’s id |
declared is the only rule that can group sessions that were never forked from one
another. Set it from the PR tab’s front-matter editor, from a workflow with
s.setPr({ problem: "checkout-latency" }), or by having the agent write it into the
doc.
The result has this shape:
totalis the group’s spend, with the same token fields ascost.tokens, plususdandunpricedwhere they apply. spwn computes it by scanning all the group’s transcripts together and deduplicating by request id. It isn’t a sum of anyone’stokens, so shared fork history is counted once.sessionsis one row per member:terminalId,title,branch, andown.ownis the part that’s safe to add up, already resolved (an absentownin the doc shows up here astokens). It’snullfor a session with no cost yet, or one markedownUnknown.
The PR tab shows the same rollup under Spend across this problem. The full types
are in spwn.d.ts
and the Workflows API reference.
In the pull request
Section titled “In the pull request”When spwn opens a pull request from a session, or you press Push update, the body
gets a spwn section between <!-- spwn:begin --> and <!-- spwn:end -->. It ends with
a one-line summary such as Cost to produce: $5.93 · 6.4M tokens. The token count
there is the session’s own input, output, cache-read and cache-write tokens added
together. See Pull requests.