Claude Code Session Transcript Format
Purpose: Reference for the Daily Session Audit (DSA) system. Documents where Claude Code
stores session transcripts on p24-infra workers, the JSONL schema, typical sizes/token counts,
and the correct jq filters for stripping tool noise before feeding a session to an audit model.
DSA plan: radieu/p24-infra#2637
Investigated: 2026-07-03 · bms-4 (Claude Code 2.1.177) + vps-i1 (2.1.179)
This file is the hard gate for DSA Issue D —
run-audit.shreadsSESSION_DIRand the strip filters defined here. Do not merge Issue D until these values are confirmed against the live hosts.
1. Transcript path (SESSION_DIR)
Claude Code writes one JSONL transcript per session under ~/.claude/projects/, in a
subdirectory named after the session’s working directory with every / replaced by -.
<HOME>/.claude/projects/<encoded-cwd>/<session-uuid>.jsonl
<encoded-cwd>— the session cwd with/→-(e.g. cwd/tmp/worker-2653-radieu-p24-infra→ dir-tmp-worker-2653-radieu-p24-infra; cwd/home/claude-runner→-home-claude-runner).<session-uuid>— a v4 UUID, matching the.sessionIdfield inside the file.
Per-host SESSION_DIR values
| Host | SESSION_DIR | Notes |
|---|---|---|
bms-4 (54.36.123.110) | /home/claude-runner/.claude/projects | All agents run as claude-runner. ~756 transcripts present. |
vps-i1 (217.154.82.162) | /home/claude-runner/.claude/projects and /root/.claude/projects | Agents run as both claude-runner (~137) and root (~34). run-audit.sh must scan both roots. |
Enumerate transcripts (recurse — sessions live one level down, under the encoded-cwd dir):
find "$SESSION_DIR" -name '*.jsonl' -type fFor vps-i1, iterate both roots:
for SESSION_DIR in /home/claude-runner/.claude/projects /root/.claude/projects; do
find "$SESSION_DIR" -name '*.jsonl' -type f 2>/dev/null
done1a. Subagent (background-agent) transcripts — subagents/ subdir
A session that spawns background agents via the Task/Agent tool stores each subagent’s full
transcript in a subagents/ subdirectory named after the parent session UUID:
<HOME>/.claude/projects/<encoded-cwd>/
<parent-session-uuid>.jsonl # parent transcript (records the Task tool_use + returned tool_result)
<parent-session-uuid>/
subagents/
agent-<agent-id>.jsonl # the subagent transcript — its lines carry "isSidechain": true
agent-<agent-id>.meta.json # {"agentType","description","toolUseId"} — toolUseId links to the parent's Task tool_use
On bms-4 (2.1.177) the parent JSONL does not inline the subagent’s steps — they live entirely
in the subagents/*.jsonl file (some older sessions embedded isSidechain:true lines in the parent
JSONL instead; check both). Verified 2026-08-07: 167 subagent transcripts present, 0 zero-byte,
median 138 KB. A DSA pass that audits only <parent-session-uuid>.jsonl misses all subagent
activity — walk the subagents/ subdir too if subagent-level audit is required.
Not to be confused with
~/.claude/tasks/— that tree holds TodoWrite task-list items (N.json) and lock files, never transcripts. Andtasks/**/*.outputfiles (seen on the Windows workstation) are transient streaming buffers that are 0 bytes once the agent completes — never a forensic source. Full detail + attribution procedure:docs/playbooks/background-agent-transcript-forensics.md(filed for #5862).
2. JSONL schema
Each transcript is JSONL — one standalone JSON object per line, appended in real time as the session runs (an in-progress session’s file is still valid JSONL up to the last complete line).
Line .type values observed
.type | Meaning | Carries prose? |
|---|---|---|
user | Human turn and tool results (see below) | Yes, when .message.content is a string |
assistant | Model turn — text, thinking, and tool calls | Yes, text/thinking content blocks |
attachment | File/context attachment metadata | No |
last-prompt | Pointer to the last prompt (.lastPrompt, .leafUuid) | No |
queue-operation | Prompt-queue bookkeeping | No |
ai-title | Auto-generated session title | Incidental |
pr-link | Recorded PR URL for the session | No |
⚠️ Quirk — there is no
"human"type. Human turns are.type == "user". The Phase-0 task’s suggested filterselect(.type=="human" or .type=="assistant")matches zero human lines. Use"user", not"human". See §4.
Common top-level fields (user / assistant / attachment)
type, uuid, parentUuid, sessionId, timestamp (ISO-8601 UTC), version (Claude Code
version string), cwd, gitBranch, userType, isSidechain, entrypoint.
assistantaddsrequestIdand.message(Anthropic API message shape:{role:"assistant", model, content:[...], usage,...})..message.modele.g.claude-opus-4-8.useradds.message({role:"user", content: ...}). Two sub-shapes:- Human prompt →
.message.contentis a string. - Tool result →
.message.contentis an array oftool_resultblocks, plus a top-leveltoolUseResultfield andsourceToolAssistantUUID.
- Human prompt →
Content blocks (.message.content[].type)
| Block | Appears in | Contains |
|---|---|---|
text | assistant | Model’s visible prose (.text) |
thinking | assistant | Extended-thinking text (.thinking) |
tool_use | assistant | A tool call — name, input, id |
tool_result | user | Tool output — echoed as a user-type line |
Tool calls = assistant lines with tool_use blocks. Tool output = user lines with
tool_result blocks (and a toolUseResult top-level field). Both are the bulk of the token
weight and are what the audit strips (§4).
3. Typical size & token estimate
Measured across all 756 transcripts on bms-4 (find … -printf '%s'):
| Metric | Bytes |
|---|---|
| min | 8.5 KB |
| median | 63 KB |
| mean | 176 KB |
| p90 | 452 KB |
| max | 1.0 MB |
Token estimates (words × 1.3), from a representative 60 KB completed transcript:
| Content | Words | ≈ Tokens |
|---|---|---|
| Raw (whole JSONL) | 5,951 | ~7,700 |
| Prose-only (tool blocks stripped, §4) | 1,832 | ~2,400 |
Stripping tool noise removes ~69% of the tokens. A median session audits at ~2.4 K tokens of prose; budget the p90 (~452 KB raw) at roughly ~18 K raw / ~5–6 K stripped tokens per session when sizing the audit model context.
4. jq filters — strip tool blocks for audit
Entry-level filtering is not enough: an assistant line still contains its tool_use
blocks, and user tool-result lines are also .type=="user". Filter at the content-block
level to keep only prose.
Recommended — prose only (human prompts + model text, drops thinking/tool noise):
jq -r '
if .type=="assistant" and (.message.content|type=="array") then
(.message.content[] | select(.type=="text") | .text)
elif .type=="user" and (.message.content|type=="string") then
.message.content
else empty end' "$SESSION_FILE"Include model reasoning — add thinking to the assistant selector:
select(.type=="text" or .type=="thinking") | (.text // .thinking)Structured (keep role + text, one JSON object per turn):
jq -c '
select((.type=="assistant" and (.message.content|type=="array"))
or (.type=="user" and (.message.content|type=="string")))
| { role: .message.role, ts: .timestamp,
text: (if (.message.content|type)=="string" then .message.content
else ([.message.content[] | select(.type=="text") | .text] | join("\n")) end) }
| select(.text != "" and .text != null)' "$SESSION_FILE"❌ Do not use the Phase-0 draft filter
select(.type=="human" or .type=="assistant")—"human"matches nothing and the assistant branch still leakstool_useblocks.
5. Format quirks & version differences
- No
"human"type — human turns are.type=="user"with string.message.content(§2). useris overloaded — same type for human prompts and tool results; disambiguate by(.message.content|type)(string= human,array= tool_result) orhas("toolUseResult").- vps-i1 dual roots — transcripts under both
/home/claude-runner/and/root/; scan both. - Version skew is cosmetic — bms-4
2.1.177, vps-i12.1.179; identical schema..versionis present on everyuser/assistant/attachmentline if the audit needs to branch on it. hostnameis unreliable on vps-i1 — reportslocalhost; use the IP/label, nothostname, to identify the host in audit output.- In-progress sessions — the active session’s
.jsonlgrows during a run; treat a non-final file as valid up to its last complete line (skip a trailing partial line defensively). - Encoded-cwd collisions — the dir name is a lossy
/→-encoding; the authoritative cwd is the.cwdfield inside each line, not the directory name.