Skip to content

Instantly share code, notes, and snippets.

@Londeren
Last active September 28, 2026 08:52
Show Gist options
  • Select an option

  • Save Londeren/beaf6bcc59b5bee51f8ac06df1991669 to your computer and use it in GitHub Desktop.

Select an option

Save Londeren/beaf6bcc59b5bee51f8ac06df1991669 to your computer and use it in GitHub Desktop.
Export Claude Code session .jsonl transcripts to readable markdown (Stop hook / CLI). Config via env vars: TRANSCRIPT_INBOX, TRANSCRIPT_USER_LABEL, TRANSCRIPT_ASSISTANT_LABEL
#!/usr/bin/env python3
"""Export a Claude Code session .jsonl transcript to readable markdown.
Synced with gist (canonical shared copy, keep identical):
https://gist.github.com/Londeren/beaf6bcc59b5bee51f8ac06df1991669
After editing this file, push the update:
gh gist edit beaf6bcc59b5bee51f8ac06df1991669 --filename export-transcript.py <path to this file>
Keeps only the user's messages and Claude's text replies. A slash command
the user typed (a skill invocation like `/glavred <text>`) is a user
message too: it is rendered as `/name` + its arguments.
Drops: thinking, tool_use, tool_result, sidechain agents, harness service
tags (<system-reminder>, <task-notification>, the IDE context blocks
<ide_opened_file> / <ide_selection> and the like), local built-in
commands (/model, /cost ...) — the harness marks those with a preceding
<local-command-caveat> entry, and their output arrives in
<local-command-stdout> — and two kinds of entries the harness writes in a
speaker's role although nobody said them: the context-compaction summary
("This session is being continued from a previous conversation...", a
user-role entry flagged isCompactSummary) and synthetic error replies
("API Error: ...", "You've hit your session limit ...", "Failed to
authenticate ..." — assistant-role entries flagged isApiErrorMessage).
Modes:
Hook: export-transcript.py --hook (reads hook JSON from stdin)
Manual: export-transcript.py INPUT.jsonl [-o OUTPUT.md]
export-transcript.py INPUT.jsonl (prints to stdout)
Hook mode details:
- The inbox root is CLAUDE_PROJECT_DIR when set (falls back to the hook's
cwd). The session cwd can wander into subfolders mid-session; resolving
against the project root keeps the inbox in one place.
- Claude Code writes the transcript .jsonl asynchronously (documented in
hooks.md): at Stop time the final assistant message(s) of the turn are
often NOT yet in the file. Three compensations:
(a) the Stop hook input carries `last_assistant_message` — when the
parsed transcript lacks it, it is appended to the export;
(b) every hook run also "heals" sibling sessions: any export still
present in the inbox whose source .jsonl is newer gets re-exported
(by then the dead session's file is fully flushed). Exports already
ingested (moved out of the inbox) are never recreated;
(c) wire the same command to both Stop and SessionEnd — at SessionEnd
the transcript is complete.
Configuration — environment variables, no code changes needed:
TRANSCRIPT_INBOX target directory in --hook mode, relative
to the project root (default: topics/_inbox)
TRANSCRIPT_USER_LABEL label for the user's messages (default: User)
TRANSCRIPT_ASSISTANT_LABEL label for assistant replies (default: Claude)
Wiring up as Stop + SessionEnd hooks in .claude/settings.json (example):
{"hooks": {"Stop": [{"hooks": [{"type": "command", "command":
"TRANSCRIPT_USER_LABEL=Alice python3 \\"$CLAUDE_PROJECT_DIR/.claude/scripts/export-transcript.py\\" --hook"}]}],
"SessionEnd": [{"hooks": [{"type": "command", "command":
"TRANSCRIPT_USER_LABEL=Alice python3 \\"$CLAUDE_PROJECT_DIR/.claude/scripts/export-transcript.py\\" --hook"}]}]}}
"""
import sys, os, json, re, argparse
from datetime import datetime
# Configuration (see docstring) — overridable via environment variables.
INBOX_DIR = os.environ.get("TRANSCRIPT_INBOX", "topics/_inbox")
USER_LABEL = os.environ.get("TRANSCRIPT_USER_LABEL", "User")
ASSISTANT_LABEL = os.environ.get("TRANSCRIPT_ASSISTANT_LABEL", "Claude")
# Harness wrapper tags whose content is not the user's words.
_STRIP_BLOCKS = [
r"<system-reminder>.*?</system-reminder>",
r"<command-message>.*?</command-message>",
r"<local-command-stdout>.*?</local-command-stdout>",
r"<local-command-stderr>.*?</local-command-stderr>",
r"<user-prompt-submit-hook>.*?</user-prompt-submit-hook>",
r"<task-notification>.*?</task-notification>",
r"<ide_opened_file>.*?</ide_opened_file>",
r"<ide_selection>.*?</ide_selection>",
]
_CAVEAT = re.compile(r"^Caveat:.*?(?=\n\n|\Z)", re.DOTALL)
_CMD_NAME = re.compile(r"<command-name>(.*?)</command-name>", re.DOTALL)
_CMD_ARGS = re.compile(r"<command-args>(.*?)</command-args>", re.DOTALL)
# Marker the harness puts before a batch of local built-in commands
# (/model, /cost ...): those are not the user's words to Claude.
_LOCAL_CAVEAT = "<local-command-caveat>"
# Inbox export file names: YYYY-MM-DD-<first 8 chars of session id>.md
_EXPORT_NAME = re.compile(r"^\d{4}-\d{2}-\d{2}-([0-9a-f]{8})\.md$")
def clean_user_text(text):
if not text:
return ""
for pat in _STRIP_BLOCKS:
text = re.sub(pat, "", text, flags=re.DOTALL)
text = _CAVEAT.sub("", text)
# A slash command the user typed: `/name` on its own line, then its
# arguments (the actual message) — instead of the raw tags.
name = _CMD_NAME.search(text)
if name:
args = _CMD_ARGS.search(text)
head = name.group(1).strip()
body = args.group(1).strip() if args else ""
rendered = f"{head}\n\n{body}" if body else head
text = _CMD_NAME.sub("", text)
text = _CMD_ARGS.sub("", text)
text = (rendered + "\n\n" + text.strip()).strip()
return text.strip()
def extract_text_blocks(content):
"""Extract only text blocks from message.content (str | list)."""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for b in content:
if isinstance(b, dict) and b.get("type") == "text":
parts.append(b.get("text", ""))
return "\n\n".join(p for p in parts if p)
return ""
def extract_ask_answer(entry):
"""The user's decision on a button question (AskUserQuestion), or None.
Such an answer arrives as a tool_result — technically not a message, but
effectively the user's words, so it is kept. Read from the structured
`toolUseResult` field ({"questions": [...], "answers": {question: answer}})
and not from the tool_result prose: the harness has worded that prose
differently across versions and even within one session, while the field
has been stable. A question the user closed without answering carries a
plain string there instead — no answers, nothing to keep.
"""
r = entry.get("toolUseResult")
if not isinstance(r, dict) or "questions" not in r:
return None
answers = r.get("answers")
if not isinstance(answers, dict) or not answers:
return None
pairs = ", ".join(f'"{q}"="{a}"' for q, a in answers.items())
return "[answer to question] " + pairs + "."
def fmt_time(ts):
"""ISO timestamp (UTC) -> local 'YYYY-MM-DD HH:MM'. Empty on failure."""
if not ts:
return ""
try:
dt = datetime.fromisoformat(ts.replace("Z", "+00:00")).astimezone()
return dt.strftime("%Y-%m-%d %H:%M")
except Exception:
return ""
def parse(path):
meta = {"title": None, "date": None, "session": None, "branch": None}
turns = [] # list of (speaker, text, time)
local_cmd = False # inside a batch of local built-in commands
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
o = json.loads(line)
except json.JSONDecodeError:
continue
if o.get("isSidechain"):
continue
t = o.get("type")
if t == "ai-title" and o.get("aiTitle"):
meta["title"] = o["aiTitle"]
if meta["date"] is None and o.get("timestamp"):
meta["date"] = o["timestamp"][:10]
if o.get("sessionId"):
meta["session"] = o["sessionId"]
if o.get("gitBranch"):
meta["branch"] = o["gitBranch"]
if t not in ("user", "assistant"):
continue
m = o.get("message")
if not isinstance(m, dict):
continue
role = m.get("role")
ts = fmt_time(o.get("timestamp"))
content = m.get("content")
if role == "user":
raw = extract_text_blocks(content)
# System/skill injections (isMeta) are not the user's messages.
if o.get("isMeta") or m.get("isMeta"):
if _LOCAL_CAVEAT in (raw or ""):
local_cmd = True
continue
# The context-compaction summary is stored in the user role
# for the model's own benefit (isCompactSummary) — nobody
# said it, so it must not be attributed to the user.
if o.get("isCompactSummary"):
continue
# A local built-in command (/model ...) and its stdout follow
# the caveat marker; skip them. Anything else ends the batch.
if local_cmd and (
_CMD_NAME.search(raw or "") or "<local-command-stdout>" in (raw or "")
):
continue
local_cmd = False
# A button-question answer arrives as a tool_result —
# it is the user's decision.
ans = extract_ask_answer(o)
if ans:
turns.append((USER_LABEL, ans, ts))
continue
text = clean_user_text(raw)
if text:
turns.append((USER_LABEL, text, ts))
elif role == "assistant":
# "API Error: ..." entries are synthesized by the harness
# (isApiErrorMessage, model "<synthetic>") — not a reply.
if o.get("isApiErrorMessage"):
continue
local_cmd = False
text = (extract_text_blocks(content) or "").strip()
if text:
turns.append((ASSISTANT_LABEL, text, ts))
return meta, turns
EMOJI = {USER_LABEL: "🟦", ASSISTANT_LABEL: "🟩"}
def render(meta, turns):
title = meta["title"] or "Conversation"
out = [f"# {title}", ""]
info = []
if meta["date"]:
info.append(f"Date: {meta['date']}")
if meta["session"]:
info.append(f"session {meta['session']}")
if info:
out += [" · ".join(info), ""]
last = None
for speaker, text, ts in turns:
if speaker != last: # header only when the speaker changes
emoji = EMOJI.get(speaker, "")
name = f"{emoji} {speaker}".strip()
label = f"# {name} • {ts}" if ts else f"# {name}"
out.append(label)
out.append("")
out.append(text)
out.append("")
last = speaker
return "\n".join(out).rstrip() + "\n"
def out_name(meta):
date = meta["date"] or "0000-00-00"
sid = (meta["session"] or "unknown")[:8]
return f"{date}-{sid}.md"
def write_atomic(path, text):
"""Write via a hidden temp file + rename, so concurrent hook runs
(two sessions open at once) never leave a half-written export."""
d, name = os.path.split(path)
tmp = os.path.join(d, f".{name}.tmp")
with open(tmp, "w", encoding="utf-8") as f:
f.write(text)
os.replace(tmp, path)
def last_assistant_text(data):
"""Text of the Stop hook's `last_assistant_message`, tolerant to shape
(plain string or a message object/content-block list). '' if absent."""
v = data.get("last_assistant_message")
if isinstance(v, str):
return v.strip()
if isinstance(v, dict):
return (extract_text_blocks(v.get("content")) or "").strip()
if isinstance(v, list):
return (extract_text_blocks(v) or "").strip()
return ""
def heal_stale_exports(inbox, transcripts_dir, skip_name):
"""Re-export inbox files whose source .jsonl grew after the export.
Claude Code flushes transcript tails lazily, so the last assistant
message(s) of a session usually land in the .jsonl only after its
final Stop hook already ran. Any later hook run repairs those files
here. Only files still PRESENT in the inbox are touched — a missing
export means the session was already ingested (or never exported),
and recreating it would resurrect ingested content.
"""
try:
inbox_files = os.listdir(inbox)
except OSError:
return
for name in inbox_files:
if name == skip_name:
continue
m = _EXPORT_NAME.match(name)
if not m:
continue
sid8 = m.group(1)
try:
sources = [
f for f in os.listdir(transcripts_dir)
if f.startswith(sid8) and f.endswith(".jsonl")
]
if len(sources) != 1:
continue
src = os.path.join(transcripts_dir, sources[0])
dst = os.path.join(inbox, name)
if os.path.getmtime(src) <= os.path.getmtime(dst):
continue
meta, turns = parse(src)
if not turns:
continue
write_atomic(dst, render(meta, turns))
except Exception:
continue # a hook must never fail
def main():
ap = argparse.ArgumentParser()
ap.add_argument("input", nargs="?")
ap.add_argument("-o", "--output")
ap.add_argument("--hook", action="store_true")
args = ap.parse_args()
if args.hook:
try:
data = json.load(sys.stdin)
except Exception:
return 0 # a hook must never fail
path = data.get("transcript_path")
root = os.environ.get("CLAUDE_PROJECT_DIR") or data.get("cwd") or os.getcwd()
if not path or not os.path.exists(path):
return 0
inbox = os.path.join(root, INBOX_DIR)
try:
os.makedirs(inbox, exist_ok=True)
meta, turns = parse(path)
# The async-written .jsonl may lack the turn's final reply —
# take it from the hook input instead (Stop provides it).
tail = last_assistant_text(data)
if tail and not any(
sp == ASSISTANT_LABEL and tx == tail for sp, tx, _ in turns
):
turns.append((ASSISTANT_LABEL, tail, ""))
current = None
if turns:
current = out_name(meta)
write_atomic(os.path.join(inbox, current), render(meta, turns))
heal_stale_exports(inbox, os.path.dirname(path), current)
except Exception:
return 0
return 0
if not args.input:
ap.error("INPUT.jsonl or --hook required")
meta, turns = parse(args.input)
text = render(meta, turns)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(text)
print(f"Written: {args.output} ({len(turns)} turns)")
else:
sys.stdout.write(text)
return 0
if __name__ == "__main__":
sys.exit(main())
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "TRANSCRIPT_INBOX=transcripts/_inbox TRANSCRIPT_USER_LABEL=Alice python3 \"$CLAUDE_PROJECT_DIR/.claude/scripts/export-transcript.py\" --hook"
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "TRANSCRIPT_INBOX=transcripts/_inbox TRANSCRIPT_USER_LABEL=Alice python3 \"$CLAUDE_PROJECT_DIR/.claude/scripts/export-transcript.py\" --hook"
}
]
}
]
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment