Claude Code conversation explorer
Treat the format as an append-only event log, not a stable public API. Inspect recent local records before relying on an exact field or enum value.
Locate the data
~/.claude/projects/{encoded-project-path}/{session-id}.jsonl: main session event log.{session-id}/: optional ancillary data for that session.subagents/agent-{id}.jsonl: subagent event log.subagents/agent-{id}.meta.json: optional subagent metadata.- Recent files can include
agentType,description,model,name,parentAgentId,spawnDepth,toolUseId,worktreeBranch, andworktreePath. - Do not assume that all fields are present.
- Recent files can include
subagents/workflows/wf_{id}/: workflow subagent logs, metadata, andjournal.jsonlfiles.tool-results/: externalized tool-result payloads.- Payloads can be text or binary files; do not parse the directory as JSONL.
workflows/: workflow state and scripts.
sessions-index.json: optional project index.memory/: project memory files, not conversation records.
~/.claude/history.jsonl: user input history across projects.
Some older records use other layouts or types. Discover what exists instead of requiring every path above.
Path encoding
Claude Code normally replaces each character outside ASCII letters and digits in the absolute project path with -.
It does not collapse adjacent replacements.
/home/user/company/proj->-home-user-company-proj/home/user/.claude->-home-user--claude/home/user/svc_410439->-home-user-svc-410439
Do not reverse an encoded name to obtain the original path; the encoding is lossy.
Use sessions-index.json.originalPath, history.jsonl.project, or a record's cwd when available.
A relocated session can remain under its original project directory while later records contain a different cwd.
If the session ID is known, avoid path reconstruction:
find ~/.claude/projects -maxdepth 2 -type f -name "$SESSION_ID.jsonl" -print
Inspect safely
- Check file size before opening a transcript.
- Main logs can reach tens of megabytes.
- A single JSONL line can contain a very large tool result.
- Parse one line at a time.
Path.read_text().splitlines()materializes the complete file and every large line.
- Project large records to the required fields before displaying them.
- Search main sessions and subagents separately unless the task needs both.
- Subagent logs repeat delegated context and can dominate results.
- Exclude
history.jsonl, workflow journals, andtool-results/from a conversation-record census.
ls -lh "$SESSION_FILE"
awk 'NR == 5 { print length; exit }' "$SESSION_FILE"
jq -c 'del(.message.content, .toolUseResult, .attachment)' "$SESSION_FILE" | head
Understand the event graph
Conversation records usually contain uuid and parentUuid.
The file order is event order, but the parent links form the conversation graph.
Retries, edits, forks, and resumes can create branches.
parentUuid: nullusually identifies a root record.- It does not identify every user-authored prompt.
- The latest
last-prompt.leafUuid, when present, identifies the current leaf.- Trace
parentUuidbackward to reconstruct that branch.
- Trace
- A chronological scan is suitable for activity history, but it can mix branches.
The common envelope is not universal. Conversation, attachment, and system events commonly include:
type,uuid,parentUuid,timestamp.sessionId,cwd,gitBranch,version,entrypoint.isSidechain,userType.- Optional execution context such as
session_id,slug,sessionKind,teamName,agentName, andagentId.
Main records normally have isSidechain: false; subagent records normally have isSidechain: true and an agentId.
Do not hardcode Claude Code versions, model IDs, entrypoints, tool names, or optional envelope fields.
Interpret record types
user
This type has three important shapes:
- User or injected text.
message.contentis a string or a list containingtext,image, ordocumentblocks.- Normal follow-up prompts usually have a non-null
parentUuid.
- Tool results.
message.contentcontainstool_resultblocks.toolUseResultoften contains the richer structured result.- Large results can use a
<persisted-output>reference andpersistedOutputPathundertoolUseResult.
- Meta or generated input.
isMeta,isCompactSummary,isVisibleInTranscriptOnly,promptSource, andorigin.kindhelp distinguish human input from compaction summaries, command expansions, hooks, task notifications, and agent coordination.
Recent human-authored records can use promptSource: typed or queued and origin.kind: human, but older records often omit these fields.
Do not use parentUuid is null as a prompt filter.
assistant
message.content is a list of blocks, commonly:
thinking:thinking,signature.text: response text.tool_use:id,name,input, and sometimescaller.
The message can also contain model, id, stop_reason, stop_details, usage, diagnostics, and context-management data.
Usage fields change over time; inspect the live key set before computing costs or token totals.
Top-level attribution can include attributionSkill, attributionAgent, attributionMcpServer, or attributionMcpTool.
queue-operation
This type records queue lifecycle, not only delivered conversation turns.
{"type":"queue-operation","operation":"enqueue","sessionId":"...","timestamp":"...","content":"queued text"}
enqueue: hascontent.dequeue: normally omitscontent.remove: has the removedcontentin recent records.popAll: has poppedcontentin recent records.
Queue text can duplicate a later user record.
It can also represent input that was removed before delivery.
Use it to investigate queue behavior, not as the canonical transcript.
attachment
attachment.type identifies injected context.
Common recent values include:
task_reminder,skill_listing,invoked_skills,dynamic_skill.deferred_tools_delta,agent_listing_delta,team_context.hook_success,hook_additional_context,hook_non_blocking_error.edited_text_file,file,compact_file_reference,nested_memory.command_permissions,date_change,read_truncation_notice.queued_command.attachment.promptcontains queued text and often duplicates a queue event.
Treat the enum as open-ended.
system
System events are keyed by subtype.
Recent values include stop_hook_summary, turn_duration, compact_boundary, local_command, agents_killed, and informational or consent events.
Fields vary by subtype.
Older logs can contain progress records for streaming tool and agent status.
Recent builds might not persist them.
Metadata and bookkeeping
These records are small but can occur repeatedly as state changes:
last-prompt:leafUuidand optionallastPrompt.- Use the last record;
lastPromptcan be absent.
- Use the last record;
ai-title,custom-title,agent-name.pr-link.permission-mode,mode,agent-setting.bridge-session,relocated,worktree-state,frame-link.file-history-snapshot,file-history-delta.
Older or specialized logs can also contain fork-context-ref, progress, and other types.
Skip unknown types only after inspecting their keys.
Workflow journal.jsonl files use their own schema, such as started and result records.
They are not conversation logs.
Extract user-authored text
Choose the source based on the question:
- Delivered conversation turns: textual
userrecords in the session JSONL. - Current visible branch: trace parents from the latest
last-prompt.leafUuid, then select textualuserrecords on that branch. - Everything typed across projects:
~/.claude/history.jsonl. - Queue forensics, including removed input:
queue-operationandqueued_commandattachments.
The following extractor returns delivered textual user turns in event order. It excludes common generated inputs while retaining records from older versions that lack provenance fields:
import json
from collections.abc import Iterable
from pathlib import Path
GENERATED_PREFIXES = (
"<bash-input>",
"<bash-stdout>",
"<command-message>",
"<command-name>",
"<local-command-caveat>",
"<local-command-stdout>",
"<system-reminder>",
"<task-notification>",
"<teammate-message>",
)
def text_blocks(content: object) -> list[str]:
if isinstance(content, str):
return [content.strip()] if content.strip() else []
if not isinstance(content, list):
return []
has_tool_result = any(
isinstance(block, dict) and block.get("type") == "tool_result"
for block in content
)
if has_tool_result:
return []
return [
block["text"].strip()
for block in content
if isinstance(block, dict)
and block.get("type") == "text"
and isinstance(block.get("text"), str)
and block["text"].strip()
]
def user_text(record: dict) -> list[str]:
generated_record = any(
record.get(field)
for field in ("isMeta", "isCompactSummary", "isVisibleInTranscriptOnly")
)
if record.get("type") != "user" or generated_record:
return []
origin = record.get("origin")
origin_kind = origin.get("kind") if isinstance(origin, dict) else None
if origin_kind in {"coordinator", "task-notification"}:
return []
if record.get("promptSource") == "system":
return []
texts = text_blocks(record.get("message", {}).get("content"))
known_human_source = record.get("promptSource") in {"typed", "queued", "sdk"}
if known_human_source or origin_kind == "human":
return texts
return [text for text in texts if not text.lstrip().startswith(GENERATED_PREFIXES)]
def session_user_messages(path: str | Path) -> Iterable[tuple[str, str]]:
with Path(path).open() as lines:
for line in lines:
if not line.strip():
continue
record = json.loads(line)
for text in user_text(record):
yield record.get("timestamp", ""), text
This is a practical heuristic, not a guaranteed authorship classifier.
When provenance matters, inspect promptSource, origin, command-related XML wrappers, and nearby queue events.
Use history.jsonl
Recent history records have this shape:
{"display":"typed text","pastedContents":{},"project":"/abs/path","sessionId":"uuid","timestamp":1765934747869}
display: typed text.- Pasted blocks can appear as placeholders such as
[Pasted text #1 +N lines].
- Pasted blocks can appear as placeholders such as
project: absolute project path.sessionId: present on recent records, but older records can omit it.timestamp: epoch milliseconds.pastedContents: map keyed by paste number.- Inline variant:
contentcontains the text. - Reference variant:
contentHashreplaces the inline content.- The history record does not identify a location from which to resolve the hash.
- Use the delivered session
userrecord when the full paste is required.
- Paste entries also have
idandtype;idcan be null.
- Inline variant:
Stream the file and preserve the placeholders unless the inline paste content is available:
import json
from collections.abc import Iterable
from pathlib import Path
def typed_messages(project_path: str) -> Iterable[tuple[int, str]]:
history = Path("~/.claude/history.jsonl").expanduser()
with history.open() as lines:
for line in lines:
record = json.loads(line)
if record.get("project") == project_path:
yield record.get("timestamp", 0), record.get("display", "")
history.jsonl is an input history, not the exact delivered transcript.
Slash commands, interrupted input, and abbreviated pastes can appear there.
Use optional session indexes
When present, sessions-index.json is a cheap way to list sessions without parsing their message bodies.
Recent indexes contain:
- Top-level
version,originalPath, andentries. - Entry fields such as
sessionId,fullPath,projectPath,firstPrompt,summary,messageCount,created,modified,fileMtime,gitBranch, andisSidechain.
The index can be absent or stale. Confirm important results against the JSONL.
Search and inspect
Prefer rg for candidate selection and jq for JSON parsing.
Do not depend on JSON whitespace when correctness matters.
head -1 "$SESSION_FILE" | jq -c 'keys'
rg -m1 -F '"type":"user"' "$SESSION_FILE" | jq
rg -m1 -F '"type":"assistant"' "$SESSION_FILE" | jq 'del(.message.content, .toolUseResult)'
jq -r '.type // "<missing>"' "$SESSION_FILE" | sort | uniq -c | sort -rn
jq -r 'select(.type == "system") | .subtype // "<missing>"' "$SESSION_FILE" | sort -u
jq -r 'select(.type == "attachment") | .attachment.type // "<missing>"' "$SESSION_FILE" | sort -u
Search only main session files for a first pass:
import json
from collections.abc import Iterable
from pathlib import Path
def search_main_sessions(
base_path: str | Path, keyword: str
) -> Iterable[tuple[Path, int, str]]:
needle = keyword.casefold()
for path in Path(base_path).glob("*.jsonl"):
with path.open(errors="replace") as lines:
for line_number, line in enumerate(lines, 1):
if needle not in line.casefold():
continue
try:
record = json.loads(line)
except json.JSONDecodeError:
continue
yield path, line_number, record.get("type", "<missing>")
Use rglob("*.jsonl") only when the task also needs subagents and workflow journals, and then classify each path before interpreting its records.