Chapter 16 of 28, in Part II, Workflows
Agent transcripts as documentation: export, redact, link the commit
A coding-agent session log explains a diff better than the diff, but it expires, leaks secrets and changes format. A workflow and script to keep a record.
A commit says what changed. The agent session that produced it says why: the prompt, the files the agent read, the test it ran before it decided, the dead end it backed out of. That is the documentation reviewers usually wish they had, and it already exists on disk the moment the session ends. It just does not stay there, it is not safe to publish as it stands, and its format belongs to the tool, not to you.
This piece sets out a workflow for keeping a short, redacted record of each session next to the commit it produced. It reads an open-source converter for Claude Code transcripts at one commit, tests its commit detection against the summary lines git prints, and ships a standard-library script that writes the record, redacts it, and runs from a session-end hook. Every number below comes from that script, from git run on 15 October 2026, or from the documents in the source list.
What the log is, according to the docs
Claude Code's documentation, read on 15 October 2026 (the npm registry listed version 2.1.301 as latest that day), puts the transcript at ~/.claude/projects/<project>/<session-id>.jsonl, one JSON object per line "for a message, tool use, or metadata entry". Three properties of that file matter for anyone who wants to keep it as a record.
It expires. The .claude directory page says Claude Code deletes these files once they are older than cleanupPeriodDays, and "The default is 30 days and the minimum is 1". A pull request that sits in review for five weeks can outlive the session that wrote it. Our cost dashboard reading hit the same rule from the other side: usage tools can only count what is still there.
It is not a stable interface. The sessions page is direct about it: "The entry format is internal to Claude Code and changes between versions, so scripts that parse these files directly can break on any release." It points scripts to /export, which writes the conversation as plain text, and to structured routes: claude -p --output-format json, the Agent SDK, and the transcript_path field that hooks receive.
It holds whatever passed through a tool. "These files are plaintext. Anything that passes through a tool is written to a transcript on disk: file contents, command output, pasted text." The same page adds that if a tool reads a .env file or a command prints a credential, "that value is written to" the session file. A transcript published as-is publishes those values too.
So the raw log is evidence, not documentation. The workflow below turns it into documentation before it expires, in a format you own, with the secrets taken out.
claude-code-transcripts at 316fd093aca3
claude-code-transcripts, written by Simon Willison according to its pyproject.toml, turns a session file into paginated HTML. Read through the GitHub API on the evening of 15 October 2026 (US Eastern; the API stamps it 16 October UTC), it had 1,700 stars, 205 forks and 70 open issues, an Apache-2.0 licence, and no latest release reported by the API. The newest commit on main was 316fd093aca3 from 12 February 2026, titled "Note that this is partially broken"; pyproject.toml at that commit says version 0.6. The README warns that the web commands "are both broken right now due to changes to the unofficial and undocumented APIs that these commands were using". The local commands, which read files on disk, are the part this piece reads, all in one 2,224-line file, src/claude_code_transcripts/__init__.py.
Four details of that file shape what its output can document.
- Only two line types survive.
_parse_jsonl_filekeeps lines whosetypeisuserorassistantand skips the rest. On the repository's own JSONL fixture that is 7 of 8 lines; the dropped one is asummaryline. - A prompt is a user line with text. Tool results arrive as user lines too, so the grouping in
generate_htmlstarts a new prompt only when the content carries text. The JSONL fixture holds 2 prompts; the JSON fixture holds 6 prompts and 12 tool calls. - Commits come from a regular expression. Line 39 defines
COMMIT_PATTERN = re.compile(r"\[[\w\-/]+ ([a-f0-9]{7,})\] (.+?)(?:\n|$)"), which is run over tool results that are plain strings. The index page uses the matches to build its timeline of prompts and commits. - Publishing is a gist.
create_gistcallsgh gist createand adds--publiconly when itspublicargument is true; all three call sites pass no argument, so--gistalways creates the gh default. The thinking blocks in a log are rendered into the HTML too (thethinkingbranch ofrender_content_block).
Neither "redact" nor "secret" appears anywhere in that source file. The tool converts; what goes in comes out.
The commit pattern, tested on git's own output
The commit timeline is the bridge between a transcript and the history, so we checked the pattern against what git actually prints. In a throwaway repository, git 2.51.2.windows.1 made seven commits on 15 October 2026, one per situation an agent session can be in. The listing re-runs this with --live-git and got the same seven results.
| Situation | Summary line git printed | Pattern at 316fd09 | Broader pattern |
|---|---|---|---|
| first commit in a repo | [main (root-commit) eab8e32] First commit |
miss | match |
| ordinary branch | [main f0c0b91] Second on main |
match | match |
| branch with a dot | [release-1.2 5892f03] On a dotted branch |
miss | match |
| branch with a slash | [feature/login fe45fc0] On a slash branch |
match | match |
| detached HEAD | [detached HEAD 7930034] On a detached HEAD |
miss | match |
| non-ASCII branch | [fix_ünï 1469ca2] Non-ASCII branch |
match | match |
| branch with a plus | [v2+hotfix 1076f21] Plus branch |
miss | match |
Three of seven match. The character class [\w\-/] has no room for a dot, a plus or a space, and the (root-commit) marker sits between the branch and the hash. Release branches with version numbers are common, and a commit made on a checked-out tag or in the middle of a rebase lands on a detached HEAD. The broader pattern in our listing, ^\[([^\]\n]+?) (?:\(root-commit\) )?([0-9a-f]{7,40})\] (.+)$, matched all seven.
There is a second gap. The scan reads a tool result only when its content is a string. In the repository's fixtures every result is a string, so nothing is missed there; but the message format allows a result to be a list of text blocks, and a commit printed into that form would not reach the timeline. Our constructed session puts one commit on release-1.2 in a list-form result: the original pattern and scan find 0 commits, the listing finds 1. Since the format is internal, we cannot say which form a given Claude Code version writes; the safe reader handles both.
Secret gists are unlisted, not private
The GitHub CLI manual says "By default, gists are secret; use --public to make publicly listed ones." GitHub's own documentation explains what secret means: "Secret gists aren't private. If you send the URL of a secret gist to a friend, they'll be able to see it. However, if someone you don't know discovers the URL, they'll also be able to see your gist." A transcript shared with --gist is therefore one forwarded link away from anyone, with every file the agent read and every command it ran on the page. That is a reasonable way to share a session you have read and cleaned. It is not a place for one you have not.
The same reasoning sits behind our security piece on agent tool access: what an agent can read, it can repeat, and the log is where it repeats it.
A record, not a dump
The workflow we recommend keeps three artefacts apart. The raw log stays local and ages out on its own schedule. A short Markdown record per session goes into the repository. The commit points at the record.
The record holds what a reviewer needs and leaves out what a reviewer does not: each prompt with its time, tool calls counted by name, files written or edited, shell commands run, and commits made. Tool output, file contents and the model's thinking stay in the raw log. On the repository's JSONL fixture, the listing writes this:
# Agent session record: sample_session.jsonl
- session: test-session-id
- branch at start: main
- from 2025-12-24T10:00:00.000Z to 2025-12-24T10:01:05.000Z
- log lines kept: 7; other line types not shown: {'summary': 1}
## Prompt 1 - 2025-12-24T10:00:00.000Z
> Create a hello world function
- tools: Bash x1, Write x1
- wrote or edited: `/project/hello.py`
- ran: `git add . && git commit -m 'Add hello function'`
- commit abc1234: Add hello function
## Prompt 2 - 2025-12-24T10:01:00.000Z
> Now add a goodbye function
To produce it without remembering to, run the listing from a SessionEnd hook in .claude/settings.json. The hooks reference says the input carries transcript_path, session_id and cwd, and that "SessionEnd hooks have a default timeout of 1.5 seconds". A per-hook timeout raises the budget "up to 60 seconds", so set one:
{
"hooks": {
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PROJECT_DIR}/tools/agent-transcripts-as-documentation.py\" --hook --secrets \"${CLAUDE_PROJECT_DIR}/.env\"",
"timeout": 10
}
]
}
]
}
}
The same reference warns that the transcript "is written asynchronously and may lag the in-memory conversation", so the final turn can be missing from a record written at the very end. The --hook mode never fails the session: on any error it prints one line to stderr and exits 0. The record lands in docs/agent-sessions/<session_id>.md, uncommitted, so a person reads it before it joins the history, which is the same habit as reading agent diffs.
Redact with the values, not the shapes
Most redaction guesses at shapes: lines that look like API_KEY=..., blocks that look like private keys. Shapes miss the case that matters most, which is a value copied somewhere else. Our constructed three-step session has the agent read a .env file holding two values, paste one of them into a curl header, and commit. A naive export of that session contains the two values 3 times.
| Pass | Occurrences left |
|---|---|
| none (full export) | 3 |
| pattern pass alone (NAME=VALUE with KEY, TOKEN, SECRET or PASSWORD in the name; PEM private keys) | 1 |
exact values from the .env file, then the patterns |
0 |
| the Markdown record, same two passes | 0 |
The pattern pass caught both .env lines and missed the copy in the curl command. Reading the actual values from the file you are protecting and replacing them wherever they appear caught it. Values shorter than six characters are skipped, because replacing true everywhere would wreck the record. The record alone still needed the exact pass, because the curl command is one of the shell commands it lists.
Link the record to the commit
Git already has a slot for this. The trailer documentation describes trailers as "trailer metadata at the end of the otherwise free-form part of a commit message", a key-value pair whose key "consists of ASCII alphanumeric characters and hyphens". In the same throwaway repository:
git commit -m "Use fixture key in payment test" --trailer "Agent-Session: docs/agent-sessions/constructed.md"
git log --format='%h %(trailers:key=Agent-Session,valueonly)' -3
# 2c3db7f docs/agent-sessions/constructed.md
# f0c0b91
# f0bec69
git log can now list which commits came with a session record and which did not, and git blame leads from a line to the commit to the prompt. A team that also runs one worktree per agent session gets a clean mapping: one branch, one session, one record.
Running the script
python agent-transcripts-as-documentation.py
# fixture sample_session.jsonl: kept (user + assistant) 7 lines
# fixture sample_session.json: prompts 6 prompts
# git summary lines matched, cct pattern 3 of 7
# git summary lines matched, broad pattern 7 of 7
# redact left after the pattern pass alone 1 occurrences
# redact left after exact values + patterns 0 occurrences
# wrote agent-transcripts-as-documentation.csv (30 rows)
The self-test downloads the converter's two test sessions pinned at 316fd093aca3 into cct_fixtures/ and never reads your own transcripts. --live-git rebuilds the seven commits with the git on your PATH. For a real session, python agent-transcripts-as-documentation.py SESSION.jsonl --secrets .env --out record.md.
The dataset
_README agent-transcripts-as-documentation.csv (30 rows, one per result printed by the self-test)
section fixture | git | redact | record
item what was measured, in words
value the number, or match/miss for each pattern on a git line
unit lines, prompts, calls, files, commits, occurrences, "of 7", or cct/broad
note the fixture commit, the git line itself, "constructed", or the replacement count
Limits
The parser follows a format its owner calls internal, so it can break on any Claude Code release; that is a reason to write the record at session end, while the format is the one the parser was checked against. The two fixtures are the converter's test data, not real sessions, and the redaction case is constructed, so the counts show what each rule does rather than how often it matters. Exact-value redaction is only as complete as the secrets file you give it; a token the agent created or fetched during the session is not in your .env. We did not install or run claude-code-transcripts itself; every claim about it comes from its source and README at 316fd093aca3.
A session record is worth keeping for the same reason a commit message is: in six months nobody remembers the conversation. For what survives from one session to the next inside the tools themselves, see coding agent memory across sessions. The record is the part you keep on purpose.