Milestone 11 · design for review

A viewer for what only reeltime knows

Screen inventory, what each screen renders, where its data comes from, and what is deliberately left out. Nothing is built yet — this is the thing to argue with first.

Position

Not an observability dashboard

LangSmith and Braintrust ship polished UIs backed by teams, and AgentTape ships a static HTML viewer. Competing on breadth loses on every axis at once. But none of them can render a fork tree, a divergence point, a context diff with truncation called out, or a doctor finding grouped by call site — because none of them have those things.

The scope test

Could a general observability tool render this from its own data? If yes, it is out — however nice it would be. Aggregate cost charts, latency percentiles, run volume, model-usage breakdowns: all out. Not because they are hard, but because they are somebody else's product, and reeltime has one developer's traces on one laptop.

Two departures from spec §10

No FastAPI, and no fork button

Standard library only

pyproject.toml says dependencies = [], and that is load-bearing rather than incidental — it is why pip install reeltime cannot break an environment, and the README says so. Spec §10 predates that property mattering.

So the server is http.server.ThreadingHTTPServer and the frontend is one HTML file with inlined CSS and JS: no build step, no npm, no CDN, no framework. A debugger that needs a toolchain to look at a crash is a debugger you will not open. It also works with no network at all, which matters because the traces it reads came from a machine that may be offline.

The cost is real: no component library, no virtual DOM, hand-rolled keyboard handling. For a six-screen read-only viewer with a fixed layout that is a good trade — and if the UI ever needs more, that is the signal it has drifted out of scope.

No fork button

Spec §10 lists “edit the event inline, hit fork, get a new run.” Excluded. Forking executes the user's agent — a subprocess, live credentials, real cost, and a run that can hang or half-write. That is a different risk surface from a read-only viewer, and the one part of the UI that could damage something. tape fork already does it well from a shell where the output is visible.

The UI instead makes forks legible: the tree screen shows lineage, and every screen that could motivate a fork shows the exact tape fork command to copy.

Architecture

The UI is a second renderer

The most important structural fact, and why this milestone is smaller than it looks: every capability already separates computation from rendering.

CapabilityPure computation — reused as-isTerminal renderer — not reused
Context viewcontext.from_event() → Contextcontext.render()
Context diffcontext.diff() → List[Change]context.render_diff()
Run difftracediff.diff() → TraceDifftracediff.render()
Doctordoctor.analyse() → Reportdoctor.render()
Chain treechain events' path / depthnode tree in tape show
Lineageheader.forked_from / fork_attape ls

The server calls the same function the CLI calls and serialises the dataclass to JSON. It computes nothing of its own. Three consequences:

  • “No new event data, no recording changes” holds by construction, not by discipline. The UI imports from core/ and never writes.
  • A UI bug is a rendering bug. If a number is wrong it is wrong in tape show too, and that already has a test.
  • The UI cannot drift from the CLI — and that is testable.
Screen inventory · six routes

What each screen renders

Five are the capabilities no other tool has; the first is navigation.

/Runs indexNavigation

Deliberately thin. A table with the columns tape ls already prints: run id, when, events, duration, cost, argv, lineage. A forked run shows ← 01M0BDHF8JK3MT@1 in its own column and the parent is a link. One text box filters on argv and run id.

Not here: totals across runs, charts, “cost this week.” That is the dashboard this is not.

Renders fromeach trace's header and footer — no event parsing, so it stays fast with hundreds of runs
/run/<id>Run viewThe spine

Where the arrow keys live. Three regions: a timeline strip, an inspector, and a status bar.

Timeline — x by t_rel, width ∝ dur_ms, colour = kind
0.00sevent 5 of 11 · 842ms43.2s
llm http tool mcp chain rand · time · uuid

Ambient events are near-zero duration, so they render as 2px ticks on a sub-track rather than invisible slivers. Above roughly 2,000 events the track buckets by time and a block becomes “n events”; scrubbing then steps bucket to bucket until you zoom. Traces that large are ordinary — an agent in a loop reads the clock hundreds of times.

The inspector shows the selected event in one of three modes: raw (event JSON, blobs resolved and marked blob:sha256… so you can see what was externalised), context, or diff.

