Docs

Auto-capture Claude Code sessions

Wire a Claude Code Stop hook to the Hjarni REST API. Each session becomes one note in your knowledge base. The first turn creates it. Every turn after that appends your latest prompt and Claude's reply.

Prerequisites

curl & jq

Both must be on your PATH.

API token

Create one in your Hjarni account under Settings → API tokens and export it as HJARNI_TOKEN. See the REST API reference for the authentication format.

Folder (optional)

If you want captures filed into a specific folder, grab its ID and export it as HJARNI_CONTAINER_ID.

The hook script

Save the following as ~/.claude/hooks/hjarni-capture-note.sh and chmod +x it.

#!/usr/bin/env bash
set -euo pipefail

: "${HJARNI_URL:=https://hjarni.com}"
: "${HJARNI_TOKEN:?set HJARNI_TOKEN in your environment}"
: "${HJARNI_CLIENT:=claude-code}"
: "${HJARNI_CONTAINER_ID:=}"
: "${HJARNI_TAGS:=$HJARNI_CLIENT}"

payload=$(cat)

safe_jq_payload() {
  local filter="$1"
  printf '%s' "$payload" | jq -r "$filter" 2>/dev/null || true
}

safe_jq_file() {
  local filter="$1"
  [ -n "$transcript" ] && [ -f "$transcript" ] || return 0
  jq -rs "$filter" "$transcript" 2>/dev/null || true
}

normalize_for_hjarni() {
  local text="${1-}"
  printf '%s' "$text" | jq -Rrs '
    gsub("\\[(?<label>[^\\]]+)\\]\\(<?/(?<root>Users|tmp|var|private|home)/(?<path>[^)>]+):(?<line>\\d+)>?\\)";
         "`\(.label):\(.line)`")
    | gsub("\\[(?<label>[^\\]]+)\\]\\(<?/(?<root>Users|tmp|var|private|home)/(?<path>[^)>]+)>?\\)";
            "`\(.label)`")
  ' 2>/dev/null || printf '%s' "$text"
}

transcript=$(safe_jq_payload '.transcript_path // empty')
cwd=$(safe_jq_payload '.cwd // "unknown"')
session_id=$(safe_jq_payload '.session_id // "unknown"')
last_reply=$(safe_jq_payload '.last_assistant_message // empty')

cwd=${cwd:-unknown}
session_id=${session_id:-unknown}

[ -n "$last_reply" ] || exit 0

session_tag="session-$session_id"
timestamp=$(date -u +%Y-%m-%dT%H:%MZ)

# Prefer Codex's event_msg/user_message entries, which represent the actual user
# prompt and avoid injected context. Fall back to older transcript shapes only
# when those event entries are missing.
user_prompts='
  def event_prompts:
    [
      .[]
      | select(.type == "event_msg"
               and .payload.type == "user_message"
               and (.payload.message | type == "string"))
      | .payload.message
      | select(length > 0)
      | select(test("^<[a-z][a-z0-9_-]*>") | not)
    ];

  def legacy_prompts:
    [
      .[]
      | if .type == "response_item"
           and .payload.type == "message"
           and .payload.role == "user" then
          ([.payload.content[]? | select(.type == "input_text" and (.text | type == "string")) | .text] | join("\n"))
        elif .type == "user"
             and (.message.content | type == "string") then
          .message.content
        else
          empty
        end
      | select(length > 0)
      | select(test("^<[a-z][a-z0-9_-]*>") | not)
    ];

  if (event_prompts | length) > 0 then event_prompts else legacy_prompts end
'

last_prompt=""
last_prompt=$(safe_jq_file "$user_prompts | .[-1] // \"\"")
last_prompt=$(normalize_for_hjarni "$last_prompt")
last_reply=$(normalize_for_hjarni "$last_reply")

turn=$(printf '### %s\n\n**You:**\n\n%s\n\n**Claude:**\n\n%s' \
  "$timestamp" "${last_prompt:-(no prompt captured)}" "$last_reply")

existing=$(curl -fsS -G "$HJARNI_URL/api/v1/notes" \
  -H "Authorization: Bearer $HJARNI_TOKEN" \
  --data-urlencode "tag=$session_tag" \
  --data-urlencode "per_page=1") || exit 0

note_id=$(printf '%s' "$existing" | jq -r '.[0].id // empty' 2>/dev/null || true)

if [ -n "$note_id" ]; then
  current_body=$(printf '%s' "$existing" | jq -r '.[0].body // empty' 2>/dev/null || true)
  new_body=$(printf '%s\n\n---\n\n%s' "$current_body" "$turn")
  jq -n --arg body "$new_body" '{ note: { body: $body } }' \
    | curl -fsS -X PATCH "$HJARNI_URL/api/v1/notes/$note_id" \
        -H "Authorization: Bearer $HJARNI_TOKEN" \
        -H "Content-Type: application/json" \
        --data-binary @- >/dev/null || {
      echo "hjarni-capture-note: failed to append to note $note_id" >&2
      exit 0
    }
  exit 0
fi

