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:

  1. the archive. every session's raw jsonl, plus its side directory (subagents/agent-*.jsonl and tool-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.
  2. 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.
  3. 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.

step chart of cumulative tokens across all drumpy claude code sessions over five months, rising to 4.0 billion; two steep stretches, then a long flat one
cumulative tokens across drumpy's claude code sessions, one point per day with a session. the flat stretch is a month i wasn't working on it. drawn from the tokens series in docs/sessions/stats.json.

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