# The hot and cold conversation archive

> How my agent conversations move from each home into a 90-day hot tier and a permanent cold tier, and why message text never reaches a metric.

- Canonical page: https://rob-knight.com/articles/the-hot-and-cold-conversation-archive/
- Kind: Article
- Published: 2026-09-26
- Updated: 2026-10-21
- Topics: agents

Every enrolled Claude, Codex and pi home sends its conversations to one archive on my homelab server: a 90-day hot tier of recent JSONL and a permanent NFS tier holding everything. Most of the design is refusals. Divergent versions of a conversation are kept side by side, a laptop cannot delete history, and a prompt never becomes a metric label. This is how the transcripts get there and stay there. What reads them back out is [agent-history](/articles/building-an-agent-transcript-indexer/), which has its own article.

<figure class="diagram figure-breakout">
  <img class="on-light" src="/diagrams/48-tldr-the-hot-and-cold-conversation-archive-light.svg" alt="A timeline of a conversation&#x27;s age, from 0 on the left through day 90 to forever on the right, with three rows. Agent home: uploads a checkpoint at most every five minutes, and at once when a session ends. Hot tier, highlighted, running from 0 to day 90: /opt/agent-sessions, plain JSONL. At day 90 a mark reads: pruned by one daily timer, only after the copy to cold succeeds. Cold tier, running from 0 with an arrow off to forever: NFS, never age-pruned, .versions/ keeps every replaced file. Below, a ledger headed what it refuses: two divergent copies of one conversation are both kept and parked, never joined or picked by time; a laptop cleaning up its own copies never deletes anything from the hub; prompts, paths and session IDs never become a metric label." width="1440" height="1016" loading="lazy" decoding="async">
  <img class="on-dark" src="/diagrams/48-tldr-the-hot-and-cold-conversation-archive-dark.svg" alt="" aria-hidden="true" width="1440" height="1016" loading="lazy" decoding="async">
  <figcaption>Both tiers are checked before anything is accepted, so a laptop that was offline for longer than the hot window still cannot upload an expired session as new.</figcaption>
</figure>

## Why keep the conversations at all

