Back to articles

Software Development

Keeping your Claude Code logs when the machine underneath them disappears

Development notes from building a small tool with Claude, and what the disagreements taught me.

https://github.com/markmatsu/claude-logkeeper

The setup that led here

A few years ago, coding with ChatGPT meant copy-pasting. You'd select some code in your editor, paste it into a chat window, read the reply, and paste the AI's version back. It worked, but it was clerical labor, and it broke down the moment a change touched more than one file. Selecting six files and pasting them into a chat box, with enough surrounding explanation that the model understood how they fit together, was tedious enough that you often just didn't bother.

Claude Code, and agentic coding tools generally, grew out of that friction. Instead of you shuttling context back and forth, the tool reads and edits the files directly.

I wanted that. What I didn't want was to run it on my own laptop. Giving an agent free rein over my local filesystem makes me uneasy, and I know myself: even with a permission prompt before every action, I'm the kind of person who mashes the "allow" button without really reading what I'm approving. The safety valve only works if you use it, and I wouldn't.

So I decided to run Claude Code in a disposable cloud environment instead. If an agent is going to have broad access to a machine, I'd rather that machine be a throwaway container than my actual computer. Standing up my own VS Code on an EC2 instance is more sysadmin work than I wanted. AWS Cloud9 was a lovely fit for exactly this, but it's closed to new users now.

GitHub Codespaces turned out to be the sweet spot. It's free at the scale I use it, it opens VS Code in the browser, and the environment is genuinely disposable. One detail that pushed me toward Claude Code over the alternatives: its remote sign-in flow actually works in a browser-hosted editor. When VS Code runs in a browser tab, it can't pop open a new tab for OAuth the way desktop VS Code does, and Claude Code handles that case cleanly. I also don't love driving a coding agent from a bare command line. If I have a full editor open, I want to work inside it, and the Claude Code VS Code extension puts the agent right in that UI.

So began a pleasant stretch of using Claude Code inside Codespaces. Editing code without narrating file structure to the model or pasting anything into a chat box was, as promised, comfortable.

Then two problems showed up.

The small one: reviewing what I'd actually discussed with the agent was awkward. The extension's conversation panel is a tall, narrow column, and the model produces a lot of text. Scrolling back through a long session to find "what did we decide about X" was unpleasant.

The big one arrived the day I installed Codex into a Codespace I'd been using for Claude Code. Something about that, and I still don't know exactly what, left the Codespace unable to start. That alone is fine; a broken Codespace is a non-event, you just create a new one. But my Claude Code history went with it. If you run Claude Code locally, the transcripts sit in ~/.claude on your disk and survive. A remote environment being rebuilt is, from the perspective of your local disk, the same as buying a new computer. There was no reason for the logs to still be there, and they weren't.

That loss is what started the conversation this essay is really about. I sat down with Claude (the chat assistant, in the browser) and asked how to keep Claude Code's logs from evaporating with the container. Somewhere in that conversation the goal grew: if I'm going to solve this, I'd like it to be something you install by dropping a couple of files into a project, and, ideally, something a team could share, so developers could read each other's prompts the way they read each other's code.

The result is a small tool I now call logkeeper. What follows is less a tutorial than an account of how it got designed: the questions I asked, the answers that changed my mind, and the specific moments where a plausible idea turned out to be wrong.

Working out where the logs even live

The first useful thing Claude told me was where Claude Code keeps its state. Sessions are stored as JSONL transcripts under ~/.claude/projects/<encoded-project-path>/<session-id>.jsonl, plus a global ~/.claude/history.jsonl index. You can reopen them with claude --resume, which reads those files and reconstructs the session so you can continue where you left off.

That immediately explained the data loss. In a Codespace, ~/.claude lives in the container's home directory: not in /workspaces, and not anywhere that persists. It's doubly fragile: a rebuild wipes anything outside /workspaces, and a deletion wipes everything. There is no server-side history browser to fall back on either. I asked specifically whether Anthropic keeps a copy I could retrieve, and the answer was essentially no. The data may be retained internally under a retention policy, but there's no user-facing way to pull your Codespace sessions back out, and the claude.ai chat history is a separate silo from Claude Code entirely. The local files are the source of truth, which is exactly why they need backing up.

So the shape of the solution was clear: get ~/.claude off the ephemeral container and somewhere durable, automatically.

The first design, and the security problem that reshaped it

My initial instinct was the obvious one: git add ~/.claude/projects into a repo and push it. I even had a private repo handy, one that Vercel deploys from. I asked whether that was safe.

