Skip to main content

transcript-viewer

Browse agent transcripts in a local web viewer — what Claude Code, Codex and Copilot write as they work.

Logs are converted on the way in to ATIF v1.7 by atif-make, which this package depends on and which does all of the reading. What is added here is only the browser interface, so a format this cannot open is a parser missing from atif-make rather than anything to change in the viewer.

Install

uv tool install transcript-viewer                    # pulls atif-make automatically
uv tool install "transcript-viewer[parquet]"         # + datasets published as Parquet
uv tool install "transcript-viewer[ai,parquet]"      # + the optional Claude features

The extras are only needed for what they name: parquet for a dataset that ships as Parquet rather than JSON, ai for the summarise and ask features. The viewer works without either.

transcript-viewer                        # the library, empty on a first run
transcript-viewer path/to/session.jsonl  # one log
transcript-viewer bundle.zip             # a bundle someone sent you
transcript-viewer --port 8080 --no-open

Nothing appears on a first run. Sessions arrive two ways:

  • ⟳ on the Local folder finds what Claude Code, Codex and Copilot have already written here. Deliberate rather than automatic: a first run should not index someone's entire history of every agent because a library happened to be empty. It is the same refresh remote folders get, in the same place.
  • Add… in the top bar brings things in from elsewhere — a file, a folder or an archive from this machine, or a URL.

Nothing downloads on the first press. A link to a single file is confirmed and fetched; anything larger opens a picker — the folder tree, one level at a time, with sizes and checkboxes — so what gets downloaded is chosen rather than accepted. Folders open as they are clicked rather than up front: the bucket this was built against holds 118,801 objects, and listing all of them to draw a tree would cost more than most of the downloads it exists to avoid. Ticking a folder takes everything under it, and the total is measured rather than estimated: the server lists inside what was ticked and answers with a real count and size, so "180 files · 3.4 GB" is what it says, not "1+ files". All and None tick the top level; Open all expands the tree when the shape of a place matters.

A couple of examples sit above the field and fill it in. The index is refreshed before anything cosmetic happens, so a failure after the download cannot cost you the rows you just paid for.

Files go to ./transcript-downloads/<owner--name>/ beside where the viewer was launched — one place, not a choice to make each time — so a dataset you pull is somewhere you can reach rather than buried in a dot-directory. Because those files are yours rather than the viewer's, removing a session from the library forgets the entry without deleting them.

What arrives keeps the shape it had where it came from — a fetch from …/attacks/model_priors reads back as Remote/Hugging Face/sleightbench/SLEIGHT-Bench/attacks/model_priors — because a repository's own folders are the organisation its author chose, and two hundred rows in a flat list are no use to anyone.

Any host may be fetched, but the name is resolved first and refused if it lands on a loopback, link-local, private or reserved address — checked again after redirects, since a redirect is a second request to a second host. That is not caution for its own sake: the page hands a URL to a server running as you, on your network, so an unguarded fetcher is a way to reach your router, a cloud metadata endpoint, or something bound to localhost. Only Hugging Face and GitHub are understood well enough to list a repository; every other host is a link to one file.

s3://bucket/prefix works too, read through the aws CLI rather than a library. Archives are fetched as readily as loose logs and unpacked on arrival — a folder of them read off this machine works the same way — because a bucket of agent runs is far more likely to hold one zip per session than bare JSONL — and what comes out of an archive is placed in the tree by where the archive came from, not by whatever the archive calls its own folders. That is deliberate: the CLI already owns the SSO session, the profile configuration and the refresh logic, so shelling out to it means this never holds an AWS credential and never has to renew one. Sign in yourself —

aws sso login --profile rw-eng

— and the viewer reuses the session. Naming a profile is only needed when a machine has several: with one configured, or AWS_PROFILE set, there is nothing to disambiguate and nothing to type. Where there are several, Settings offers them as a list read from the CLI rather than asking anyone to remember one. It never runs the login: that is an interactive, browser-based act belonging to the person at the keyboard. When there is no usable session it says so and gives the exact command, because the CLI's own advice (aws login) is not the SSO one.

