Docs

Auto-capture Codex CLI sessions

Wire a Codex CLI 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 Codex's reply.

Same pattern as Auto-capture Claude Code sessions. The shell script is identical. Only the config location, file format, and a feature flag differ.

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.

Hooks feature flag

Hooks are off by default in Codex. Enable them in ~/.codex/config.toml (see the config section below).

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 ~/.codex/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:=codex}"
: "${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**Codex:**\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:-"Codex 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. Codex's reply comes straight from stdin (last_assistant_message). Your prompt is pulled from the 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 Codex's reply to each prompt. Each firing makes one GET lookup plus one PATCH (or POST on the first turn). Individual tool calls don't fire it.

The parser prefers Codex's current user_message transcript entries when they exist, and it rewrites local file links into inline code before saving so the note doesn't end up with dead /Users/... links on hjarni.com.

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

Codex config

Two files. First, enable the feature flag in ~/.codex/config.toml:

[features]
codex_hooks = true

Then register the hook in ~/.codex/hooks.json (user-level) or .codex/hooks.json (project-level):

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "$HOME/.codex/hooks/hjarni-capture-note.sh",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

Export HJARNI_TOKEN from your shell profile so it stays out of version control. The script also reads HJARNI_TAGS, HJARNI_CONTAINER_ID, and HJARNI_CLIENT (defaults to codex for this page's copy of the script).

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 .codex/hooks.json (via an env block) so each project files into its own folder.

Capture only on demand

Swap Stop for UserPromptSubmit and add a short prefix check inside the script (for example /save). Codex currently ignores matcher for this event, so the filter has to live in the script.

Use MCP instead of the REST API

Codex supports MCP servers. Connect Hjarni via MCP and call the notes-create tool directly from an agent. Useful when you want Codex 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"}' \
  | ~/.codex/hooks/hjarni-capture-note.sh

echo '{"session_id":"test","transcript_path":"/tmp/empty.jsonl","cwd":"'"$PWD"'","last_assistant_message":"second turn"}' \
  | ~/.codex/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. 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 codex tag to scope to captured sessions.

Ask Codex via MCP

Connect Hjarni as an MCP server, then ask: "Find the Codex session where I debugged retries in the payment service." Codex searches your notes and surfaces the match.

Via the REST API

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

Related docs