·
18 min read
Detailed: Auto-archive every Claude Code session into an `ai_sessions` folder
Detailed: Auto-archive every Claude Code session into an `ai_sessions` folder
For readers who own a Fresh Jots API token, and it's set and want a clean session-archive pattern: every `/clear`, every `/compact`, every window close lands as a new note inside a dedicated `ai_sessions` folder, titled `claude-code-YYYY-MM-DD-session-HH-MM-SS`, with a 50-file rolling local stash as a fallback. One script, one folder, never lose a transcript again.
Four design choices worth knowing up front:
- **`FRESHJOTS_TOKEN`** is read from the environment — exported once in your shell profile, never written into the repo or echoed back by the script.
- **`claude-code-YYYY-MM-DD-session-HH-MM-SS`** titling — date plus a wall-clock timestamp, so notes sort naturally in the Fresh Jots UI, the day is obvious at a glance, and every title is unique per second (no counter, no API round-trip to pick a number).
- **A single `ai_sessions` folder** for every note. Fresh Jots seeds this folder for every new account at signup, so on a fresh account the script's first run finds it without needing to create one. If you ever rename or delete the folder from the UI, the script's create-fallback re-makes it on the next session and the cached id self-heals.
- **A 50-file rolling local stash** — the most recent fifty sessions stay on disk; older ones are pruned automatically.
If you don't want to do the manual integration in this post, a much simpler way to achieve the above is detailed in One prompt: paste this into Claude Code and your sessions auto-archive to Fresh Jots. You can Connect any AI coding agent to Fresh Jots.
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. The matcher filters on the end *reason*, so one entry covers **`/clear`** (`reason=clear`), an explicit logout (`reason=logout`), `Ctrl-D` at the prompt (`reason=prompt_input_exit`), and a normal window/terminal close (`reason=other`).
- **`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 title is just `date +%Y-%m-%d` plus `date +%H-%M-%S`, which 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 ai_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="ai_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: date + HH-MM-SS — sortable per day and collision-free. (A per-day
# count would stick once the 50-file rotation caps the file count, so
# every later session that day would reuse the same number and overwrite;
# a wall-clock timestamp avoids that failure mode.)
TITLE="claude-code-$(date +%Y-%m-%d)-session-$(date +%H-%M-%S)"
STASH_PATH="$STASH_DIR/${TITLE}.txt"
# Flatten JSONL → role-prefixed plain text. The block() helper normalises
# content items: Claude Code sometimes emits a bare string (not a typed
# object) inside a content array, an assistant message's content can
# itself be a string, and .text/.name/.input can be null. The // fallbacks
# and string/object type checks keep any stray shape from crashing the
# render (a crash here would silently fall back to dumping raw JSONL).
jq -r '
def block(role):
if type == "string" then role + ":\n" + . + "\n"
elif type == "object" then
if .type == "text" then role + ":\n" + (.text // "") + "\n"
elif .type == "tool_use" then "[Tool: " + (.name // "?") + "]\n" + ((.input // {}) | tojson) + "\n"
elif .type == "tool_result" then
"TOOL RESULT:\n" + ((.content // "") |
if type == "string" then .
elif type == "array" then
([.[] | if type == "object" and .type == "text" then (.text // "")
elif type == "string" then .
else tojson end] | join("\n"))
else tojson end) + "\n"
else empty end
else empty end;
if type != "object" then empty
elif .type == "user" then (.message.content) as $c
| if ($c | type) == "string" then "USER:\n" + $c + "\n"
elif ($c | type) == "array" then [$c[] | block("USER")] | join("\n")
else empty end
elif .type == "assistant" then (.message.content) as $c
| if ($c | type) == "string" then "ASSISTANT:\n" + $c + "\n"
elif ($c | type) == "array" then [$c[] | block("ASSISTANT")] | join("\n")
else empty end
else empty end
' "$TRANSCRIPT_JSONL" > "$STASH_PATH" 2>>"$LOG_FILE"
render_status=$?
# Fall back to raw JSONL only if the structured render actually failed
# (jq parse/runtime error) or produced an empty file — not on a byte
# count. A short but valid session (e.g. a quick one-line exchange)
# renders to very few bytes and is still correct; a size threshold here
# would wrongly discard it and dump raw JSONL in its place.
if [ "$render_status" -ne 0 ] || [ ! -s "$STASH_PATH" ]; 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 ai_sessions folder. Cache the id so we don't
# round-trip the lookup on every session. The name match is case-
# insensitive to mirror the server's LOWER(name) uniqueness rule.
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 // "" | ascii_downcase) == ($n | ascii_downcase)) | .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 title is a wall-clock timestamp, not a counter.** `TITLE="claude-code-$(date +%Y-%m-%d)-session-$(date +%H-%M-%S)"` — date for human sorting, `HH-MM-SS` for per-second uniqueness. An earlier design counted `claude-code-YYYY-MM-DD-session-*.txt` files in the stash for an incrementing `N`, but that count sticks once the 50-file rotation caps the directory: every later session that day reuses the same number and the API rejects the duplicate title (`422 already exists`). A timestamp has no such coupling to the stash — it's immune to rotation and needs no API round-trip to pick a number. The only residual edge is two fires in the *same second*, which is rare enough to accept.
- **The folder id is cached at `~/.claude/freshjots-stash/.folder-id`.** First run: GET-list, scan for the `ai_sessions` folder seeded at signup, persist its id (the POST-create branch covers accounts that have since renamed or deleted the seeded folder). Subsequent runs: GET-by-id to verify the id still resolves, then reuse — that's one round-trip instead of a list-and-scan on every `/clear`. If the verify GET returns 404 (you deleted the folder via the UI between sessions, or copied the stash dir from another account), the script falls through to the list-and-create branches on its own.
- **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 every JSONL shape Claude Code actually writes.** A `block(role)` helper normalises each content item: a user or assistant message's `content` can be a plain string *or* an array; inside an array, an item can be a typed object (`text`, `tool_use`, `tool_result`) *or* a bare string; and `.text`/`.name`/`.input` can be null. `// ""` / `// "?"` / `// {}` fallbacks and explicit string-vs-object type checks keep any stray shape from crashing the render, and a non-object top-level line is skipped rather than fatal. If the filter outright fails (jq parse/runtime error) or writes an empty file — Claude Code changed its JSONL shape, your `jq` is too old, the transcript was truly empty — the script falls back to copying the raw JSONL so you still have *something*. We don't fall back on a byte-count threshold: a short but valid session (a quick one-line exchange) renders to very few bytes and is still correct.
- **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|logout|other|prompt_input_exit",
"hooks": [
{ "type": "command", "command": "bash /home/USERNAME/.claude/hooks/freshjots-claude-sessions.sh", "timeout": 60 }
]
}
]
}
}
```
**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/...`.
The `SessionEnd` entry's `"timeout": 60` is deliberate, not decoration. Claude Code's documented default for command hooks is 600 seconds — comfortably above the script's two ≤30-second `curl` calls — so dropping the key won't truncate the POST in today's build. We set it explicitly anyway, for two reasons: it documents the budget the script actually needs at the config level, and it insulates the hook against any future Claude Code build that special-cases `SessionEnd` to a shorter implicit default (older builds did this; the docs no longer call it out, but defensive hygiene is cheap). `PreCompact` keeps the same 600s default — compaction does briefly wait on it, but the script is stash-first and best-effort on the network, so a healthy run returns in well under a second.
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 happen
4. First-run folder bootstrap
4. First-run folder bootstrap
Fresh Jots seeds an `ai_sessions` folder for every new account at signup, so on a fresh account step 2 below finds it without falling through to step 3. The folder API has no single "find-or-create" endpoint, so the script composes the verify / find / create steps itself — step 3 covers accounts that have renamed or deleted the seeded folder. The 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). The verify-by-id step exists to self-heal a stale cache: maybe you deleted the folder via the Fresh Jots UI between sessions, maybe you renamed it, maybe you copied the stash directory between accounts. Whichever happened, a 404 from the verify GET drops the script into the list-and-create branches, which re-resolve the folder and rewrite the cache.
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-14-22-05.txt (8432 bytes)
[2026-05-14T14:22:06+02:00] SUCCESS: created note #142 'claude-code-2026-05-14-session-14-22-05' 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-HH-MM-SS` note with a later timestamp lands in the same folder. Same folder, new timestamped note, 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.
**Note posted at the account root instead of the folder.** `WARN: could not resolve/create folder 'ai_sessions'; posting at root` means the folder lookup failed — a network blip mid-session, or (very rarely) an account with no `ai_sessions` folder that has also hit its folder cap, since the folder is normally seeded at signup. The transcript is still safe: it lands at the account root and is stashed locally regardless. To force the right folder, open the `ai_sessions` folder in the Fresh Jots UI, copy its id out of the URL, and write it to `~/.claude/freshjots-stash/.folder-id` — the next run verifies that id and reuses it.
**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
A related read:
- Everything you can do here — set `append_deadline_hours` on a `claude-code-heartbeat` note and Fresh Jots emails you if you stop using Claude Code. Proof-of-life for the integration itself.
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.