Arguments reach the CLI as a list with no shell, and the bucket, prefix and profile are checked against strict patterns first — a profile called --profile would otherwise be read as a flag.

Gated repositories need a token — see Settings below.

A first run opens empty. Add… brings transcripts in, and the Local folder's ⟳ finds what this machine has already written; naming a path on the command line views it without touching the library.

The library

Sessions are organised by where they came from, and the tree is derived rather than filed by hand:

Local                      Remote
  Claude Code                Hugging Face
    transcript-viewer                  sleightbench
    PaperParser                  SLEIGHT-Bench
  Codex                            attacks/model_priors/…
    …                        GitHub

Both sides have the same shape: what produced the session, then the unit of work — a project on this machine, a repository on the web. There is no filing step and nothing lands unfiled, because the tree reads a fact rather than a decision. Collections were tried first and removed: a second way to organise laid over one that already existed, which the two could disagree about. Tags remain for grouping that cuts across the tree, and a session can still be renamed and starred.

A remote node links back to the folder it mirrors.

Look on this machine in the Add dialog can be left running: tick keep watching and it looks again every minute, so a session started after the viewer opened turns up on its own. It runs only while the viewer is open — repeatedly reading someone's whole working history is not a thing to leave going out of sight — and a scan nobody asked for never redraws the table, since a minute later is not a moment anyone chose to be interrupted. It says so quietly when something new arrives.

Indexing has no cut-off: it takes whatever Claude Code and Codex still have on disk, which is as far back as they keep it rather than a window this chooses.

Removing a node removes the sessions under it — a node cannot be deleted on its own, since it describes where something came from rather than a choice anyone made. Removing a session takes everything kept about it: title, tags, stars, starred steps and any cached AI summaries, all of which live in one record. Files are deleted only where they are the viewer's own copy under ~/.transcript-viewer/opened/; a session found on this machine, or downloaded into a folder of yours, keeps its file.

Some datasets ship as a single file holding many runs — ATBench publishes a thousand agent trajectories as one JSON array, METR's as Parquet shards (install with uv tool install "transcript-viewer[parquet]" to read those). Those are opened rather than listed: the file is split into a transcript apiece and each is indexed on its own, so a download of one file becomes a thousand sessions you can read. The pieces are kept beside what they came from, so removing the folder removes them too.

This tool used to be called atif-view and kept all of that under ~/.atif. If that directory is still there, the first run moves it to ~/.transcript-viewer — index, annotations and stored keys together — and says so. Nothing is left behind and nothing is copied twice.

Clear library above the tree forgets every session at once, after saying how many and what it will touch. Files are left alone by the same rule as a single removal — only copies the viewer made itself are deleted — and stored tokens are settings rather than library, so they stay.

Claude Code names a session's directory by replacing every / in the working directory with -, which cannot be undone by reading the string: atif-make and atif/make escape identically. The path is rebuilt by asking the filesystem which one exists, so a project appears under its real name.

Asking Claude about a transcript

Two optional AI features, both off until you press something:

  • explain this call — on any tool call, summarises what it tried to do and what came back. The summary is kept, so you pay for it once — and once it exists the call simply shows it, with no button to press.
  • Ask Claude — a collapsible panel holding a conversation about the session. Most sessions go to the model whole; only one too large for the budget is sampled, and then by scoring each step against the question. Of 81 sessions here, 58 are sent entire and 23 sampled. Follow-ups carry the earlier questions and answers, but not their step dumps: those are already digested into the answers, and replaying a page of transcript per turn would make a long conversation quadratic. An answer reports what it read — "read all 62 steps", or "read 40 of 312 steps" — so a partial view is visible rather than implied, and the step numbers it cites are links into the transcript.

When sampling is needed, steps are scored by how many of the question's words they mention, longer words counting for more, with a long word also scoring at a discount on its first four characters so "authentication" finds test_auth.py. Candidates are trimmed to the budget in score order and then read back chronologically, so what survives is the most useful rather than the earliest. A question that matches nothing falls back to the steps the previous answer read, or, on a first question, to the closing steps.