The answer reframed the whole project, and it's worth stating plainly because it's the single most important design fact in logkeeper. A Claude Code transcript is not just your prompts and the model's replies. It also contains, verbatim, the contents of every file the model read and the output of every command it ran. For my projects specifically, that means the JSONL could contain AWS credentials, an Auth0 management token, database connection strings (anything the agent saw), and, because some of my work touches databases holding personal data, potentially the personal information the agent pulled from a table during some debugging session.

Two consequences followed. First, committing that into a Vercel-connected repo was a genuinely bad idea: even setting aside Vercel's build surface, git history is permanent, and once a secret is in history, removing it is a painful rewrite. Second, and this is the insight the whole tool is built on, the danger lives specifically in the tool-call parts of the transcript. The prompts and the model's prose are comparatively safe to share. The file dumps and command outputs are where the secrets are.

That distinction became the core of the design: two layers. A plaintext layer containing only the human and assistant text, with every tool_use and tool_result block stripped out, safe enough to read and share within a team. And an encrypted layer containing the complete JSONL, so that full-fidelity restore via claude --resume is still possible, but only for someone holding a private key.

We went back and forth on storage. I explored non-AWS options because AWS felt heavy for "back up one directory." Cloudflare R2 and Backblaze B2 were attractive because they're S3-compatible, so an aws s3 sync pipeline ports over by changing an endpoint. rclone with its crypt overlay was attractive because it encrypts client-side. But I kept coming back to a constraint I liked: I wanted the whole thing to live in GitHub, installable by copying files. So the storage target became a second, dedicated private GitHub repository, separate from the working repo, so code and conversation logs never mix.

Choosing the trigger: not git hooks, but Claude Code's own hooks

Early on I assumed the trigger would be a git hook: back up on every commit. Claude pushed back on this in a way that turned out to be right twice over.

The first correction was mechanical. I wanted to run /export from a git post-commit hook. But /export is an interactive slash command inside the Claude Code REPL; there's no way to invoke it from an external shell script. The backup couldn't be driven by /export at all. It had to parse the JSONL directly, which, conveniently, is where all the data is anyway.

The second correction was conceptual, and it's the one I'd have gotten wrong on my own. My motivation was "don't lose history when the Codespace vanishes." A commit-time trigger doesn't serve that well: history accumulates during exploration, before any commit, and a Codespace can die at any moment. Claude Code has its own hook system (Stop fires after each assistant response, SessionEnd fires when a session ends), and, crucially, the hook receives the transcript_path of the current session as input. That solves a subtle bug I wouldn't have anticipated: with a "grab the newest JSONL" heuristic, running two sessions in the same project at once would mix them up. Because the hook is handed the exact path, sessions never get confused.

So logkeeper became a Stop + SessionEnd hook. Stop means that even a sudden container death only costs you the last in-flight response; everything up to the previous completed turn is already saved. This is a direct answer to the original problem.

I want to flag this as the clearest example of why the back-and-forth mattered. I came in with "commit hook." I left with "Claude Code hook, on Stop, keyed off transcript_path." The final design is better than my starting point in three specific ways I couldn't see from where I started, and none of those improvements came from the assistant simply agreeing with me.

Encryption, and refusing to design my own crypto

For the encrypted layer I said "RSA," from vague memory, and I'm glad I didn't say OpenSSL, which I also half-remembered. Claude corrected the mistake and offered something more modern: you don't encrypt files directly with RSA, since it can only encrypt a few hundred bytes. Real file encryption is hybrid (a symmetric key encrypts the data; RSA wraps the symmetric key). Rather than assemble that by hand, the right tool is age: modern, small, and it does the hybrid construction correctly.

age also shaped the team story in a way I liked. It encrypts to multiple recipients at once, and each recipient decrypts with their own private key, so there's no shared secret to distribute. Onboarding a teammate is: they generate a keypair locally, they hand me their public key, I add one line to a recipients file and commit it. Offboarding is: remove the line. It even accepts SSH public keys as recipients, and every GitHub user's SSH keys are published at https://github.com/<user>.keys, so a team that wants zero new key management can reuse keys they already have.

One rule stayed fixed the entire time: the private key is generated on a trusted local machine and never leaves it: never committed, never placed in a Codespaces secret. Putting the private key in GitHub would make the encryption pointless, since it would sit next to the ciphertext. logkeeper only ever handles public keys.

The safety valves, and a preference for graceful degradation

A few design choices came from imagining how this fails in practice.

Refuse to write to a public repo. If someone misconfigures the destination to a public repository, logkeeper stops rather than publishing transcripts. It checks visibility via gh when available, and falls back to an unauthenticated API call where a 200 response proves the repo is public and triggers the refusal. This is a hard stop, one of the few, because "save anyway" would be exactly the wrong instinct here.

