keeping the receipts: tracking what a coding agent actually did
recently i worked on three things with claude code in quick succession: a lifecasting app i built to share with friends, drumpy (a self-hosted drum-practice app i've been building for a while), and a personal notebook. along the way i built a set of tools for keeping track of what the agent had actually done. it started as a nervous habit and turned into a small practice. this is a write-up of the process, the tooling, and what the numbers said once i could see them.
why
a coding agent leaves two kinds of trace. the repo is one: commits, a plan doc, a changelog. those are the intent.. what i asked for and what we decided. the transcript is the other: every prompt, every reply, every tool call, and the token usage stapled to each turn. that is the evidence.
the trouble is that the agent doesn't keep the evidence. claude
code writes one jsonl file per session under
~/.claude/projects/ and prunes them after a while
(thirty days by default). the repo is the only place i control that
will still exist in a year. records over chat has always meant
"capture intent in the repo." turns out the evidence belongs there
too.. the transcript, alongside the plan and the changelog.
there was also a plainer motivation. the lifecasting app went from nothing to a shipped, installable thing in about a week: 190 commits, seventy service-worker releases. i wanted to know what that had cost, in tokens and in dollars, and i wanted to be able to read back how a decision got made without trusting my memory of a chat window that had already scrolled away.
three layers
the tooling settled into three derived things, each computed from the one below it:
- the archive. every session's raw jsonl, plus
its side directory (
subagents/agent-*.jsonlandtool-results/*.txt), copied into the repo byte-for-byte. no condensing, no filtering. whatever i decide to derive later, i can only derive if the raw record survived. the repos are private, so the copies are plaintext, committed like any other file. - the thread. one readable file per session: my prompts, the agent's prose, and a one-line summary of each tool call ("edited src/blog.clj ×4", "ran: make test", "launched a subagent"). harness envelopes stripped, thinking blocks dropped, sidechain and meta records ignored. a header with the title, start, end, and counts. an index of every session, newest first. this is the thing a person opens.
- the stats. one small json: commits, first and last commit, lines of code by kind, tokens split into input / output / cache-write / cache-read, assistant and subagent turn counts, an estimated cost at list price, and cumulative per-day series for code and tokens. mechanics only.. no titles, no text.. so it can ship in the open.
three design rules fell out of building those, all of them the kind you only learn by getting them wrong first:
raw ∪ archive, unioned by session id. every derivation reads the agent's directory and the archive, and takes the newer copy of each session. that's what makes the thread rebuild on a machine that never had the raw files, and what keeps old sessions from silently falling out of the index when claude code prunes them. with no raw directory at all, the build runs from the archive alone.
idempotent, and rewrite only if changed. the derived files are committed, so a no-op run has to leave git clean. archive files are skipped when the source hasn't grown (mtime); the thread and the stats are written only when their text differs. and when the token sum fails (no directory to read), the previous block carries forward rather than emitting a stats file with a hole in it.
wrap-up as a standing ritual, not a task. the
archive is only complete because make sessions runs at
the end of every session without anyone deciding to run it. it's in
CLAUDE.md as a standing instruction: run it, commit it
as its own commit, don't ask. the moment it becomes a judgement
call it gets skipped on the short sessions, which are exactly the
ones nobody remembers later.
what the data taught me
the first version of every tool was wrong in a way that only showed up once i looked at the output. five of those, in rough order of how much they moved the numbers:
prompts typed mid-turn are invisible unless you go
looking. anything i typed while the agent was still
working never appears as a user text block. it's
recorded as a queue-operation record and delivered to
the model inside a tool result. the first thread builder dropped
them wholesale, and on the two sessions i checked the prompt count
was off by half.. 6 that should have been 12, 26 that should have
been 32. read the queue records, place them at their own timestamp,
and de-dupe against the drained copy that sometimes follows.
half of what looks like a prompt is a harness
envelope. user records carry harness
envelopes.. system-reminder,
command-name, local-command-stdout,
task-notification blocks.. and tool results. strip the
envelopes and count only what has text left; drop
isMeta and isSidechain. sessions with
fewer than two real prompts are noise.. an accidental launch, a
lone /command.
subagents are separate transcripts, possibly on a
different model. they live one directory down, in the
session's subagents/ folder, which a top-level
*.jsonl glob misses. their usage is mirrored nowhere
else.. the main file only carries the "launched a subagent" tool
call. in the lifecasting app that was 17 files, 610 turns, about 27
million tokens (roughly 2%) missing until the archive and the sum
learned to look one level down. and they can run on a different
model than the main thread, which is how the "no pricing for …"
warning earns its keep.
cache creation comes in two shapes. usage sits
on every assistant record's
message.usage. cache writes are either a flat
cache_creation_input_tokens (older records) or a
cache_creation object split into five-minute and
one-hour buckets, priced differently. handle both, price per
message.model from a small table, and warn on models
you haven't priced so the estimate is visibly low rather than
silently low.
timestamps are utc; days are local. bucketing the per-day series by the raw iso date puts late-evening work on tomorrow. convert first. and make the series cumulative.. a rising line reads better than daily bars for a build that has quiet days.
where the money goes
this was the number that surprised me. the lifecasting app, at the end of that week: 1.4 billion tokens. of those, 1.3 billion.. 98%.. are cache reads. output is 5.2 million. at list price the estimate is about $1,900, and the cache reads are about 70% of it. long sessions are expensive because every turn re-reads the whole context, not because the model writes a lot.
which sounds like an argument against caching, and it isn't..
cache reads are the cheap path, a tenth the price of fresh input.
the alternative would be ten times worse. the lever is context ×
turns. here are the first four long sessions of that build, from
message.usage:
| session (first 8) | turns | avg ctx | max ctx | turns ≥ 500k |
|---|---|---|---|---|
| 267dbdab (day 1) | 920 | 320k | 600k | 180 |
| b1779fad (day 2) | 1100 | 430k | 790k | 420 |
| 5a003d78 | 380 | 470k | 620k | 240 |
| de13f16d | 260 | 120k | 210k | 0 |
at 500k of context every turn costs about 0.50 in cache reads before a word is written; at 100k, about 0.10. same work, five times the price, purely from how long the session had been running.
so i changed how i work, in three small ways. a fresh session
per round instead of one all-day session.. the plan doc and the
changelog already make cold starts cheap; that's what records over
chat buys. big reads delegated to subagents, whose context dies
with them. and /clear at natural breaks instead of
pushing on inside a huge context. the difference between the second
and fourth rows of that table is the whole argument.
the port, and time machine
a few days later i ported the tools to drumpy, which by then had
about five months of sessions behind it. the port differs in two
deliberate ways: babashka instead of python (the repo is clojure),
and a markdown file per session instead of a reader app (there's no
app to read it in; a .md in
docs/sessions/ is what a person opens). same content
rules, same three layers, same wrap-up.
and this is where the "your archive is the source, not the
agent's directory" rule stopped being theory. when the tooling
landed, claude code had already pruned all but 3 sessions from
drumpy's project directory. ~/.claude/history.jsonl
knew about 65 session ids. mounted time machine snapshots
(tmutil listbackups -m) still held 58 of them; a
session file only ever grows, so the newest snapshot that still had
a file held the fullest copy. the 7 that didn't come back were
/resume, /continue, and
/help stubs with no content. so the archive is
effectively complete from the first drumpy session.
cleanupPeriodDays is now 3650 in
~/.claude/settings.json so this doesn't recur.. but
that's a setting on one laptop, and the point of the archive is
that it doesn't depend on one.
five months of drumpy, from its stats.json: 4.0
billion tokens across 22,000 turns (19,000 main, 3,100 subagent),
about $3,200 at list price, 56,000 lines of code across 270
commits. 97% of the tokens are cache reads.
the day after that, the same tools went into a third repo.. the personal notebook.. in about twenty minutes, verbatim, because the playbook had a porting checklist and the second port had already proven the babashka version. that's the part i didn't expect: the third time, there was nothing to decide.
known gaps
being honest about what the numbers are and aren't:
- the cost is a list-price estimate from a hand-maintained pricing table. what a subscription actually bills is a different number, and i don't have a way to derive it from the transcripts. the row is labelled "list" everywhere it appears.
- the thread ignores subagent transcripts. their tokens are summed and their files are archived, but their prompts and replies never show in the readable thread.. only the "launched a subagent" line in the parent.
- session titles are content. the auto-generated title will cheerfully spell out whatever you were building, so anything that ships in a list gets a hand-named title from an override map. the build nags when one is missing, but nothing stops an unnamed session from shipping if the nag is ignored.
- the current session is always one run behind. the wrap-up captures everything up to the moment it runs.. not the commit that follows it. the next session's wrap-up closes the gap. don't try to make the wrap-up commit include itself.
- lines of code is a blunt count..
git ls-filesby extension, bucketed by directory. it says how big the thing is, not how good.
the short version
if you build with an agent and want the receipts: decide on day one whether the transcripts belong in git at all, and what ships in the open (the numbers, usually). archive the raw session files byte-for-byte, side directories included. derive a readable thread and a small stats file from them, reading your archive and the agent's directory together, and rewriting only what changed. wrap all of it in one idempotent target, put that target in the agent's standing instructions as the end of every session, and run it the first day so the archive starts at session one. the tools are the easy part. the habit is the practice.