# Codex CLI hooks: auto-capture every session

Wire a [Codex CLI `Stop` hook](https://developers.openai.com/codex/hooks) 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](https://hjarni.com/docs/claude-code-hooks). The shell script is identical. Only the config location, file format, and a feature flag differ.

**Quick answer**

Codex CLI hooks run a shell command automatically when a session event fires. To auto-capture every Codex session into a knowledge base, point a Stop hook at the Hjarni REST API: the first turn creates one note, and every turn after appends your latest prompt and Codex's reply. It is the same shell script as Claude Code hooks, only the config location and file format differ.

## How Codex CLI hooks work

A Codex CLI hook is a shell command Codex runs automatically when a session event fires. Hooks are off by default: you turn them on with a feature flag and register each one in a `hooks.json` file. Creating one takes two files:

`~/.codex/config.toml`

Turns hooks on. Add `codex_hooks = true` under `[features]`.

`~/.codex/hooks.json`

Registers the hook. Under `hooks.<Event>`, each entry names a `type` (`command`), the `command` to run, and an optional `timeout`. User-level, or `.codex/hooks.json` per project.

The `Stop` event fires each time Codex finishes responding. That is the one this guide hooks into: it appends every turn to a single note so the whole session is captured. The steps below wire it to auto-capture into Hjarni, but the same mechanism runs any command you want when the event fires.

## Prerequisites

curl & jq

Both must be on your `PATH`.

API token

Create one in your Hjarni account under **Settings → Connections** and export it as `HJARNI_TOKEN`. Give it **Write** on **Personal**, which covers your Inbox, or on the folder you set in `HJARNI_CONTAINER_ID`: a new token reaches no folders, and the hook fails silently without one. See [Agent tokens](https://hjarni.com/docs/agent-tokens). See the [REST API reference](https://hjarni.com/docs/api) 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
  # append_body adds to the end of the note as it is now, so an edit made
  # elsewhere since the lookup is kept rather than overwritten.
  jq -n --arg turn "$(printf '\n---\n\n%s' "$turn")" '{ note: { append_body: $turn } }' \
    | 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

Register the script under `UserPromptSubmit` instead of `Stop` and do the filtering in the script, since Codex currently ignores `matcher` for this event. Its stdin carries a `prompt` field rather than `last_assistant_message`, so the script above would exit before saving anything: read `prompt=$(safe_jq_payload '.prompt // empty')`, exit unless it starts with `/save`, and save the prompt in place of the reply.

### 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=YOUR_API_TOKEN

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.

## Your notes on every device

**Hjarni has its own native apps.** Free on every plan for [iPhone, iPad and Apple Silicon Mac](https://apps.apple.com/app/id6761733903) and [Android](https://play.google.com/store/apps/details?id=com.hjarni.app). Same account, same notes as the web. An internet connection is required.