Both stream: text appears as it is written rather than after the call finishes. The response is newline-delimited JSON read with fetch, not server-sent events — EventSource reconnects when the connection closes, which would silently repeat a paid call. The framing makes no difference to how finely tokens arrive; SSE would stream exactly the same. X-Content-Type-Options: nosniff is set, or the browser withholds the opening bytes while it sniffs the type.

Measured, rather than assumed: over loopback the server delivers 202 of 202 frames individually at a 3.8 ms median gap, with TCP_NODELAY making no difference either way, and building the HTML for a 30,000-character answer costs under a millisecond. What remains is how coarsely the API itself emits text.

Thinking is reported separately from text. A model that thinks for twenty seconds before its first word is indistinguishable from a hang, so the panel says "Thinking…" while that is what is happening — the deliberation itself is never sent to the page. A call summary skips thinking entirely: two sentences about one tool call are delayed by it, not improved.

A conversation lives in the page, not the library — switching transcripts starts a fresh one. Call summaries are cached and do persist.

Nothing is sent on load, on hover, or in the background. Every request is one click, of yours.

uv tool install "transcript-viewer[ai]"    # brings in the anthropic SDK

The SDK and a credential are two separate requirements, and Settings names whichever is missing — a key saved with no SDK installed still reports as saved, rather than looking like the save failed. Running from a checkout, the extra is not implied:

uv run --extra ai transcript-viewer

Then either export ANTHROPIC_API_KEY, or paste a key into Settings in the top bar. Settings holds three credentials — an Anthropic key for the AI features, and Hugging Face and GitHub tokens for From URL… — each with the same treatment described here, and each falling back to its usual environment variable (HF_TOKEN, GITHUB_TOKEN) when nothing is stored. A key set in Settings is stored at ~/.transcript-viewer/config.json, mode 0600 inside a 0700 directory; it is sent to the Anthropic API and nowhere else, and is never read back into the page — the page only ever sees its last four characters. With a key saved, the field shows dots and that tail, so it is obvious at a glance that one is there; they are a placeholder rather than a value, since a value could be submitted back and stored as the key. A key in your keychain or password manager is safer than one in a file, so prefer the environment variable if you have the choice. Settings shows which of the two is in use, and Remove clears the stored one.

With no key configured, every AI control is hidden and the endpoint refuses.

Each transcript also has its own With AI support switch. Turn it off and that session's controls disappear — useful when a transcript holds something that should not leave the machine. The server enforces it too, so a switched-off transcript is refused even if a request is made directly.

Themes

Three, from Diwan: paper, cool (both light) and dark. The control in the top bar cycles them, as Diwan's own header does, and the choice is remembered per browser. Note that "light and dark" is really three modes here — two of Diwan's palettes are light.

What it shows

transcript-viewer view              # everything in the index
transcript-viewer view path/to/log  # a single file
transcript-viewer view --port 8080 --no-open

Stack: Python's standard-library http.server and a single self-contained HTML page — vanilla JS and CSS, no framework, no build step, no CDN. That is the whole point of the zero-dependency rule: the viewer is one file you can read.

Binds 127.0.0.1 only — session logs routinely contain source code and tool output, and must not be reachable off-host. Trajectories convert on demand and cache in memory, so opening a large corpus is cheap.

Links. URLs and absolute filesystem paths are both linkified in one pass — in prose, inside JSON argument values, and in tool output. Clicking a path reveals it in Finder (open -R, which selects the item rather than launching it, so clicking a path in a log can never execute anything); a path that no longer exists is struck through instead. file:// links cannot be used for this because Chrome refuses to follow them from an http page, so the link calls back to the local server. Path detection requires a plausible root (~, /Users, /opt, …) so prose like "and/or", "3/4" and "2026/08/20" is left alone; inside a JSON string the quotes bound the value, so paths with spaces work there.

Code spans and fenced blocks are linkified too. Standard Markdown leaves them literal, but in agent transcripts a path or URL is usually written in backticks — on one real corpus, 131 of 181 linkable targets sat inside code, so honouring the convention would have hidden most of them.

