claude-scriptorium 0.1.5

Render Claude Code sessions as self-contained HTML
Documentation

claude-scriptorium

Render Claude Code sessions as self-contained HTML.

Each session becomes one folio: a single .html file with its markup, styles, and images inlined. It can be mailed, gisted, or opened offline as one file.

Install

cargo install claude-scriptorium

Or grab a prebuilt binary with cargo-binstall, skipping the compile:

cargo binstall claude-scriptorium

Usage

Pick a session interactively (a project list, then its sessions, each labelled with Claude's own title for it):

claude-scriptorium render

The current project floats to the top and every list starts on its first row, so pressing Enter twice renders the current project's most recent session. To get that same session without the prompt (for scripts, or over SSH with no terminal), pass --latest:

claude-scriptorium render --latest --open

--open opens the rendered folio in your browser. Pass --output/-o to render to a chosen path, or into a directory:

claude-scriptorium render ~/.claude/projects/-home-me-work/<session-id>.jsonl -o folio.html
claude-scriptorium render --latest -o folios/

Locally serve a session over HTTP with live reload, for watching a session (the same session selection applies, so serve --latest or a bare serve picker both work):

claude-scriptorium serve <session-id>.jsonl

A served folio re-renders as the session is written, so its dock carries a follow control that keeps the newest message pinned, like tail -f. A written or published folio is a snapshot and never gains a message, so it offers no such control.

Sharing a folio

Publish a folio to a GitHub gist via the gh CLI, which handles authentication. It publishes as whichever account gh resolves: set GH_HOST to target a different GitHub host, and switch the active account with gh auth switch (gh gist create has no flags of its own for either). publish confirms the resolved account before pushing, so you can abort if it is not the one you meant, and reminds you that a secret gist is unlisted but still readable by anyone with the URL:

claude-scriptorium publish <session-id>.jsonl

The same session selection as render/serve applies. GitHub shows a gist's HTML as source rather than rendering it, whatever its size, so reading a published folio takes one of the two paths below.

View offline

Download the gist's files and open the HTML locally, with no network rendering in the loop:

claude-scriptorium fetch <gist-url-or-id> --open

publish prints this exact command for the gist it just created. It keeps the browser viewer out of the loop, but the gist itself already lives on GitHub; for a truly sensitive session, don't publish at all: render the folio and share the HTML file directly.

View in a browser

The gist page shows the folio's HTML source, not the folio, so publish also prints a link to a viewer page that renders it. The viewer is a small static page (this project's Pages site by default); the reader's browser fetches the gist straight from GitHub's API and writes it into the page, so the viewer's host never receives the transcript.

Point it at a different viewer with --preview-base <url>, or set CLAUDE_SCRIPTORIUM_VIEWER_BASE to default it on a machine that always publishes to the same one. A github.com gist falls back to this project's viewer; any other host has no built-in viewer, so supply your own.

Self-hosting a viewer (and GHES)

For full control, or for a GitHub Enterprise instance (whose gists this project's viewer can't reach), scaffold your own viewer site and serve it from GitHub Pages:

claude-scriptorium scaffold-viewer ./folio-viewer               # for github.com
claude-scriptorium scaffold-viewer ./folio-viewer --host ghe.example.com

That writes a small git repo (a viewer index.html, its GitHub API base set to the chosen host, plus a README with deploy steps). Push it, enable Pages, then publish with --preview-base <your Pages URL>. The viewer is vendored from GistHost (MIT).

Claude Code stores transcripts under ~/.claude/projects/, one directory per project path, one JSONL file per session. Set CLAUDE_CONFIG_DIR to read from somewhere other than ~/.claude.

What gets rendered

Assistant and user turns, in order, with:

  • Text and thinking as markdown, with GFM tables, task lists, and autolinks
  • Fenced code highlighted into classed spans by syntect, so the theme owns the colors
  • Tool calls and their results as marginalia, labelled with the tool name and the subject of the call, and set in the shape that suits the tool: a command as highlighted shell, an edit as a diff, a plan or a subagent prompt as the markdown it was composed as, a read's result as the language of the file it read, a search's result as the links it found. A call its label already states in full is one flat line; everything else folds. Each result sits with the call it answers, and its line previews the first thing that came back rather than naming the call again: what a command printed, a file's opening line, the option that was chosen. A result that says only that the call was carried out is left out, since the call above it already shows what happened.
  • Terminal colour kept: output written with ANSI escapes reads in the folio's own pigments, rather than showing the escapes
  • Pasted images inlined as data URLs

