Codex CLI hooks: auto-capture every session
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.
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
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.
Make every coding session write itself down. Free.
Capture decisions and context to Hjarni once. The next session reads them back instead of starting from zero. Free to start.
Make every coding session write itself down. Free.