Markdown. Agent messages are written in Markdown — headings, lists, code fences, tables — so the viewer renders them as such. The renderer is ~60 lines of vanilla JS inlined in the page: it escapes the source first and only then applies transforms, so no markup from a log can reach the DOM, and only http(s) links become anchors. A raw text toggle shows the unrendered string when you need to see exactly what the model emitted.

Images are served from memory at /api/image and rendered inline, so a session with screenshots is browsable without writing anything to disk.

Opening things. Open… in the sidebar takes a normal file dialog, and files can be dropped anywhere on the window. Either way the upload goes to the same corpus.scan() the CLI uses, so the button and transcript-viewer <path> can never disagree about what counts as openable — logs, converted trajectories and archives all work. A client-supplied filename is reduced to a leaf before anything is written, and uploads live in a temporary directory for the session.

Duration. Each trajectory reports how long it actually ran, from the first step's timestamp to the last, in whatever unit fits — these range from seconds to 63h across two and a half days.

Favourites. Star individual steps inside a transcript — the star sits in the gutter beside the step number, visible at rest rather than on hover — and the Favourited lens filters to them. Stars are keyed the same way the step anchors are, so one set inside a subagent (whose ids restart at 1) cannot land on the wrong step. Rename a transcript from its own heading by double-clicking it, or from the table; both write the same record.

Expand all. Tool calls and branches open collapsed so a long transcript is readable; one control opens or closes every one of them. It acts on what is already on screen rather than repainting, so you keep your place.

Finding things. A run of several thousand steps needs more than scrolling. Search this run matches across message text, reasoning, tool names, tool arguments and observation output — the things a reader can actually see. Filter lenses (All / User / Agent / System / Tools / Reasoning / Branches) carry live counts for the whole run, so you can tell at a glance that a session is 7,628 tool turns and 451 user messages before filtering to any of them.

Provenance and sources. Three tabs: Trajectory renders the run; Raw shows the head of the original log, so you can see what was converted rather than trusting the conversion; Files lists everything that travelled with the session — subagent traces, sidecar manifests, bundled images — each revealable in the file manager. A details strip records the schema version, detected source format, model, session id and transcript size.

Reading a trajectory. Every step is a tinted card — one colour per role, so user, agent and system turns are distinguishable without reading labels — with its ATIF step_id in the gutter to the left of the timeline. The number is also an anchor, so you can link someone to a specific step. Subagent steps are numbered independently (ATIF restarts them at 1) and scoped so their anchors cannot collide with the parent's. Tool-call arguments are syntax-coloured, and tool output is coloured only when it really parses as JSON, so ordinary command output is left as plain text. The session list collapses with the button at the top left, or the \\ key.

Branching. A delegated subagent is a complete trajectory in its own right, so the viewer renders it as one: collapsed under the tool call that spawned it, labelled with the agent type, its task, and its step count. Expanding it shows that agent's own steps — and because ATIF nests arbitrarily deep, a subagent that delegates further renders the same way, with depth marked. Where a ref points at an external file (--split-subagents) rather than an embedded trajectory, the viewer says so instead of silently showing nothing.

Because branches sit anywhere in a trajectory that may run to thousands of steps, every session with branches gets a jump list at the top — agent type, task, step count — and an "only branches" filter. Steps render 250 at a time so a 8,000-step session stays responsive.

Developing alongside atif-make

The two packages are developed together. Installing the viewer editable makes its own code live:

uv tool install --force --editable .

That alone still resolves atif-make from git, so edits to the converter would not show up. To run with both live, add it explicitly:

uv run --with-editable ../atif-make transcript-viewer

Use that while changing anything in atif-make. Reinstall from the index (uv tool install --force transcript-viewer) when you want to test what users actually get.

Layout

src/transcript_viewer/
  page.html    the whole front end — 2,232 lines of HTML, CSS and JavaScript
  viewer.py    the HTTP server: sixteen endpoints over the page and the library
  corpus.py    the index — what is on this machine, and where it came from
  library.py   what you decide about a session: title, tags, stars, summaries
  fetch.py     bringing transcripts in from Hugging Face, GitHub, S3 or a link
  ai.py        the optional Claude-backed features
  config.py    settings and tokens
  store.py     small JSON files under ~/.transcript-viewer, written so a crash cannot truncate them
  cli.py       the command line
  page.html    the whole interface: one page, no build step, no dependencies

