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 Drun-audit.sh reads SESSION_DIR and 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 .sessionId field inside the file.

Per-host SESSION_DIR values

HostSESSION_DIRNotes
bms-4 (54.36.123.110)/home/claude-runner/.claude/projectsAll agents run as claude-runner. ~756 transcripts present.
vps-i1 (217.154.82.162)/home/claude-runner/.claude/projects and /root/.claude/projectsAgents 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 f

For 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
done

1a. 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. And tasks/**/*.output files (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

.typeMeaningCarries prose?
userHuman turn and tool results (see below)Yes, when .message.content is a string
assistantModel turn — text, thinking, and tool callsYes, text/thinking content blocks
attachmentFile/context attachment metadataNo
last-promptPointer to the last prompt (.lastPrompt, .leafUuid)No
queue-operationPrompt-queue bookkeepingNo
ai-titleAuto-generated session titleIncidental
pr-linkRecorded PR URL for the sessionNo

⚠️ Quirk — there is no "human" type. Human turns are .type == "user". The Phase-0 task’s suggested filter select(.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.

  • assistant adds requestId and .message (Anthropic API message shape: {role:"assistant", model, content:[...], usage,...}). .message.model e.g. claude-opus-4-8.
  • user adds .message ({role:"user", content: ...}). Two sub-shapes:
    • Human prompt.message.content is a string.
    • Tool result.message.content is an array of tool_result blocks, plus a top-level toolUseResult field and sourceToolAssistantUUID.

Content blocks (.message.content[].type)

BlockAppears inContains
textassistantModel’s visible prose (.text)
thinkingassistantExtended-thinking text (.thinking)
tool_useassistantA tool call — name, input, id
tool_resultuserTool 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'):

MetricBytes
min8.5 KB
median63 KB
mean176 KB
p90452 KB
max1.0 MB

Token estimates (words × 1.3), from a representative 60 KB completed transcript:

ContentWords≈ 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 leaks tool_use blocks.


5. Format quirks & version differences

  • No "human" type — human turns are .type=="user" with string .message.content (§2).
  • user is overloaded — same type for human prompts and tool results; disambiguate by (.message.content|type) (string = human, array = tool_result) or has("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-i1 2.1.179; identical schema. .version is present on every user/assistant/attachment line if the audit needs to branch on it.
  • hostname is unreliable on vps-i1 — reports localhost; use the IP/label, not hostname, to identify the host in audit output.
  • In-progress sessions — the active session’s .jsonl grows 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 .cwd field inside each line, not the directory name.