A folio opens with a caveat, the one piece of the reading column this tool wrote rather than set from the session. A session file isn't everything the model was told: the system prompt, the tool descriptions, and the CLAUDE.md and rule files loaded when a session starts are sent with every request but never recorded, so no folio can show them. The caveat is not a panel, and nothing that reads panels counts it as one: the dock won't step to it, the minimap draws no band for it, the key can't set it aside, and the search never returns it as a hit.

The rest of the reading column is pure transcript. The folio's title, facts, and a colophon (what wrote it, when, and what the render cost) live in a plaque you open from the corner, and the parchment leaf is bordered with illuminated vines threaded with marginal drolleries: colourful little beasts (snail, frog, cat, butterfly, stag, and more) seated among the vine at intervals, cycling through the whole bestiary so a long folio keeps turning up new ones.

A folio is scrolled by a scroll: the scrollbar thumb is a written sheet of parchment wound onto two turned rollers, which lengthens and shortens with the document and lies on its side for a code block that runs off the edge. It is never hidden or narrowed, being both the position indicator and a drag target, and a reader in a high-contrast mode gets their own system's scrollbar instead.

Turns a subagent produced are marked as such, and take Tyrian purple across every kind at once, so a sidechain reads as one whoever was speaking in it.

Transcript content is escaped, never executed: a session that discusses <script> renders it as text.

Glosses

Much of what shapes a session is written into it by the harness rather than typed by anyone, and a reader who can't see it is left guessing why the assistant did what it did. So each of those notes gets its own quiet panel, a gloss, labelled by what wrote it there, with its content folded away behind a summary line:

Gloss What it holds
hook What a hook printed, injected, or failed with
rule A CLAUDE.md or rule file pulled into context
skill The instructions a skill carries, whether a command loaded it or the assistant reached for it
command A slash command, its arguments, and what it printed
plan Entering plan mode, and leaving it with or without a plan
note A file edited outside the session or attached to it, a truncated read, a model swapped mid-conversation

A command that works the harness rather than the conversation, /copy or /config say, is left out: being told the last reply went to the clipboard tells a reader nothing. The ones that change the conversation stay.

A gloss is a panel like any other, so the key names its kind, the search and the minimap reach it, and the dock steps to it along whichever side of the exchange it came from. Only note, the catch-all, stands aside from that walk. Its content stays folded behind its summary line either way, so the reading column stays the conversation while the context behind it stays a click away.

Every kind of panel carries its own pigment, along one axis: warm is what the model produced, cool is what reached it from outside. So scrolling tells you which side of the exchange you're passing before you read a label. The assistant speaks in its own orange, reasons in that orange drawn back toward the ink, and reaches for a tool in ochre; you speak in lapis, type a command in that lapis drawn back toward the ink, and your skills and hooks arrive in teal and malachite. A plan boundary is the exception, rubricated in vermilion because it marks a division in the text rather than anything said in it, and the ambient kinds stay in faint ink so the rest can stay loud.

What a folio cannot show

A transcript records the conversation, not the instructions a session opened with. The project's CLAUDE.md, your global one, and every rule with no path to trigger on are assembled into the system prompt, which is never written to the session file, so no folio can show them: what you see are the rules and nested CLAUDE.md files the harness pulled in as it worked, one gloss each. A session can therefore read as though the assistant knew something from nowhere, and the standing instructions are where to look.

The same goes for anything else outside the file: a tool's own side effects, the state of the repository, and whatever you told Claude in another session.

Unrecognized content

Claude Code's transcript format grows new block types over time. An unrecognized block renders as formatted JSON rather than aborting the folio, because a new block type is a producer adding something optional, not malformed input. The same goes for the harness's own notes: one whose shape the folio doesn't recognize is left unset rather than breaking the render. Lines that carry no conversation and no context (an inventory of the available tools or skills, a restatement of the checklist, the harness's own timings, mode changes, file-history snapshots) are skipped.

Tools are treated the same way. A tool with no view of its own, such as one an MCP server provides, shows the input it was sent as formatted JSON, and so does a built-in whose input doesn't match the shape its view expects.

Reading a folio

