Overview
Haunt catches every AI coding session as it ends — Claude Code, Codex CLI, and Cursor — and turns it into clean, searchable markdown. One Python file, zero dependencies, plus a small Mac menu-bar app for config.
Two ingestion tracks, depending on what the source supports:
- Stop hook in
~/.claude/settings.json— real-time for Claude Code. Async, so your prompt is never blocked. - Watcher LaunchAgent (
com.haunt.watch) — polls~/.codex/sessions/and Cursor's SQLite store every 30s, archives any new content. Belt-and-suspenders for sources without native hooks.
archive.py. Standard library only. No virtualenv. No package manager.Install
One command. Sign in at /app, copy your personalized install command (your token baked in), paste it into any terminal:
$ curl -fsSL https://haunt-pied.vercel.app/api/v1/install/<your-token> | sh
Wires the Stop hook, schedules the watcher, kicks off a parallel backfill of every existing Claude / Codex / Cursor session, and returns your prompt in about a second. Idempotent — safe to re-run whenever you regenerate a token.
From inside Claude Code
If you're already in a Claude Code session and have the archiver on disk, the slash command shortcut runs the same flow:
/haunt install
Already have the script?
If ~/.claude/haunt/archive.py exists (you ran the curl install previously), this re-applies everything from scratch. Idempotent — safe to re-run.
$ python3 ~/.claude/haunt/archive.py install
~/.claude/settings.json (real-time, async — Claude Code), a watcher LaunchAgent at ~/Library/LaunchAgents/com.haunt.watch.plist (30-second poll — Codex + Cursor), and a one-shot backfill of every session under ~/.claude/projects/, ~/.codex/sessions/, and Cursor's SQLite store. Re-runnable, undo-able with uninstall.Layout
~/.claude/Haunt/
├── archive.py # the whole thing — parser + renderer + dispatcher
├── config.json # backend choice + creds (edit this)
├── state.json # per-session fingerprints, prevents re-uploads
└── logs/
├── archive.log # operational log
└── launchd.* # stdout/stderr from the daily runDefault output (local backend):
~/Documents/claude-logs/
└── 2026-05-10/
├── _index.md # daily roll-up with previews
├── build-claude-sessions-cloud-backup__24457bf2.md
├── add-language-picker-switch__b7d98826.md
└── …Choosing a backend
Set backend in ~/.claude/Haunt/config.json. The script auto-creates a default config on first run; edit it whenever you want to switch.
Local folder (default)
{
"backend": "local",
"local": { "root": "~/Documents/claude-logs" }
}Sync the folder however you like — iCloud, Dropbox, Syncthing, a git repo. The archiver doesn't care.
VPS over SSH
{
"backend": "vps",
"vps": {
"user": "abhishek",
"host": "vps.example.com",
"remote_root": "~/claude-logs",
"ssh_key": "~/.ssh/id_ed25519"
}
}Uses ssh + scp from your shell. Key-based auth only — the script never prompts. The remote directory is created on first upload.
Vercel endpoint (your own)
{
"backend": "vercel",
"vercel": {
"url": "https://your-app.vercel.app/api/claude-log",
"token": "shared-secret"
}
}Posts a JSON body to your URL with Authorization: Bearer <token>:
{
"path": "2026-05-10/add-language-picker-switch__b7d98826.md",
"content": "# Add language picker switch\n…"
}Haunt cloud
Don't want to host anything? Sign in with GitHub at haunt-pied.vercel.app, generate an API token from your dashboard, and paste the snippet below into ~/.claude/haunt/config.json. Everything you archive shows up at /app — searchable, owner-scoped, free while it's in beta.
{
"backend": "vercel",
"vercel": {
"url": "https://haunt-pied.vercel.app/api/v1/sessions",
"token": "<token from /app>"
}
}Adding your own
Backends in archive.py are just functions of the form:
def _backend_yourname(rel_path: str, body: str, conf: dict) -> None:
# rel_path: "2026-05-10/some-session__abcd1234.md"
# body: the rendered markdown
# conf: config.json["yourname"]
...Add one line to dispatch() and you're done. Around 10 lines for an S3 backend, less for git.
Manual commands
| Command | What it does |
|---|---|
archive.py session <file> | Archive one specific JSONL. Used by the Stop hook. |
archive.py daily | Today's sessions + write the daily index. Used by the LaunchAgent. |
archive.py daily --date 2026-05-09 | Re-process a specific date. |
archive.py backfill | Every session ever recorded. Idempotent. |
archive.py … --force | Ignore state.json and re-upload everything. |
Config reference
| Key | Default | Meaning |
|---|---|---|
backend | "local" | local, vps, or vercel. |
include_tool_results | true | Embed tool stdout in <details> blocks. |
tool_result_max_lines | 40 | Per-result truncation cap. |
skip_short_sessions_turns | 0 | Skip sessions with fewer than N user turns. |
local_tz | "Asia/Kolkata" | Timezone for date bucketing & timestamps. |
State & idempotency
state.json stores <size>:<mtime> per JSONL path. A session is re-uploaded only when its file grows or changes — typical for sessions that are still active, or that you resume later. Sessions deleted from ~/.claude/projects/ stay archived in your destination.
If something looks wrong, delete state.json and run backfill. It's that simple.
Disable / re-enable
# Pause the watcher (Codex + Cursor stop being polled) $ launchctl unload ~/Library/LaunchAgents/com.haunt.watch.plist # Resume it $ launchctl load -w ~/Library/LaunchAgents/com.haunt.watch.plist
To pause per-session uploads, remove the hooks.Stop block from ~/.claude/settings.json.
Output format
Each session becomes a single markdown file with this shape:
# Build Claude sessions cloud backup system - **When:** 2026-05-10 12:58:30 IST - **Project:** `company` — `/Users/abhishek/Documents/GitHub/company` - **Branch:** `main` - **Session ID:** `24457bf2-d3b3-49dc-9573-daa93fe87488` - **Turns:** 14 user / 20 assistant --- ### User · 12:58:30 build something that automatically takes allllll of my claude sessions… ### Assistant · 12:58:35 Got it. Let me first check how Claude stores sessions on your machine. **Tool · Bash** — `ls -la ~/.claude/` ```bash ls -la ~/.claude/ ``` <details><summary>Result</summary> ``` total 2200 drwx------ 26 abhishek staff ... ``` </details>
Troubleshooting
The Stop hook isn't firing
- Run
claude --debugand look for hook execution logs. - Verify JSON parses:
python3 -c "import json; json.load(open('/Users/you/.claude/settings.json'))". Invalid JSON disables all settings silently. - If you just installed the hook, open
/hooksin Claude Code once — the watcher only re-reads settings.json after a session that started with one.
The LaunchAgent isn't running
launchctl list | grep claude-archiver— should print one row.- Check
~/.claude/Haunt/logs/launchd.errfor stderr. - Path issue? LaunchAgents have a minimal
$PATH. Use absolute paths everywhere —/usr/bin/python3, notpython3.
VPS uploads fail with auth error
- Try the same
ssh user@hostmanually first. The archiver doesn't do anything magical — if your shell can't connect, neither can it. - Use
StrictHostKeyChecking=accept-newon first run (already set). - Confirm the key in
config.jsonhas no passphrase, or is loaded intossh-agent.
I want to start over
$ rm ~/.claude/Haunt/state.json $ python3 ~/.claude/Haunt/archive.py backfill
Built in one afternoon. Reads JSONL straight out of ~/.claude/projects/ — no API calls, no auth dance. If Claude Code's storage layout ever changes, the parser is ~80 lines.