Change-triggered backup — hook install¶
One page on wiring the backup_on_change.py PostToolUse hook into Claude
Code so a new snapshot fires automatically whenever you edit a tracked
config file (~/.claude/settings.json, agents, skills, top-level
manifests, etc.).
What it does¶
On every Edit / Write / MultiEdit tool call, the hook:
- Reads the tool payload from stdin.
- Resolves
tool_input.file_pathand checks if it sits under~/.claudein a file/tree/memory path tracked byBackupConfig. - If tracked, shells out to
python <repo>/src/backup_mirror.py snapshot-if-changed --reason <tool>:<basename>. snapshot-if-changedhashes the same eligible file set that capture would persist, including the size cap and tree/memory destination exclusions. It compares that state against the most recent snapshot'smanifest.jsonand only creates a new folder when at least one SHA differs.
No-op edits don't create folders. The hook always exits 0 so a bug in the backup layer cannot stall a Claude session.
Register the hook¶
Edit ~/.claude/settings.json and add the following under hooks (keep
any existing entries alongside it). Replace <REPO> with the absolute
path to this checkout, such as /home/you/src/ctx on Linux or
/Users/you/src/ctx on macOS.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "python <REPO>/hooks/backup_on_change.py"
}
]
}
]
}
}
Notes:
- The
matcheris a regex against the tool name. The three names above are the supported direct file-edit events; use the watchdog below to catch changes made by Bash commands, external editors, or other processes. - If
pythonon your PATH is not the interpreter you want, give the absolute path instead (for example,/Users/you/.pyenv/shims/python).
Verify it works¶
- Reload Claude Code (the hook registration is read at session start).
- Edit a tracked file, e.g.
~/.claude/CLAUDE.md. - Watch
~/.claude/backups/— a new folder named<timestamp>__edit-claude-mdshould appear within a second. - Edit the same file again with identical content — no new folder appears (SHA is unchanged).
If nothing shows up, run the verb manually to isolate the failure:
The JSON output tells you which files the detector considered new, changed, or removed.
What gets backed up¶
See src/backup_config.py and the backup section of
src/config.json for the current defaults:
- top_files —
settings.json,skill-manifest.json,pending-skills.json,CLAUDE.md,AGENTS.md,user-profile.json,skill-system-config.json,skill-registry.json. User overrides must be top-level filenames only: no path separators, absolute paths, drive letters, UNC paths,.entries, or..traversal. - trees —
agents/,skills/. - memory —
projects/*/memory/**whenmemory_globis true. - max_file_bytes —
5242880(5 MiB) by default. Files exactly at the cap are included. For larger top-level, tree, and memory files, capture records atoo_largemanifest entry without storing the content, and change detection ignores the file. - excludes — optional destination-relative patterns for tree and memory
files. A pattern matches either the complete destination path or every file
with the same basename. These custom patterns do not suppress an explicitly
configured
top_filesentry; usetop_filesto control that list. - always excluded —
.credentials.json,claude.json, token caches; these are dropped even if a user config lists them.
To override per user, drop a partial config at
~/.claude/backup-config.json. Fields you omit fall back to the repo
default. Invalid top_files entries are ignored with a [backup-config]
stderr warning; if every supplied top_files entry is invalid, the effective
config falls back to the defaults instead of disabling top-level backups.
Example:
Manual CLI¶
The same verb is available as a one-shot command:
# snapshot only when something changed
python -m backup_mirror snapshot-if-changed --reason manual-check
# force an unconditional snapshot with a reason label
python -m backup_mirror create --reason pre-upgrade
Both land under ~/.claude/backups/<timestamp>__<reason>/ and write a
manifest.json that records the reason alongside every file's SHA-256.
Watchdog — snapshot on changes outside a Claude session¶
The PostToolUse hook only fires on Edit / Write / MultiEdit tool
calls inside a Claude session. If a Bash command, VS Code edit, git pull,
or another process updates a tracked file, the hook never sees it.
For that gap, run the polling watchdog — a simple loop that calls
snapshot-if-changed every N seconds:
Flags:
| Flag | Meaning |
|---|---|
--interval N |
Seconds between polls. Clamped to [5, 3600]. Default 60. |
--reason-prefix LBL |
Prefix used for each snapshot's --reason label. Default watchdog. |
--once |
Run exactly one tick and exit. Useful for cron. |
--json |
Emit run stats as JSON on exit. |
Because change detection is SHA-gated, polling is cheap — a tick with no real changes does zero disk writes.
Running it as a background service¶
Ready-to-use service manifests live under
docs/services/.
Each one expects you to edit a handful of paths — there's no installer
that guesses where you keep the checkout.
- Linux (systemd user unit) —
docs/services/systemd/claude-backup-watchdog.service. Copy to~/.config/systemd/user/, setCTX_REPO, thensystemctl --user enable --now claude-backup-watchdog.service. - macOS (launchd agent) —
docs/services/macos/com.claude.backup.watchdog.plist. Edit theProgramArgumentspaths, drop into~/Library/LaunchAgents/, thenlaunchctl load -w ~/Library/LaunchAgents/com.claude.backup.watchdog.plist. Both manifests assume the watchdog runs as an unprivileged user — no admin/root — because it only reads~/.claude/and writes~/.claude/backups/.
The watchdog stops cleanly on SIGINT/SIGTERM, flushes its stats line to stderr, and exits 0. Pair it with the hook: the hook handles in-session edits in real time; the watchdog catches everything else.
Retention — how old snapshots get pruned¶
Auto-pruning runs after every successful snapshot-if-changed, so the
hook cannot fill the disk. The active policy comes from
BackupRetention in src/backup_config.py (or your user override):
| Field | Default | Meaning |
|---|---|---|
keep_latest |
50 |
Always keep the N most-recent snapshots. |
keep_daily |
14 |
For the M most-recent UTC days that have snapshots, keep the newest snapshot from each. |
A snapshot survives the sweep iff it's in the union of those two
sets. Snapshots whose manifest.json has a missing or zero
created_at are always protected — we never silently delete something
we can't place in time.
To override per user, add a partial config at
~/.claude/backup-config.json:
Manual prune¶
# Dry-run the configured policy — no deletions, JSON report.
python -m backup_mirror prune --policy --dry-run --json
# Apply the configured policy for real.
python -m backup_mirror prune --policy
# Legacy mode (still works): keep only the N newest.
python -m backup_mirror prune --keep 20 --dry-run
python -m backup_mirror prune --keep 20
The policy output tells you which snapshots were kept by keep_latest
versus keep_daily, so a surprising retention decision is easy to
audit. Legacy --keep dry-runs print the snapshot IDs that would be removed
without deleting their directories.
Troubleshooting¶
| Symptom | Likely cause |
|---|---|
| Hook never fires | Settings not reloaded, or matcher typo. |
Snapshot folder with no reason suffix |
Called create without --reason. |
| Hook fires but no folder appears | Content hash matched — nothing actually changed. |
| Credentials appear in a snapshot | User put them in top_files; the ALWAYS_EXCLUDE filter would drop them — check you're on the current backup_config.py. |
ImportError: backup_config from the hook |
Repo moved; update the path in settings.json. |
| Snapshots pile up forever | retention.keep_latest / keep_daily too high. Run prune --policy --dry-run --json to see what the current policy would do, then lower the caps in ~/.claude/backup-config.json. |
| Prune removed too much | Run prune --policy --dry-run before committing to a new policy. A snapshot with a missing/zero created_at is always protected, so if it's getting deleted the manifest is probably fine and the policy is genuinely too aggressive. |