Skip to content
· 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

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

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

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

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

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

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

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

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.

Share this post

Ready to start taking better notes? Sign up free