first_prompt=""
first_prompt=$(safe_jq_file "($user_prompts | .[0] // \"\") | gsub(\"\\n\"; \" \")[:120]")
first_prompt=$(normalize_for_hjarni "$first_prompt")

validated_container_id=""
if [[ "$HJARNI_CONTAINER_ID" =~ ^[0-9]+$ ]]; then
  validated_container_id="$HJARNI_CONTAINER_ID"
elif [ -n "$HJARNI_CONTAINER_ID" ]; then
  echo "hjarni-capture-note: ignoring non-numeric HJARNI_CONTAINER_ID" >&2
fi

title=${first_prompt:-"Claude Code session $timestamp"}
body=$(printf '# %s\n\n**cwd:** `%s`\n**session:** `%s`\n\n---\n\n%s' \
  "$title" "$cwd" "$session_id" "$turn")

jq -n \
  --arg title "$title" \
  --arg body  "$body" \
  --arg src   "$HJARNI_CLIENT://$session_id" \
  --arg tags  "$HJARNI_TAGS,$session_tag" \
  --arg cid   "$validated_container_id" '
  {
    note: ({
      title: $title,
      body:  $body,
      source_url: $src,
      tag_list:   $tags
    } + (if $cid == "" then {} else { container_id: ($cid | tonumber) } end))
  }' \
  | curl -fsS -X POST "$HJARNI_URL/api/v1/notes" \
      -H "Authorization: Bearer $HJARNI_TOKEN" \
      -H "Content-Type: application/json" \
      --data-binary @- >/dev/null || {
    echo "hjarni-capture-note: failed to create note" >&2
    exit 0
  }

The script keys the note to a session-<id> tag. Claude's reply comes straight from stdin (last_assistant_message). Your prompt is pulled from the JSONL transcript since stdin doesn't include it. On the first turn it POSTs a new note. On every turn after that it looks up the note by tag and PATCHes the prompt-plus-reply pair onto the body.

The hook fires once per turn, at the end of Claude's reply to each prompt. Each firing makes one GET lookup plus one PATCH (or POST on the first turn). Individual tool calls and streamed chunks don't fire it.

The script also rewrites local file links into inline code before saving so the note stays readable on the web instead of ending up with dead /Users/... links.

Every curl step returns exit 0 on failure, so a bad token or an offline server never blocks your Claude Code session.

Wire it into settings.json

Add this to ~/.claude/settings.json (user-level) or .claude/settings.json (project-level):

{
  "env": {
    "HJARNI_TOKEN": "hj_xxxxxxxxxxxxxxxxxxxxxxxx",
    "HJARNI_TAGS": "claude-code"
  },
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "$HOME/.claude/hooks/hjarni-capture-note.sh"
          }
        ]
      }
    ]
  }
}

Prefer settings.local.json for the token so it stays out of version control. Or export HJARNI_TOKEN from your shell profile and drop it from env entirely.

Variations

One note per turn

Prefer a fresh note for every Stop instead of one growing note? Drop the lookup and PATCH branches and keep only the POST. Simpler script, noisier inbox.

Per-project folder

Set HJARNI_CONTAINER_ID inside each repo's .claude/settings.local.json so each project files into its own folder.

Capture only on demand

Swap Stop for UserPromptSubmit with "matcher": "^/save\\b" to only capture prompts that start with /save.

Log tool activity

Add a PostToolUse hook for "matcher": "Bash" that PATCHes each command onto the session note as it runs. Pairs with the default script so you get command-by-command entries alongside the turn-by-turn replies.

Use MCP instead of the REST API

If you already have Hjarni connected as an MCP server, call the notes-create tool from an agent in-session. Useful when you want Claude to summarize before saving.

Verify

export HJARNI_TOKEN=hj_xxx

echo '{"session_id":"test","transcript_path":"/tmp/empty.jsonl","cwd":"'"$PWD"'","last_assistant_message":"first turn"}' \
  | ~/.claude/hooks/hjarni-capture-note.sh

echo '{"session_id":"test","transcript_path":"/tmp/empty.jsonl","cwd":"'"$PWD"'","last_assistant_message":"second turn"}' \
  | ~/.claude/hooks/hjarni-capture-note.sh

curl -s -H "Authorization: Bearer $HJARNI_TOKEN" \
  "https://hjarni.com/api/v1/notes?tag=session-test" | jq '.[0] | {id, title, body}'

The first call creates a note tagged session-test. The second call finds it and appends. The body should contain both turns separated by a horizontal rule. Since the transcript path doesn't exist in this test, the prompt slot will read (no prompt captured). Real sessions will show the actual prompt.

Find a session later

Every session note is titled after your first prompt and the body is full-text indexed. Search however you prefer:

In Hjarni

Search on hjarni.com. Filter by the claude-code tag to scope to captured sessions.

Ask Claude via MCP

With Hjarni connected as an MCP server, ask: "Find the Claude Code session where I debugged retries in the payment service." Claude searches your notes and surfaces the match.

Via the REST API

GET /api/v1/notes?q=<query>&tag=claude-code returns matching sessions. Useful for scripts and shell one-liners.

Related docs