·
15 min read
Auto-archive every Claude Code session into a `claude_sessions` folder
Auto-archive every Claude Code session into a `claude_sessions` folder
For readers who **already have `FRESHJOTS_TOKEN` set** and want a clean session-archive pattern: every `/clear`, every `/compact`, every window close lands as a new note inside a dedicated `claude_sessions` folder, titled `claude-code-YYYY-MM-DD-session-N`, with a 50-file rolling local stash as a fallback. One script, one folder, never lose a transcript again.
Four deltas from the earlier post:
- **`FRESHJOTS_TOKEN`** instead of `MYNOTES_API_TOKEN` (the brand-aligned name).
- **`claude-code-YYYY-MM-DD-session-N`** titling — date plus per-day counter, no event label.
- **A single `claude_sessions` folder** for every note, created idempotently on first run via the API.
- **A 50-file rolling local stash** — the most recent fifty sessions stay on disk; older ones are pruned.
If you don't yet have a token, see Get & set your Fresh Jots API token. If you want the wider treatment — labelled titles like `verbatim-compact-…`, search recipes, offline re-upload patterns — that's the other post linked above. This one stays narrow.
1. How the trigger works
1. How the trigger works
Claude Code emits hook events at well-defined moments. Two of them cover all three triggers we care about:
- **`SessionEnd`** fires when the session ends. That covers both **`/clear`** (with `reason=clear`) and a normal window/terminal close (`reason=other` or `reason=prompt_input_exit`).
- **`PreCompact`** fires immediately before a context compaction — i.e. when you type **`/compact`**, or when Claude Code auto-compacts as the context window fills.
Two hook entries in `~/.claude/settings.json`, one script behind both. Same script handles every case — the only branch is the title math, and `date +%Y-%m-%d` doesn't care which event called it.
2. The hook script
2. The hook script
Save this as `~/.claude/hooks/freshjots-claude-sessions.sh`. Walk-through below.
```bash
#!/usr/bin/env bash
# Auto-archive each Claude Code session as a new Fresh Jots note inside
# the claude_sessions folder, with a rolling 50-file local stash as a
# fallback for offline / API-down moments.
# Wired in ~/.claude/settings.json for PreCompact + SessionEnd events.
# Always exits 0 — failures log, never block the user.
set -uo pipefail
STASH_DIR="$HOME/.claude/freshjots-stash"
LOG_FILE="$STASH_DIR/.log"
FOLDER_ID_FILE="$STASH_DIR/.folder-id"
FOLDER_NAME="claude_sessions"
KEEP=50
mkdir -p "$STASH_DIR"
log() { printf '[%s] %s\n' "$(date -Iseconds)" "$*" >> "$LOG_FILE"; }
INPUT=$(cat)
SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty')
EVENT=$(printf '%s' "$INPUT" | jq -r '.hook_event_name // empty')
log "FIRED event='$EVENT' session='$SESSION_ID'"
[ -z "$SESSION_ID" ] && { log "ABORT: no session_id"; exit 0; }
[ -z "${FRESHJOTS_TOKEN:-}" ] && { log "ABORT: FRESHJOTS_TOKEN not set"; exit 0; }
# Locate the JSONL Claude Code is writing for this session.
TRANSCRIPT_JSONL=$(find "$HOME/.claude/projects" -maxdepth 2 \
-name "${SESSION_ID}.jsonl" -type f 2>/dev/null | head -1)
if [ -z "$TRANSCRIPT_JSONL" ] || [ ! -s "$TRANSCRIPT_JSONL" ]; then
log "ABORT: could not locate JSONL for session_id=$SESSION_ID"
exit 0
fi
# Title: per-day counter, derived from how many stash files we already
# have for today. The counter is best-effort — it's not a global UUID,
# just enough to keep the day's titles sortable.
TODAY=$(date +%Y-%m-%d)
EXISTING=$(ls -1 "$STASH_DIR"/claude-code-"$TODAY"-session-*.txt 2>/dev/null | wc -l)
N=$((EXISTING + 1))
TITLE="claude-code-${TODAY}-session-${N}"
STASH_PATH="$STASH_DIR/${TITLE}.txt"
# Flatten JSONL → role-prefixed plain text.
jq -r '
if .type == "user" then
if (.message.content | type) == "string" then
"USER:\n" + .message.content + "\n"
elif (.message.content | type) == "array" then
[.message.content[] |
if .type == "tool_result" then
"TOOL RESULT:\n" + (if (.content | type) == "string" then .content
elif (.content | type) == "array" then
([.[] | if .type == "text" then .text else (. | tostring) end] | join("\n"))
else (. | tostring) end) + "\n"
elif .type == "text" then "USER:\n" + .text + "\n"
else empty end
] | join("\n")
else empty end
elif .type == "assistant" then
[.message.content[] |
if .type == "text" then "ASSISTANT:\n" + .text + "\n"
elif .type == "tool_use" then "[Tool: " + .name + "]\n" + (.input | tojson) + "\n"
else empty end
] | join("\n")
else empty end
' "$TRANSCRIPT_JSONL" > "$STASH_PATH" 2>>"$LOG_FILE"
# If the structured render produced nothing usable, fall back to raw JSONL.
if [ "$(wc -c < "$STASH_PATH")" -lt 100 ]; then
cp "$TRANSCRIPT_JSONL" "$STASH_PATH"
fi
printf '\n==================== TRANSCRIPT END · %s ====================\n' \
"$(date -Iseconds)" >> "$STASH_PATH"
log "STASHED $STASH_PATH ($(wc -c < "$STASH_PATH" | tr -d ' ') bytes)"
# Find-or-create the claude_sessions folder. Cache the id so we don't
# round-trip the lookup on every session.
folder_id=""
if [ -s "$FOLDER_ID_FILE" ]; then
cached=$(cat "$FOLDER_ID_FILE")
status=$(curl -sS --max-time 15 -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $FRESHJOTS_TOKEN" \
"https://freshjots.com/api/v1/folders/$cached")
[ "$status" = "200" ] && folder_id=$cached
fi
if [ -z "$folder_id" ]; then
folder_id=$(curl -sS --max-time 15 \
-H "Authorization: Bearer $FRESHJOTS_TOKEN" \
"https://freshjots.com/api/v1/folders" \
| jq -r --arg n "$FOLDER_NAME" \
'.folders[]? | select(.name == $n) | .id' | head -1)
fi
if [ -z "$folder_id" ]; then
folder_id=$(curl -sS --max-time 15 \
-H "Authorization: Bearer $FRESHJOTS_TOKEN" \
-H "Content-Type: application/json" \
-X POST -d "{\"folder\":{\"name\":\"$FOLDER_NAME\"}}" \
"https://freshjots.com/api/v1/folders" \
| jq -r '.id // empty')
fi
if [ -n "$folder_id" ]; then
echo "$folder_id" > "$FOLDER_ID_FILE"
else
log "WARN: could not resolve/create folder '$FOLDER_NAME'; posting at root"
fi
# Build the JSON payload and POST.
PAYLOAD=$(mktemp)
RESPONSE=$(mktemp)
trap 'rm -f "$PAYLOAD" "$RESPONSE"' EXIT
if [ -n "$folder_id" ]; then
jq -Rs --arg title "$TITLE" --argjson fid "$folder_id" \
'{note: {title: $title, plain_body: ., format: "plain", folder_id: $fid}}' \
< "$STASH_PATH" > "$PAYLOAD"
else
jq -Rs --arg title "$TITLE" \
'{note: {title: $title, plain_body: ., format: "plain"}}' \
< "$STASH_PATH" > "$PAYLOAD"
fi
STATUS=$(curl -sS -o "$RESPONSE" -w '%{http_code}' --max-time 30 \
-X POST https://freshjots.com/api/v1/notes \
-H "Authorization: Bearer $FRESHJOTS_TOKEN" \
-H "Content-Type: application/json" \
--data-binary "@$PAYLOAD" 2>>"$LOG_FILE")
case "$STATUS" in
201) log "SUCCESS: created note #$(jq -r '.id // "?"' < "$RESPONSE") '$TITLE' folder_id=$folder_id" ;;
*) log "FAILURE: status=$STATUS body=$(head -c 256 "$RESPONSE" 2>/dev/null); LOCAL STASH at $STASH_PATH" ;;
esac
# Rotate the stash: keep the 50 most-recently-modified .txt files.
( cd "$STASH_DIR" && ls -1t *.txt 2>/dev/null | tail -n +$((KEEP + 1)) | while IFS= read -r f; do rm -f -- "$f"; done )
exit 0
```
Make it executable and syntax-check:
```bash
chmod +x ~/.claude/hooks/freshjots-claude-sessions.sh
bash -n ~/.claude/hooks/freshjots-claude-sessions.sh && echo "syntax OK"
```
Six things to know about this script:
- **Stash is written *before* the network call.** Offline, on a plane, Fresh Jots down — doesn't matter. The flattened transcript hits disk first; the POST is best-effort. If the POST fails, the log line points you at the stash file. Re-upload manually whenever you want.
- **The per-day counter is derived from the stash directory.** `N = (count of `claude-code-YYYY-MM-DD-session-*.txt` files today) + 1`. It's not a UUID and it's not globally monotonic — just enough to sort today's sessions. If rotation prunes today's earliest files (unlikely; you'd need 50+ sessions in one day), the counter restarts. The date prefix keeps days distinct regardless.
- **The folder id is cached at `~/.claude/freshjots-stash/.folder-id`.** First run: GET-list, scan for `claude_sessions`, POST-create if absent, persist the id. Subsequent runs: GET-by-id to verify the id still resolves (it does, unless you deleted the folder), reuse. Two API calls' worth of latency saved per session.
- **The script always exits 0.** Failed upload, missing JSONL, missing token, malformed response — every failure mode logs and exits clean. Claude Code never hangs on `/clear` or `/compact` because of a Fresh Jots problem.
- **The `jq` filter handles the JSONL shape Claude Code actually writes.** User turns can be either a string or an array of content blocks (the latter for tool results and structured text); assistant turns are always arrays, with `text` and `tool_use` blocks interleaved. The filter renders each shape as a readable role-prefixed paragraph. If the filter ever returns less than 100 bytes (Claude Code changed the shape, your `jq` is too old, etc.), the script falls back to copying the raw JSONL so you still have *something*.
- **Stash rotation runs after the upload, not before.** The file you just wrote can't be the one rotated out. `ls -1t` orders by mtime descending, `tail -n +51` selects everything past position 50, and a `while IFS= read -r f; do rm -f -- "$f"; done` loop deletes each. Using `while` instead of `xargs -r` keeps this portable — `xargs -r` is GNU-only and breaks on older macOS BSD utilities; an empty pipeline simply never enters the loop body.
3. Wiring the hooks
3. Wiring the hooks
In `~/.claude/settings.json`, add the two hook entries below. If the file doesn't exist yet, this minimal version is enough:
```json
{
"hooks": {
"PreCompact": [
{
"matcher": "",
"hooks": [
{ "type": "command", "command": "bash /home/USERNAME/.claude/hooks/freshjots-claude-sessions.sh" }
]
}
],
"SessionEnd": [
{
"matcher": "clear|other|prompt_input_exit",
"hooks": [
{ "type": "command", "command": "bash /home/USERNAME/.claude/hooks/freshjots-claude-sessions.sh" }
]
}
]
}
}
```
**Replace `USERNAME` with your actual username** — `~` doesn't expand inside JSON strings, so the hook command needs an absolute path. On macOS the prefix is `/Users/USERNAME/...`.
If you already have a `settings.json` with a `hooks` block, **merge** the two entries into the existing object — don't overwrite. Validate the result:
```bash
jq . ~/.claude/settings.json > /dev/null && echo "JSON valid"
```
Hooks load at session start. **You must restart Claude Code** before any new hook fires — otherwise the registration never happens.
4. First-run folder bootstrap
4. First-run folder bootstrap
The Fresh Jots folder API has no "find-or-create" endpoint; you compose the find and the create yourself. The script's logic, distilled:
```bash
# 1. Verify the cached id, if we have one.
curl -sS -H "Authorization: Bearer $FRESHJOTS_TOKEN" \
"https://freshjots.com/api/v1/folders/$cached"
# 200 → reuse the cached id. 404 → fall through.
# 2. List folders and scan for the name.
curl -sS -H "Authorization: Bearer $FRESHJOTS_TOKEN" \
"https://freshjots.com/api/v1/folders"
# .folders[] | select(.name == "claude_sessions") | .id
# 3. Create the folder if it's not there.
curl -sS -X POST \
-H "Authorization: Bearer $FRESHJOTS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"folder":{"name":"claude_sessions"}}' \
"https://freshjots.com/api/v1/folders"
# 201 → use the new id. Persist it.
```
The id is cached at `~/.claude/freshjots-stash/.folder-id` because folder lookups are cheap but not free — each session would otherwise burn a list-and-scan on every `/clear`. With the cache, the verify GET is one round-trip (and Claude Code is closing or compacting anyway, so the user doesn't notice). If you ever delete the `claude_sessions` folder in the Fresh Jots UI, delete the cache file too — or let the script's verify-by-id step catch the 404 and recreate it on the next session.
Folder ownership is implicit through the token scope: personal-token folders belong to your user, team-token folders belong to the team. Same script works for both — no branching needed.
5. Local stash and 50-file rotation
5. Local stash and 50-file rotation
Two reasons the stash earns its keep beyond "what if Fresh Jots is down":
- **Offline survival.** Flying, in a tunnel, behind a proxy that strips outbound HTTPS — the stash file is already on disk when the POST fails. The log line tells you which file is unsynced; re-upload by hand whenever you're back online.
- **Grep the recent past from disk.** `grep -lr "rack_attack" ~/.claude/freshjots-stash/` finds every recent session that mentioned Rack::Attack, no Fresh Jots search needed. Useful when you remember *what* you discussed but not *when*.
Why 50 files specifically? It's the inflection point where the directory stays useful for "the recent few weeks" without growing forever. Heavy users (5–10 sessions a day) get a week of history; light users get a month or more. Older sessions live in Fresh Jots (where they belong) — the local stash is the freshness buffer, not the archive.
The rotation one-liner:
```bash
( cd "$STASH_DIR" && ls -1t *.txt 2>/dev/null | tail -n +51 | while IFS= read -r f; do rm -f -- "$f"; done )
```
`ls -1t` sorts by mtime descending (newest first). `tail -n +51` selects everything from line 51 onward (i.e. everything past the 50 newest). The `while IFS= read -r f` loop reads those filenames line by line and `rm -f --` deletes each — `-f` suppresses any "file not found" noise, `--` stops `rm` from interpreting a filename starting with a dash as an option. A `while` loop instead of `xargs -r` keeps this portable: `xargs -r` is GNU-only and breaks on older macOS BSD utilities, whereas an empty pipeline here simply never enters the loop body. The whole thing runs in a subshell so the `cd` doesn't affect the script's working directory.
If you want a different retention window, change `KEEP=50` at the top of the script. If you want **no** local pruning, set `KEEP=999999` — the rotation still runs but matches nothing.
6. Verifying it works
6. Verifying it works
Open a second terminal and tail the log:
```bash
tail -f ~/.claude/freshjots-stash/.log
```
In a fresh Claude Code session, ask Claude something trivial (`echo hello`, "what's 2+2"), then trigger any one of the three:
- Type **`/compact`** → fires `PreCompact`
- Type **`/clear`** → fires `SessionEnd` with `reason=clear`
- Close the window → fires `SessionEnd` with `reason=other`
You should see lines like:
```
[2026-05-14T14:22:05+02:00] FIRED event='SessionEnd' session='abc-123'
[2026-05-14T14:22:05+02:00] STASHED /home/you/.claude/freshjots-stash/claude-code-2026-05-14-session-1.txt (8432 bytes)
[2026-05-14T14:22:06+02:00] SUCCESS: created note #142 'claude-code-2026-05-14-session-1' folder_id=37
```
Then:
- `ls ~/.claude/freshjots-stash/` shows the stash file.
- `cat ~/.claude/freshjots-stash/.folder-id` prints the folder id.
- Open [freshjots.com](https://freshjots.com), enter the `claude_sessions` folder, and the note is there.
Run a second session, `/clear` again — a `claude-code-2026-05-14-session-2` note lands in the same folder. Same folder, incremented counter, no second folder created.
7. Failure modes
7. Failure modes
A few things that go wrong in practice. The log always tells you which.
**Token unset or wrong.** `ABORT: FRESHJOTS_TOKEN not set` means the env var didn't make it into the hook's process. Hooks inherit Claude Code's environment, which inherits your shell's environment — so an `export` typed at a live prompt won't reach a Claude Code that was launched earlier. Put `export FRESHJOTS_TOKEN=mn_…` in your `~/.bashrc` or `~/.zshrc`, then restart both the shell and Claude Code. If you see `FAILURE: status=401`, the token is set but the server rejected it — usually expired or revoked. Rotate it at Fresh Jots Settings → API tokens and update your shell profile.
**Folder cap reached.** `FAILURE: status=413 code='cap_exceeded'` on the folder POST means you've hit the per-account folder limit. Either delete a folder you don't use, or manually create `claude_sessions` once in the Fresh Jots UI, copy its id out of the URL, and pre-populate `~/.claude/freshjots-stash/.folder-id` — the script will skip the create attempt.
**Hook never fires.** No log lines at all after `/clear`? The hooks weren't registered. Quit and relaunch Claude Code — registration is at session-start only. Still nothing? `jq . ~/.claude/settings.json` to catch JSON typos (a trailing comma is the usual culprit), and confirm the absolute path to the script is correct. A wrong path silently no-ops; an unreadable script silently no-ops.
**JSONL not found.** `ABORT: could not locate JSONL for session_id=…` means the transcript file isn't where the script expects. Run `find ~/.claude -name "*.jsonl"` to see your actual layout; on most installs it's `~/.claude/projects/<project-slug>/<session-id>.jsonl`, which is what the `find` line matches with `-maxdepth 2`. If your layout differs (older Claude Code versions, custom config), widen the `maxdepth`.
8. Going further
8. Going further
- **Dead-man alerts on the folder's notes.** If you'd rather monitor *whether you're using Claude Code at all* than catalog every session, switch from `POST /api/v1/notes` to `POST /api/v1/notes/by-filename/claude-code-heartbeat/append` and set `append_deadline_hours` on that note in the UI. Now Fresh Jots emails you if you stop appending — proof-of-life for the integration itself. See [Everything you can do here](/blog/everything-you-can-do-here).
- **Token bootstrap.** Don't have `FRESHJOTS_TOKEN` set yet? Get & set your Fresh Jots API token walks the sign-up → token-create → shell-export → verify loop.
- **The one-prompt version.** Don't want to wire each piece yourself? Paste a single prompt into Claude Code and it does all of this for you — script written, `settings.json` merged, folder bootstrapped, end to end. See Paste one prompt and your sessions auto-archive.
- **Cron and systemd patterns.** The same plain-body POST flow drives Write to Fresh Jots from a cron job — useful if you want to mix automated jobs into your `claude_sessions` folder, or peel them out into their own folders.
One hook script, one folder, fifty rotating local backups — every session you run from now on lands in `claude_sessions`, whether the network agrees or not.