tinkerlab.dev
/blog/building-the-session-rec...

Building the session record

This is the third part of the series, and the one I was still living inside when I published it. Part 1 named the gap; Part 2 mapped how twelve other projects approach it. This part was posted in July as a spine of headings and a promise to fill them in as the project shipped. In the month since, the project shipped — and then I deleted it and built it again. So this is the build log, including the part where I threw the first one away.

”Keep the byte-faithful transcript as ground truth. Everything clever is an optional layer on top.”

The thesis hasn’t moved since Part 2: keep the lossless, vendor-neutral transcript as the source of truth, and treat distillation as a derived layer. Where the field stores summaries and discards the record, this stores the record and makes summaries optional.

What has moved is everything else. The first implementation lived in a private repo on my own forge, was shaped by my homelab, and has been quietly logging every session on this machine since February. The second — Claude Transcripts, public, documented, and tagged v0.0.1 — is a clean rebuild of the same idea with none of my hostnames in it. Both facts are load-bearing, and the honest version of this post is about the distance between them.

Status, plainly: Claude Transcripts is an early preview. Tier 1 — one machine, one user, no auth and no security model. It has not been validated on a clean install, breaking changes land without notice, and stored data may have to be thrown away between revisions. Read it, poke at it, don’t depend on it.

§1 · What actually got built

The shape survived both versions, which is the best evidence I have that it was right.

Claude Code ──hook──► webapi ──► CouchDB + S3        webui ─┐
                        ▲                             cli  ──┼─► webapi
                        └───────── reads/writes ──────agents┘

A hook registered against Claude Code’s session-activity events — SessionStart, UserPromptSubmit, PostToolUse, PostToolUseFailure, SubagentStart, SubagentStop, Stop, SessionEnd — writes each event as it happens. At session end it uploads a summary and the full transcript. A read side (an HTTP API, a small React browser, and a CLI) serves it back. Two stores, no columnar tier, no message queue — deliberately the lightweight middle that Part 2 argued for.

The rule the hook lives by is that it never blocks a session. Every external call is wrapped; if CouchDB is down, or the object store is unreachable, or the network has gone away, the write is lost and the session carries on. A logger that can stall your actual work is worse than no logger.

Here is what that has accumulated on one desktop, queried out of the live database while writing this paragraph:

Sessions summarised345, February → August 2026
Event documents40,673
Transcript archive~330 MB of transcript.jsonl
Tokens accounted3.82 billion — of which 3.73 B cache reads, 74 M cache creation, 17.5 M output, 1.1 M input
Most-used toolsBash 17,153 · Read 6,325 · Edit 3,991 · Write 1,728

Two things jump out of that table. The first is that cache reads are 97% of the token traffic — the visible cost of an agent re-reading its own context on every turn, which you simply cannot see from the outside. The second is the tool distribution: this is a record of an agent that mostly runs commands and reads files, and writes comparatively little. Neither number is available anywhere else. That’s the whole argument for keeping the record, restated as data.

§2 · Why a document store plus object storage

This is the choice everything else follows from, so it got the longest argument.

CouchDB for the metadata. A session log is heterogeneous, schemaless data: a stream of event docs whose fields differ by event type, plus a summary at the end. What won it:

  • Schemaless fit. Event docs of different shapes coexist with no migrations. New fields — CLI version, host identity, config fingerprint — just get written.
  • HTTP-native. Everything is plain HTTP, so the hook writes with nothing but fetch. No driver, no client library, no binary dependency on the machine being logged. This matters more than it sounds: the hook has to install on any machine where I run Claude Code, and the cheapest install is one that needs nothing.
  • Fauxton. CouchDB ships an admin UI. The data is browsable and queryable before I write a single line of front end — which meant the viewer could stay a convenience instead of becoming a dependency.
  • Map/reduce views. One corpus, projected many ways — by date, by working directory, by tool usage, an activity timeline, token totals — and new projections are new design docs, not a schema change on the write path.
  • Replication. CouchDB’s replication is the whole reason to pick it if you ever want more than one machine. Every doc already carries a hostname. Linking instances over a private network so a team shares one history is a feature I get by not building it.
  • It reads back to the agent. The point is not a dashboard. The point is that a future Claude Code session can query what past sessions did. An HTTP-native document store is a thing an agent can already use.

S3 for the transcript. The transcript is a byte-faithful copy of Claude Code’s own record — not a re-serialisation, not a normalised version, the actual bytes — and object storage is where multi-megabyte immutable blobs belong. I use Garage, which is FOSS, light, and self-hosted; MinIO stopped being maintained as FOSS, which forced the move. But the storage layer is addressed through Bun’s built-in S3 client via plain env vars, so MinIO, R2 or AWS work by changing an endpoint. Object storage also gives me somewhere to put the things a document store should never hold: pasted screenshots that were inputs to a session, test artefacts, whatever binary ends up adjacent to a conversation.

