I often run Codex inside tmux. That works well for long-running terminal work, but it is a poor place to preserve useful Q/A history.
The failure mode is familiar:
tmux copy mode is tedious.The goal is simple: after every Codex answer, append the last user prompt and the assistant response to a local Markdown file. The implementation should be local, deterministic, and easy to test.
Codex stores session transcripts under ~/.codex/sessions. The exact path is nested by date, but the file itself is a JSONL transcript. JSONL means newline-delimited JSON: one JSON object per line.
When a Codex stop hook runs, it receives a small JSON payload on standard input. The important fields are:
{
"last_assistant_message": "The answer Codex just produced",
"transcript_path": "/Users/me/.codex/sessions/2026/06/12/rollout-2026-06-12T10-00-00-example.jsonl"
}
The hook does not need to ask Codex to do anything. It can:
last_assistant_message from the hook payload.transcript_path from the hook payload.jq.That gives this flow:
User prompt
-> Codex response
-> Stop hook receives payload on stdin
-> Shell script reads transcript_path
-> Shell script appends Q/A to Markdown
-> Obsidian indexes the note later
The important property is that logging happens outside the model path. No MCP call, no summarization request, no extra prompt content.
We will create two files:
~/.codex/hooks/
βββ log-session-to-markdown.sh
βββ test-log-session-to-markdown.sh
Then we will register the logger as a Codex Stop hook.
The logger creates one Markdown file per Codex session:
~/myFiles/obsidian/personal/codex-session/
βββ 2026-06-12T10-00-00-example.md
If the same Codex session continues, the hook appends more Q/A blocks to the same file.
Each new note starts with YAML front matter:
---
type: codex-session
date: 2026-06-12
tags:
- codex-session
- ai-log
- needs-review
---
The front matter is written only when the note is created. That matters because you can later edit the tags manually without the hook overwriting your metadata.
Install jq if it is not already available:
brew install jq
Check that Codex is writing session transcripts:
find "$HOME/.codex/sessions" -name '*.jsonl' | tail -n 5
Find the Obsidian vault root. The vault root is the directory that contains .obsidian:
find "$HOME/myFiles/obsidian" -maxdepth 3 -name .obsidian -type d -print
On my machine this returns:
/Users/$USER/myFiles/obsidian/personal/.obsidian
That means my vault path is:
/Users/$USER/myFiles/obsidian/personal
Obsidian only shows Markdown files inside the opened vault. In this setup personal is the vault folder, so the Codex log directory must be inside personal.
Create the destination directory inside the vault:
mkdir -p "$HOME/myFiles/obsidian/personal/codex-session"
mkdir -p "$HOME/.codex/hooks"
If your vault root is different, replace "$HOME/myFiles/obsidian/personal" with your actual vault root everywhere below.
Create ~/.codex/hooks/log-session-to-markdown.sh:
#!/usr/bin/env bash
set -u
input=$(cat)
response=$(printf '%s' "$input" | jq -r '.last_assistant_message // empty' 2>/dev/null)
transcript=$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/null)
if [ -z "$response" ] || [ -z "$transcript" ] || [ ! -f "$transcript" ]; then
exit 0
fi
prompt=$(jq -r '
def text_content:
if type == "string" then
.
elif type == "array" then
[
.[]
| if (.text? | type) == "string" then
.text
elif (.content? | type) == "string" then
.content
else
empty
end
] | join("\n")
else
empty
end;
(
if .type == "response_item"
and .payload.type == "message"
and .payload.role == "user" then
.payload.content | text_content
elif .type == "user" then
.message.content | text_content
else
empty
end
) | select(length > 0)
' "$transcript" 2>/dev/null | tail -n 1)
if [ -z "$prompt" ]; then
exit 0
fi
default_log_dir="$HOME/myFiles/obsidian/personal/codex-session"
log_dir="${CODEX_SESSION_LOG_DIR:-$default_log_dir}"
session_date=$(date '+%Y-%m-%d')
session_base=$(basename "$transcript")
session_name="${session_base%.jsonl}"
session_name="${session_name#rollout-}"
session_file=$(printf '%s' "$session_name" | tr '/:' '__')
log_file="$log_dir/$session_file.md"
mkdir -p "$log_dir" || exit 0
if [ ! -f "$log_file" ]; then
{
printf -- '---\n'
printf 'type: codex-session\n'
printf 'date: %s\n' "$session_date"
printf 'tags:\n'
printf ' - codex-session\n'
printf ' - ai-log\n'
printf ' - needs-review\n'
printf -- '---\n\n'
printf '# Codex Session: %s\n\n' "$session_name"
printf -- '- Transcript: `%s`\n\n' "$transcript"
} > "$log_file"
fi
{
printf '\n---\n\n'
printf '## %s\n\n' "$(date '+%Y-%m-%d %H:%M:%S %Z')"
printf '### Question\n\n%s\n\n' "$prompt"
printf '### Answer\n\n%s\n' "$response"
} >> "$log_file"
exit 0
Make it executable:
chmod +x "$HOME/.codex/hooks/log-session-to-markdown.sh"
The script exits with status 0 on missing data. A logging hook should not break the Codex session just because the transcript format changed, the destination directory is unavailable, or the payload is incomplete. Silent failure is usually the right default for a non-critical append-only logger.
This line gets the assistant response directly from the hook payload:
response=$(printf '%s' "$input" | jq -r '.last_assistant_message // empty' 2>/dev/null)
This line gets the JSONL transcript path:
transcript=$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/null)
The latest user prompt is extracted from the transcript, not from terminal scrollback. That is the key part. It avoids tmux copy mode entirely and works even when the terminal UI has wrapped or truncated output.
The output file name is derived from the transcript file name:
session_base=$(basename "$transcript")
session_name="${session_base%.jsonl}"
session_name="${session_name#rollout-}"
session_file=$(printf '%s' "$session_name" | tr '/:' '__')
That produces one stable Markdown file per session. If the hook runs again for the same transcript, it appends to the same note.
The front matter is created once:
if [ ! -f "$log_file" ]; then
...
fi
That is intentional. After a session, I might edit the note:
tags:
- codex-session
- ai-log
- my-service
- pr-review
The hook should not erase that manual classification on the next append.
One shell detail is worth keeping:
printf -- '- Transcript: `%s`\n\n' "$transcript"
The -- prevents printf from treating a format string that starts with - as an option.
Create ~/.codex/hooks/test-log-session-to-markdown.sh:
#!/usr/bin/env bash
set -eu
tmp_dir=$(mktemp -d)
transcript="$tmp_dir/rollout-2026-06-12T10-00-00-test-session.jsonl"
output_dir="$tmp_dir/output"
cat > "$transcript" <<'JSONL'
{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"How do I reproduce the issue?"}]}}
{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Run the failing test first."}]}}
JSONL
payload=$(jq -n \
--arg answer "Run the failing test first." \
--arg transcript "$transcript" \
'{last_assistant_message: $answer, transcript_path: $transcript}')
CODEX_SESSION_LOG_DIR="$output_dir" \
"$HOME/.codex/hooks/log-session-to-markdown.sh" <<< "$payload"
log_file=$(find "$output_dir" -name '*.md' | head -n 1)
test -f "$log_file"
grep -q 'How do I reproduce the issue?' "$log_file"
grep -q 'Run the failing test first.' "$log_file"
grep -q 'type: codex-session' "$log_file"
printf 'PASS: %s\n' "$log_file"
Make it executable and run it:
chmod +x "$HOME/.codex/hooks/test-log-session-to-markdown.sh"
"$HOME/.codex/hooks/test-log-session-to-markdown.sh"
Expected output:
PASS: /var/folders/.../output/2026-06-12T10-00-00-test-session.md
This test does not require a live Codex session. It creates a fake transcript, sends a fake hook payload to the real logger, and verifies that the Markdown file contains the prompt, answer, and front matter.
Create or update ~/.codex/hooks.json:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "/Users/YOUR_USER/.codex/hooks/log-session-to-markdown.sh"
}
]
}
]
}
}
Replace /Users/YOUR_USER with your actual home directory.
If you already have Stop hooks, do not replace the whole file. Add this command to the existing Stop hook list:
{
"type": "command",
"command": "/Users/YOUR_USER/.codex/hooks/log-session-to-markdown.sh"
}
Restart Codex after changing hook configuration. Codex may ask you to review or trust the hook command. Read the path carefully and approve it only if it points to the script you created.
Start a new Codex session and ask a small question. After Codex answers, check the output directory:
ls -lt "$HOME/myFiles/obsidian/personal/codex-session" | head
Open the newest Markdown file:
latest=$(ls -t "$HOME/myFiles/obsidian/personal/codex-session"/*.md | head -n 1)
sed -n '1,120p' "$latest"
You should see:
---
type: codex-session
date: 2026-06-12
tags:
- codex-session
- ai-log
- needs-review
---
# Codex Session: 2026-06-12T10-00-00-test-session
- Transcript: `/Users/me/.codex/sessions/.../rollout-....jsonl`
---
## 2026-06-12 10:05:12 EDT
### Question
...
### Answer
...
If you ask another question in the same Codex session, the same Markdown file should get another timestamped Q/A block.
Do not log secrets blindly. Codex transcripts can contain commands, file paths, error messages, and pasted configuration. If you routinely work with credentials or customer data, point CODEX_SESSION_LOG_DIR to a private local folder and review notes before syncing them.
Keep the hook append-only. Rewriting old session notes from a hook makes debugging harder and can destroy manual annotations. If you want cleanup, run a separate offline maintenance script.
Keep automatic tags generic. It is tempting to derive tags from the current directory, branch, or repository name. That can leak private project names and create noisy taxonomy. I prefer generic tags like codex-session, then manual project tags after review.
Do not route this through an MCP server unless you actually need model-mediated note operations. MCP is useful when Codex needs to read or reason over your notes. This use case is only logging, so a local shell hook is cheaper and more reproducible.
If no Markdown file is created, check that the hook is executable:
ls -l "$HOME/.codex/hooks/log-session-to-markdown.sh"
If the test harness fails, check jq:
jq --version
If the real session does not log but the test passes, check hook registration and restart Codex. Also confirm that the path in ~/.codex/hooks.json is absolute and points to the executable script.
If the note is created but the question is empty, inspect the latest transcript format:
latest_transcript=$(find "$HOME/.codex/sessions" -name '*.jsonl' | sort | tail -n 1)
tail -n 20 "$latest_transcript"
The script handles the common Codex transcript shapes I have seen:
{"type":"response_item","payload":{"type":"message","role":"user","content":[{"text":"..."}]}}
and:
{"type":"user","message":{"content":"..."}}
If Codex changes the transcript schema, update only the jq extraction block. The rest of the hook can stay the same.
This gives you a local, reproducible session log:
For my workflow, that is the right boundary: Codex does the engineering work, and the local machine records the audit trail.