Degrade instead of failing silently when a key is missing. An earlier version hard-failed if no valid age key was configured. I asked to change that, with a specific rationale: errors printed to a Codespaces terminal are easy to miss. So now, if there's no key, logkeeper still writes the plaintext transcript and, in place of the encrypted JSONL, writes a .ENCRYPTION-ERROR.txt note explaining what's wrong and how to fix it. The error becomes visible in the logs repo itself, not just in a terminal scrollback nobody reads. Once a key appears, the next save replaces the error note with the real ciphertext.

Never let a setup hiccup break the environment. This one I learned the hard way, which I'll come to.

The part where reality pushed back

Designing on paper is one thing. The last stretch was a sequence of small, real failures, the kind you only find by running the thing, and each one left a mark on the final tool or its documentation.

The universal image was painfully slow. My first devcontainer used the universal base image, GitHub's everything-included environment. The first build spent close to ten minutes pulling and extracting several gigabytes. Watching a build bar sit at "preparing layers for inline cache" for that long, I was sure it had hung. It hadn't: that step is BuildKit caching layers to speed up future builds, which is a slightly absurd thing to wait for on your first one. The fix was to switch to the lightweight base:ubuntu image and let the devcontainer features install exactly what's needed (Node.js, the Claude Code CLI and extension, gh) plus age and jq. That cut the first build to a couple of minutes. Not instant, and I made sure the README says so, because "a couple of minutes" set against an unqualified "fast" is its own kind of lie.

A broken third-party apt repo took down my whole setup script. With the lightweight image working, the build failed at the very end, in my setup.sh. The culprit was an expired yarn signing key baked into the base image, which made apt-get update return a non-zero exit code. My script had set -euo pipefail at the top (good hygiene in general), so the instant apt-get update returned non-zero, the entire script aborted before installing age and jq. The lesson generalized into a rule: a setup convenience script must never leave the environment in a failed state over something incidental. The rewrite drops set -e, tolerates a noisy apt-get update, retries the install while tolerating unsigned repos as a fallback, reports clearly which tools (if any) are still missing, and always exits 0.

Codespaces secrets aren't injected until you grant repository access. With everything building, setup.sh still reported LOGKEEPER_REPO env var is not set. I had created all three secrets. What I'd missed: a freshly created Codespaces secret has empty "Repository access" (it shows "0 repositories"), and a secret with no repository access is injected into no Codespace. You have to explicitly grant each secret access to your project repo, and then, because secrets are only injected at container start, restart the Codespace. Once I did, echo $LOGKEEPER_REPO finally returned a value. This is exactly the sort of thing that's obvious in retrospect and invisible in the moment, so it's now called out prominently in the README.

And then the token that was configured correctly still couldn't push. This was the last and most instructive failure. Environment variables were confirmed present. The fine-grained PAT had precisely the right scope: Contents read-and-write on the logs repo, nothing more. And the push still failed with "cannot write." The tell was one line above the error: warning: You appear to have cloned an empty repository. The problem wasn't the token; it was that my script never handed the token to git. It cloned and pushed with a bare https://github.com/... URL, which relies on git's ambient credentials (the ones scoped to the working repo, not the logs repo). gh auth status succeeding was a red herring, because that's gh's auth, not git's. The fix was to build an authenticated remote URL, https://x-access-token:${GH_TOKEN}@github.com/..., from GH_TOKEN (or gh auth token), and to handle the first-push-into-an-empty-repo case by setting the upstream branch. That was the change that made a real transcript finally land in the logs repo.

And then the fix for that broke ordinary git push. The obvious way to hand the token to git is to put the PAT in a Codespaces secret named GH_TOKEN. It works, and it is also a trap. GH_TOKEN is a name that both gh and git reach for automatically, for every operation. So the moment I set it, my everyday git push to the working repo started authenticating with the logs-repo PAT, which is scoped to the logs repo and nothing else. Pushing my own code failed with "Write access not granted," and the token it was complaining about was one I had deliberately given the narrowest possible permissions. The credential I had carefully restricted to one repo had quietly taken over authentication for all of them.

There are three ways out and they are worth distinguishing, because the tempting one is wrong. You can widen the PAT to cover the working repo too, which fixes the symptom by giving up exactly the least-privilege property that made a fine-grained token worth using. You can prefix a one-off GH_TOKEN= git push forever, which is not a fix. Or you can stop using the magic name: put the PAT in LOGKEEPER_GH_TOKEN, have the hook prefer that variable, and leave gh and git to their default Codespaces credentials. The hook now reads LOGKEEPER_GH_TOKEN first, falling back to GH_TOKEN and GITHUB_TOKEN only if it's absent. Everyday pushes go back to using the Codespace's own token, and logkeeper's credential does one job. The general lesson is that an environment variable is a namespace you're sharing with every tool on the box, and some names are already spoken for.