A transcript is the only complete record of what an agent did. The closeout that ends a run in [the agent loop protocol](/articles/the-agent-loop-protocol/) is the root's own summary and can be wrong, and a root doesn't count its own wakeups. [Why I don't use Codex for long-running agent loops](/articles/why-i-dont-use-codex-for-long-running-agent-loops/) shows what counting them over transcripts turned up.

The other reason is resume and restore: a conversation started on one Mac should resume on another, and one lost to a disk, a bad hook or a client's own cleanup should come back. Both need a copy off the machine that wrote it.

## One namespace per tool and context

The archive is split by tool and context, not by machine. I run two contexts along account and ownership lines, each with its own Claude, Codex and pi home, as [one agent context is not enough](/articles/one-agent-context-is-not-enough/) describes, and each enrolled home carries a `.context-transcripts.json` that binds it to exactly one namespace:

```json
{
  "context": "<context>",
  "home": "/Users/rob/.claude",
  "machine": "<mac>",
  "namespace": "claude-<context>",
  "tool": "claude",
  "version": 1
}
```

A conversation belongs to one tool and one context for life. Claude, Codex and pi write different formats and contexts are kept apart, so no two of them share a namespace, and archives of retired homes stay in their own namespaces, never imported wholesale into a live one. A pi home, `~/.loop-pi-<context>`, binds to its context's `pi-<context>` namespace with `"tool": "pi"`, and a variant pi home for the same context shares that namespace rather than getting its own. Only a loop-pi home can take a pi binding, never a native one, and it syncs its `sessions/` directory and nothing else. The retired GPT-routed Claude home, `~/.claude-openai`, kept its own `claude-openai` namespace, and its archived conversations never move to or from that context's main Claude namespace. There are no per-machine namespaces.

The server enforces the binding. Each Mac uploads through a forced-command SSH key restricted to one namespace and one machine identity, so a misconfigured home still cannot write into another context's history. The operating contract says not to broaden those keys for convenience; a key that can write two namespaces is the mistake namespaces exist to prevent. And a policy file published into a home does not prove the home is enrolled. Only the binding and a working key do that.

## Checkpoints from the client

Uploading only at session end would leave a gap: a single agentic turn can run for hours, and if the Mac slept, crashed or lost its disk mid-turn, none of it would have reached the server.

Claude homes checkpoint automatically. The `UserPromptSubmit` and `PostToolUse` hooks upload the transcript, and only the transcript, throttled to one successful upload every five minutes per home under a shared non-blocking lock. [Claude Code's hook reference](https://code.claude.com/docs/en/hooks) defines `PostToolUse` as firing after a successful tool call, with failures handled by a separate `PostToolUseFailure` event. Without the throttle, the busiest part of a session would become a backup job. `Stop` and `SessionEnd` upload immediately.

Codex runs the same hooks and script. Codex kills detached hook descendants, so on both tools the checkpoint runs synchronously inside the hook with a 15-second bound. The difference is that Codex fires a new or changed hook only after it has been approved by hand through `/hooks` in each context, and an installed hook with a passing test doesn't prove it fired. Once they are trusted, its checkpoints are as automatic as Claude's.

pi has no hooks to approve. The pi home's loop-continuation extension runs the same sync script under the same lock file, on the same rule: at most one checkpoint every five minutes after the last successful upload, triggered by input, the end of a tool call and a 60-second ticker, with an unthrottled upload when a turn settles or the session shuts down. The ticker matters because lanes don't load the continuation extension, so only the root's process uploads their transcripts. pi homes only push.

Claude and Codex homes also pull, at session start and from a job that uploads then pulls every enrolled home every five minutes, so an idle Mac converges too. A pull imports transcripts the home is missing from its own namespace. Where the local file is an exact byte prefix of the hub copy it appends the rest, whole lines only, but only once nothing local may still be writing it: not modified for ten minutes, not held open, and for Claude not registered to a running session. Anything uncertain is deferred to a later pull, and a local file that has diverged is kept as a conflict instead of being overwritten.

## Staging and the accept journal

The hot tier is `/opt/agent-sessions` on that server: plain JSONL, 90 exact days, not a Git repository. Append-only files written from more than one machine conflict constantly in Git, and a merge is the wrong operation for two versions of one conversation.

Uploads land first in a private staging mirror, `/opt/agent-sessions-incoming/<namespace>/<machine>/`, which rsync keeps between runs as its delta baseline. The forced SSH command promotes only linear appends into the hot store, and a journal per namespace and machine records every candidate as `pending`, `accepted` or `parked`. Nobody promotes or edits staging by hand. A raw drop into the hot tree would let any client overwrite anything its key could reach.

## Continuations and parked copies

Byte-identical copies deduplicate, and so does an older copy that is already a byte prefix of the stored one. A candidate is accepted as a continuation only when the existing copy is an exact byte prefix of it. Anything else is divergent and parked, never joined: the receiver copies it to `/opt/agent-session-conflicts/<namespace>/<machine>/<time>/`, records it as `parked` and skips it until the client changes that file again. The nightly archive run copies the conflicts tree into cold storage, and resolving a fork is a manual decision.

Forks are normal: a session resumed on two machines, a retry that rewrites the tail, a client restoring an older copy. Picking a winner by modification time silently destroys one branch, and concatenating produces a transcript that never happened. Keeping both only costs disk. Divergent files can share one session ID, so identity is the bytes, not the ID, and each copy stays separate.

The prefix check runs against cold as well as hot, so a laptop offline for longer than the hot window cannot upload an old copy of an expired session as new. Successful-upload ledgers per namespace, machine and home stop unchanged files already in cold storage from being resurrected into the hot tier.

<figure class="diagram figure-breakout">
<img class="on-light" src="/diagrams/22-accept-dedupe-or-park-light.svg" alt="An uploaded candidate goes through three checks. If it is byte-identical to an existing hot or cold copy it is deduplicated. If an existing copy is a byte prefix of it, it is accepted as a continuation. Otherwise it is parked, both copies are kept and a person resolves it. Every check runs against hot and cold, and nothing is joined by modification time or session ID." width="1440" height="864" loading="lazy" decoding="async">
<img class="on-dark" src="/diagrams/22-accept-dedupe-or-park-dark.svg" alt="" aria-hidden="true" width="1440" height="864" loading="lazy" decoding="async">
<figcaption>Identity is tested before continuation, so re-uploading an unchanged file never counts as new history, and parking is what happens whenever neither test passes.</figcaption>
</figure>

## The cold tier, and the one timer that prunes

The permanent authority is `/nasmount/Admin/agent-sessions` on NFS. Its JSONL is never age-pruned, `.versions/` keeps every replaced file and `.archive-receipts/` records each archive run.

`agent-session-archive.timer` is the only thing that deletes from the hub. Once a day it copies the hot tree into cold storage, versions replaced files, writes its receipt, and only then prunes hot JSONL older than 90 exact 24-hour periods. A missing mount or a failed archive write blocks the prune.

Homes do delete their own local copies. Claude Code's [memory documentation](https://code.claude.com/docs/en/memory) describes transcript deletion after `cleanupPeriodDays` while retaining memory files, and a Codex home deletes rollouts older than ten days, keeping its newest five so resume is never empty, but only once the hub listing shows it holds at least as many bytes and nothing local is writing the file. A tombstone stops the next pull fetching a pruned file back unless the hub copy has grown since. Local cleanup never touches the hub, so it can't delete an unarchived session.

A receipt proves a filesystem snapshot completed, not that every JSONL line is valid. A missing hot file doesn't prove a session never existed either; check the matching cold namespace and its `.versions/`. The catalogue reads both tiers when it rebuilds, so it isn't limited to the 90-day window, but it is derived data: never an archive and never a restore source.

<figure class="diagram figure-breakout">
<img class="on-light" src="/diagrams/11-session-archive-tiers-v3-light.svg" alt="Agent homes upload through forced-command keys to private staging and a 90-day hot JSONL tier. Conflicts are parked, never joined. The daily timer copies to permanent NFS cold storage before pruning hot files. Cold keeps the main tree, replaced versions and archive receipts. The derived catalogue reads hot and cold for rebuilds, and collectors export OTLP." width="1440" height="1080" loading="lazy" decoding="async">
<img class="on-dark" src="/diagrams/11-session-archive-tiers-v3-dark.svg" alt="" aria-hidden="true" width="1440" height="1080" loading="lazy" decoding="async">
<figcaption>The catalogue sits beside the two retained tiers. Losing an index is a rebuild problem; losing the only transcript copy is a restore problem.</figcaption>
</figure>

## Restoring a conversation

Restore has four rules:

- Restore only into the matching tool, context and transcript bucket, after preserving whatever is there.
- Refresh the restored file's modification time, so the client's local cleanup doesn't delete it straight away.
- For Codex, mark the existing local index stale so Codex rebuilds it from the imported JSONL.
- Never copy or merge a client's SQLite, WAL or shared-memory file, and never manufacture rows.

Only a real round trip proves a restore: checkpoint, upload, archive receipt, restore and an actual resume through the current route. Static checks and file counts don't.

## Message text stops at the metrics boundary

Transcript text can hold credentials and private conversations, so it must not reach metric labels, logs, tickets or tracked files. Labels stay bounded operational dimensions such as tier, namespace, agent, machine, model, role and status. Paths, working directories, session and turn IDs, hashes, prompts, responses and artefact names are forbidden as labels, and any new metric has to stay content-free.

The metrics come from collectors inside the agent-history indexer, which push over OTLP; nothing writes a textfile or serves a scrape endpoint. The efficiency collector is the only one that reads transcript content, and it does so in process, so only counts and bounded labels leave it. The publishing boundary enforces the rest. A label value outside its producer's vocabulary is refused rather than rewritten, a model not on a public allowlist is exported as `other`, and an archive-only standalone namespace collapses to `<agent>-standalone` with machine `other`. The systemd state of the archive timer and the receiver comes from a separate node exporter, so the alerts on those units don't depend on the indexer running. [Building an agent-transcript indexer](/articles/building-an-agent-transcript-indexer/) covers the collectors, the efficiency classifier and the dashboards and alerts built on them.

The catalogue on the other side of that boundary stores everything unredacted, which is why it's treated like the transcript directories it came from.

## Resuming on another Mac

Homes bound to the same tool and context share a namespace across my Macs, under three rules:

- A resume on another Mac waits for the upload to complete. A checkpoint that hasn't landed is not a copy.
- One writer at a time, or an explicit fork. Two machines appending to one session produce exactly the divergent copies the receiver parks.
- An active transcript is never a reconciliation target, because the local copy may be mid-turn.

Consolidating history between homes of the same context follows the same rules, using immutable preimages, verified IDs and the prefix check.

## What this does not claim

The archive proves a conversation was stored and versioned. It doesn't prove every line parses, or that a resume works until one has been done end to end; each has its own metric or test, because one green light would merge separate claims. The efficiency metrics show where time and calls went, not whether the work was good. That still comes down to me reading the run's [closeout](/reference/loop-contract/#12-waiting-recovery-and-closeout), whose `For ratification` section lists every ruling and open question with the root's recommendation.