Note the deliberate asymmetry: the storage layer is vendor-neutral; the AI-product layer is not. This targets Claude Code specifically. An adapter layer over “agents in general” would have to erase exactly the details — the hook events, the transcript format — that make the features worth having. Avoid lock-in where it’s cheap; embrace specificity where it pays.

The decision I got wrong

For months the transcript was stored twice: as an S3 object and as a CouchDB attachment on the summary doc, so the data would be self-contained for anyone running without an object store. It was unconditional, despite a feature flag that was supposed to gate it and that the handler simply ignored.

Transcripts are big — one session in my own corpus is 4.2 MB. Attaching them bloated the primary store with bytes S3 already held, inflated replication and view-build cost, and bought nothing in the deployment I actually run. The flag was never once enabled, and the two read paths meant every “does this session have a transcript?” check had to consult attachments or a size field.

So it came out. The 281 legacy docs still carrying attachments were verified against S3 first — every attachment byte-identical to, or a byte-exact prefix of, the Garage copy — then deleted and the database compacted. 367 MB → 17 MB. The summary doc now records transcript_bytes and nothing else; S3 is the transcript’s sole home.

”An escape hatch nobody uses is not a feature. It’s a second code path you pay for on every read.”

The reason I can tell you this story in that much detail is that both the original decision and its reversal are written down as architecture decision records, one file each, in the repo. That’s the actual argument for ADRs — not the decisions you keep, the ones you reverse.

§3 · The rebuild

On the 21st of July I started the whole thing again in an empty repository. Nine days and 64 commits later it was tagged v0.0.1. Here is why.

The first version worked, and it was still shaped by the room it was born in. It was consolidated out of three earlier homelab things — a Claude Code plugin, a viewer that used to live inside another app, and a third repo used as a structural template. It uploaded blobs by shelling out to rclone with named remotes configured out of band. During a storage migration it had learned to dual-write to two object stores at once, and kept the config schema that migration needed. It lived on a private forge, so the ADRs I keep citing weren’t readable by anyone but me. The design was documented; the documentation was inaccessible; the code assumed my machine.

None of that is fixable by refactoring, because the problem isn’t the code. It’s that the project’s centre of gravity was a homelab rather than a user. So: new repo, public, on GitHub, new name — Claude Transcripts — and the design set re-written rather than copied across, with the reversed decisions reconciled instead of papered over. The predecessor, meanwhile, is still running. It is the thing logging this session as I type. I’ll cut over when the rebuild has earned it, and not before.

Update, 26 August 2026: it earned it. The predecessor was retired, its history imported, and its database and bucket archived and dropped — eleven releases later. Part 4 is the cutover and what the month in between turned up.

The rebuild is where the decisions I hadn’t been forced to make yet got made:

Three tiers, stacking. The ambition here runs from “keep my transcripts on one laptop” to “a replicated multi-user corpus that agents learn from.” Designing for all of it at once either over-builds the simple case or bakes in single-machine assumptions that block the ambitious one. So: Tier 1 — one machine, one user, no auth, retention and browse and programmatic access. Tier 2 — make the history useful: attribution across machines, analytics, recall during live sessions. Tier 3 — multiplayer: replication, auth, a real security model. Each tier a strict superset of the one below, and nothing in a higher tier may break a lower one. Tier 1 is what shipped.

The API is the only door. Several backends, several consumers — a UI, a CLI, agents, whatever comes later. If each consumer talks to each backend, every internal change breaks everything. So the HTTP gateway is non-optional and is the single path for all application I/O: all writes go through curated endpoints that own the document shapes, and reads go through it too, including read-only passthroughs to CouchDB’s own HTTP API and to S3 objects (because those native surfaces are genuinely useful as-is). The UI and CLI are clients and nothing more, with their TypeScript clients generated from the OpenAPI spec. The internals are then free to churn — chunking on or off, search engine swapped, object store absent — without breaking the contract.

Migrations, written by hand. CouchDB has no modern migrations framework — no standard versioned up/down tooling of the kind relational ecosystems take for granted. And the schema will move: new doc types, new fields, changed views. So the rebuild ships its own: a stored schema-version marker, migrations that declare both directions, and — the part off-the-shelf tools wouldn’t have covered anyway — design views migrated in the same versioned step, so a view can never drift from the document shape it maps over. It’s idempotent and --dry-run-able, and the same machinery powers export/import, so a bundle exported at one schema version can be imported and brought forward.

Hooks and actions, decoupled. The first version mapped one hook event to one handler, which conflates two different things: which events exist and what we do about them. They’re now two independently-maintained lists with a many-to-many mapping between them — so one event can drive several actions and one action can be driven by several events, and the canonical hook list can be diffed against upstream Claude Code to catch drift when the harness adds an event.

