GitHub - markmatsu/claude-logkeeper: Auto-save Claude Code sessions to a private GitHub repo. Plaintext transcripts for reading, age-encrypted jsonl for full restore.

15 min read Original article ↗

日本語版はこちら

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

Requirements

  • git, jq, curl — preinstalled in most dev environments

  • age — a small, modern file encryption tool. Install:

    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.

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

Setup

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.

⚠️ Already have a .devcontainer/devcontainer.json? Merge, don't overwrite.

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 things to your existing file: the features that install Claude Code and gh, and a call to setup.sh.

Before (a typical Next.js project):

After (the two additions marked):

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 own image. Don't switch to this repo's base:ubuntu — your image is chosen for your stack. Note that if your image already includes Node.js (like javascript-node above), you do not need 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 your current Codespace 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.)

Getting the files

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.

In GitHub Codespaces (recommended)

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

  2. 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 access Settings → Codespaces → Secrets your working repo (which Codespaces receive this value)
    Fine-grained PAT → Repository access Developer 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.

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

  4. 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").

On your local machine

  1. Copy the two folders into your project (see "Getting the files" above). (.devcontainer/ is only used by Codespaces, but it's harmless to keep.)

  2. Install the requirementsage, jq, git (see Requirements).

  3. Make the hook executable. This is the one step Codespaces does for you but a local setup needs:

    chmod +x .claude/hooks/logkeeper.sh
  4. 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.)
  5. 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.

Creating the LOGKEEPER_GH_TOKEN (fine-grained PAT), step by step

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:

  1. On github.com, click your profile picture (top-right) → Settings.
  2. In the left sidebar, scroll all the way to the bottom → Developer settings.
  3. Click Personal access tokensFine-grained tokensGenerate new token.
  4. Token name: anything, e.g. logkeeper.
  5. Expiration: pick a duration (fine-grained tokens can't be non-expiring; 90 days is a fine default — set a calendar reminder to regenerate).
  6. Resource owner: your own account (the one that owns claude-logs).
  7. Repository access: choose Only select repositories, then select your claude-logs repo. (Don't use "All repositories".)
  8. 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.)
  9. Click Generate token, then copy the token now — GitHub shows it only once. If you lose it, just generate another.
  10. Add it as a Codespaces secret named LOGKEEPER_GH_TOKEN (step 2 above).

Alternative to LOGKEEPER_GH_TOKEN: you can instead uncomment the customizations block in .devcontainer/devcontainer.json and hardcode the logs repo name. Two gotchas: that block cannot read env vars (GitHub reads it before the container exists, so it needs a literal owner/name), and it only 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.

Generating keys (on a trusted local machine)

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.

Merging into an existing settings.json

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.

Using logkeeper with a team

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

  1. The new member generates their own pair locally: age-keygen -o ~/.config/age/logkeeper.txt
  2. They send you only the age1... public key (it is not sensitive — chat or PR is fine).
  3. Add it as a new line in .claude/logkeeper.pub and commit. Every log saved from then on is decryptable by them.
  4. 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.

Behavior when no key is configured

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

Restoring a session from the raw layer

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

Choosing a base image

.devcontainer/devcontainer.json ships with a lightweight base image:

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:

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

Optional: make your own install archive

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 unzip in a terminal, not a double-click. On macOS, Archive Utility wraps multi-item archives in a folder named after the zip (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 logkeeper.conf / logkeeper.pub for your team, remember what's in them. Public keys and a repo name are fine to share. A private key never is.

Known limitations

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

Status and support

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.

Disclaimer

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.

License

MIT. See LICENSE.