The status bar carries event i of n, kind, call site, duration, model, tokens and cost — and when cost is null, why (no price row: Claude-on-Bedrock routing tier), because a blank cost otherwise reads as a bug.

Renders fromTrace.events, plus the decoder enrichment already on each event
/run/<id> · c · dContext & context diffHighest value

The sleeper feature, and the one to get right. Most agent bugs are context bugs, and nothing else shows the exact bytes the model received.

Context mode renders a Context: the full message array, one collapsible card per message with role, char count, and tool calls broken out as structured rows rather than buried in JSON. The completion sits below the input, visually separated — it is the one thing that came back.

Diff mode picks a baseline (default: the previous LLM event, any earlier event selectable) and renders List[Change].

Diff mode — gutter carries status, not colour alone
=[0] system · unchanged · 43 chars
~ [1] user · 791 → 280 chars TRUNCATED
kept “Here are the files in the folder: report_10…”
lost 511 chars — report_11.pdf … invoice.pdf
=[2] assistant · unchanged · 32 chars

Truncation gets its own treatment. Change.truncated is already computed — a message that survived but lost most of itself — and Change.kept_prefix says the new text is the old one cut short. When both hold, the card is flagged and the kept prefix and dropped tail are shown as separate blocks rather than an inline diff, because an inline diff of “the last 500 characters vanished” is unreadable. This is the exact bug the demo GIF is built around; it should be impossible to miss.

Renders fromcontext.from_event() and context.diff() — both already behind tape show --context --diff
/run/<id>/chainChain treeConditional

Present only when the run has chain events; the tab is absent otherwise rather than empty. An indented tree from each node's path and depth — name, type, duration, fan-out.

Nested HTTP and LLM events appear as children of the node they happened inside, which is precisely what the transport layer alone cannot show, and the reason the LangChain adapter exists. Selecting a node selects the matching timeline event so the two views stay in sync.

Model nodes are deliberately not recorded — the transport shim already records that crossing, with wire bytes the callback lacks — so a node's LLM child is the llm event at that position. The tree makes that adjacency visible instead of leaving a gap someone reads as a missing node.

Renders fromchain events' framework, name, type, path, depth, step
/treeFork treeLineage

A forest. Each root is an unforked run; each child is a run whose header carries forked_from, attached at its fork_at index.

Indented tree, not a graph canvas — lineage here is shallow and a canvas would be decoration. Each node shows run id, the patch expressions from its footer's patched list, event count and cost. The edge label is the fork point: @13.

Selecting two nodes and pressing x opens the divergence screen for that pair — the common motion is fork, then compare against the parent.

Renders fromheaders across all traces; footer["patched"] for the labels
/diff/<a>/<b>DivergenceDifferentiator

Divergence first, per spec §8.2 — the differentiator, not a footer. Before any per-field detail: the step where the two runs stopped being the same run, and what each did afterwards.

Aligned pairs below it, two columns, Step by Step. Identical stretches collapse to one line — steps 0–12 identical (12 events) — not twelve rows.

A path split renders differently from a value difference, because it is a different claim: run A called tool·path_a; run B called tool·path_b, with an explicit note that everything after is incomparable, not divergent. Totals footer carries cost and tokens per side, and the deltas.

Renders fromtracediff.diff() → steps, find_divergence(), _totals()
/doctor/<id>Doctor findingsRead-only

Reads a report produced by tape doctor --json. The UI does not run the agent — doctor executes the user's command N times with real calls and real cost, and a web page must not do that behind a click. The screen says so, and shows the command to run.

Findings grouped by call site, which is already how analyse() groups them: an agent in a loop reads the clock forty times, and the report should be as long as the number of distinct places it is nondeterministic. Each finding carries site, kind, name, the distinct values observed, a count, and the suggestion. Clicking one jumps to that event in the run view.

Path splits are a separate section, for the same reason the terminal report separates them: a split points at code behaving perfectly.

Renders fromdoctor.Report → Source and Split
Keyboard

Every action reachable without the mouse

KeyAction
← →The primary motion — previous / next event
j kSame, vim-style
Home EndFirst / last event
g + digitsJump to event N
r c dInspector mode: raw / context / context-diff
[ ]In diff mode, move the baseline earlier / later
n pNext / previous event of the same kind
fFilter by kind (multi-select)
tFork tree
xDiff — from the tree, the two selected runs
lChain tree, when the run has one
yCopy the tape … command for the current view
?Keyboard help overlay
EscBack / close overlay

