# Show HN: Back up Claude Code sessions from Codespaces to a private repo

> Source: <https://github.com/markmatsu/claude-logkeeper>
> Published: 2026-07-21 14:00:09+00:00

Drop two folders into your project, `git add`

them, and every Claude Code
session gets saved to a **private** GitHub repo in two layers:

`transcripts/<repo>/<date>-<session>.md`

—**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`

— the**full jsonl, age-encrypted**. Only holders of a secret key can decrypt it. Restored into`~/.claude/projects/`

, it brings the session back in`claude --resume`

.`index.json`

— session metadata for a viewer.

Trigger: Claude Code hooks (`Stop`

= after every response, `SessionEnd`

= on
session end). If your Codespace dies mid-session, everything up to the last
completed response is already saved.

For the background — why the two layers, and the failures that shaped the
design — see the [development notes](https://www.markmatsu.com/en/articles/claude-logkeeper-development-notes).

-
`git`

,`jq`

,`curl`

— preinstalled in most dev environments -
— a small, modern file encryption tool. Install:`age`

OS Command macOS `brew install age`

Windows `winget install FiloSottile.age`

(or`scoop install age`

/`choco install age.portable`

)Debian/Ubuntu `sudo apt install age`

Other platforms and prebuilt binaries: see the

[age README](https://github.com/FiloSottile/age#installation). -
`gh`

(GitHub CLI) — optional but recommended; used for exact private-repo verification and as a clone/push fallback -
In

**Codespaces**, none of this matters:`setup.sh`

installs everything, and Claude Code itself is installed by the devcontainer feature.

Both paths come down to the same thing: copy the `.claude/`

and `.devcontainer/`

folders from this repo into your project, then point logkeeper at a private logs
repo and an age key.

If you're already running Claude Code in a Codespace, you almost certainly have your own

`devcontainer.json`

configured for your stack.Overwriting it with this repo's copy will break your dev environment.This repo's`devcontainer.json`

is a working example for people who don't have one yet, not a drop-in replacement.You only need to add

two thingsto your existing file: the features that install Claude Code and`gh`

, and a call to`setup.sh`

.

Before(a typical Next.js project):

```
{
  "name": "example-app Next.js",
  "image": "mcr.microsoft.com/devcontainers/javascript-node:1-22-bookworm",
  "forwardPorts": [3000],
  "postCreateCommand": "npm install",
  "customizations": {
    "vscode": {
      "extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
    }
  }
}
```

After(the two additions marked):

```
{
  "name": "example-app Next.js",
  "image": "mcr.microsoft.com/devcontainers/javascript-node:1-22-bookworm",
  "forwardPorts": [3000],

  // ADDED: installs the Claude Code CLI + VS Code extension, and gh
  "features": {
    "ghcr.io/anthropics/devcontainer-features/claude-code:1": {},
    "ghcr.io/devcontainers/features/github-cli:1": {}
  },

  // CHANGED: append setup.sh, keep your existing command
  "postCreateCommand": "npm install && bash .devcontainer/setup.sh",

  "customizations": {
    "vscode": {
      "extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
    }
  }
}
```

The diff is only:

```
+ "features": {
+   "ghcr.io/anthropics/devcontainer-features/claude-code:1": {},
+   "ghcr.io/devcontainers/features/github-cli:1": {}
+ },
- "postCreateCommand": "npm install",
+ "postCreateCommand": "npm install && bash .devcontainer/setup.sh",
```

Keep your ownDon't switch to this repo's`image`

.`base:ubuntu`

— your image is chosen for your stack. Note that if your image already includes Node.js (like`javascript-node`

above), you donotneed the`node`

feature; the Claude Code feature just needs Node to be present. If your image has no Node.js, add`"ghcr.io/devcontainers/features/node:1": {}`

to the features list.

`.devcontainer/setup.sh`

is a new file and won't collide with anything, so copy it in as-is.

devcontainer changes only take effect in a new container.After committing, either run`Codespaces: Rebuild Container`

from 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`

