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
Both must be on your PATH.
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 are off by default in Codex. Enable them in ~/.codex/config.toml (see the config section below).
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.