Arrow-key scrubbing is the interaction the whole layout serves. Selection changes instantly — no transition, no easing. The timeline is the scrubber; the inspector is what it drives.

Look

It should read as a profiler

Dark, monospace throughout, no animation beyond instant state changes. Dense, high information per pixel, no empty hero space, no rounded cards with drop shadows.

Colour carries exactly one meaning — event kind — and is reused nowhere else, so a colour always answers the same question. Selection is an outline; diff status is a gutter character plus a muted background tint (+ − ~ =), which stays legible for the roughly 8% of men with colour vision deficiency and keeps the terminal's own vocabulary.

Both themes ship, defaulting to dark, because a projector or a bright room is the one place a dark debugger fails.

Server

Safe by construction, not by policy

tape ui [--port 7654] [--tape-dir .tape] [--no-open]

  • Binds 127.0.0.1 explicitly, never 0.0.0.0, and not configurable. Redaction is pattern-matching and best-effort, so the correct assumption is that a trace may still hold something private — which makes “never reachable from the network” load-bearing rather than a default.
  • No auth, no accounts, no sessions — correct because of the bind, not instead of it.
  • No telemetry, no external requests. The page loads zero remote assets, so it works offline and cannot phone home — enforced by everything being inlined in one file.
  • Read-only. No route mutates a trace; there is no write path to audit.
Testing

Prove the UI cannot drift from the CLI

That claim is the thing worth testing, and it is testable without a browser.

  • The API equals the CLI. For a fixture trace, /api/diff/<a>/<b> must carry the same divergence index, step count and totals that tracediff.diff() produced. If the UI ever starts computing its own numbers, this fails.
  • Every route on a fixture trace — including a run with chain events, a forked run, and an empty run.
  • Truncation reaches the API. The truncated and kept_prefix flags must survive serialisation, since the headline case is built on them. Driven by examples/truncation_bug.py, which already produces the bug deterministically with no API key.
  • The server binds loopback. Asserted directly — a connection to the non-loopback address is refused. It is the security claim, so it is a test rather than a comment.
  • No new runtime dependency. A check that dependencies == [] still holds, since the whole no-framework decision is otherwise invisible.

A browser-driven end-to-end test is deliberately not proposed: a heavy dev dependency to cover a layer already covered above.

Scope boundary

What is out, and why

ExcludedWhy
Fork button, inline editingExecutes the user's agent — cost, credentials, subprocess. tape fork owns it
Running doctor from the UISame: real runs, real money, behind a click
Aggregate metrics and chartsThe observability product this is not
Auth, accounts, sharing, hosted modeLoopback-only makes them unnecessary; adding them makes the bind negotiable
Live tailing of a running agentTraces flush per line so it would work, but it is a second interaction model — its own milestone if wanted
Editing or annotating tracesRead-only is what makes “no recording changes” structural
Build order · each step ends usable

Steps 1–3 are the milestone's value

  1. Server skeleton and runs index. Proves the stdlib server, the loopback bind, and trace discovery.
  2. Run view: timeline, raw inspector, arrow-key scrubbing. The spine. Already useful at this point.
  3. Context and context-diff modes, truncation treatment included. Where the demo GIF's bug becomes visible.
  4. Divergence view. Reuses tracediff wholesale.
  5. Fork tree, and x from tree to diff.
  6. Chain tree and doctor findings. Both conditional, both thin over reports that already exist.
  7. Help overlay, both themes, README, CHANGELOG.
Open questions

Three things to decide before step 1

  1. Is the runs index worth a screen at all, or should tape ui <run> open straight into the run view, with the index as an Esc overlay? The second is more debugger-like — you usually arrive knowing which run you want.
  2. Doctor: read a --json report from a path, or re-run analyse() over stored runs? Doctor keeps its runs, so the second needs no new data and no execution — but a report is a pair of runs and the UI would have to infer which. Leaning toward reading the JSON.
  3. The 2,000-event bucketing threshold is a guess. It should be measured against a real long agent run before it is fixed.