Chunks that carry their content. This is the interesting one. The hook flushes chunk docs mid-flight rather than only writing at session end, which buys crash resilience: a session that dies without a SessionEnd isn’t a total loss. But a chunk originally recorded only a byte range — a pointer into the S3 transcript. Which meant CouchDB could not see a single conversational turn, and map/reduce cannot run over an object store. Everything I wanted downstream — speaker-split views, per-turn search indexing, feature extraction, prompt provenance — was blocked on that. So chunks were promoted to carry their parsed entries: role, stable per-entry index, timestamps, content. Append-only, immutable, keyed by byte offset, and reconstructable from the transcript. That single change is what makes content search and the You/Claude speaker toggle possible at all.

§4 · The parts I stole, honestly

Part 2 was reconnaissance for this section, so it’s worth reporting what actually got taken.

Adopted. Langfuse’s session → trace → observation vocabulary shaped the event model, because it turns out to be the right decomposition whether you’re debugging a model call or reconstructing an afternoon. Meilisearch, from the same neighbourhood, does full-text search over session metadata and conversation content, returning snippet hits — that’s the one recall feature in the running system today. Graphiti’s bi-temporal instinct arrives here in its cheapest possible form: every document is append-only and immutable, carries a schema version, and is never edited in place, so the record has provenance without anyone standing up a graph database.

Deferred, deliberately. Mem0’s ADD/UPDATE/DELETE fact reconciliation, Letta’s sleep-time reflection, Basic Memory’s defragmentation — all still the right ideas, all still sitting above the line where the interesting work is. None of them made Tier 1, because all of them are derived layers, and the thesis says derived layers wait until the ground truth is solid. Semantic recall over the archive and an MCP surface so a live session can query its own past are Tier 2, written down as issues rather than as code.

That ordering is the discipline the whole series is arguing for. It is very tempting to build the clever recall layer first, because that’s the part that demos. The record underneath it is the part that can’t be recreated later if you skip it.

§5 · What the release taught me

I cut v0.0.1 on the 29th of July. Before tagging, I dry-ran the three tag-triggered CI workflows — none of which had ever executed — and found two things worth repeating:

The image mirror pulled an image that doesn’t exist. The mirroring script kept its own hardcoded copy of the backing-image list, in which one publisher’s account was misspelled. The application model had the correct list the whole time. The bug wasn’t the typo; the bug was that the list existed twice. The script now projects it from the model, so the two can’t drift, and the images are pinned rather than floating on latest.

The published image shipped the entire dev toolchain. The runtime stage copied the full node_modules, so the bundler, the linter, the codegen tool and all of their vulnerabilities went out to users — and tripped the release’s own severity gate on the way. Fixed by installing production dependencies only and patching the base image’s OS packages, because published base images lag behind security updates between rebuilds.

Both bugs are the same bug wearing different clothes: something was duplicated instead of derived. And both were found by running the pipeline before it mattered, which is the cheapest possible time to discover that a workflow you’ve never executed doesn’t work.

§6 · What’s missing

The honest ledger, because a build log that only lists wins isn’t one:

  • No auth. None. Tier 1 is explicitly a trusted single user on 127.0.0.1. Anything network-facing is Tier 3, and until then the correct deployment is a localhost one.
  • No secret masking yet. A faithful transcript is faithful about everything, including the API key you pasted at 1 a.m. Scanning and masking on write is on the roadmap and is the single most important thing not yet built. Keeping the store local is a mitigation, not a solution.
  • The recall loop is unbuilt. The whole point of Part 1 was that I couldn’t reconstruct my own work. Search over the corpus helps. An agent that queries its own history during a session is Tier 2, and it’s the feature the other two parts of this series were written in anticipation of.
  • The scale ceiling is real and untested. 345 sessions and 40,000 documents is comfortable. I don’t know where CouchDB views stop being comfortable, and I haven’t gone looking. Full-content chunks make the corpus grow considerably faster than it used to.
  • The external-backends path is unverified. Pointing it at a CouchDB, S3 store or Meilisearch you already run is designed for and plumbed, but nobody has exercised it end to end. It’s marked TODO in the docs rather than quietly implied to work.

There’s a symmetry I like here. Part 1 was about not being able to reconstruct my own work from memory. Every number in this post — the session count, the token split, the tool ranking, the 367 MB that became 17 — came out of the record the system kept, not out of my recollection, which would have been wrong about all of them. The thing works well enough to have written its own build log.


This is Part 3 of The Session Record. Start at Part 1 for the gap, or Part 2 for the field it grew out of; Part 4 continues the build log. The project is github.com/vredchenko/claude-transcripts — design docs, ADRs and all, at vredchenko.github.io/claude-transcripts.