{"slug": "show-hn-back-up-claude-code-sessions-from-codespaces-to-a-private-repo", "title": "Show HN: Back up Claude Code sessions from Codespaces to a private repo", "summary": "A developer released a tool called logkeeper that backs up Claude Code sessions from GitHub Codespaces to a private GitHub repo, storing plaintext transcripts and age-encrypted full session data. The tool uses Claude Code hooks to save sessions after every response or on session end, and requires only two folders and a setup script to integrate into existing devcontainer configurations.", "body_md": "Drop two folders into your project, `git add`\n\nthem, and every Claude Code\nsession gets saved to a **private** GitHub repo in two layers:\n\n`transcripts/<repo>/<date>-<session>.md`\n\n—**plaintext**, user and Claude text only. Tool results (file contents, command output — where secrets tend to end up) are stripped. Safe-ish to browse and share within the team.`raw/<repo>/<session>.jsonl.age`\n\n— the**full jsonl, age-encrypted**. Only holders of a secret key can decrypt it. Restored into`~/.claude/projects/`\n\n, it brings the session back in`claude --resume`\n\n.`index.json`\n\n— session metadata for a viewer.\n\nTrigger: Claude Code hooks (`Stop`\n\n= after every response, `SessionEnd`\n\n= on\nsession end). If your Codespace dies mid-session, everything up to the last\ncompleted response is already saved.\n\nFor the background — why the two layers, and the failures that shaped the\ndesign — see the [development notes](https://www.markmatsu.com/en/articles/claude-logkeeper-development-notes).\n\n-\n`git`\n\n,`jq`\n\n,`curl`\n\n— preinstalled in most dev environments -\n— a small, modern file encryption tool. Install:`age`\n\nOS Command macOS `brew install age`\n\nWindows `winget install FiloSottile.age`\n\n(or`scoop install age`\n\n/`choco install age.portable`\n\n)Debian/Ubuntu `sudo apt install age`\n\nOther platforms and prebuilt binaries: see the\n\n[age README](https://github.com/FiloSottile/age#installation). -\n`gh`\n\n(GitHub CLI) — optional but recommended; used for exact private-repo verification and as a clone/push fallback -\nIn\n\n**Codespaces**, none of this matters:`setup.sh`\n\ninstalls everything, and Claude Code itself is installed by the devcontainer feature.\n\nBoth paths come down to the same thing: copy the `.claude/`\n\nand `.devcontainer/`\n\nfolders from this repo into your project, then point logkeeper at a private logs\nrepo and an age key.\n\nIf you're already running Claude Code in a Codespace, you almost certainly have your own\n\n`devcontainer.json`\n\nconfigured for your stack.Overwriting it with this repo's copy will break your dev environment.This repo's`devcontainer.json`\n\nis a working example for people who don't have one yet, not a drop-in replacement.You only need to add\n\ntwo thingsto your existing file: the features that install Claude Code and`gh`\n\n, and a call to`setup.sh`\n\n.\n\nBefore(a typical Next.js project):\n\n```\n{\n  \"name\": \"example-app Next.js\",\n  \"image\": \"mcr.microsoft.com/devcontainers/javascript-node:1-22-bookworm\",\n  \"forwardPorts\": [3000],\n  \"postCreateCommand\": \"npm install\",\n  \"customizations\": {\n    \"vscode\": {\n      \"extensions\": [\"dbaeumer.vscode-eslint\", \"esbenp.prettier-vscode\"]\n    }\n  }\n}\n```\n\nAfter(the two additions marked):\n\n```\n{\n  \"name\": \"example-app Next.js\",\n  \"image\": \"mcr.microsoft.com/devcontainers/javascript-node:1-22-bookworm\",\n  \"forwardPorts\": [3000],\n\n  // ADDED: installs the Claude Code CLI + VS Code extension, and gh\n  \"features\": {\n    \"ghcr.io/anthropics/devcontainer-features/claude-code:1\": {},\n    \"ghcr.io/devcontainers/features/github-cli:1\": {}\n  },\n\n  // CHANGED: append setup.sh, keep your existing command\n  \"postCreateCommand\": \"npm install && bash .devcontainer/setup.sh\",\n\n  \"customizations\": {\n    \"vscode\": {\n      \"extensions\": [\"dbaeumer.vscode-eslint\", \"esbenp.prettier-vscode\"]\n    }\n  }\n}\n```\n\nThe diff is only:\n\n```\n+ \"features\": {\n+   \"ghcr.io/anthropics/devcontainer-features/claude-code:1\": {},\n+   \"ghcr.io/devcontainers/features/github-cli:1\": {}\n+ },\n- \"postCreateCommand\": \"npm install\",\n+ \"postCreateCommand\": \"npm install && bash .devcontainer/setup.sh\",\n```\n\nKeep your ownDon't switch to this repo's`image`\n\n.`base:ubuntu`\n\n— your image is chosen for your stack. Note that if your image already includes Node.js (like`javascript-node`\n\nabove), you donotneed the`node`\n\nfeature; the Claude Code feature just needs Node to be present. If your image has no Node.js, add`\"ghcr.io/devcontainers/features/node:1\": {}`\n\nto the features list.\n\n`.devcontainer/setup.sh`\n\nis a new file and won't collide with anything, so copy it in as-is.\n\ndevcontainer changes only take effect in a new container.After committing, either run`Codespaces: Rebuild Container`\n\nfrom the command palette, or delete the Codespace and create a fresh one. A plain stop/start will not apply the new features. (To get going in yourcurrentCodespace without rebuilding, just install the two dependencies by hand:`sudo apt-get install -y age jq && chmod +x .claude/hooks/logkeeper.sh`\n\n.)\n\nLikewise, if you already have a `.claude/settings.json`\n\n, merge the `hooks`\n\nblock\nrather than replacing the file — see \"Merging into an existing settings.json\"\nbelow. (A `.claude/settings.local.json`\n\nis a separate file and won't conflict.)\n\nYou need two folders from this repo: `.claude/`\n\nand `.devcontainer/`\n\n. Click the\ngreen **Code** button at the top of this repo → **Download ZIP**, unzip it, and\ncopy those two folders into your project. (They're hidden dotfolders; on macOS\npress `Cmd+Shift+.`\n\nin Finder to see them.)\n\nThat's it if your project has neither folder yet. If it does, see the merge notes above — nothing else in this repo is needed at runtime.\n\n-\n**Copy the two folders into your project**(see above). That's the whole file step — no`chmod`\n\n, no installing anything by hand.`setup.sh`\n\nmarks the hook executable and installs`age`\n\n,`jq`\n\n, and the Claude Code CLI + extension when the Codespace is created. -\n**Set three Codespaces secrets**(Settings → Codespaces → Secrets), scoped to your project repo. This is the zero-edit path — you never touch the config files:Secret Value `LOGKEEPER_REPO`\n\n`owner/claude-logs`\n\n— a**private** repo you created (`gh repo create claude-logs --private`\n\n)`LOGKEEPER_PUBKEY`\n\nyour `age1...`\n\npublic key (see \"Generating keys\"; multiple keys space/newline separated)`LOGKEEPER_GH_TOKEN`\n\na fine-grained PAT with Contents write on the logs repo (see step-by-step below) **Easy to miss — grant each secret access to your WORKING repo.** After creating a secret, its \"Repository access\" is empty by default (the Secrets list shows**\"0 repositories\"**). A secret with no repository access is** not injected into any Codespace**, so logkeeper reports`LOGKEEPER_REPO env var is not set`\n\neven though you created it. For each of the three secrets, edit it and grant access to**the repo you run Codespaces from**(your project, e.g.`you/my-app`\n\n).Don't confuse this with the PAT's own repository access. There are two different settings with similar names:\n\nSetting Where What to select **Codespaces secret**→ Repository accessSettings → Codespaces → Secrets your **working** repo (which Codespaces receive this value)**Fine-grained PAT**→ Repository accessDeveloper settings → Tokens your **logs** repo (what the token may write to)Selecting the logs repo on the\n\n*secret*is the common mistake: you never run a Codespace from the logs repo, so the value is injected nowhere and`echo $LOGKEEPER_GH_TOKEN`\n\ncomes back empty.Secrets are injected only at container start, so after changing this, stop and restart the Codespace. Verify with\n\n`echo $LOGKEEPER_REPO`\n\n. -\n**Commit and push:**`git add .claude .devcontainer && git commit -m \"add logkeeper\" && git push`\n\n-\n**Create a new Codespace.** Everything is wired up on creation; start`claude`\n\nand sessions begin saving.\n\nThat's it for Codespaces — the file step really is just a copy. Note the first\nbuild takes **a couple of minutes** (see \"Choosing a base image\").\n\n-\n**Copy the two folders into your project**(see \"Getting the files\" above). (`.devcontainer/`\n\nis only used by Codespaces, but it's harmless to keep.) -\n**Install the requirements**—`age`\n\n,`jq`\n\n,`git`\n\n(see[Requirements](#requirements)). -\n**Make the hook executable.** This is the one step Codespaces does for you but a local setup needs:\n\n```\nchmod +x .claude/hooks/logkeeper.sh\n```\n\n-\n**Configure**, either way:- Env vars (take precedence):\n`export LOGKEEPER_REPO=\"owner/claude-logs\"`\n\nand`export LOGKEEPER_PUBKEY=\"age1...\"`\n\nin your shell profile, or - Edit the files: put your repo in\n`.claude/logkeeper.conf`\n\nand your public key in`.claude/logkeeper.pub`\n\n. (No`LOGKEEPER_GH_TOKEN`\n\nneeded locally — your normal git credentials push to the logs repo.)\n\n- Env vars (take precedence):\n-\n**Commit:**`git add .claude && git commit -m \"add logkeeper\"`\n\nBecause `.claude/settings.json`\n\nis committed, the hooks apply to every teammate\nwho pulls — they only add their own key and secrets.\n\nThe default Codespaces token can only reach the repo you launched from, so\npushing to a separate `claude-logs`\n\nrepo needs your own token. Fine-grained is\npreferred over a classic token because it can be locked to just this one repo\nwith just one permission. It takes about a minute:\n\n- On github.com, click your\n**profile picture**(top-right) →** Settings**. - In the left sidebar, scroll all the way to the bottom →\n**Developer settings**. - Click\n**Personal access tokens**→** Fine-grained tokens**→** Generate new token**. **Token name**: anything, e.g.`logkeeper`\n\n.**Expiration**: pick a duration (fine-grained tokens can't be non-expiring; 90 days is a fine default — set a calendar reminder to regenerate).**Resource owner**: your own account (the one that owns`claude-logs`\n\n).**Repository access**: choose** Only select repositories**, then select your`claude-logs`\n\nrepo. (Don't use \"All repositories\".)**Permissions**→ expand** Repository permissions**→ find** Contents**and set it to** Read and write**. That's the only one you need. (\"Metadata: Read-only\" gets added automatically — leave it.)- Click\n**Generate token**, then** copy the token now**— GitHub shows it only once. If you lose it, just generate another. - Add it as a Codespaces secret named\n`LOGKEEPER_GH_TOKEN`\n\n(step 2 above).\n\nAlternative toyou can instead uncomment the`LOGKEEPER_GH_TOKEN`\n\n:`customizations`\n\nblock in`.devcontainer/devcontainer.json`\n\nand hardcode the logs repo name. Two gotchas: that blockcannot read env vars(GitHub reads it before the container exists, so it needs a literal`owner/name`\n\n), and itonly affects newly created Codespaces, not existing ones. On creation GitHub shows a one-click prompt to authorize write access. The PAT route above is simpler and is the true zero-edit path.\n\n```\nage-keygen -o ~/.config/age/logkeeper.txt\n```\n\nThe `age1...`\n\nline is the public key (safe to share/commit, and what goes in\n`LOGKEEPER_PUBKEY`\n\nor `.claude/logkeeper.pub`\n\n). The `AGE-SECRET-KEY-...`\n\nline is\nthe secret key: keep it locally and in a password manager only, never on GitHub\nor in a Codespaces secret. Losing it makes the raw layer permanently\nundecryptable. For team setups, see \"Using logkeeper with a team\" below.\n\nIf your project already has `.claude/settings.json`\n\n, don't overwrite it — copy\nthe `hooks`\n\nblock from this repo's `settings.json`\n\ninto yours (merge the `Stop`\n\nand `SessionEnd`\n\narrays). Copy the other files (`hooks/logkeeper.sh`\n\n,\n`logkeeper.conf`\n\n, `logkeeper.pub`\n\n) as-is.\n\nage can encrypt to **multiple recipients at once**, and logkeeper uses that\ndirectly: every key listed in `.claude/logkeeper.pub`\n\n(or in the\n`LOGKEEPER_PUBKEY`\n\nenv var) becomes a recipient, and each member decrypts with\n**their own** secret key. There is no shared secret to distribute.\n\n**Onboarding a member**\n\n- The new member generates their own pair locally:\n`age-keygen -o ~/.config/age/logkeeper.txt`\n\n- They send you only the\n`age1...`\n\npublic key (it is not sensitive — chat or PR is fine). - Add it as a new line in\n`.claude/logkeeper.pub`\n\nand commit. Every log saved from then on is decryptable by them. - Give them read access to the private logs repo.\n\nBecause `.claude/settings.json`\n\nand the hook script are committed to the\nproject repo, the member needs no further setup — their sessions start being\nlogged automatically once they pull.\n\n**Offboarding a member**\n\nRemove their line from `logkeeper.pub`\n\nand revoke their access to the logs\nrepo. Logs saved *after* that point are no longer decryptable by them. Be\naware of the honest limitation: logs saved *before* removal remain\ndecryptable with their old key if they kept copies of the ciphertext. If that\nmatters (e.g. a hostile departure), rotate by creating a fresh logs repo, or\nre-encrypt the `raw/`\n\ntree to the new recipient list:\n\n```\nfor f in raw/**/*.jsonl.age; do\n  age -d -i ~/.config/age/logkeeper.txt \"$f\" \\\n    | age -R .claude/logkeeper.pub -o \"$f.new\" && mv \"$f.new\" \"$f\"\ndone\n```\n\n**Alternative: reuse existing SSH keys**\n\nage also accepts `ssh-ed25519`\n\n/ `ssh-rsa`\n\npublic keys as recipients, and\nevery GitHub user's SSH public keys are available at\n`https://github.com/USERNAME.keys`\n\n. A team can skip age key generation\nentirely: put each member's GitHub SSH public key in `logkeeper.pub`\n\n, and each\nmember decrypts with `age -d -i ~/.ssh/id_ed25519`\n\n. Zero new keys to manage —\nthough members' backups then depend on how well they protect their SSH keys.\n\nlogkeeper degrades instead of failing silently: the plaintext transcript is\nstill saved, and `raw/<repo>/<session>.ENCRYPTION-ERROR.txt`\n\nis written in\nplace of the encrypted jsonl, containing the error and how to fix it. Errors\nprinted to a Codespaces terminal are easy to miss; an error note sitting in\nthe logs repo is not. Once a valid key appears, the next save replaces the\nerror note with the real `.jsonl.age`\n\n.\n\nSaving is refused entirely only when: dependencies are missing, no destination\nrepo is configured, or the destination repo is **public** (safety gate).\n\n```\nage -d -i ~/.config/age/logkeeper.txt raw/<repo>/<session>.jsonl.age \\\n  > ~/.claude/projects/<encoded-project-path>/<session>.jsonl\nclaude --resume   # the restored session appears in the picker\n```\n\n`<encoded-project-path>`\n\nis the absolute project path with every\nnon-alphanumeric character replaced by `-`\n\n.\n\n`.devcontainer/devcontainer.json`\n\nships with a lightweight base image:\n\n```\n\"image\": \"mcr.microsoft.com/devcontainers/base:ubuntu\",\n```\n\nEven with this lightweight image, expect the **first** Codespace build to take\nroughly **2–3 minutes** — it still installs three features (Node.js, the Claude\nCode CLI + extension, GitHub CLI) plus `age`\n\n/`jq`\n\n, so it is not instant. It's\nnoticeably faster than the universal image (which can take ~10 minutes on first\nbuild), and it's cached afterward, so subsequent stop/start is quick — only a\nfresh creation or a rebuild pays the build cost again. If the first build feels\nslow, that's expected; give it a couple of minutes.\n\nEverything logkeeper needs gets installed via features and `setup.sh`\n\n(Node.js,\nthe Claude Code CLI + extension, `age`\n\n, `jq`\n\n, `gh`\n\n).\n\nIf you'd rather have GitHub's full \"universal\" image — with many languages and runtimes (Python, Ruby, Go, Java, etc.) preinstalled, matching the default Codespaces environment — swap that one line for:\n\n```\n\"image\": \"mcr.microsoft.com/devcontainers/universal:2\",\n```\n\nThe trade-off is a much slower first build: the universal image is several GB,\nso the initial pull and extract can take several minutes. It's cached\nafterward, so only the first creation (or a rebuild) pays that cost. If you go\nthis route and create Codespaces often, consider enabling\n[prebuilds](https://docs.github.com/en/codespaces/prebuilding-your-codespaces)\nto make creation near-instant.\n\nEither way, keep the `node`\n\nfeature in the list — the Claude Code feature needs\nNode.js, and `base:ubuntu`\n\ndoesn't include it.\n\nIf you install logkeeper into projects often, or want to hand a preconfigured copy to teammates, it's convenient to package the two folders into a zip. This repo doesn't ship one (a committed binary would drift out of sync with the source), but you can build one in a second:\n\n```\ngit clone --depth 1 https://github.com/markmatsu/claude-logkeeper\ncd claude-logkeeper\nzip -r ../logkeeper-install.zip .claude .devcontainer\n```\n\nThe archive holds `.claude/`\n\nand `.devcontainer/`\n\nat the top level, so extracting\nit at a project root drops them exactly where they belong without touching your\nexisting files:\n\n```\ncd /path/to/your-project\nunzip /path/to/logkeeper-install.zip\n```\n\nTwo things worth knowing:\n\n**Use** On macOS, Archive Utility wraps multi-item archives in a folder named after the zip (`unzip`\n\nin a terminal, not a double-click.`logkeeper-install/`\n\n), and since`.claude`\n\nand`.devcontainer`\n\nare hidden dotfolders, it looks like nothing happened. If you already double-clicked:`mv logkeeper-install/.claude logkeeper-install/.devcontainer . && rmdir logkeeper-install`\n\n**If you pre-fill** Public keys and a repo name are fine to share. A private key never is.`logkeeper.conf`\n\n/`logkeeper.pub`\n\nfor your team, remember what's in them.\n\n-\n**Failures are effectively silent.** This is the sharpest edge, so know it going in. logkeeper writes its errors to stderr, but hook stderr is only surfaced in Claude Code's transcript view (`Ctrl+O`\n\n) or under`claude --debug`\n\n— and in a browser-based Codespace with the VS Code extension, it is very easy to never see it. If logkeeper stops working (an expired token, a renamed repo, a secret that lost its repository access),**the symptom you will notice is simply that no new files appear in the logs repo.** Nothing pops up to tell you.We deliberately did not route errors through Claude Code's\n\n`systemMessage`\n\nJSON channel. Doing so would require the hook to write JSON to stdout, which Claude Code parses — and since this hook shells out to`git`\n\n,`age`\n\n,`jq`\n\n, and`curl`\n\n, a single stray line of stdout from any of them would corrupt that JSON and break the session. Keeping stdout empty is the safer design, at the cost of quieter failures.To diagnose, run the hook by hand and read the error directly:\n\n```\ncd /path/to/your-project\nENC=$(pwd | sed 's/[^a-zA-Z0-9]/-/g')\nLATEST=$(ls -t ~/.claude/projects/${ENC}/*.jsonl | head -1)\necho \"{\\\"transcript_path\\\":\\\"${LATEST}\\\",\\\"session_id\\\":\\\"manual-test\\\"}\" \\\n  | bash .claude/hooks/logkeeper.sh\n```\n\nIt prints exactly what went wrong. A good habit is to glance at the logs repo occasionally: if the newest transcript is older than your last session, something is broken.\n\n-\nThe jsonl format is internal and unversioned. The extraction filter ignores unknown entry types for forward compatibility, but breaking changes may require updates.\n\n-\nPlaintext transcripts exclude tool results, but\n\n**do include anything you pasted into prompts and any code Claude quoted in its replies**. Mind the sharing scope of the logs repo. -\nThe\n\n`Stop`\n\nhook clones and pushes after every response. On a large logs repo this gets slow; drop`Stop`\n\nfrom settings.json to save only on`SessionEnd`\n\n(at the cost of losing the final session if the container dies abruptly). -\nConcurrent sessions in the same project are safe: each hook receives its own\n\n`transcript_path`\n\n, so sessions never get mixed up. -\nThe Codespaces \"universal\" image currently ships a third-party apt repo (yarn) with an expired signing key, which makes\n\n`apt-get update`\n\nreturn a non-zero code.`setup.sh`\n\ntolerates this (it never aborts on it), but if you see a yarn`NO_PUBKEY`\n\nGPG warning during creation, it's harmless —`age`\n\n/`jq`\n\nstill install. The lightweight`base:ubuntu`\n\nimage avoids the noisy warning entirely.\n\nThis is a personal tool I built for myself and published in case it's useful to others. I'm not planning active maintenance and can't promise timely responses to issues.\n\nThat said, the tool was designed in conversation with Claude, and it's small\nenough that an AI can reason about the whole thing at once. **If you hit a\nproblem or want to add a feature, you will likely get a faster and better answer\nby handing DESIGN.md to an AI assistant than by waiting for me.**\nThat file contains the full specification, the reasoning behind each design\ndecision, and the failure modes I hit while building it — everything an AI needs\nto modify this safely.\n\nBug reports are still welcome, and PRs even more so.\n\nlogkeeper is an independent, community project. It is not affiliated with, endorsed by, or sponsored by Anthropic. \"Claude\" and \"Claude Code\" are trademarks of Anthropic, PBC, used here only to describe what this tool works with.\n\nMIT. See [LICENSE](/markmatsu/claude-logkeeper/blob/main/LICENSE).", "url": "https://wpnews.pro/news/show-hn-back-up-claude-code-sessions-from-codespaces-to-a-private-repo", "canonical_source": "https://github.com/markmatsu/claude-logkeeper", "published_at": "2026-07-21 14:00:09+00:00", "updated_at": "2026-07-21 14:12:56.245592+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "ai-agents"], "entities": ["Claude Code", "GitHub Codespaces", "GitHub", "logkeeper", "Anthropic", "FiloSottile/age"], "alternates": {"html": "https://wpnews.pro/news/show-hn-back-up-claude-code-sessions-from-codespaces-to-a-private-repo", "markdown": "https://wpnews.pro/news/show-hn-back-up-claude-code-sessions-from-codespaces-to-a-private-repo.md", "text": "https://wpnews.pro/news/show-hn-back-up-claude-code-sessions-from-codespaces-to-a-private-repo.txt", "jsonld": "https://wpnews.pro/news/show-hn-back-up-claude-code-sessions-from-codespaces-to-a-private-repo.jsonld"}}