Skip to content

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.

Each session has one PR doc:

.spwn/pr/<branch-slug>.md

It 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 = 1
model = "claude-opus-5"
sessions = ["6b254ae6-c327-43f6-a071-e19fc5baad4d"]
usd = 5.9289
[cost.tokens]
cacheRead = 6120455
cacheWrite = 188920
cacheWrite1h = 170512
cacheWrite5m = 18408
input = 310
output = 41877
requests = 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.

With the branches fetched, you don’t need a checkout:

Terminal window
git show origin/spwn/1a2b3c4d:.spwn/pr/spwn-1a2b3c4d.md

For one branch on its own, cost.usd and cost.tokens are the figures you want: the whole conversation, including anything inherited from a fork.

To total several branches, add up each one’s own spend, not its tokens. Three rules cover every doc:

  1. If own is present, use it. The session is a fork, and own excludes the conversation it inherited.
  2. If own is absent, it equals tokens. The session inherited nothing.
  3. If ownUnknown = true, skip the session. spwn couldn’t measure what it inherited, and its tokens include 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.0
for 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:

Terminal window
git fetch origin '+refs/heads/spwn/*:refs/remotes/origin/spwn/*'
python3 scripts/spwn-cost.py

A few notes on the result:

  • Tokens sum exactly. Dollars are an estimate. A fork’s ownUsd is its usd split by request share, and usd itself comes from a rate table. If the table was emptied, there’s no usd, and the script reports unpriced.
  • 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 the refs/remotes/origin/spwn/ pattern to match.

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:

Terminal window
doc=.spwn/pr/spwn-1a2b3c4d.md
sha=$(git log --diff-filter=D --format=%H -1 -- "$doc")
git show "$sha^:$doc"

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:

  • total is the group’s spend, with the same token fields as cost.tokens, plus usd and unpriced where 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’s tokens, so shared fork history is counted once.
  • sessions is one row per member: terminalId, title, branch, and own. own is the part that’s safe to add up, already resolved (an absent own in the doc shows up here as tokens). It’s null for a session with no cost yet, or one marked ownUnknown.

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.

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.