Replay a saved run
Save a run as three things — snapshot, events, structure — then show it in Why Lens or Flow Lens. Nothing re-runs, no model is called.
You have a run. You want to look at it later — in a bug report, in a review, on another machine. Two steps: record it, then render it.
A recording is exactly three things. Miss one and one surface goes dark, so save all three together.
| what it is | what it buys | |
|---|---|---|
events | the typed agentfootprint stream, in order | the story, the commentary, the summary |
snapshot | the footprintjs run snapshot | state, the commit axis, provenance, every attached recorder's data |
structure | the agent's build-time chart | the chart — and only the chart. Nothing else can draw it |
Step 1 — Record
In the app that runs the agent. recordRun wires everything and hands back the
three fields:
import { recordRun } from 'agentfootprint/observe';
import { narrative, metrics } from 'footprintjs/recorders';
// Optional, and each lights one panel:
agent.attach(narrative()); // the Story panel (without it: "X executed. Wrote: y")
agent.attach(metrics()); // real durations (without it: the Gantt shows ORDER, and says so)
const recorder = recordRun(agent); // BEFORE the run
const reply = await agent.run({ message });
fs.writeFileSync('run.json', JSON.stringify(recorder.toRecording()));
recorder.stop();toRecording() returns { snapshot, events, structure } — the exact shape the
viewers consume. There is no assembly step and no per-viewer adapter.
Record time is the only time
A recording is collected as the run happens, and none of the three can be
reconstructed afterwards: a finished run has no chart to give you, the event
stream is dropped rather than queued when nothing is listening, and the commit
log records what each stage wrote — never when a boundary was crossed. Call
recordRun() before run(), or you are recording the next one.
What a recording keeps of a vector
A retrieval turn drags every retrieved entry's embedding through the
memory-read subflow's boundary output — measured at 2.76 MB for one recorded
turn, ~1.1 MB of it floats nothing renders. Since 8.20.0 a recording keeps a
vector's shape, not its bytes: every embedding / embeddings field is
replaced with an EmbeddingSummary — { dims, norm } — by recordRun and by
boundaryRecorder at capture time. The retrieval evidence a debugger reads
(scores, passages, documents, rejected candidates) carries no vectors and is
untouched. Pass recordEmbeddings: true to either to keep the raw floats, and
use the exported summarizeEmbeddings (whole values, copy-on-write) or
summarizeVector (one vector) from agentfootprint/observe when a recording
post-processor wants to apply or recognise the same projection.
If you are wiring it by hand
You rarely should, but the requirement is the one recordRun satisfies for
you: the boundary recorder — the thing that puts stops on the step strip —
needs three connections at record time, and each missing one fails
differently.
import { boundaryRecorder } from 'agentfootprint/observe';
const boundary = boundaryRecorder({
getCommitCount: () => agent.getCommitCount(), // 3. where each boundary sits
});
agent.attach(boundary); // 1. the boundaries themselves
boundary.subscribe(agent); // 2. what happened inside them| missing | what you get |
|---|---|
attach | no boundaries at all — loud, the strip is empty |
subscribe | boundaries with nothing in them — no LLM or tool detail behind any stop |
getCommitCount | every boundary stamped commitIdxBefore: 0 — silent. The events look complete and the strip cannot be rebuilt |
enable.flowchart() and enable.localObservability() wire all three for you
(since 7.8 — before that they missed the third, and every recording made
through them carried a flat axis).
A plain footprintjs pipeline
The same fields, one layer down:
const recording = {
events: [], // typed events are an agentfootprint thing
snapshot: executor.getSnapshot(),
structure: chart.buildTimeStructure, // on the BUILT chart — no snapshot carries it
};Step 2a — Render it in Why Lens (the agent view)
import { observeRecording, Lens } from 'agentfootprint-lens';
const { recorder, runner, boundaryRanges } = observeRecording(
JSON.parse(await fs.readFile('run.json', 'utf8')),
);
return (
<>
{boundaryRanges === 0 && <p>This recording has no step boundaries.</p>}
<Lens recorder={recorder} runner={runner} theme={{ mode: 'light' }} />
</>
);Nothing re-runs, no model is called, no network is touched. boundaryRanges === 0
means the run was recorded without a commit-tracking boundary recorder — the step
strip stays quiet rather than inventing stops.
Step 2b — Render it in Flow Lens (the pipeline view)
import { ExplainableShell, overlayFromSnapshot } from 'footprint-explainable-ui';
import { structureGraphFromSpec } from 'agentfootprint-lens/core';
const rec = JSON.parse(await fs.readFile('run.json', 'utf8'));
return (
<ExplainableShell
runtimeSnapshot={rec.snapshot}
traceGraph={structureGraphFromSpec(rec.structure)}
runtimeOverlay={overlayFromSnapshot(rec.snapshot)}
traceTheme={{ mode: 'light' }}
/>
);You do not pass narrative entries: the story is read out of the snapshot's own
recorder data. traceTheme={{ mode }} re-themes the whole shell — for a light
app, that one word is the entire theme wiring.
Pass runtimeOverlay explicitly. Without it the chart renders in its unvisited
colours with no warning — the snapshot is right there, but the shell does not
reach into it on your behalf.
<Replay trace> — the chart-only subset
localObservability() freezes a different, smaller artifact: a Trace, which
is the domain-event log plus the chart. <Replay> renders it.
import { Replay } from 'agentfootprint-lens';
const trace = JSON.parse(fs.readFileSync('run.trace.json', 'utf8'));
return <Replay trace={trace} />; // draws trace.structureThe flowchart below is that component rendering a Trace captured from a real
run (scripts/gen-replay-trace.mjs) — offline, with no runner.
Be clear about what this is: <Replay> draws the chart's shape and stops
there. It does not read trace.events — time-travel over them is a planned
refinement — so an offline <Replay> is a strictly smaller view than the live
<Lens>, not a match for it. And a Trace carries no footprintjs snapshot
unless you ask for one, so on its own it cannot drive ExplainableShell,
WhereFrom, or the commit axis:
const dev = agent.enable.localObservability({ includeSnapshot: true });
const trace = dev.getTrace(); // now carries state + commit log tooReach for <Replay> when a static picture of the run's shape is what you want.
When you want the run, record it and use step 2a.
Redaction travels with the Trace
A live, in-process model is fine to hold raw, but serializing is a
trust-boundary crossing — the artifact can travel. So redaction runs at
getTrace(), and the result is self-describing: trace.redaction is
'pii' when a redact ran, 'none' when it didn't. When a Trace carries raw
content, <Replay> shows a banner so a shared trace is never mistaken for safe.
| You want… | Call |
|---|---|
| Best-practice redaction | getTrace({ redact: redactContent }) |
| Custom scrub (write-once fn) | getTrace({ redact: (e) => myScrub(e) }) |
| Raw content (trusted, local) | getTrace() → redaction: 'none' (banner shown) |
The graph is always derived from the (already-redacted) events — it is never stored — so redaction reaches the rendered flowchart with no second content surface to leak.
redact runs per domain event, which is exactly why includeSnapshot is
opt-in: a snapshot's shared state is the run's raw working memory, and no
per-event function can reach inside it. Redact that half at run time with
footprintjs's setRedactionPolicy(), where the values are written.
What a recording honestly cannot show
Each viewer states these on screen rather than faking them:
- Per-stage durations without
metrics()— the Gantt shows execution ORDER instead, and says so. - The step strip without a commit-tracking boundary recorder — the strip stays quiet.
- Error messages — the commit log has no error channel. A failing stage's writes land; its message does not.
- Deep subflow internals — footprintjs deliberately keeps them out of the run-level commit log, so a replay lights the mount stages, not their insides.
- Detail behind each stop, on a lean recording —
recordRun(agent, { boundaryDetail: 'lean' })stores boundary structure without captured content. The bundle says so in its ownmeta.mode, so a viewer can tell you rather than showing empty panels.
Next steps
- Debugging — backtrack a wrong answer to the context that caused it
- Monitoring exporters — ship runs to OTEL / AWS / Datadog (Tier-4)
Recorded chat
Record a chat turn-by-turn so any reply can be explained, counterfactually re-run, and forked — without re-writing the glue every chat host gets subtly wrong.
File a bug with the run attached
A bug report IS the evidence. describeBugReport() measures the run into selectable units so a human can consent; exportBugReport() bundles what they kept as a real zip; githubBugReporter() commits it and files the issue.