The page is a file rather than a string inside viewer.py, which is where it used to live. Two thirds of that module was CSS and JavaScript typed as though it were Python — a template expression once shipped inside static HTML and rendered its own source, which is harder to miss in a file that knows what it is. It is re-read whenever it changes, so editing the interface and reloading shows the edit — twice it did not, and read as an edit that had failed. The cost is a stat per page load, and none on what the page then calls; for an installed copy the file never changes and the stat always says so. The wheel carries it.

Tests

uv run pytest

While changing atif-make at the same time, run against the local converter or the tests will resolve the published one:

uv run --with-editable ../atif-make pytest

Add --extra ai to either command to exercise the AI paths against a real SDK; the tests stub the model call, so this never contacts the API. --extra parquet does the same for the Parquet path, which is otherwise skipped.

The suite starts a real server on an ephemeral port and exercises the endpoints, including that it binds loopback only. It is isolated from your own library, index, settings and opened-file store — a frozen default argument once let it write to them, so there is a test for that too.

Most of the viewer's behaviour is browser JavaScript, which pytest cannot reach — two real breaks shipped that way, a row click that did nothing and a trajectory pane stuck on "Converting…". Those checks live in tests/page.test.js, load the real page script against a stub DOM, and run from tests/test_page.py as part of the same suite (skipped without node).

The AI tests never call the API. Most stub the model call and check the part that matters when it is wrong — that nothing is sent unasked, that a summary is paid for once, that a stored key never reaches a response, and that a transcript switched off is refused by the server rather than merely hidden.

tests/test_stream.py is the exception: it drives the one function that does touch the SDK, using a fake client but the SDK's real exception classes, so a rename or re-parenting fails here instead of on someone's first paid call. It found one already — the SDK moved from httpx to httpx2 at 1.0. CI installs the extra and fails on any skipped test, since a silently skipped test is worse than no test.

tests/test_readme.py checks this file against the code, pairing each claim with the marker that makes it true. Prose drifts quietly: a documented behaviour outlived the code twice here, once because an edit matched nothing and reported success anyway.

Metadata

Release files for transcript-viewer 0.7.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for transcript-viewer 0.7.0
File Size Uploaded
transcript_viewer-0.7.0.tar.gz 157.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for transcript-viewer 0.7.0
File Interpreter ABI Platform
transcript_viewer-0.7.0-py3-none-any.whl Python 3 none any Details

Total release size: 244.8 kB

Release files / transcript_viewer-0.7.0.tar.gz

Download URL transcript_viewer-0.7.0.tar.gz
Size 157.7 kB
Tags Source
SHA-256 checksum
How to use checksums
ec9a3dfa9346e30664d9748f6c282be93f625b1ff63f9593e18af1cfb0e7e456
BLAKE2b-256 checksum
How to use checksums
8109e116e68bd5211db730c2d2f182c87697fc11e94f10984b699b4faf0de3b5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / transcript_viewer-0.7.0-py3-none-any.whl

Download URL transcript_viewer-0.7.0-py3-none-any.whl
Size 87.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
b406451793172de85d53647f92c8bfebc9503cc682642072356bf9fc1cdbee6f
BLAKE2b-256 checksum
How to use checksums
9671dda7bfc2a1d7f47487bddde41b0c27ca8f7f30c635aab0c3a76e525f1572
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

0.16.1

2 release files

0.16.0

2 release files

0.15.0

2 release files

0.14.1

2 release files

0.14.0

2 release files

0.13.0

2 release files

0.12.0

2 release files

0.11.3

2 release files

0.11.2

2 release files

0.11.1

2 release files

0.11.0

2 release files

0.10.1

2 release files

0.10.0

2 release files

0.9.0

2 release files

0.8.1

2 release files

0.8.0

2 release files

0.7.2

2 release files

0.7.1

2 release files

This release

0.7.0 This release

2 release files

0.6.2

2 release files

0.6.1

2 release files

0.6.0

2 release files

0.5.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page