.)

Likewise, if you already have a `.claude/settings.json`

, merge the `hooks`

block
rather than replacing the file — see "Merging into an existing settings.json"
below. (A `.claude/settings.local.json`

is a separate file and won't conflict.)

You need two folders from this repo: `.claude/`

and `.devcontainer/`

. Click the
green **Code** button at the top of this repo → **Download ZIP**, unzip it, and
copy those two folders into your project. (They're hidden dotfolders; on macOS
press `Cmd+Shift+.`

in Finder to see them.)

That'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.

-
**Copy the two folders into your project**(see above). That's the whole file step — no`chmod`

, no installing anything by hand.`setup.sh`

marks the hook executable and installs`age`

,`jq`

, and the Claude Code CLI + extension when the Codespace is created. -
**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`

`owner/claude-logs`

— a**private** repo you created (`gh repo create claude-logs --private`

)`LOGKEEPER_PUBKEY`

your `age1...`

public key (see "Generating keys"; multiple keys space/newline separated)`LOGKEEPER_GH_TOKEN`

a 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`

even 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`

).Don't confuse this with the PAT's own repository access. There are two different settings with similar names:

Setting 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

*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`

comes back empty.Secrets are injected only at container start, so after changing this, stop and restart the Codespace. Verify with

`echo $LOGKEEPER_REPO`

. -
**Commit and push:**`git add .claude .devcontainer && git commit -m "add logkeeper" && git push`

-
**Create a new Codespace.** Everything is wired up on creation; start`claude`

and sessions begin saving.

That's it for Codespaces — the file step really is just a copy. Note the first
build takes **a couple of minutes** (see "Choosing a base image").

-
**Copy the two folders into your project**(see "Getting the files" above). (`.devcontainer/`

is only used by Codespaces, but it's harmless to keep.) -
**Install the requirements**—`age`

,`jq`

,`git`

(see[Requirements](#requirements)). -
**Make the hook executable.** This is the one step Codespaces does for you but a local setup needs:

```
chmod +x .claude/hooks/logkeeper.sh
```

-
**Configure**, either way:- Env vars (take precedence):
`export LOGKEEPER_REPO="owner/claude-logs"`

and`export LOGKEEPER_PUBKEY="age1..."`

in your shell profile, or - Edit the files: put your repo in
`.claude/logkeeper.conf`

and your public key in`.claude/logkeeper.pub`

. (No`LOGKEEPER_GH_TOKEN`

needed locally — your normal git credentials push to the logs repo.)

- Env vars (take precedence):
-
**Commit:**`git add .claude && git commit -m "add logkeeper"`

Because `.claude/settings.json`

is committed, the hooks apply to every teammate
who pulls — they only add their own key and secrets.

The default Codespaces token can only reach the repo you launched from, so
pushing to a separate `claude-logs`

repo needs your own token. Fine-grained is
preferred over a classic token because it can be locked to just this one repo
with just one permission. It takes about a minute:

- On github.com, click your
**profile picture**(top-right) →** Settings**. - In the left sidebar, scroll all the way to the bottom →
**Developer settings**. - Click
**Personal access tokens**→** Fine-grained tokens**→** Generate new token**. **Token name**: anything, e.g.`logkeeper`

.**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`

).**Repository access**: choose** Only select repositories**, then select your`claude-logs`

repo. (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
**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
`LOGKEEPER_GH_TOKEN`

(step 2 above).

Alternative toyou can instead uncomment the`LOGKEEPER_GH_TOKEN`

:`customizations`

block in`.devcontainer/devcontainer.json`

and 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`

), 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.

```
age-keygen -o ~/.config/age/logkeeper.txt
```

The `age1...`

line is the public key (safe to share/commit, and what goes in
`LOGKEEPER_PUBKEY`

or `.claude/logkeeper.pub`

). The `AGE-SECRET-KEY-...`

line is
the secret key: keep it locally and in a password manager only, never on GitHub
or in a Codespaces secret. Losing it makes the raw layer permanently
undecryptable. For team setups, see "Using logkeeper with a team" below.

If your project already has `.claude/settings.json`

, don't overwrite it — copy
the `hooks`

block from this repo's `settings.json`

into yours (merge the `Stop`

and `SessionEnd`

arrays). Copy the other files (`hooks/logkeeper.sh`

,
`logkeeper.conf`

, `logkeeper.pub`

) as-is.

age can encrypt to **multiple recipients at once**, and logkeeper uses that
directly: every key listed in `.claude/logkeeper.pub`

(or in the
`LOGKEEPER_PUBKEY`

env var) becomes a recipient, and each member decrypts with
**their own** secret key. There is no shared secret to distribute.

**Onboarding a member**

- The new member generates their own pair locally:
`age-keygen -o ~/.config/age/logkeeper.txt`

- They send you only the
`age1...`

public key (it is not sensitive — chat or PR is fine). - Add it as a new line in
`.claude/logkeeper.pub`

and commit. Every log saved from then on is decryptable by them. - Give them read access to the private logs repo.

Because `.claude/settings.json`

and the hook script are committed to the
project repo, the member needs no further setup — their sessions start being
logged automatically once they pull.

**Offboarding a member**

Remove their line from `logkeeper.pub`

and revoke their access to the logs
repo. Logs saved *after* that point are no longer decryptable by them. Be
aware of the honest limitation: logs saved *before* removal remain
decryptable with their old key if they kept copies of the ciphertext. If that
matters (e.g. a hostile departure), rotate by creating a fresh logs repo, or
re-encrypt the `raw/`

tree to the new recipient list:

```
for f in raw/**/*.jsonl.age; do
  age -d -i ~/.config/age/logkeeper.txt "$f" \
    | age -R .claude/logkeeper.pub -o "$f.new" && mv "$f.new" "$f"
done
```

**Alternative: reuse existing SSH keys**

age also accepts `ssh-ed25519`

/ `ssh-rsa`

public keys as recipients, and
every GitHub user's SSH public keys are available at
`https://github.com/USERNAME.keys`

. A team can skip age key generation
entirely: put each member's GitHub SSH public key in `logkeeper.pub`

, and each
member decrypts with `age -d -i ~/.ssh/id_ed25519`

. Zero new keys to manage —
though members' backups then depend on how well they protect their SSH keys.

logkeeper degrades instead of failing silently: the plaintext transcript is
still saved, and `raw/<repo>/<session>.ENCRYPTION-ERROR.txt`

is written in
place of the encrypted jsonl, containing the error and how to fix it. Errors
printed to a Codespaces terminal are easy to miss; an error note sitting in
the logs repo is not. Once a valid key appears, the next save replaces the
error note with the real `.jsonl.age`

.

Saving is refused entirely only when: dependencies are missing, no destination
repo is configured, or the destination repo is **public** (safety gate).

```
age -d -i ~/.config/age/logkeeper.txt raw/<repo>/<session>.jsonl.age \
  > ~/.claude/projects/<encoded-project-path>/<session>.jsonl
claude --resume   # the restored session appears in the picker
```

`<encoded-project-path>`

is the absolute project path with every
non-alphanumeric character replaced by `-`

.

`.devcontainer/devcontainer.json`

ships with a lightweight base image:

```
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
```

Even with this lightweight image, expect the **first** Codespace build to take
roughly **2–3 minutes** — it still installs three features (Node.js, the Claude
Code CLI + extension, GitHub CLI) plus `age`

/`jq`

, so it is not instant. It's
noticeably faster than the universal image (which can take ~10 minutes on first
build), and it's cached afterward, so subsequent stop/start is quick — only a
fresh creation or a rebuild pays the build cost again. If the first build feels
slow, that's expected; give it a couple of minutes.

Everything logkeeper needs gets installed via features and `setup.sh`

(Node.js,
the Claude Code CLI + extension, `age`

, `jq`

, `gh`

).

If 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:

```
"image": "mcr.microsoft.com/devcontainers/universal:2",
```

The trade-off is a much slower first build: the universal image is several GB,
so the initial pull and extract can take several minutes. It's cached
afterward, so only the first creation (or a rebuild) pays that cost. If you go
this route and create Codespaces often, consider enabling
[prebuilds](https://docs.github.com/en/codespaces/prebuilding-your-codespaces)
to make creation near-instant.

Either way, keep the `node`

feature in the list — the Claude Code feature needs
Node.js, and `base:ubuntu`

doesn't include it.

If 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:

```
git clone --depth 1 https://github.com/markmatsu/claude-logkeeper
cd claude-logkeeper
zip -r ../logkeeper-install.zip .claude .devcontainer
```

The archive holds `.claude/`

and `.devcontainer/`

at the top level, so extracting
it at a project root drops them exactly where they belong without touching your
existing files:

```
cd /path/to/your-project
unzip /path/to/logkeeper-install.zip
```

Two things worth knowing:

**Use** On macOS, Archive Utility wraps multi-item archives in a folder named after the zip (`unzip`

in a terminal, not a double-click.`logkeeper-install/`

), and since`.claude`

and`.devcontainer`

are hidden dotfolders, it looks like nothing happened. If you already double-clicked:`mv logkeeper-install/.claude logkeeper-install/.devcontainer . && rmdir logkeeper-install`

**If you pre-fill** Public keys and a repo name are fine to share. A private key never is.`logkeeper.conf`

/`logkeeper.pub`

for your team, remember what's in them.

-
**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`

) or under`claude --debug`

— 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

`systemMessage`

JSON channel. Doing so would require the hook to write JSON to stdout, which Claude Code parses — and since this hook shells out to`git`

,`age`

,`jq`

, and`curl`

, 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:

```
cd /path/to/your-project
ENC=$(pwd | sed 's/[^a-zA-Z0-9]/-/g')
LATEST=$(ls -t ~/.claude/projects/${ENC}/*.jsonl | head -1)
echo "{\"transcript_path\":\"${LATEST}\",\"session_id\":\"manual-test\"}" \
  | bash .claude/hooks/logkeeper.sh
```

It 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.

-
The jsonl format is internal and unversioned. The extraction filter ignores unknown entry types for forward compatibility, but breaking changes may require updates.

-
Plaintext transcripts exclude tool results, but

**do include anything you pasted into prompts and any code Claude quoted in its replies**. Mind the sharing scope of the logs repo. -
The

`Stop`

hook clones and pushes after every response. On a large logs repo this gets slow; drop`Stop`

from settings.json to save only on`SessionEnd`

(at the cost of losing the final session if the container dies abruptly). -
Concurrent sessions in the same project are safe: each hook receives its own

`transcript_path`

, so sessions never get mixed up. -
The Codespaces "universal" image currently ships a third-party apt repo (yarn) with an expired signing key, which makes

`apt-get update`

return a non-zero code.`setup.sh`

tolerates this (it never aborts on it), but if you see a yarn`NO_PUBKEY`

GPG warning during creation, it's harmless —`age`

/`jq`

still install. The lightweight`base:ubuntu`

image avoids the noisy warning entirely.

This 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.

That said, the tool was designed in conversation with Claude, and it's small
enough that an AI can reason about the whole thing at once. **If you hit a
problem or want to add a feature, you will likely get a faster and better answer
by handing DESIGN.md to an AI assistant than by waiting for me.**
That file contains the full specification, the reasoning behind each design
decision, and the failure modes I hit while building it — everything an AI needs
to modify this safely.

Bug reports are still welcome, and PRs even more so.

logkeeper 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.

MIT. See [LICENSE](/markmatsu/claude-logkeeper/blob/main/LICENSE).