What I decided not to build

One thing worth recording is a change I considered and rejected, because rejecting it was itself a design decision.

logkeeper's failures are effectively silent. If the token expires, or a secret loses its repository access, or the logs repo gets renamed, the hook writes an error to stderr and nothing surfaces. Claude Code does show hook stderr, but only in the transcript view or under --debug, and in a browser-based Codespace running the VS Code extension you will realistically never look there. The symptom you actually notice is that no new files appear in the logs repo. That's a bad property for a backup tool, whose entire job is to be working when you aren't watching.

Claude Code does offer a proper channel for this: a hook can return a JSON object on stdout with a systemMessage field, and Claude Code will display it. So I asked whether to use it, and the answer changed my mind about wanting it.

The problem is that Claude Code parses hook stdout as JSON. Today logkeeper writes every message to stderr and puts nothing on stdout, which means stdout is inert and cannot break anything. Adopting systemMessage would make stdout a parsed channel in a script that shells out to git, age, jq, and curl, and if any of those ever emits an unexpected line on stdout, the JSON is corrupted and the session breaks. This is a known failure mode; a chatty shell profile is enough to do it. I would be trading quiet failures for a new and much louder one, in which my backup tool damages the session it's supposed to be backing up. On top of that, there are open reports of hook output not reaching users in the UI at all, so the improvement might not even land in the environment I care about.

So logkeeper keeps stdout empty, keeps failing quietly, and the README says so in plain language, along with the one-line command to run the hook by hand and see the real error. It's an unsatisfying answer, and writing it down as a known limitation is more honest than shipping a fix that trades a visible annoyance for an invisible risk.

What it looks like when it works

The end state is what I'd hoped for at the start. Drop .claude/ and .devcontainer/ into a project, set three Codespaces secrets (the destination repo, your age public key, and a PAT), and every Claude Code session backs itself up to a private repo in two layers. The plaintext transcript, holding only the conversation, is readable directly on GitHub and safe to browse; the encrypted .jsonl.age holds the full session for anyone with the key; an index.json tracks it all for a future viewer. Because the hook config is committed, it applies to every teammate who pulls: they add their own key and their own secrets, and their sessions start backing up too.

The first successful transcript confirmed the design held: the file contained the prompts and the replies and nothing else. The command outputs and file dumps, the parts that could leak a credential, were absent, sitting encrypted in the other layer.

A note on how it was built

I want to be honest about the process, because it's the actual subject here. I didn't hand an AI a spec and receive a finished tool. I brought a rough goal and a handful of wrong assumptions, and the design improved through disagreement. RSA-directly became hybrid encryption via age. Commit hook became Claude Code hook on Stop. "Just git add the logs" became a two-layer encrypted-and-plaintext design, because the assistant told me plainly that transcripts contain secrets, rather than helpfully doing what I asked. Hard-fail became graceful degradation. GH_TOKEN became LOGKEEPER_GH_TOKEN, because the obvious name was already claimed by every other tool on the machine. And a proposal to surface errors properly got rejected on the grounds that it would introduce a worse failure than the one it fixed.

That last one deserves emphasis. When I asked what the risks were of adding systemMessage, the useful answer was not a list of implementation steps. It was "don't, and here is why": a recommendation against the thing I had just asked for. An assistant that treats every request as an instruction to be executed would have happily built it, and I would have shipped a backup tool capable of corrupting the sessions it backs up.

Meanwhile a chain of real-world failures (a slow image, a broken apt repo, an un-granted secret, a token that was never actually passed to git, and then a token that was passed to everything) got diagnosed one at a time by reading the actual error output, not by guessing.

None of the improvements came from agreement. They came from a correction I could evaluate and either accept or push back on. That's the mode of AI-assisted work I've found actually productive: not delegation, and not autocomplete, but an argument with someone who's read more of the docs than you have and will tell you when your plan is wrong.

logkeeper is a small tool. But the reason its logs are worth keeping is the same reason the process of building it was worth writing down: the interesting part of working with an AI isn't the code it types, it's the reasoning you do together to get there, and that reasoning is exactly what used to vanish when a container died.


The tool is at github.com/markmatsu/claude-logkeeper (MIT). It ships with a DESIGN.md containing the full specification and every failure mode described above, written so that you can hand it to an AI and modify the tool without repeating my mistakes.