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.
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.
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.
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.
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.
| Capability | Pure computation — reused as-is | Terminal renderer — not reused |
|---|---|---|
| Context view | context.from_event() → Context | context.render() |
| Context diff | context.diff() → List[Change] | context.render_diff() |
| Run diff | tracediff.diff() → TraceDiff | tracediff.render() |
| Doctor | doctor.analyse() → Report | doctor.render() |
| Chain tree | chain events' path / depth | node tree in tape show |
| Lineage | header.forked_from / fork_at | tape 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 showtoo, and that already has a test. - The UI cannot drift from the CLI — and that is testable.
What each screen renders
Five are the capabilities no other tool has; the first is navigation.
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.
Where the arrow keys live. Three regions: a timeline strip, an inspector, and a status bar.
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.
Trace.events, plus the decoder enrichment already on each eventThe 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].
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.
context.from_event() and context.diff() — both already behind tape show --context --diffPresent 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.
chain events' framework, name, type, path, depth, stepA 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.
footer["patched"] for the labelsDivergence 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.
tracediff.diff() → steps, find_divergence(), _totals()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.
doctor.Report → Source and SplitEvery action reachable without the mouse
| Key | Action |
|---|---|
| ← → | The primary motion — previous / next event |
| j k | Same, vim-style |
| Home End | First / last event |
| g + digits | Jump to event N |
| r c d | Inspector mode: raw / context / context-diff |
| [ ] | In diff mode, move the baseline earlier / later |
| n p | Next / previous event of the same kind |
| f | Filter by kind (multi-select) |
| t | Fork tree |
| x | Diff — from the tree, the two selected runs |
| l | Chain tree, when the run has one |
| y | Copy the tape … command for the current view |
| ? | Keyboard help overlay |
| Esc | Back / 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.
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.
Safe by construction, not by policy
tape ui [--port 7654] [--tape-dir .tape] [--no-open]
- Binds
127.0.0.1explicitly, never0.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.
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 thattracediff.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
truncatedandkept_prefixflags must survive serialisation, since the headline case is built on them. Driven byexamples/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.
What is out, and why
| Excluded | Why |
|---|---|
| Fork button, inline editing | Executes the user's agent — cost, credentials, subprocess. tape fork owns it |
| Running doctor from the UI | Same: real runs, real money, behind a click |
| Aggregate metrics and charts | The observability product this is not |
| Auth, accounts, sharing, hosted mode | Loopback-only makes them unnecessary; adding them makes the bind negotiable |
| Live tailing of a running agent | Traces flush per line so it would work, but it is a second interaction model — its own milestone if wanted |
| Editing or annotating traces | Read-only is what makes “no recording changes” structural |
Steps 1–3 are the milestone's value
- Server skeleton and runs index. Proves the stdlib server, the loopback bind, and trace discovery.
- Run view: timeline, raw inspector, arrow-key scrubbing. The spine. Already useful at this point.
- Context and context-diff modes, truncation treatment included. Where the demo GIF's bug becomes visible.
- Divergence view. Reuses
tracediffwholesale. - Fork tree, and x from tree to diff.
- Chain tree and doctor findings. Both conditional, both thin over reports that already exist.
- Help overlay, both themes, README, CHANGELOG.
Three things to decide before step 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. - Doctor: read a
--jsonreport from a path, or re-runanalyse()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. - The 2,000-event bucketing threshold is a guess. It should be measured against a real long agent run before it is fixed.