A folio is interactive, driven by one small script inlined alongside the styles, so the file stays a single self-contained artifact:

  • Set the key at the head of the rail down the right: a chip per kind of panel, carrying that kind's own pigment, so it says what every edge in the margin means as well as which kinds you want. What reached the model runs down one column and what it produced down the other. Everything under it answers to it, so you say once what you're reading through.
  • Search the session from the fixed box: matches are highlighted, and ‹ › or Enter steps through them, opening any collapsed fold that holds a hit. It looks only at the kinds the key leaves in play.
  • Press the light you want to read by, in the corner: the sun for day, the candle for after dark. There is no toggle, because the lights are the control. Whichever is burning is the one in force, so by day the sun is up and the candle stands smoking, and after dark the moon hangs among its stars and the candle is lit. A small reset appears once you have chosen, to hand the choice back to your system; until then the folio follows your OS preference. The choice is remembered across visits.
  • Copy any code block, a whole message, or a fold's prose (a skill's instructions, a rule, a plan, a subagent's prompt), from the button standing muted in its corner, and hear a quill take it down: the scratch is synthesized on the spot, so it costs the folio no bytes to carry.
  • Navigate from the corner dock: step along either side of the exchange, the cool arrows seeking what reached the model and the warm ones what it produced, or the middle pair taking both. The arrows honour the key too, so narrowing it to skills walks you through the skills alone. Each step names the turn in the URL, so it is a link you can share and a place a reload returns you to. Collapse or expand every fold at once from the same dock.
  • Scrub the minimap at the foot of the rail, drawn as the bound volume itself: the session shut and seen from above its front board, a band per message in that message's own pigment among the leaves, with your place on the leaf drawn over it. Drag along it to travel, and turn the wheel over it to zoom the map alone, so a long session's messages come apart far enough to pick one out without moving from where you are reading. It honours the key as well, fading what you have set aside.
  • Open the plaque in the corner for the folio's title, facts, and colophon.

Whichever light is burning flickers or turns its rays and casts a faint glow across the leaf, and holds still if you prefer less motion.

On wide screens an expanded tool call holding a diff or code block unfurls past the reading column so wide content fits without sideways scrolling, while prose stays in a narrow, legible measure.

Vocabulary

The code names things after the scriptorium that produced manuscripts by hand:

Term Meaning
folio One rendered session
quire The gathering of folios belonging to one project
caveat The folio's own note to its reader, at the head of the column
marginalia A collapsible tool call or result
gloss A note the harness wrote into the session, set as its own panel
key Which kinds of panel are in play, and what each edge's pigment means
rail The column of cards the key leads: search, dock, and minimap
drollery A marginal creature drawn in the border
luminary A light the folio is read by, and the control that chooses it
colophon Generation metadata, shown in the plaque
illumination The theme layer

Development

just drives the project tasks, and just --list shows them all. After cloning:

just setup   # nightly rustfmt, the pre-commit hook, and the browser tests' toolchain
just check        # format, lint, and every test suite
just test
just test-js      # the folio's own script, without a browser
just test-browser # a rendered folio, driven in a headless Chromium
just render <session>
just bench        # time rendering the fixtures, with hyperfine
just bench-huge   # the same, over generated sessions of megabytes
just profile      # sample a render and report where the time went, with perf and samply

The folio ships one inlined script, split into a functional core (src/illumination.core.js) and the shell that wires it to the document (src/illumination.shell.js), so the arithmetic behind its controls is tested as plain values and only the wiring needs a browser.

Profiling needs perf and two kernel settings; just setup-profiling installs them (once per machine, with sudo).

Formatting runs under nightly rustfmt, since rustfmt.toml uses unstable options. Committing runs the same formatting and linting through pre-commit.

mise.toml carries the tools the recipes shell out to (gh, hyperfine, just, node, samply, uv), so mise users get all of them with mise install. The Rust toolchain comes from rust-toolchain.toml instead, since rustup is what resolves the nightly rustfmt. The only JS dependency is Playwright, used by the browser tests; nothing it installs reaches a folio.

Fonts

Each folio embeds its typefaces so it renders identically anywhere with no network fetch: Junicode for body text, Fira Code for code, and UnifrakturCook for headings and dropped initials. All three are licensed under the SIL Open Font License 1.1; their license texts are vendored in src/fonts/licenses/. just fonts re-vendors the woff2 files and licenses from those upstreams, and cuts them down.

The faces are almost the whole of a short folio, so each is vendored twice: as upstream shipped it, and cut down to the Latin, punctuation, symbol, and box-drawing blocks a transcript actually sets. A folio carries the cut faces, about a fifth the bytes, unless its own text reaches a character the cut ones dropped, in which case it carries the whole faces and says so on stderr. Pass --whole-fonts to embed the whole ones regardless. Characters no face ever carried, an emoji or a CJK ideograph, fall back to the reader's own fonts as they always have.