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 inclaude --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 ageWindows winget install FiloSottile.age(orscoop install age/choco install age.portable)Debian/Ubuntu sudo apt install ageOther 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.shinstalls 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.jsonconfigured for your stack. Overwriting it with this repo's copy will break your dev environment. This repo'sdevcontainer.jsonis 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 tosetup.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'sbase:ubuntu— your image is chosen for your stack. Note that if your image already includes Node.js (likejavascript-nodeabove), you do not need thenodefeature; 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.shis 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 Containerfrom 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)
-
Copy the two folders into your project (see above). That's the whole file step — no
chmod, no installing anything by hand.setup.shmarks the hook executable and installsage,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_REPOowner/claude-logs— a private repo you created (gh repo create claude-logs --private)LOGKEEPER_PUBKEYyour age1...public key (see "Generating keys"; multiple keys space/newline separated)LOGKEEPER_GH_TOKENa 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 seteven 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_TOKENcomes 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
claudeand 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
-
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). -
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"andexport LOGKEEPER_PUBKEY="age1..."in your shell profile, or - Edit the files: put your repo in
.claude/logkeeper.confand your public key in.claude/logkeeper.pub. (NoLOGKEEPER_GH_TOKENneeded 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.
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:
- 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-logsrepo. (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 to
LOGKEEPER_GH_TOKEN: you can instead uncomment thecustomizationsblock in.devcontainer/devcontainer.jsonand hardcode the logs repo name. Two gotchas: that block cannot read env vars (GitHub reads it before the container exists, so it needs a literalowner/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.txtThe 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
- 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.puband 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.
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 .devcontainerThe 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.zipTwo things worth knowing:
- Use
unzipin 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.claudeand.devcontainerare 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.pubfor 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 underclaude --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
systemMessageJSON channel. Doing so would require the hook to write JSON to stdout, which Claude Code parses — and since this hook shells out togit,age,jq, andcurl, 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
Stophook clones and pushes after every response. On a large logs repo this gets slow; dropStopfrom settings.json to save only onSessionEnd(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 updatereturn a non-zero code.setup.shtolerates this (it never aborts on it), but if you see a yarnNO_PUBKEYGPG warning during creation, it's harmless —age/jqstill install. The lightweightbase:ubuntuimage 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.