Agent profiles, part three: backup and automation
How eight agent profiles get backed up to a self-hosted git server, a homelab knowledge repo built the same way, and the tooling that keeps two shared documents honest across 41 repositories.
RevisedAdds live drift-checker output, and points at the new part six for the telemetry that made the campaign model measurable.
Part one covered the eight isolated agent profiles I run, and part two covered how their shared configuration is rendered rather than hand-edited. This part covers where all of it actually lives, a homelab knowledge repository that copies the same idea, and the tooling that keeps one shared document identical across every board that consumes it.
Backing up an agent home, not just its code
Every profile is a real git repository, backed up to a self-hosted Forgejo instance reachable only on my LAN and tailnet. Each profile’s .gitignore is an allowlist rather than an exclusion list: /* first, then explicit re-includes for what should be committed. For Claude that means settings, a sanitised trust file, credentials, session history, generated rules and references, native skills, hooks and the render receipt. Plugin caches, session indexes and desktop junk are excluded. Codex profiles follow the same shape and additionally exclude their SQLite indexes, derived state you can rebuild by replaying the session log rather than something worth carrying forward.
Secrets and personal data are committed on purpose. That is only defensible because the remote is a private instance reachable only on my own network, and it is not a pattern to copy onto GitHub or anywhere else public. It trades one risk, losing a working machine, for another, a private repository holding live credentials. I would not do it on a host I did not own.
Two merge drivers in .gitattributes exist because of a real failure, not as a precaution taken in the abstract:
history.jsonl merge=union
projects/**/*.jsonl merge=union
.agent-policy-render.json merge=ours
.claude.json merge=ours
Session history is append-only JSONL, written concurrently from more than one machine. Without a union merge driver it conflicts on every single auto-merge, the backup hook aborts, and the two sides diverge permanently rather than temporarily; one machine reached 36 commits ahead and 80 behind before anyone noticed. Files that are wholesale machine-local state, like the render receipt, take merge=ours instead, because a line-by-line merge of them would be meaningless: whichever side is local is right, full stop.
Non-interactive git access needs its own care. My credential store resets any inherited helper before setting its own:
[credential "https://forgejo.internal"]
helper =
helper = store --file /Users/rob/.config/git/forgejo-credentials
The empty helper = line is not decorative. Without it, an inaccessible macOS Keychain helper swallows headless and SSH requests silently, and backup correctness ends up depending on a GUI login session being open, which defeats the point of an unattended backup.
Home dotfiles get a separate ownership boundary: a bare repository at ~/.dotfiles.git with $HOME itself as the worktree, driven through a small alias rather than cd-ing into it. A scheduled job updates tracked files only, using git add -u rather than -A, so new paths need one explicit add and the timer never goes discovering arbitrary files under your home directory. No agent lifecycle hook touches the dotfiles repository, and the dotfiles timer never touches a profile repository. Every one of these repositories is append-only: merge remote changes, never rebase, reset away a commit or force-push.
What the wire capture costs
Part one covered the proxy that Codex Personal and Mobile route through. What earns it its keep is telemetry that exists nowhere else. codex-lb is a passive observer of the Codex CLI’s websocket session, and codexlb2otel tails the archives it writes and emits OTLP metrics, Loki logs and optional traces: OpenAI’s internal engine ids, engine queue timing, the sub-agent spawn tree, per-response token counts. None of that appears in anything the CLI prints.
It also carries the conversation. Assistant messages, tool input, complete command stdout. Anything an agent printed, including a secret it happened to cat, lands in the log store verbatim. loki.record_types is the control there: listing only turn, transport and error gives you an event timeline with no bodies in it, and leaving it empty ships all of them. So the switch exists and mine is turned the permissive way on purpose.
That is the same trade as committing credentials to a private git server, and it holds for the same reason and only that reason. Single tenant, my own data, a store I control. Pointed at a shared log store it would be indefensible, and I would not make the same call.
One thing no amount of configuration gets you back. reasoning.encrypted_content and the sub-agent spawn payloads are encrypted with OpenAI’s key rather than the proxy’s. They are well-formed Fernet, which makes them look like they ought to open with the proxy’s own key, and they never do. Reasoning traces are permanently unrecoverable and only the token counts survive.
Measuring the thing before changing the rule about it
The proxy capture answers what a session did. It does not answer what happens when a long session runs out of context, which turned out to be the question that mattered, because the campaign instruction I had been shipping for months was built on a guess about it.
A fourth thing answers that: a read-only collector over the session files the CLI already writes, running as a launchd service every 300 seconds, storing no prompt, no command, no tool output and no credential, and living in none of the backup repositories because a transcript-derived research export has no business being committed where the transcripts themselves are not. What it found changed the instruction, which is part four. How it is built, what the three telemetry layers each fail to see, and the contract deciding what any of its numbers may be used to claim, are part six.
The general point is the one worth carrying here. The rule it replaced had a plausible story attached and no number, and a plausible story is the expensive kind of wrong, because nothing about it ever fails.
chat-personal, the homelab knowledge repo
If you run a homelab, this is the part I’d steal first. chat-personal is a multi-topic technical workspace, backed up by the same hook as my personal Claude profile, structured as one subfolder per topic: camden/ for the primary Docker Compose host, jules/ for an SDR receiver box that doubles as a CI runner, proxmox/, scotty/ for the NAS, unifi/, opnsense/, homeassistant/, and so on for every system I run at home.
You launch the agent from the folder you’re working in, which is what makes the pattern work: you pick up that folder’s own AGENTS.md, its own tool grants, and its transcript lands under that folder’s key, while project memory stays keyed on the repository root so it’s the same memory store from every folder. Every new topic folder gets exactly three files: an AGENTS.md, a CLAUDE.md whose entire content is a pointer to it, and a settings file granting only the tools that folder needs.
The root AGENTS.md carries a folder table, and the rule for that table is that it documents only the folders you would get wrong. A host folder that behaves exactly as its name suggests gets no entry. One that hides a surprise, a service with an odd dependency or a box doing two jobs, does. Pad the table with obvious rows and you bury the rows that were worth reading.
Every line in that repository’s .gitignore carries a comment saying why it is there, specifically so nobody tidies away a line that is doing real work. Two of them are re-includes rather than exclusions, and those are the two that bite:
# Machine-wide git ignore excludes docs/superpowers/ for every repo; a repo-level
# .gitignore is the only thing that outranks it. Deleting this line silently stops
# tracking every plan in the repository.
!**/docs/superpowers/
# Re-include per-topic tool grants for the same reason as above.
!**/.claude/settings.local.json
Without the first line, a machine-wide ignore rule set once in ~/.config/git/ignore would quietly stop tracking every planning document in every topic folder, and nothing would announce that it had happened. A .gitignore full of unexplained lines invites exactly that kind of deletion; a .gitignore full of explained lines survives someone else, or a later you, trying to clean it up.
One folder, health/, excludes its scrape logs specifically, because those logs list medical record identifiers while the scripts that produce them are fine to sync. Splitting a folder by sensitivity, instead of calling the whole thing safe or not safe, is the useful move there.
agent-docs, keeping one document honest across many repositories
Some documents, like the fan-out protocol that part four is about, need to exist in dozens of repositories that each run their own tracker. Two of them do: the fan-out protocol in 41 repositories and a project-specific wave operating model in seven more, 48 rendered copies in total. Hand-copying it into each one produced real, measured failures: an operating-model update reached three of five boards for several waves while everyone believed it had reached all five; import headers describing the same import drifted into eleven shapes, two of which named the wrong repository outright; a fleet-wide re-import matched on a filename and silently skipped one repository, which then reported as covered because it happens to keep that document under a different id.
None of that was visible without measuring it, so the copies became build output: canonical sources, a per-repository header template, a registry mapping repository to document and local id, a renderer that prints the exact expected content for one pair, a sync step that writes it, and a doctor that reports every consumer whose copy doesn’t match. The header records the source commit rather than a date, because a date can’t be checked and a commit splits one vague worry, “is this up to date”, into two precise questions: does the copy match what that commit renders, and is that commit still HEAD. Those need different fixes, so the doctor reports them separately, plus a third state for a copy that simply isn’t there.
Its output is one line per consumer and a count, and the count is the point. Running it while writing this said current=47 stale=0 missing=1, with the missing one named. That is a more useful answer than a green tick, because the interesting states are the ones where a copy exists and is wrong, and a checker that can only say pass or fail cannot tell you which of the three you are in. The repository this site is built from is itself on that registry, so the instructions the agents working on the blog load get drift-checked exactly like every other consumer’s.
Correction stays manual on purpose. Detection runs on a schedule; pushing a fix into every consumer from CI would need broad write access across the whole fleet to correct something that is rarely urgent. Thirty other documents share a title with each other and almost nothing else, which somebody had to measure before they could be left alone, because there was no canonical body to point them at. “These are not the same document, do not unify them” earned its place in the README.
The document those 41 repositories are kept honest for is the campaign protocol itself: how a long run with several agents working at once is specified, owned and reported. That is part four, and it is the part of this setup I would actually recommend to somebody who runs none of the rest of it.