Skip to content

About

Why Lens: React components for inspecting recorded agentfootprint prompts, tool calls, context and decisions.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Why Lens

npm package: agentfootprint-lens

React components for inspecting recorded agent runs, grouped by context and decisions.

Install and quick start · Agent runtime

Part of the footprintjs ecosystem — the self-explaining stack.

One causal trace. Replay it as every step (Flow Lens), grouped steps (Why Lens), or the story (Story Lens).

See the context engineering as it happens.

Why Lens is the grouped view of a causal trace: React components for watching agents built on agentfootprint. Every injection into the Agent's slots (RAG, Memory, Skills, Instructions, Tools) is tagged inline — students and engineers see exactly what was put into the prompt, by whom, on which iteration. No hidden abstractions.


The pitch

agentfootprint = 2 primitives (LLM, Agent) + 3 compositions (Sequence, Parallel, Conditional) + N patterns (ReAct, Reflexion, Tree-of-Thoughts...) + cross-cutting context engineering. Why Lens is the surface that makes the context engineering visible — not as a "RAG view" or a "Memory view," but as tagged injections inside the ONE Agent card. That's the whole pedagogy.

npm version

coverage: 88%

license: MIT


30-second quick start

npm install agentfootprint agentfootprint-lens footprintjs foottrace

Lens 0.80.0 supports agentfootprint 14, footprintjs 11 and Foottrace 1.11.1 or newer, with Node.js 22 or newer and React 18 or 19. The stable peers are agentfootprint ^14.0.0 and footprintjs ^11.0.0. Earlier installed runtime lines are no longer supported. Reading older saved recordings remains supported; their fixtures and tests are retained unchanged.

The renderer peer remains footprint-explainable-ui >=0.34.0 <1.0.0 || ^1.0.0. Its minimum supplies the renderer APIs used by Lens. Public imports from agentfootprint-lens, /core, /why, /skillgraph, and /context stay the same. Flow Lens's separate /record door is tested independently.

In plain words groups the existing evidence under STAND — State · Trace · Assess · Name · Decide. Name displays the answer's standing and its recorded reason. These headings preserve the existing row text, order and evidence pointers; they derive no new verdict. New attempt summaries use answered and effect. Historical narrative text, including succeeded, remains recorded data and is not rewritten by Lens.

Compatibility proof and final validation

The 0.80.0 development cohort pins agentfootprint@14.0.0, footprintjs@11.0.0 and foottrace@1.11.1. Release validation uses a normally generated committed lock after the upstream finals are published, with npm ci --strict-peer-deps on Node 22 and 24.

The active cohort is derived from the exact development pins in package.json and the version, registry URL and integrity entries in the committed lock. CI joins registry SHA512/SHA1 with the downloaded bytes and locked integrity, compares installed files with the archive, and requires one physical Foottrace matching the selected pin. Archive SHA256 and size are measured receipt data.

The preceding RC proof compared the published agentfootprint@14.0.0-rc.0 archive with the reviewed offline candidate. Both had SHA256 0cabda7905ac5342c57e5741e7f7c48f788114dd6504b92138e9a609f76e7cfc. That is historical candidate evidence, not a digest or approval for the final 14.0.0 package. The final cohort requires fresh validation against its own published metadata and committed lock. npm ls foottrace and the physical package scan must show one Foottrace 1.11.1 owner.

Both Nodes run the whole Lens suite, including historical saved-record data, approval-fixture generation, helpers, typechecking, build and packaging checks. One Lens archive feeds renderer 0.34.0, 0.38.0 and the exact reviewed 1.0.0 candidate, each with React 18/19 on Node 22/24. Every consumer installs the registry AgentFootprint candidate normally and checks its locked integrity and installed bytes against the same proven archive. Public ESM/CommonJS doors, strict NodeNext/Bundler declarations, mock-agent recording/replay and Chromium rendering remain covered. Candidate /record isolation and browser checks remain separate.

Each matrix job first installs the repository's committed source lock. Consumer evidence lives outside that checkout to avoid inheriting its dependencies. Its isolated consumer fixture then performs a normal strict resolution and a fresh npm ci replay for that renderer/React combination and the newly built Lens tarball. Those generated fixture locks contain a CI-local Lens archive path: they are non-portable evidence, not the repository's committed source lock.

The renderer candidate's tracked build is pinned by commit and digest in scripts/compat/contract.mjs; npm 11.21.0 packs it without rebuilding. Archives, source-lock digests, consumer locks, logs, dependency trees and browser evidence remain available on failed runs. Browser builds use Vite without aliases, externals or stubs.

The previous one-time offline staging lane is removed; its proof remains in Git history. New validation always follows the current manifest and committed lock. All supported runtime lanes use --kind native; --browser no remains diagnostic-only and cannot report a passing lane.

import { useMemo } from 'react';
import { Agent } from 'agentfootprint';
import { mock } from 'agentfootprint/providers';
import { Lens, lensRecorder } from 'agentfootprint-lens';

export function App() {
  // Build the agent and the recorder once, and point the recorder at it.
  const { agent, recorder } = useMemo(() => {
    const agent = Agent.create({ provider: mock({ reply: 'Hi!' }), model: 'mock' })
      .system('You are a helpful assistant.')
      .build();
    const recorder = lensRecorder();
    recorder.observe(agent);
    return { agent, recorder };
  }, []);

  return (
    <>
      <button onClick={() => agent.run({ message: 'Hello!' })}>Run</button>
      <Lens recorder={recorder} runner={agent} />
    </>
  );
}

Three lines of wiring — lensRecorder(), recorder.observe(agent), <Lens recorder runner />.

  • recorder is what Lens READS: the event log, the run tree, the summary.
  • runner is what Lens DRAWS: the composition chart, read from runner.getSpec().buildTimeStructure. Pass it and the whole chart is visible from t=0; leave it out and Lens says so instead of drawing an empty box.

Swap mock(...) for anthropic(...) / openai(...) / ollama(...) from agentfootprint/providers — nothing else changes.


Doors

Two subpath imports name the lens you are mounting. They are additions — everything behind them is also on the root barrel, and the root barrel never changes.

import the job
agentfootprint-lens/why Replay a recording as the agent's own milestones. <WhyLens recording={...} /> takes the recording straight (recordRun()'s { snapshot, events, structure }, or the persistRecording envelope), validates it at mount, and mounts the shipped <Lens> shell on the milestone axis — plus the axis helpers (scrubAxisFor, stepForCommitIdx, stepForRuntimeStageId, resolveNavigation) for hosts holding one cursor across views.
agentfootprint-lens/skillgraph Debug how a run routed through its skills. <SkillGraphDebugger recorder={...} /> plus its headless selectors (selectSkillRoute, selectSkillBeats, selectSkillTopology, selectSkillFrameContext, stepForRuntimeStageId, resolveNavigation).
agentfootprint-lens/context What the model HAD at a stop — the served document of that epoch on top (the Served tab itself: pieces, messages, tools, verdicts, delta since the previous call) — and what it was BUILT from beneath: the record key by key, who wrote each, what moved; a long value opens in place (0.54.0). <ContextView runner={...} /> walks the milestone axis on its own; hand it cursor and it follows the ONE cursor (0.53.1). Under the record, the Findings band (0.61.0): on an armed run (.findings(), agentfootprint 9.101) the model's own ledger at the stop, folded for reading — facts, conflicts as first seen (each witness with its current standing, no verdict), open, ruled out, noise, undeclared — every string off the record, absent on an unarmed run. Beside it, the Reasoning lens (0.62.0): <ReasoningLens runner={...} shared={shared} /> — the same ledger read BY CALL, one card per tool call at the cursor (basis before, result, current standing after, the model's words quoted) and an answer card; absent on an unarmed run. Its exchange view (0.63.0) lays the same beats out as the transcript between model and tools — the call with its _findings JSON, the result, the ticket served instead, the findings piece, the answer — only what crossed the wire. Beside both, the Ontology view (0.64.0): <OntologyView runner={...} shared={shared} /> — the map an agent declared (.ontology(...), agentfootprint 9.106.0) drawn from the run constant on the record: sources, the terms they hold and via which tool, the terms no source holds under known, not held here, relations in the author's words — a chart and the same data as a list; absent on an agent that declared no map. Under the Reasoning lens's cards, the Coverage band (0.65.0): what the tools DECLARED they checked, did not check and can never cover (coverage(...) / absent(...), agentfootprint 9.109) — the merged boundary the library used to append to the answer, each item with the tool that declared it, and every declaration by call; drawn alone when the record has declarations and no ledger; <CoverageBand runner={...} shared={shared} /> standalone; absent when no tool declared. Beside them all, the Proof map (0.66.0): <ProofMap runner={...} shared={shared} /> — what the answer rests on as ONE graph off the record (agentfootprint 9.110.0): the answer, the calls with their declared standings (undeclared dashed), the tools, the sources the declared map joins them to; stands on, calls and reads edges, and the contingent overlay (a value the model used from a result it had itself set aside) and the conflict overlay — a chart and the same data as a list; absent on an unarmed run. And for the Story Lens, storyMarks(trace, record) (0.67.0): a pure fold that joins the AgentThinkingUI player's beats (by the toolCallId agentfootprint 9.111.0 stamps on ask and return) to the ledger at a stop and returns the chips for the player's marks prop — the basis and the model's proposition before a call, the current standing after it (undeclared when none), the answer's counts — nothing on an unarmed run; the player stays generic.

The axis model in three sentences: one run leaves one causal trace, and each lens replays one AXIS of it — the Why reading scrubs the milestones, the Flow reading scrubs every committed stage, the skill door scrubs the routing stops. The cursor and the commentary are shared: a position travels between axes by its commit index (scrubAxisFor + stepForCommitIdx), never by copying a step number from one ruler to another. Hand a door the wrong thing and it says so on screen — what it received and where to go — instead of rendering a blank panel.


What you actually see

The default view (view="engineer") is one screen:

Region Shows
Summary + transport (top) status · latency · LLM/tool calls · tokens, and ◀ ▶ ⟳Live with the clickable step strip
Agents (left, when a run has 2+) every Agent / LLMCall instance in the run — click to jump to it
The chart (centre) the composition that ran, lit up as the cursor moves. Click a box to drill into it
What happened (right) the moment-by-moment rail — click any moment to move the one cursor; the focused moment expands to the full detail (prompt, tool args, result, written keys, Where did this come from?)
Events (bottom) the raw typed event stream

Two other audiences are one prop away: view="analyst" (summary + humanized commentary) and view="user" (status line + final answer).

Lens watches agentfootprint's typed event stream — agentfootprint.agent.*, agentfootprint.stream.* (llm_start / token / tool_start / …), agentfootprint.context.injected, agentfootprint.composition.* and the rest of the 65-type registry. You never wire events yourself; recorder.observe() subscribes to all of them.

A long tool call is no longer a silence. When a tool reports from inside its own execute with ctx.progress(payload) (agentfootprint 9.52+), each report lands in the stream between that call's start and its end:

`walk_graph` reported progress (iteration 1): {"hop":1,"of":3,"node":"svc-a"}

The three identity fields are stamped by the framework, so the line states them as facts. The payload is the tool author's own data — the Lens shows a preview of it rather than guessing at a shape it was never promised, and says so when it had to cut one short. A tool that never calls ctx.progress files nothing, and nothing here invents a report it did not make.


Multiple watchers, one agent

Lens doesn't own the agent. Anything can watch it — a Lens, a telemetry exporter, a custom logger, or three at once.

// Lens in the sidebar
<Lens recorder={recorder} runner={agent} />

// At the same time — ship events to your telemetry backend
useEffect(() => {
  const stop = agent.on('agentfootprint.stream.llm_end', (event) => {
    telemetry.record('llm.tokens', event.payload.usage.input + event.payload.usage.output);
  });
  return stop;   // auto-unsubscribe on unmount
}, [agent]);

runner.on(type, handler) is the single subscribe primitive. It returns a () => void unsubscribe. Subscribe to one type, a domain wildcard ('agentfootprint.context.*'), or '*' for everything.


Works with every agentfootprint runner

The same two props work for all of them:

const caller = LLMCall.create({ provider, model }).build();          // one prompt in, one answer out
const agent  = Agent.create({ provider, model }).build();            // a ReAct loop
const chain  = Sequence.create({ name: 'Chain' })                    // …and Parallel / Conditional / Loop
  .step('draft', caller)
  .step('review', agent)
  .build();

const recorder = lensRecorder();
recorder.observe(caller);

<Lens recorder={recorder} runner={caller} />

One mental model. The runner does the work; Lens watches.


Trust boundaries and source prefixes

TrustBoundaryView reads the optional version-1 TrustBoundaries recorder bundle. It shows observed permission, policy, middleware, and credential facts with retained/dropped counts. It does not infer missing decisions from a finished execution tree or certify security or regulatory compliance.

Share the same cursor with execution replay to inspect a fact's actual source location:

const shared = useSharedCursor(recorder, recording.snapshot);
return <>
  <TrustBoundaryView snapshot={recording.snapshot} shared={shared} />
  <Lens recorder={recorder} shared={shared} />
</>;

Import these components and the hook from agentfootprint-lens. A fact's sourcePosition identifies its exact log, complete runtime subflow path, and inclusive committed prefix (-1 means the base before any commit). Source navigation does not substitute an enclosing stage or the final state. Missing positions remain Unplaced; absent, incompatible, or damaged logs remain unavailable.

Metadata-only exporters should mark their snapshot or selected child context with stateValuesWithheld: true. Lens then distinguishes a known source location from unavailable state values. For full-content snapshots, prefix folding requires the recorded base and intact prefix; a missing base never falls back to globalContext. Show final execution step explicitly leaves source-prefix mode. Older recorder bundles and manually supplied legacy cursors remain readable without source navigation.

Headless consumers can import readTrustBoundaries, readSourcePosition, and resolveSourcePrefix from agentfootprint-lens/core. See the reader contract and cursor ownership for limits and failure codes.

Watching a run that already finished

A recording is a run you kept. It is exactly THREE things — miss one and one surface goes dark, so save all three together.

Step 1 — record it (in the app that runs the agent)

const events = [];
runner.on('*', (e) => events.push(e));

await runner.run({ message });

const recording = {
  events,                                          // 1. the timeline
  snapshot:  runner.getLastSnapshot(),             // 2. state + commit log + every recorder's data
  structure: runner.getSpec().buildTimeStructure,  // 3. THE CHART. Nothing else can draw it.
};
fs.writeFileSync('run.json', JSON.stringify(recording));

structure is the one piece a run does not leave behind on its own — getSnapshot() never includes it — which is why so many stored runs replay without a chart.

Step 2 — render it

import { useMemo } from 'react';
import { observeRecording, Lens } from 'agentfootprint-lens';

// One call = one replay: it builds a recorder and walks the whole event log.
// Keep it out of the render body.
const observed = useMemo(() => observeRecording(recording), [recording]);

return <Lens recorder={observed.recorder} runner={observed.runner} />;

Nothing re-runs, no model is called, no network is touched. Anything the recording could not give back, Lens states on screen — you do not have to render the return values yourself.

piece where it comes from what it buys
events runner.on('*', …) collected during the run the messages, the moments rail, the commentary, the summary
snapshot the run's footprintjs getSnapshot() the commit axis, <WhereFrom>'s provenance, and every attached recorder's data
structure runner.getSpec().buildTimeStructure the chart — the composition that actually ran

Recordings frozen as { snapshot, events, blueprint } work as-is: blueprint is read when structure is absent.

The step strip needs one more thing at RECORD time: attach agentfootprint's boundaryRecorder (with commit tracking) while the run happens. Its snapshot entry stamps every subflow entry/exit with the commit index it crossed at, and that is what the strip is indexed by. A recording without it is still fully watchable — the strip stays quiet and says so. Lens will not guess the ranges from the commit log: the log cannot say when a boundary opened (a fork's branches all open at a moment it has no row for), and guessing produced 20 stops on a run that had 17.

observeRecording also returns the counts, for code that wants to check before rendering: chart ('drawn' | 'absent'), eventsReplayed, eventsSkipped, boundaryEvents, boundaryRanges, and notes — one line per thing the recording carried that could not be READ, as opposed to was not there.

Replaying a Trace

agentfootprint's enable.localObservability().getTrace() produces a Trace — a different transport for the same idea. <Replay trace={trace} /> adapts it onto the same path:

import { Replay } from 'agentfootprint-lens';

const trace = JSON.parse(await fs.readFile('run.trace.json', 'utf8'));
<Replay trace={trace} />

A Trace carries the boundary log rather than the typed event log, so it replays as chart + step strip + detail, with the commentary rail quiet — and Lens says so. For the full surface, record { snapshot, events, structure } and use observeRecording.


Report a bug with this run

A bug report about an agent is only worth reading with the run attached. A run carries prompts, tool arguments and retrieved documents — which is exactly why nobody should send one without seeing it first.

<BugReportButton> puts the consent step in the way. Clicking it opens a dialog that shows every selectable unit of evidence — one row per conversation, one per derived file — with its size, its event and turn counts, and the names of every state key that was already scrubbed. The reporter's own account (title, steps, expected, actual) is in the same dialog, because a report missing either half is not one. Nothing leaves until a person ticks it.

The evidence itself is agentfootprint's (9.9.0 or newer): describeBugReport measures the run, exportBugReport bundles the units that were kept. Lens renders the offer and hands the ids back — it never assembles a bundle and never decides what a unit is. The /observe door those live on is loaded when the button is pressed, not when your page imports it (0.52.2) — the pressed button reads Working… until it arrives, and a page that mounts the button carries none of the door's other families.

import { BugReportButton } from 'agentfootprint-lens';

// The whole integration. `source` is whatever this Lens is already showing.
<BugReportButton
  source={recording}                                // or a recordRun() handle, a Runner, or an array
  issuesUrl="https://github.com/acme/agent/issues"  // owner + repo are read from this
  labels={['bug', 'from-lens']}
/>;

What the reporter sees

  • The consent manifest — one checkbox per unit. The 3 most recent conversations start ticked, older ones do not (defaultRecentConversations changes the number). Derived files — the readable transcript, the narrative, the environment block — are rebuilt over the conversations that survive, so unticking a conversation takes it out of those too.
  • A live size meter — 12.4 MB of 24.0 MB, recomputed on every toggle. Over the ceiling it turns red, submit is refused, and it names what to do about it: "Untick conv-3 (20.0 MB) to fit." The number is an estimate and says so — the derived files shrink as conversations come out, so the real zip is that size or smaller.
  • The redacted keys, by name. footprintjs scrubbed the values upstream at commit time; the manifest can therefore say which secrets were protected without ever carrying one.

The three submit modes

They stack by what you configured. The first is always there, so the button is never a dead end, and a mode you did not configure is simply not offered — nothing fails at click time.

mode needs who the issue is from where the zip goes
Copy report + download zip nothing the reporter, by hand their machine
Sign in with GitHub deviceClientId the reporter, as themselves their machine, attached by hand
File automatically endpoint your application wherever your relay puts it

(a) Copy + download copies the composed issue body to the clipboard, saves the evidence zip, and opens the repo's new-issue form prefilled. A body too long for a URL is cut at a line break with a sentence pointing at the clipboard, never silently shortened.

(b) Sign in with GitHub runs the OAuth device flow inside the dialog: Lens shows the user code and the verification link, GitHub polls in the background, and on approval the issue is filed from the browser as that person — so a maintainer can ask them a follow-up.

<BugReportButton
  source={recording}
  issuesUrl="https://github.com/acme/agent/issues"
  deviceClientId="Iv1.0123456789abcdef"   // an OAuth App with Device Flow enabled
/>;

The client id is public by design (the device flow has no client secret). The token it yields is not: Lens holds it in memory for the life of the modal and drops it — never localStorage, never a cookie, never a log line, never the issue body. Signing in does not give Lens anywhere to push the zip, so the zip downloads to the reporter's machine and the issue says plainly that it must be attached by hand.

(c) File automatically POSTs the finished bundle to your own endpoint, which holds the token and files the report with githubBugReporter. One click for the reporter; nothing about your repository lives in the browser.

<BugReportButton source={recording} issuesUrl={ISSUES} endpoint="/api/bug-report" />;
// The server side, in full. The browser already built the bundle — the relay
// only holds the credential.
import { githubBugReporter } from 'agentfootprint/observe';

const reporter = githubBugReporter({ issueRepo: 'acme/agent' }); // token: GITHUB_TOKEN

app.post('/api/bug-report', async (req, res) => {
  const { manifest, filename, zipBase64 } = req.body; // kind: 'agentfootprint-lens.bug-report'
  const zip = Buffer.from(zipBase64, 'base64');
  res.json(await reporter.file({ manifest, files: [], zip, filename }));
});

The response Lens renders is { issueUrl, zipUrl }; anything else — a non-2xx, an error string — is shown to the reporter exactly as the server wrote it. That is the rule for every failure in this flow: agentfootprint's refusals teach what to do next, so they are rendered verbatim rather than paraphrased.

On an older agentfootprint

The substrate shipped in agentfootprint 9.9.0. On 7.x or 8.x the button does not render at all — a one-line hint says which version it needs. Lens will not send a run it cannot measure first.

Props

prop
source required a Recording, a recordRun() handle, a Runner, or an array of them
issuesUrl required https://github.com/OWNER/REPO/issues — owner, repo and API root are read from it (GitHub Enterprise Server works unchanged)
endpoint your relay URL — offers "File automatically"
deviceClientId OAuth App client id — offers "Sign in with GitHub"
labels labels applied to the issue
defaultRecentConversations 3 how many of the most recent conversations start ticked
maxBytes 24 MB the ceiling the meter measures against
appVersion your app's version, for the environment block
label the button's own text
api the agentfootprint functions to call — defaults to the installed ones

The headless half is on agentfootprint-lens/core — defaultSelection, measureSelection, trimHintFor, buildIssueBody, buildNewIssueUrl, parseGithubRepo — so a CLI or a Vue shell can build the same dialog.


Render an artifact by its ref

agentfootprint 9.21–9.23 taught tools to check large results into an artifact store and hand the model a ~30-token claim ticket (art_…) instead of the bytes; the model finishes with present({ ref, as, label }). The lens is the screen's half of that handshake: it redeems the ref and renders a component the app registered for the artifact's kind — ids + props, never markup the model wrote. The model can say what to show; only your registry says how.

import {
  ArtifactPane,
  httpArtifactResolver,
  presentedFromEvents,
  registerArtifactComponent,
} from 'agentfootprint-lens';

// 1. Teach Lens your kinds, once at startup. ('dataset/rows' already ships.)
registerArtifactComponent({
  kind: 'chart/spec',
  component: ({ meta, data }) => <MyBarChart title={meta.label} spec={data} />,
});

// 2. Point a resolver at the served agent — the SAME invoke path the
//    conversation posts to. Session identity rides every redemption.
const resolver = httpArtifactResolver({ url: '/invoke', sessionId });

// 3. Render every artifact the run presented. On history reload, walk the
//    stored transcript instead: readPresentedResult(toolMessage.content).
{presentedFromEvents(recording.events).map((call) => (
  <ArtifactPane key={call.toolCallId ?? call.ref} presented={call} resolver={resolver} />
))}

<ArtifactPane> heads the ref first (the render decision), then gets the payload and mounts the registered component. A kind nothing is registered for falls back to an honest metadata card — ticket facts, a bounded payload preview, copy/download — plus one line naming the registerArtifactComponent call that closes the gap. Never a blank pane.

When the artifact is gone

Refs expire — TTL, retention budgets, a restarted store. The present result carries a description snapshot at speak time, so a reloaded conversation can still say what stood there, from history alone:

Chart — "Q3 sales by region" (bar-chart, 41.0 KB) — expired; re-run to regenerate.

That placeholder needs no store round-trip beyond the failed head — this is the spreadsheet's #REF!, taught manners. Missing, expired and another-session's ref all answer one indistinguishable not-found by design (a leaked ref probes nothing), and the pane renders the same stated absence for all three. A resolution door failure (no store attached, wire down) is a different sentence: the pane shows the server's own teaching refusal, verbatim.

The two resolvers

import { httpArtifactResolver, storeArtifactResolver } from 'agentfootprint-lens/core';

// Over a served agent's wire ops (standingAgent / httpHost / nodeHost, or the
// managed-runtime dialect): POST { op: 'artifact-head' | 'artifact-get', ref,
// sessionId } to the invoke path. `userId`, extra `headers` and a custom
// `fetch` are options.
const overHttp = httpArtifactResolver({ url: 'https://host/invoke', sessionId: 's-1' });

// Over a directly passed store, for same-process demos and tests. The scope is
// REQUIRED — refs only resolve under the identity they were minted in.
const inProcess = storeArtifactResolver({ store, scope: { conversationId } });

Both return { status: 'live' | 'absent' | 'failed' } outcomes instead of throwing, so a pane can branch. In the EventStream, the whole artifact lifecycle — minted, resolved, expired, refused, presented — already reads as prose via the default humanizer.


Answer a paused run with a real component (typed HITL)

agentfootprint 9.24 taught every human-ask door (askHuman, the ask middleware, defineTool({ checkInComponent })) to carry an optional typed half beside the prose question: { componentId, props?, propsRef? }. The id names a component your screen registered — ids + props, never markup the model wrote, the same no-eval law artifacts follow. Small props ride the ask inline; the big half (a 200-option picker's options) rides the artifact store as a propsRef claim ticket, redeemed through the same resolver and the same session identity as every other artifact.

<AwaitingPane> is the screen's half:

import {
  AwaitingPane,
  decisionRequestBody,
  httpArtifactResolver,
  registerDecisionComponent,
} from 'agentfootprint-lens';

// 1. Teach Lens your collectors, once at startup. ('option-picker' ships.)
registerDecisionComponent({
  componentId: 'refund-form',
  component: ({ question, props, data, respond }) => (
    <MyRefundForm limits={props} rows={data} onSubmit={(answer) => respond(answer)} />
  ),
});

// 2. When a reply comes back awaiting, render the pane. The lens does NOT
//    own the POST — your app does; decisionRequestBody formats the body.
const resolver = httpArtifactResolver({ url: '/invoke', sessionId });
<AwaitingPane
  awaiting={reply.awaiting}
  resolver={resolver}
  onDecision={(decision) =>
    fetch('/invoke', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(decisionRequestBody({ decision, sessionId })),
    })
  }
/>

The person clicks; onDecision receives the structured decision — the option's id, or the approve/decline record (approveDecision / declineDecision, byte-compatible with agentfootprint's check-in vocabulary). After answering, the pane renders the decision as one sentence — "Approved by alice@ops — 'verified'." — which is display only: the structured decision is the record; the words are a rendering of it.

Nothing on this road dead-ends the human. An unknown componentId falls back to the prose question plus a plain answer box and says so; an expired propsRef renders the honest placeholder plus the answer box; a crashed registered component is caught and stated; a consent gate (checkIn / middleware ask) falls back to Approve/Decline with a required "deciding as" field, because an audit record with no actor names nobody.

The headless half is on agentfootprint-lens/core — readAwaitingComponent (era-robust: reads awaiting.component and the three pauseData homes), approveDecision / declineDecision, decisionRequestBody, decisionSentence, isConsentAsk — so a Vue or CLI shell can build the same pane.


Scrubbing by group — the active group is a named place

One causal trace, replayed at two zoom levels. On the per-step ruler every executed stage is a stop — the commit trace itself, nothing skippable, so the ruler's count is the run's stage count. On the grouped ruler the stops are the agent's milestones (iteration, context, LLM turn, route, tool call), drawn as labelled iteration bands; ◀ ▶ still move one stop at a time, and clicking a band jumps to its first stop.

The chart used to paint both the same way, and that was wrong for the second one. A stage's styling is by TYPE — the LLM call carries a hero emphasis (accent border, tint, glow) and the cursor's one node pulses — so landing on a group of six nodes pulled the eye to the LLM box. The group, the thing the ruler had actually moved by, never read as the position at all.

Pass granularity="group" and it does:

  • one accent for every member. An LLM call, a tool and a context pill light identically — same tint, same intensity. What a node IS stays legible in its icon and its shape; how loud it is no longer depends on its type.
  • everything else recedes uniformly — one dim, not a second ranking.
  • a boundary is drawn around the members, from their real measured positions, with the group's name on its top edge. Scrubbing group to group animates it (and doesn't, under prefers-reduced-motion: reduce).

The name is groupDisplayName — the same spelling the WHAT HAPPENED boundary list uses. One place, one name.

<Lens recorder={recorder} runner={runner} granularity="group" />

Or, in a shell that owns its own canvas and its own cursor:

import { LensFlow, useChartGroup } from 'agentfootprint-lens';

function WhyLensChart({ recorder, chart, cursorCommitIdx }) {
  // The group the cursor stands in, as chart node ids. Derived from the
  // boundary ranges the grouped ruler already computes its stops from —
  // no extra fetch, no second cursor.
  const group = useChartGroup(recorder, cursorCommitIdx);
  return <LensFlow chart={chart} granularity="group" activeGroup={group} />;
}

granularity defaults to 'step', and on that path nothing changes: no classes, no boundary, the same chart the Flow Lens has always drawn. 'group' at a commit no boundary encloses also renders as 'step' — a mode with nothing to draw draws nothing, rather than boxing the whole chart.

Restyle it with one variable (--lens-group-accent, falling back to --fp-group-accent), or target the classes directly: .lens-group-node--member, .lens-group-node--outsider, .lens-group-boundary, .lens-group-boundary-name.

Headless: activeChartGroup({ groups, commits, commitIdx }) on agentfootprint-lens/core is the pure function behind the hook — same answer for a Vue or CLI shell.


Scrubbing without dead air — snap stops

Sharing an axis is not the same as sharing a stride.

A view mounted inside the lens scrubs the host's axis — that is the one-cursor law working correctly. But the host's axis is the run's. A four-tool turn puts 73 stops on it, and a view like the Skill Graph changes at eight of them: 65 presses of ◀ ▶ walk framework stages that change nothing in its picture. The scrubbing is not wrong, it is dead air.

The fix that must not be taken is a second slider with its own numbers. Two transports over one run is the drift this package refuses. So pass snapSteps — an ascending list of positions on the axis the transport already has — and only the step buttons' landing set narrows:

<TimeTravel
  total={73}
  focusSeq={step}
  onFocusChange={setStep}
  isLive={false}
  // ◀ ▶ and ← → now land only here. Everything else is untouched.
  snapSteps={[0, 9, 17, 31, 44, 58, 72]}
/>

What stays true when it is set:

  • the drag track and the step strip keep the full axis — every position is still reachable, just not by ◀ ▶;
  • Home / End still go to the axis endpoints, because they are jumps, not steps (band clicks and strip clicks are jumps too, and also unaffected);
  • a position between two stops is disclosed, never rounded down. Drag to a step no stop covers and the readout says so — between stops 2 and 3 of 4 · 8 / 12 — because with the buttons narrowed, that is a position they cannot reproduce and the reader has to be told;
  • nothing is stored. The position is still the host's step, and every move still leaves through onFocusChange(step).

Entries the axis cannot hold (negative, past the end, non-integer) are ignored — a stop the axis does not have is not a stop. An empty list, or one whose entries are all off-axis, behaves exactly as passing nothing.

snapSteps is orthogonal to bands: bands group the axis, snaps narrow the unit of movement along it. Set both and the readout names all three truths — Iteration 2 · stop 3 of 4 · 10 / 12.

<SkillGraphDebugger> forwards the same prop to the transport it mounts, so a host can hand it the steps its routing picture actually changes at:

import { selectSkillBeats, selectSkillRoute, stepForRuntimeStageId } from 'agentfootprint-lens/skillgraph';

const beats = selectSkillBeats({ route: selectSkillRoute({ log: recorder.selectEventLog() }) });
const snapSteps = [
  ...new Set(
    beats
      .map((b) => (b.runtimeStageId !== undefined ? stepForRuntimeStageId(positions, b.runtimeStageId) : -1))
      .filter((s) => s >= 0),
  ),
].sort((a, b) => a - b);

<SkillGraphDebugger step={step} totalSteps={totalSteps} onStepChange={onNavigate} snapSteps={snapSteps} … />

The host builds the list because the host owns the axis. On the debugger the prop is ignored when it is scrubbing its own routing stops (no step / totalSteps) — every routing stop already changes the picture, so there is nothing to narrow.

Headless: snapPositionOf, nextSnapStep and prevSnapStep on agentfootprint-lens/core are the pure queries behind it — the same at-or-before resolution (with its exact flag) and the same strict next/previous laws, for a shell that draws its own transport.


Driving the cursor from your app

Omitting these props keeps the lens self-driving. It holds the position itself, follows the live run, and needs nothing from you — that is the default and it has not changed.

Pass step and you own the cursor. That is the difference between a lens that happens to be on your page and a lens that is part of your app: two tabs can show the same moment, a "jump to the failure" button in your own UI can move it, and switching tabs no longer loses the position.

function Debugger({ recorder, runner }) {
  // ONE cursor, held by you. Both lenses show the same moment.
  const [step, setStep] = useState(0);

  return (
    <>
      <Lens recorder={recorder} runner={runner} step={step} onStepChange={setStep} />
      <Lens recorder={recorder} runner={runner} step={step} onStepChange={setStep} view="analyst" />
      <button onClick={() => setStep(0)}>Back to the start</button>
    </>
  );
}

Just want to watch the cursor? Pass onStepChange alone. The lens keeps owning the state and calls you on every move — the same contract <TraceExplorerShell onSelectionChange> has in footprint-explainable-ui.

The unit is a step, and the callback carries the rest

A step is one stop on the lens's scrub axis — the same number the transport counts ("3 / 12"), the same one the WHAT HAPPENED rail dots. Valid values are 0 … totalSteps - 1, and totalSteps grows while a run is live.

Every call hands you the other two units of the same position, so you never invert the mapping yourself:

onStepChange(step, at)
// at = { step, totalSteps, runtimeStageId, commitIdx, label, kind?, clamped }
  • at.runtimeStageId — footprintjs's address, the string <TraceExplorerShell selectedRuntimeStageId> and <RunSlider cursorRuntimeStageId> take.
  • at.commitIdx — the commit-log index the position anchors to.

Why the step is the controlled unit and not one of those: it is the only one that is one-to-one with a position the lens can show. A group's start and its end are the same group, so "Run · start" and "Run · end" are both __root__#0; a parallel fork's branches open at the same commit. Address the cursor by either and the second of every such pair becomes unreachable — half the positions silently unselectable, which is the exact failure this prop exists to prevent.

Move it out of range and the lens clamps and says so: it renders the nearest real position, calls onStepChange(clamped, { clamped: true }), and warns once on the console. It clamps rather than refuses because the axis grows under you — a step you stored from a finished run is a perfectly good value that the same run, earlier, does not have yet. Store what the callback hands back and the two cursors agree again.

Every mover reports: the step strip, ◀ ▶ ⟳Live, the arrow keys, a chart node click, a WHAT HAPPENED moment, a provenance jump, and the auto-advance that follows a live run. One cursor, one funnel — a mover that moved without telling you would be a second cursor wearing the first one's clothes.

Pointing at a step: navigatorRef

step lets you own the cursor. navigatorRef lets you say where — when the only thing you know is a stage's address. That is what a chat answer has: it can tell you the tool call went wrong, and now it can point at the exact step it means.

import { Lens, type LensNavigator } from 'agentfootprint-lens';

const nav = useRef<LensNavigator>(null);

<Lens recorder={recorder} navigatorRef={nav} />

// Anywhere — a chat message, a dashboard row, a deep link:
const to = nav.current?.navigateTo('call-llm#18');
// → { ok: true, step: 7, runtimeStageId: 'call-llm#18', match: 'exact', label: 'call-llm 1' }

It resolves against the ruler you are actually scrubbing, so the same address is step 7 under granularity="step" and step 3 under granularity="group" — and then it goes through the same funnel a click on that stop uses. Uncontrolled, the lens moves and reports. Controlled, it reports and your step lands it. Nothing new holds a position.

A miss is answered, never guessed. Three rungs, in order:

what happened you get
match: 'exact' a stop is that address { ok: true, step, label }
match: 'enclosing' no stop is that address, but one contains it (a stage inside a subflow, on a ruler that stops at whole subflows) { ok: true, step, runtimeStageId } — the stop's own address, so you always know where it went
— nothing holds it { ok: false, reason, message, nearest? } — the cursor did not move

nearest is the stop just before the address, handed back as data. It is an offer, not a jump: a cursor that silently lands somewhere the person did not ask for is worse than one that stays put. Take it when you want to — one more call, and it is an exact hit by construction:

const to = nav.current?.navigateTo(stageId);
if (to?.ok) {
  // landed
} else if (to?.nearest) {
  // "That step isn't on this ruler. Go to Iteration 1 instead?"
  onConfirm(() => nav.current?.navigateTo(to.nearest.runtimeStageId));
} else {
  show(to?.message); // says exactly why, in a sentence you can print
}

No component? Same answer. The resolver is pure and ships headlessly, so a server-rendered link or a CLI can compute the step with nothing mounted:

import { scrubAxisFor, resolveNavigation } from 'agentfootprint-lens/core';

const positions = scrubAxisFor(recorder, 'step');   // the ruler, as data
const to = resolveNavigation(positions, 'call-llm#18');
const href = to.ok ? `/runs/${runId}?step=${to.step}` : undefined;

One cursor across several lenses: useSharedCursor

A debug tool that mounts Why, Flow, Context and the Skill Graph over one recording keeps ONE cursor. The lenses draw different axes — Flow every commit, Why and Context the agent's milestones — so what the host holds must be an ADDRESS on the record, not a step on an axis. useSharedCursor holds that address and hands each lens a LensCursor for the axis it draws:

import { useSharedCursor, Lens, ContextView } from 'agentfootprint-lens';

const shared = useSharedCursor(recorder);

// The Lens on the same owner (0.56.0): it derives its own step over
// whatever axis it draws — pick tags, drill, change granularity — and every
// mover in it moves the shared address.
<Lens recorder={recorder} granularity="step" shared={shared} />

// The Skill Graph on the same owner (0.57.0): it reads the address over the
// run's commit axis, moves it from its own transport, and finds its own
// snap stops — nothing else to hand it.
<SkillGraphDebugger recorder={recorder} shared={shared} />

// The Context view on the same owner (0.58.0): it reads the address over the
// run's grouped axis — the Why Lens's own stops — with the same transport,
// and names the stop before by itself.
<ContextView runner={recording} recorder={recorder} shared={shared} />

// A view still on the older step + report contract is bridged into the same
// address with `shared.onStepChange`, so a host migrates one lens at a time.

The law: a tab DERIVES its step from the address (the exact stage when its axis has it, else the stop that contains the commit); only a MOVER changes the address; a visit to a coarser axis and back lands on the same commit. With no move yet the address is the run's end, from which each axis derives its own last stop; a new recorder drops the held address. Headless pieces on /core: stepForAddress, cursorForAddress, addressOf. Design and the facts the fixtures taught: docs/design/2026-09-shared-cursor.md.

If the held address has no position on a child or filtered axis, Lens says so instead of displaying that axis's first stage. It does not fold stage state or render a final answer for that missing position. Return to the parent view to recover the same address, or explicitly choose a recorded stop to move it. With a shared cursor, Latest is a jump to the current last stop, not a subscription to future stops. Before any explicit selection, the shared cursor continues to derive the growing end. The older numeric step contract keeps its existing clamp and live-follow behavior.

Time travel through one port

Nothing to do — this is how the cursor already moves. Read it if you are building a second surface over the same run, or wondering why the ruler behaves identically in the Why Lens, the Flow Lens and your own view.

Why

Every consumer of a footprintjs run was writing the same six lines: clamp at zero, clamp at the end, resolve an address to a stop, decide what a miss does. The lens had them in <TimeTravel>, the flow view had its own copy, and a dashboard had a third — and they disagreed at exactly the places nobody tests: a ▶ at the last stop, a jump to a stage that is not on this ruler, a step stored from a longer run.

Foottrace owns that arithmetic through its public record-reading API. timeTravel(snapshot, { strategy }) is the reader's cursor over a finished trace: first / last / prev / next / jumpTo, one position, and one law — a refused move never moves the cursor, it returns the reason and the nearest stop it could name. Since 0.46.0 the lens's movement goes through it.

Which stops exist did not change. The lens still computes its own axis — milestones banded by iteration for the Why reading, one stop per executed stage for the Flow reading, drill-scoped either way — with the same labels, the same order and the same kinds. Those positions ARE the port's stops, through a lens-owned TimeTravelStrategy. The library decides where a move lands; the lens decides what there is to land on.

Still one cursor. The port instance is a calculator, not a position: it is re-seated on the step the lens owns before every question and remembers nothing between them. <Lens step onStepChange> is unchanged, and so is everything a panel reads.

The port, headless

import { scrubAxisFor, openLensCursor } from 'agentfootprint-lens/core';

const positions = scrubAxisFor(recorder, 'group');   // the ruler, as data
const cursor = openLensCursor(positions);

cursor.next(4).step;          // 5
cursor.next(positions.length - 1);
// { step: <last>, moved: false, reason: 'clamped', clamped: false }

cursor.toStep(2, 9999);
// { step: <last>, moved: true, clamped: true }   ← off the axis, said so

cursor.toAddress(2, 'call-llm#18');
// { ok: true, step: 7 }
cursor.toAddress(2, 'never-ran#4');
// { ok: false, reason: 'miss', nearest: <the closest stop the library
//   could name — the same stage at another execution index if this axis
//   holds one, otherwise the last stop whose execution index is not past
//   the one you asked for> }
//   the cursor is still at 2 — a miss is offered, never taken

cursor.positionAt(7);
// the LENS position: runtimeGroupId, depth, coActiveGroupIds, milestone —
// everything the port's `Stop` has no slot for, unchanged and unflattened

openLensCursor answers where, never what: it has no stateAt, no changedSince, no drill. Those are folds over the run's commit log, and a fold wants the run's snapshot — which movement never reads. Want them over this same ruler? Build the library's cursor directly, with the lens's stops:

import { timeTravel } from 'foottrace';
import { scrubAxisFor, lensStopsStrategy } from 'agentfootprint-lens/core';

// A LIVE run. `runner.getLastSnapshot()` IS footprintjs's own snapshot, so it
// is already a `TimeTravelSource` and the two libraries meet with no cast.
const snapshot = runner.getLastSnapshot();
if (snapshot) {
  const positions = scrubAxisFor(recorder, 'group');
  const tt = timeTravel(snapshot, { strategy: lensStopsStrategy(positions) });
  tt.jumpTo(7);
  tt.changedSince();       // the keys this stop's slice of the log wrote
  tt.stateAt().state;      // state as of that stop, detached
}

A stored recording meets the Foottrace port with no cast either. The lens types Recording.snapshot.commitLog as readonly unknown[] — a recording arrives as parsed JSON, and the lens will not claim that what came back off disk is a CommitBundle — and TimeTravelSource.commitLog accepts exactly that, narrowing per row where it folds, which is the only place that can honestly do it. A row that is not a bundle is reported by index in stateAt().skipped, never crashed on:

if (recording.snapshot) {
  const tt = timeTravel(recording.snapshot, { strategy: lensStopsStrategy(positions) });
  tt.jumpTo(7);
  tt.stateAt().skipped;  // undefined, or the rows the fold could not read
}

Movement never needs a snapshot at all: openLensCursor reads the stops and nothing else, which is why the lens's own cursor is unaffected either way.

Two axes, and drilling

Rebuild the port when the positions change; its identity is that list's, which is already memoised per (recording, axis, drill level):

const port = useMemo(() => openLensCursor(positions), [positions]);

Drilling rebuilds the axis — the drilled group's own stops, which is a different set — so it rebuilds the port. The lens does not use the port's drill() for this: drill() opens a subflow's own commit log, and a lens drill level is not always a subflow (it is a group, and a subflow whose stages commit in their own memory scope is reconstructed from the runtime overlay). Using it would change which stops appear at a drill level, which is the one thing this change refuses to do.

snapSteps also stays the lens's. Snaps narrow ◀ ▶ to a subset of the axis while the cursor may be parked between two of them — a question jumpTo does not answer, since it takes an exact stop.


Explain this answer — In plain words

One answer, explained for a reader who is not an engineer: seven rows, every line from the run's record, with who says so. <PlainWords> draws agentfootprint's answer account — accountForAnswer, a pure Fold over one answer's recording, computed on the SERVER by the answer-account hosting op — as In one line plus seven rows: You asked · It understood · It checked · It did not check · It found · How sure · Anything wrong.

Why

Every sentence in the pane is filled from the record by one of the library's fixed, versioned templates — no model writes it, and the lens writes none of its own. Each line carries a said by chip (you · the library's record · a tool · the model · the app), a fact the record does not hold says not recorded instead of a guess, and show me opens the leaf of the record the line came from. The component is props only: it fetches nothing and receives no recording — the op returns { account, shown }, where shown holds only the leaf values the server's allow-list lets out.

Mount it

import { PlainWords, printAnswerAccount } from 'agentfootprint-lens';

// One request per Explain — the op answers from the recording on the server.
const res = await fetch('/invoke', {
  method: 'POST',
  headers: { 'content-type': 'application/json', 'x-session-id': sessionId },
  body: JSON.stringify({ op: 'answer-account', ref: reply.reasoning.ref }),
});
const { account, shown } = await res.json();

<PlainWords
  account={account}
  shown={shown}
  labelledBy="tab-plain"            // your drawer's tab → role="tabpanel"
  focusOnMount                      // opened from "Explain this answer"
  onSaveAsPdf={() => printAnswerAccount(account)}
  onOpenInLens={debug ? openFlowLensAt : undefined} // only when the engineer lenses exist
/>
Prop Type Description
account AnswerAccount Required. The op's account (type from agentfootprint/observe).
shown Record<string, AnswerAccountShownLeaf>? The op's shown. Looked up by answerAccountPointerKey. Omit → "show me" lists the pointers as text.
onOpenInLens (pointer) => void? Draws "Open in the Flow Lens" beside each record pointer in "show me". Pass it only where those lenses exist.
showQuestionAndAnswer boolean? The question and the answer above the one-liner. Default true.
onSaveAsPdf () => void? Draws Save as PDF; pass () => printAnswerAccount(account).
templateIdsToggle boolean? The "template ids" toggle (each line's id@version). Default true.
theme { mode: 'light' | 'dark' }? Standalone: stamps the lens palette. Inside <Lens theme> leave it out.
labelledBy string? Your tab's id: the pane becomes role="tabpanel" labelled by it. Otherwise a region named "In plain words".
focusOnMount boolean? Focus the pane's "In one line" heading on mount.

What it keeps

  • No sentence of its own. Headings, lines, chips and the one-liner are the library's text; the pane's own strings are PLAIN_WORDS_LABELS — names, never claims (walked by test/served/no-own-claims.test.ts).
  • No HTML from data. A sentence renders from its typed parts as text: code → <code>, quote → a span, a declared label → <strong> with its own voucher. A <script> in a question is shown as the characters <script>.
  • No punctuation of its own. The account's text carries its quotation marks (“{{question:quote}}”), so a quote is never a <q> — the browser would draw a second pair around it.
  • Show me = leaves. A withheld leaf says why (not shown here, too large to show here, …); a line into a call the model read without the tool's report-only fields says so softly.
  • Said by, on the line. The library puts one said-by chip per source on the row; the pane pairs each to its recorded lines by the library's own rule, and leaves them on the row if a row ever does not pair.
  • Accessible. h2 "In one line", an h3 per row, lists for lines and items, every "show me" a <button aria-expanded aria-controls>; the tone is a border AND a word; four tone tokens (--fp-tone-ok|warn|bad|unknown) in both palettes, each ≥ 4.5:1 on its surfaces.

Save as PDF

printAnswerAccount(account, { recordedAt? }) prints a one-page report from a hidden, aria-hidden frame whose document is titled Answer report (so the browser's print header says that): the question, a meta line — run id, the recorded and printed times as ISO-8601 UTC, model, template set — the answer as plain text (folded at 1,200 characters), In one line, and the rows as a table with "said by …" and the template id under each line (lists fold at six items). It never prints "show me", makes no request, and removes the frame on afterprint. <AnswerReportPrint account /> is the same report as a component. The account does not carry the recorded time; pass the answer's turn_start meta.wallClockMs as recordedAt when you hold it (<Lens> reads it off its recording), else the line says not recorded.

In the Lens: the analyst view

<Lens recorder={recorder} view="analyst" account={account} accountShown={shown} />

With account, the analyst view leads with <PlainWords> and folds the summary card, the transport and the commentary under a native More detail <details>. Without it, the analyst view is byte-for-byte what it was. The engineer and user views never read the prop.

Needs agentfootprint ≥ 9.116.0 (the release that ships AnswerAccount and the op) — the lens's peer floor since 0.68.0.

Time — what the run took "now" to be, and which window each call read

Every time decision an armed agent makes, as the record holds it. With agentfootprint's time layer armed (.time(), 9.129.0), the run files rows on its honesty ledger: the clock it froze for the turn, what it read from the person's words, which window each call carries and how, whether a call reads MORE than was asked, and how far the wall clock had moved by the time the tool ran. <TimeBand> draws them; <ContextView> mounts it under the Findings band, so it moves with the one cursor.

Why

A time bug is quiet. "Yesterday" sent to a tool that only takes a look-back reads from yesterday's midnight to NOW — a day and a half, not a day — and the answer looks fine. After a 30-minute pause, a "last hour" look-back reads a different hour than the person asked about. The library records each of these; this band is where a person sees them: a widened fill says wider than asked with the extra ranges, a drift says redrawn or shifted and by how much, a result whose held range is unknown says clock unknown.

Mount it

import { ContextView, TimeBand } from 'agentfootprint-lens/context';

<ContextView runner={recording} />            // the band appears once the ledger holds a time row

// or on its own, over rows you already hold:
<TimeBand rows={state.findingsLedger} coverage={state.coverageDeclared} />

A paused ask with a time field (requestInput with format) shows its time half inside <AwaitingPane> — the choices with the library's labels and, on a re-ask, the refused value with the library's reason. A dataset/rows artifact that declares a time axis shows it above the table (<TimeAxisLine meta rows>), judged by the library: column, unit, zone, grain, and how many values have no known clock.

The laws it keeps

  • Omit, never deny. No time row at the stop, no band — an unarmed run has no band, never an empty one. A row prints no field it does not carry.
  • Never infer. Nothing here compares two instants. wider than asked renders only on a row that carries differs; a drift only on a call row that carries drift; clock unknown only where the record says unknown; a result check only where the period row carries it; settled by the person only where a time-answer row of the turn names the mention.
  • No sentence of its own. Every printed string is a value off the record or a TIME_LABELS entry; the one sentence it shows — an ask's refusal reason — is the library's, printed as data.

One tab: <TimeView> (agentfootprint 9.132.0)

import { TimeView } from 'agentfootprint-lens';   // also on 'agentfootprint-lens/context'

<TimeView
  rows={agent.findings()}                  // the ledger: time rows + period rows
  coverage={state.coverageDeclared}        // optional — each call's declared period
  ask={pause}                              // optional — a paused ask with a time field
  assessment={await agent.assessment()}    // optional — the library's standing
/>

It draws, in one view: the time reasons the library filed on the answer (period-differs-from-asked, period-beyond-retention, period-not-held, period-partly-held, period-unknown, period-undeclared, derived-from-reading, and argument-assumed when its witness is a period argument), each with a plain line and the calls it names; the time ask; and the band. Since 9.132.0 the band also shows the person's answer to a reading (settled by the person · confirmed / edited · the window they settled — the reading's own open choice is still printed as filed), the values the library derived from a reading, each call's source clock zone, and the period row's result checks — differs from asked (against, the range compared, what was read, missing, extra), shifted by how much, and beyond retention. Each call reads in the order it happened (0.71.0): asked → sent (the row's own sent on a widened fill; on every other dispatched call the row's asked, which the library's row defines as the sent range — as asked; a fill rounded outward says so and names no range the record did not keep) → read (the period the result declared, or undeclared) → the difference the period row filed. sentOf(window) and readOf(call) (agentfootprint-lens/core) are the same reading for a host's own view.

At a stop, hand it the record AT that stop. A host that moves a cursor folds the record through the stop's foldCommitIdx, not its commitIdx (0.71.0): "Run · start" shares its commit index with the first stage but stands BEFORE it — on a resumed leg, before the paused call's stand-in bound the answer and dispatched — so it folds the base (-1):

import { contextAt, foldCursorOf } from 'agentfootprint-lens/core';

const at = shared.forAxis('group').at;               // any LensCursor reading
const ctx = contextAt(snapshot, foldCursorOf(at));   // NOT { commitIdx: at.commitIdx }
const rows = ctx.keys.find((k) => k.path === 'findingsLedger')?.value;
const coverage = ctx.keys.find((k) => k.path === 'coverageDeclared')?.value;
<TimeView rows={rows} coverage={coverage} />

On a resumed leg the rows filed before the pause (the clock, the reading, the call's window waiting on the ask) are part of the leg's base — the state at the pause — and are in the record at every stop of it, "Run · start" included; what the leg itself files appears at the stop that filed it.

The law: never compute a reason. The standing section lists a reason only when the assessment you pass names it; the ledger is read only to resolve the library's own witness pointers to the calls they name. A period row that carries differs with no assessment passed shows the check in the band and no reason above it:

timeStandingOf(undefined, ledger);                    // undefined — nothing filed, nothing listed
timeStandingOf(await agent.assessment(), ledger);
// { standing: 'not-sure', reasons: [{ reason: 'period-differs-from-asked',
//   calls: [{ toolCallId: 'c1', toolName: 'client_activity' }] }, …], otherReasons: 0 }

The standing is said, never silence. An answer can be not sure for reasons that are not about time — a value no tool returned, an unverified argument. With an assessment that names no time reason, the view still draws the standing: no time-related reason, the standing as filed, and other reasons — see In plain words (counted in otherReasons, listed by In plain words) or no reason filed. An argument-assumed the lens cannot place — the data projection's bare name, or a ledger not handed in — is listed as undetermined, and "no time-related reason" is never said beside it:

timeStandingOf({ standing: 'not-sure', reasons: [{ reason: 'value-survived-revision', witness: [] }] });
// { standing: 'not-sure', reasons: [], otherReasons: 1 }

Headless: foldTimeRows(ledger, coverage?), answerOfReading(turn, reading), timeStandingOf(assessment, ledger?), timeAskOf(pause), datasetTimeAxisOf(meta, rows?).

The dataset-axis helper depends only on AgentFootprint's three time-axis read functions, not its execution runtime. It reads those optional properties at call time: peers before 9.128.0 still show a declared axis as unjudged, while newer peers supply the judgment and counts. The browser packaging test checks that importing this helper does not retain the agent or engine execution code.

The Served tab

At every LLM call, exactly what the model was served — provable from the log. The Why Lens's right rail has a second tab, Served, mounted for every run — including for a host that supplies slots.detail, whose own pane is the tab beside it (0.50.0). Stand on an LLM turn and it shows the request that call went out with: the system prompt (piece by piece), the messages as sent, the tools as sent, the dials, the cache breakpoints — and beside every field, what the record can PROVE about it.

Why

The request a provider receives is assembled from committed pieces and is itself never committed — the call-llm bundle holds the answer, not the ask. Every earlier "what did the model see?" panel in this family read an event (llm_start) that only some runs record, or rebuilt the prompt from the pieces with no way to know whether the rebuild matched what went out.

agentfootprint 9.88.0 closes that with two halves and one law:

hash(servedAt(k)) === receiptAt(k).hash

servedAt(k) REBUILDS the request for epoch k (one epoch = one LLM call) from the committed pieces. receiptAt(k) reads the receipt — the hashes-only record the call itself committed at the stop (system hash and pieces, one hash per message, tool names and schema hashes, the sampling dials, the cache verdict). When the two agree the record is complete; when they disagree, something reached the model that the run never wrote down. The tab renders both halves at the lens's one cursor and checks them with the library's own receiptHash over messageDigestInput (a message) and toolDigestInput (a tool schema).

What each badge means

badge exactly this
Verified the receipt's hash for the field EQUALS the hash of what the rebuild produced — run-salted SHA-256, computed with agentfootprint's exported receiptHash (over messageDigestInput for a message, toolDigestInput for a tool schema). System text, every piece, every message and every tool schema can read it. Nothing softer earns it.
Reconstructed rebuilt from the log, but nothing to check it against: this epoch committed no receipt, the receipt carries no hash for this KIND of row, or a gap on the view covers the field (a declared hole, printed beside it). Tool NAMES are always here — they carry no hash. Tool SCHEMAS are here only under an agentfootprint peer older than 9.89.0, which does not export toolDigestInput; the lens detects the export at call time rather than copy the serializer.
Damaged the record contradicts itself: the receipt's hash disagrees with the rebuild and no gap excuses it, the rebuild produced a row the receipt never witnessed (no gap says a rebuild may be LONG — the receipt is the witness in both directions), or something under the receipt key was refused as not-a-receipt (cause: 'receipt-shape-rejected' — the library refuses a value with no basis.epoch, the lens refuses one missing the containers it reads).
Not on record the field is absent on both sides — a receipt-only field (model, provider, params, cache) on an epoch that minted no receipt.

The laws the tab keeps

  • Omit, never deny. A field a gap covers is never rendered as "empty" or "none". The gap IS the empty state: its why sentence, verbatim from SERVED_GAPS, is printed beside the field it covers, and the section is marked with the gap's name.
  • The tab writes no claim sentences of its own. Every explanatory sentence on it is the library's — SERVED_GAPS[k].why, UNGAPPED_FIELDS[k], RECEIPT_BOUNDARY — or is computed data: a status, a count, a hash, a diff. The strings the tab owns are labels (SERVED_LABELS), and test/served/no-own-claims.test.ts walks every literal in the tab's source to keep it that way; its header states what such a walk cannot catch.
  • Authority omissions come from the fold. A receipt never names what a caller's ROLE was not allowed to see (the library's first law). The tab's audience is an operator, so it MAY show the skill ids hidden from the model — but it reads them from the committed state at the stop through Foottrace's stateAt, labelled hidden from the model, never from the receipt.
  • One cursor. The tab takes the lens's position as props and holds none of its own. On an llm-turn stop it shows that epoch. On any other stop it shows the nearest PRECEDING call with the note as of call k — this stop is between calls. Clicking the call's id asks the lens to move the one cursor there. A resumed leg's first call has no previous epoch IN THIS RECORDING; the tab prints that epoch's number with Not on record, never "no previous epoch".
  • A record never takes the Lens down. A half-shaped receipt reads Damaged; a commit-log row the fold cannot read is data in the FOLD section; a render throw is caught by a boundary around the tab and printed as the Damaged badge plus the message. The rest of the Lens — its one cursor, its What happened tab — stays mounted.

The sections

SERVED — system prompt with per-piece boundaries (slot · source) and a word-level diff against the previous epoch when the text changed; messages as sent (role, tool-call ids, request-only lines marked with the mechanism that composed them); tools as sent (names, expandable schemas, the forced tool marked, withheld shown as the library states it). BASIS — epoch, call id, commit index, model, provider, params (only the dials the receipt carries — an absent dial is not rendered as a default), cache (transform, breakpoints applied, and since 0.52.0 the cache strategy as the receipt names it — * is the built-in pass-through, printed as itself; the receipt's null reads no cache strategy; a receipt from before agentfootprint 9.93.0 reads Not on record). FOLD — iteration, currentSkillId, stepPointer, engagement, active injections, hidden skill ids, read from the fold at the stop; a row the fold could not read is printed there as data (skipped indices, or the fold's error), beside a Damaged badge. OMISSIONS — the attention drops from the receipt (0.52.0): the count, then one line per turn the agent's window evicted for budget — its hash and the epoch it was last served on, paired by the library's own rule (the hash is that earlier receipt's messages.entries[].hash), or Not on record when no earlier receipt in this recording served it; a receipt that says nothing was dropped reads count 0, and a record that cannot say draws the badge. GAPS — every gap on the view: its kind, the fields it covers, its sentence verbatim, and its cause when the library established one (receipt-shape-rejected styled as damage). SINCE PREVIOUS — messages entered/left, tools added/removed, schemas whose receipt hashes changed, and the system-text word diff.

Headless

import {
  servedRowAt, servedRowForEpoch, verify, sincePrevious, foldFactsAt,
} from 'agentfootprint-lens/core';

const snapshot = runner.getLastSnapshot();
const row = servedRowAt(snapshot, { runtimeStageId: 'call-llm#18', commitIdx: 15 });
if (row) {
  row.epoch;                                         // 1
  row.betweenCalls;                                  // false — on the call itself
  const checks = verify(row.view, row.receipt, row.receipt?.basis.runId ?? '');
  checks.system.status;                              // 'verified'
  checks.messages.map((c) => c.status);              // ['verified']
  row.view.gaps.map((g) => g.why);                   // the library's sentences
  const prev = row.previousEpoch !== undefined
    ? servedRowForEpoch(snapshot, row.previousEpoch) : undefined;
  if (prev) sincePrevious(row, prev).tools.added;    // ['charge']
  foldFactsAt(snapshot, { runtimeStageId: 'call-llm#18', commitIdx: 15 }).hiddenSkillIds;
}

Every function is pure and every return is frozen. <ServedTab runner cursorRuntimeStageId commitIdx onJumpTo> is exported for shells that hold the one cursor themselves.

A recording made before agentfootprint 9.88 still renders: the rebuild works on every epoch, no receipt was minted, and the tab says so with the library's no-receipt-on-chart gap and cause: 'no-receipt-committed' — every row Reconstructed, never a fabricated Verified.

The Served graph (0.49.0)

A list is right for checking one row and wrong for seeing that the system string was assembled from a base plus one skill body plus two injections while three tools were withheld. That is one picture, and since 0.49.0 the Served tab has a List ⇄ Graph toggle (the list stays the default) that draws it: HELD — what the record holds at this stop, from the fold — then SERVED — what crossed into the call, one edge per system piece, message, request-only line and tool, each carrying its badge and, against the previous epoch, whether it entered, left or is unchanged — then WITHHELD: held and not sent. The withheld band is the reason to build it. The model never learns what it was denied; the operator should — the withheld tool list, each evicted turn as an edge into the messages slot with the epoch that last served it (0.52.0), the skills a role hid (read from the fold, since a receipt carries no authority names), a redacted fold, and every gap the view declares, each with the library's own sentence. Same cursor, same row, same badges: a Damaged row draws Damaged here too, and a field a gap covers draws the gap rather than an empty node.

graph what it draws the question it answers
skill graph (<SkillGraph>) the skills, their steps and their edges where a run MAY go, and where it went
data graph (Where from) a value's writers, back through the commit log where THIS value came from
Served graph (Served tab · Graph) one call: held · served · withheld what this ONE CALL was made of, and what we held back
import { foldFactsAt, servedGraphAt, servedRowAt, verify } from 'agentfootprint-lens/core';

const cursor = { runtimeStageId: 'call-llm#18', commitIdx: 15 };
const row = servedRowAt(snapshot, cursor)!;
const graph = servedGraphAt({
  row,
  fold: foldFactsAt(snapshot, cursor),
  checks: verify(row.view, row.receipt, row.receipt?.basis.runId ?? '', row.receiptCause),
});
graph.served.map((s) => `${s.slot} ${s.rebuilt}`);                 // ['system-prompt 3', …]
graph.edges.filter((e) => e.state === 'entered').map((e) => e.origin);  // ['instructions']
graph.withheld.filter((w) => w.from === 'fold').map((w) => w.name);     // ['payroll']

<ServedGraph graph> is exported for shells that build the input themselves. Deliberately absent: an edge from a served piece back to the STAGE that wrote it. It needs a commit-log walk keyed by piece text or slot — the part most likely to rot — and the question above is answered without it.


Since 0.59.0 the tab also draws the provider's cache boundary where the receipt says it fell — a line after the system piece, message or tool the breakpoint sat on, so the reusable prefix of a call is visible on the pieces themselves.

Bookmarks, and scrubbing by declared tag

The reader's marks, kept beside the recording — and the author's tags, as a ruler. Since 0.48.0 the Why Lens's right rail has a third tab, Bookmarks (a fourth when a host fills slots.detail), and the grouped ruler carries a Tags strip.

Why

footprintjs's design page for declared tags names three marks that end in the same operation ("keep these stops, fold the rest into them") and differ in who puts the mark and when:

Mark Who, when Lives In the lens
Declared tag the author, at build time the chart's structure → stamped into the commit bundle (bundle.tags) the Tags strip: legend and picker
Derived tag the reader, at read time, from the fold computed by a strategy; never stored the lens's own grouping (milestones, banded by iteration) — the default axis
Bookmark the reader, at read time, by choice on the cursor (mark / marks / jumpToMark); persisted beside a recording, never in it the Bookmarks tab

Only the first two had a place in the lens. Foottrace holds a bookmark on the cursor and, by its own law, nowhere else — the log and the snapshot are the run's record, not the reader's notes — so a bookmark vanished when the page closed. And a run's own tags (agentfootprint 9.90 declares every milestone as one) were in the recording with nothing to read them.

Bookmarks

Stand on a stop and press Bookmark this stop. The library names the mark (its own mark(), seated on the lens's one cursor); the lens writes it to a sidecar — a small document keyed to the run, in a store you choose. Click a bookmark to jump: the port's jumpToMark, through the same funnel every other mover uses, so the chart, the timeline and the Served tab all follow. Add a note inline; remove with one click.

import { Lens } from 'agentfootprint-lens';
import { memoryBookmarkStore } from 'agentfootprint-lens/core';

<Lens recorder={recorder} runner={agent} granularity="group" />                       // localStorage, by default
<Lens recorder={recorder} runner={agent} bookmarkStore={memoryBookmarkStore()} />    // this session only

The laws the tab keeps:

  • One cursor. The tab holds a list, never a position. A jump moves the lens cursor (onStepChange fires); a miss never moves.
  • Never in the record. Nothing is written into the snapshot or the log.
  • Omit, never deny. A bookmark whose stop this recording does not hold — a truncated recording, a sidecar filed under another run — is shown greyed as not in this recording, never dropped.
  • Persistence is a label. A browser that refuses storage (private mode, a quota, a server render) makes the tab say bookmarks not saved; a snapshot with no runId says no run id. Nothing throws.
  • No claim sentences. Every string the tab prints is a label (BOOKMARK_LABELS) or computed data; test/served/no-own-claims.test.ts walks the file.

The root bookends (Run · start / Run · end) are not bookmarkable — Home and End reach them on every axis, and they share one synthetic address.

Headless, the sidecar is four functions in /core: bookmarkKey(snapshot), toSidecar(key, bookmarks), fromSidecar(json, snapshot) (reports orphaned), bookmarksToMarks(bookmarks, positions) to seed Foottrace's timeTravel(snapshot, { marks }); stores are localStorageBookmarkStore(), memoryBookmarkStore(), noBookmarkStore(), or any BookmarkStore of your own. See src/core/bookmarks/README.md.

The Tags strip

On the grouped ruler, above the chart: one chip per declared tag, labelled with agentfootprint's milestone label when the tag is a milestone (LLM turn, Route) and its raw name otherwise (audit), with the number of bundles this run stamped it on. The list comes from the recording's structure — the chart advertises the tags it can produce, so the legend is drawn before a stop is reached — and the counts from the log. A recording that carried no structure lists only tags this run hit and says no chart in this recording. A declared tag the run never hit is shown and disabled.

Pick a chip and the ruler is rebuilt through Foottrace's tagStops(names): the stops whose bundle carries any picked tag, the untagged stages folded into the tagged stop before them, the run's bookends kept. It is the same one cursor on a different list — the cursor keeps its commit, not its step number: it is re-seated at the stop that holds its commit (nearest preceding when the new axis has none there — stepForCommitIdx, the same rule the granularity doc gives a host), and the host hears it as an ordinary onStepChange. stateAt at a picked stop equals the default axis's fold at the same commit (src/core/tags/tags.test.ts pins the equivalence). Clear restores the lens's own grouping, by the same rule. The strip appears on granularity="group" at the root level only: the per-step reading's ruler is every commit, byte for byte, and a drilled level scrubs its own stops.

A pick scrubs the run's OWN log (tagStops reads no other), so a chip is pickable only when the tag was hit on the root log; a tag hit only inside mounted subflows (the grouped turn's LLM turn, the context slots) is shown disabled with in subflows — real, said, and not a stop on this axis. Counts walk each mount's log once (subflowResults is dual-keyed; only the #n entries are read).

import { tagLegend, tagAxisPositions, scrubAxisFor } from 'agentfootprint-lens/core';

const legend = tagLegend(runner.getSpec().buildTimeStructure, runner.getLastSnapshot());
legend.source;                                  // 'structure' | 'log'
legend.entries.map((e) => [e.label, e.hits]);   // [['Iteration', 2], ['LLM turn', 2], ['audit', 0], …]

const axis = tagAxisPositions(snapshot, ['milestone:llm-turn'], scrubAxisFor(recorder, 'group'));
axis.map((p) => p.label);                       // ['Run · start', 'LLM turn 1', 'LLM turn 2', 'Run · end']

Rendering your own detail pane

A slot fills a PANE, never the CHROME around it. The right rail's tab strip, the one cursor and the collapse pill belong to the library, because chrome is how the library adds capability over time — a consumer that fills a slot must still receive every reading the rail gains later. That is why slots.detail is ONE TAB and not a takeover, and why detailOnly is an opt-out rather than the default.

slots.detail renders the right column's FIRST tab. The column itself — its width, its border, its collapse pill, its cursor — is unchanged, and so is the strip: your pane is selected by default, with What happened, Served and Bookmarks one click away beside it. Omit slots and the built-in timeline renders exactly as before.

const Detail: React.FC<LensDetailSlotProps> = ({ step, cursorRuntimeStageId, node, onNavigate }) => (
  <div>
    <h3>{node?.label ?? 'nothing focused'}</h3>
    <code>{cursorRuntimeStageId}</code>
    <button onClick={() => onNavigate(0)}>rewind</button>
  </div>
);

<Lens recorder={recorder} runner={runner} slots={{ detail: Detail, detailLabel: 'My bands' }} />
// rail: [ My bands* | What happened | Served | Bookmarks ]

The slot receives the cursor in every unit (step, totalSteps, cursorRuntimeStageId, commitIdx, label, kind), the StepNode it sits on and the ones that ran inside its scope (node, relatedNodes), the recorder for anything else, and onNavigate — the same funnel every built-in mover uses, so your pane moves the ONE cursor rather than starting a second one.

key what it does
detail your pane, as the rail's first tab, selected by default
detailLabel what that tab is CALLED. Default "Details" — the library's generic word for someone else's pane. Name it and the strip reads in your reader's words.
detailOnly opt OUT of the strip: your pane takes the whole rail, as it did before 0.50.0. For a full-height custom layout that would read as a second chrome under the library's strip. The cost: nothing the rail gains later can reach you.

Switching tabs moves nothing and remounts nothing. The cursor stays where it was, and your pane is hidden rather than unmounted — a reader who glances at Served and comes back finds their bands open and their scroll where they left it.

The worked example (0.50.0). An app supplies detail + detailLabel and changes nothing else: it sees its own pane first and selected, exactly as before, and it GAINS Served, the Served graph and Bookmarks on the version bump. Before 0.50.0 that same app lost the entire rail — the tab strip included — and could not reach any of them.

Keep the slots object stable across renders (module scope or useMemo), same as <TraceExplorerShell slots>.


One address, one cursor — for every view

A stage id is an ADDRESS, not a POSITION. It says WHICH stage, never WHERE on an axis. Only an axis can answer that, and it may honestly answer "not here".

Until 0.51.0 the same one cursor reached three views in three vocabularies, and every new view invented a fourth:

view handed reads
skill graph cursorRuntimeStageId + onJumpTo the EVENT record
a consumer's data graph { step, total, stepOf(id), onStep } the COMMIT record
Served tab / graph cursorRuntimeStageId + commitIdx the COMMIT record

LensCursor is their superset, and every view now gets exactly it. It is three things and deliberately nothing more:

interface LensCursor {
  readonly at: LensCursorReading;              // step · totalSteps · runtimeStageId · commitIdx · label · kind
  readonly total: number;
  resolve(runtimeStageId: string): NavigationResult;   // an ADDRESS, answered honestly
  moveTo(step: number): void;                          // the ONE funnel
}

It holds no position of its own, and no view may gain one: at is derived from the axis and the step on every render, resolve is a pure query, and moveTo is the same funnel the step strip, the ◀ ▶ buttons and the live auto-advance go through. Read it; never store it.

Adopting it — one line

<Lens
  recorder={recorder}
  slots={{ detail: (p) => <SkillGraphDebugger recorder={p.recorder} cursor={p.cursor} /> }}
/>

cursor arrives on LensDetailSlotProps beside every prop that was already there, and <SkillGraphDebugger cursor> / <ServedTab cursor> are the narrow form of the props they already took. Nothing was removed; an explicit prop still wins wherever both are passed.

Placing an element by address

This is the behaviour the packet generalises. A real consumer's data-graph view took stepOf(id) and drew an element unplaced when the answer came back below zero — a number every view had to invent a meaning for. Written against resolve, the meaning and the words come from the library:

function Element({ cursor, runtimeStageId, name }) {
  const to = cursor.resolve(runtimeStageId);
  if (!to.ok) {
    // Drawn, never hidden — omit never deny — and the sentence is the library's.
    return <li data-unplaced title={to.message}>{name}</li>;
  }
  return (
    <li data-enclosing={to.match === 'enclosing'} onClick={() => cursor.moveTo(to.step)}>
      {to.label}
    </li>
  );
}

The three cases the contract names

Each is a real run, and each has a test measured on a frozen fixture:

  1. The axis does not stop there. A filtered or tag axis over an untagged stage. resolve refuses 'not-on-axis', carries a printable message, and OFFERS the nearest earlier stop in nearest — an offer, never a move.
  2. The id belongs to an inner log. A subflow's stages commit into their own isolated log, so the axis holds the MOUNT rather than the stage. The ladder's 'enclosing' rung answers { ok: true, match: 'enclosing' } whose runtimeStageId is the mount's — a true landing at the granularity this axis has. Branch on match when you must tell "on it" from "inside it", and offer a drill to go deeper.
  3. The event has no stage at all. Run start, run end. There is no id; resolve('') refuses 'no-id' and your view says so rather than inventing an address.

Headless

import { scrubAxisFor, lensCursorFrom } from 'agentfootprint-lens/core';

const positions = scrubAxisFor(recorder, 'step');
const cursor = lensCursorFrom(positions, 0, (step) => setStep(step));
const to = cursor.resolve('llm#7');
const href = to.ok ? `/run/${runId}?step=${to.step}` : undefined;

stepForRuntimeStageId still ships and still behaves byte for byte; it is deprecated in favour of resolveNavigation / cursor.resolve, the same ladder with its rungs named instead of flattened to -1.


Theming

Lens inherits theme tokens from your app via CSS variables. Set --fp-* (the same names footprint-explainable-ui uses) on any parent and Lens picks them up — no theme= prop needed, no flash of unstyled content on a theme switch. Lens's stylesheet ships with the library and injects itself; there is no CSS file to import.

The one-line switch

<Lens recorder={recorder} runner={agent} theme={{ mode: 'light' }} />

mode applies eui's full light/dark preset at the Lens root — so the chart, the panels, the edge colours and the injection-source chips all follow, in all three views, from that one word.

The token contract — set these on :root (or any parent of <Lens>)

:root {
  /* Surfaces */
  --fp-bg-primary:   #0f172a;
  --fp-bg-secondary: #1e293b;
  --fp-bg-tertiary:  #334155;
  --fp-bg-elevated:  #1e293b;   /* the card fill behind Lens's own panels */

  /* Text */
  --fp-text-primary:   #f8fafc;
  --fp-text-secondary: #94a3b8;
  --fp-text-muted:     #64748b;

  /* Border */
  --fp-border: #334155;

  /* Accent / state */
  --fp-color-primary: #6366f1;
  --fp-color-success: #22c55e;
  --fp-color-error:   #ef4444;
  --fp-color-warning: #f59e0b;
}

Resolution order per token: --lens-X → --fp-X → hardcoded fallback. So Lens-specific overrides win over shared --fp-* design tokens, which win over the built-in defaults. theme={{ mode }} writes into the --fp-* tier only — your --lens-* still wins.

Lens-only overrides

.my-lens-container {
  --lens-bg-primary:      #0a0e1a;   /* darker than the app */
  --lens-color-primary:   #f59e0b;   /* amber accent for Lens chips */
  --lens-edge-decision:   #ec4899;   /* decision arrows in the graph */
  --lens-src-skill:       #7c3aed;   /* skill-injection chip */
  --lens-agent-color-0:   #22d3ee;   /* first agent's swatch in the legend */
}

Every token has a built-in value, so nothing is ever unpainted. See src/react/theme/tokens.ts for the full list (surfaces / text / border / accent / 4 edge kinds / 7 injection-source chips / 8 agent swatches / 4 In plain words tones / typography), all of it exported as T, RAW_DEFAULTS, AGENT_COLORS and MODE_PALETTES.

Server rendering

LENS_STYLESHEET is the stylesheet as a string — put it in your own <style> if a strict CSP blocks the automatic injection.


Responsive

Lens resizes to whatever space you give it. The engineer view is a chart column beside an inspector with a 300px minimum, and below LENS_NARROW_BREAKPOINT (690px of available row width) that pair stops fitting — so the columns stack instead of clipping. Nothing is hidden and nothing is cut off; the same panes are read top to bottom instead of left to right. A split panel dragged down to 392px gets a readable lens, not a sliver of one.

The threshold is exported, so a shell that lays out around Lens can use the same number rather than guessing it:

import { LENS_NARROW_BREAKPOINT, isNarrowRow } from 'agentfootprint-lens';

Drop it in a splitter, a drawer, or a full-screen tab — no config needed, both themes.


API reference

lensRecorder(rootLabel?, options?)

Builds a LensRecorder. options.maxEvents caps the event log (default 50 000, oldest evicted, counted in getDiagnostics().droppedEvents — never silent); options.debug forces the dev-mode console diagnostics on or off.

recorder.observe(runner)

Subscribe to a runner's typed dispatcher, its recorder channel and its step graph in one call. Returns an unsubscribe. Call it once per run.

<Lens>

Prop Type Description
recorder LensRecorder Required. What Lens reads.
runner Runner? The runner (or observeRecording's) whose build-time structure Lens draws. Omit and the chart region says what is missing.
theme LensTheme? { mode?: 'dark' | 'light', ground?, visited?, current? }.
view 'engineer' | 'analyst' | 'user' Default 'engineer'.
stepStrip boolean? Show the clickable step strip. Default true.
showSummary boolean? Show the status/metrics bar. Default true.
appName string? The name used as the active actor in every commentary line. Default 'Chatbot'.
humanizer Humanizer? Override the commentary function entirely.
commentaryTemplates Partial<CommentaryTemplates>? Override individual lines (locale, brand voice).
chart LensFlowProps['chart']? Render YOUR graph instead of the derived one.
stepGraph StepGraph? Bring your own step graph; by default Lens uses the recorder's.
toolChoice ToolChoiceSource? Mount the per-iteration tool-choice panel.
granularity 'step' | 'group'? Which ruler is scrubbing the chart. 'group' paints the cursor's group as a named place. Default 'step'. See Scrubbing by group.
step number? Controlled cursor. Omit it and the lens is self-driving, exactly as before. Pass it and you own the position; out-of-range values are clamped and reported. See Driving the cursor.
onStepChange (step, at) => void? Fires on every cursor move — required for movement in controlled mode, an observation hook otherwise. at carries runtimeStageId, commitIdx, label, kind and clamped.
navigatorRef Ref<LensNavigator>? Move the cursor to a stage by its runtimeStageId. ref.current.navigateTo(id) returns { ok: true, step, match, label } or { ok: false, reason, message, nearest? } — a miss never moves. See Pointing at a step.
slots LensSlots? Slot overrides. slots.detail renders your content as the right rail's FIRST TAB (slots.detailLabel names it; slots.detailOnly takes the whole rail instead). The strip stays the library's. Omit for the built-in timeline. See Rendering your own detail pane.
account AnswerAccount? With view="analyst": lead with the In plain words pane and fold the rest under "More detail". See Explain this answer.
accountShown Record<string, AnswerAccountShownLeaf>? The op's shown leaves, for "show me".

<LensFlow> — the chart canvas on its own

The chart without the shell, for consumers who own their layout and their cursor. Takes chart, the runtime overlay, the cursor, and — for the grouped ruler — granularity="group" plus activeGroup (from useChartGroup). Every other prop is unchanged by group mode.

useChartGroup(recorder, commitIdx, options?) / activeChartGroup(...)

The group the cursor stands in, resolved to CHART NODE IDS: the boundary's commit range read off the recording, each commit's runtimeStageId stripped of its #executionIndex (the id rule every chart-click and co-active highlight in the ecosystem already uses), plus the group's own mount. Returns undefined when no boundary encloses the cursor. options.includeRoot opts into the synthetic Run root, which is off by default because a box around the whole chart states nothing. activeChartGroup is the pure, React-free twin on agentfootprint-lens/core.

observeRecording(recording, options?)

The offline twin of recorder.observe(runner). Takes { snapshot, events, structure } (or blueprint) — whatever the recording carries — and returns { recorder, runner, chart, eventsReplayed, eventsSkipped, boundaryEvents, boundaryRanges, notes }. Hand recorder and runner straight to <Lens>. See Watching a run that already finished.

<Replay trace={trace} />

The Trace-shaped door into the same replay path. See Replaying a Trace.

structureGraphFromRunner(runner) / structureGraphFromSpec(structure)

The runner → chart adapter, exported from agentfootprint-lens/core. It walks a footprintjs build-time spec into an explainable-ui TraceGraph whose node ids ARE the real runtime stage ids, so a runtime overlay lights the executed path. <Lens runner> calls it for you; call it yourself to render the chart in your own shell, or to feed <ExplainableShell traceGraph={…} />:

import { structureGraphFromSpec } from 'agentfootprint-lens/core';

const graph = structureGraphFromSpec(recording.structure);

structureGraphFromRunner(runner) is the same builder from a live runner.

<WhereFrom> — walk any value's causes on the one cursor

In the engineer view's detail panel, the cursor stage's written keys render as chips; picking one shows the backward slice that produced its value (footprintjs sliceForKey — the same query the backtrack LLM tool runs). ◀ Walk the causes freezes that slice as reverse-time stops and steps the ONE cursor through them ("◀ earlier cause / toward result ▶") — both parents of a fork are always visited, the chart cone follows the walk, and [Copy story] emits the exact formatSlice text the LLM tool returns. Honest absence stays honest: "never written — initial state / args / a closure", and reads-off runs say "unknowable, not absent".

<ServedTab> — what the model was served at the cursor's call

<ServedTab runner cursorRuntimeStageId commitIdx onJumpTo?>. The Why Lens mounts it as the right rail's second tab; exported for consumer-built shells. Renders servedAt(k) and receiptAt(k) (agentfootprint 9.88.0) at the one cursor with a Verified / Reconstructed / Damaged / Not on record badge per field, the library's gap sentences verbatim, the fold's hidden skill ids, and a since-previous diff. Headless: servedRowAt · verify · sincePrevious · foldFactsAt in /core. See "The Served tab" above.

<BookmarksTab> — the reader's marks, riding the one cursor

<BookmarksTab bookmarks orphaned persistence positions step port moveTo onAdd onRemove onNote>. The Why Lens mounts it as the right rail's third tab (<Lens bookmarkStore?> picks the store; default localStorageBookmarkStore()); exported for consumer-built shells, with useBookmarkSidecar({ snapshot, store }) as the list and openLensCursor(positions, { marks }) as the port. A jump is the port's toMark through moveTo; orphans are greyed and labelled; the store's availability is a label. Headless: bookmarkKey · toSidecar · fromSidecar · bookmarksToMarks and the three stores in /core. See "Bookmarks, and scrubbing by declared tag" above.

<TagPicker> — the declared-tag legend and picker

<TagPicker legend picked onPick available?>. The engineer view mounts it above the chart on granularity="group" at the root level; legend is tagLegend(structure, snapshot) (source: 'structure' | 'log'), a pick is a list of tag names the lens hands to tagAxisPositions(snapshot, picked, base) (Foottrace's tagStops). Picking never moves the cursor; it changes the list the one cursor is a step into. available defaults to true; false holds the strip read-only — the legend still shows, every chip is disabled, and the strip says so (TAG_PICKER_LABELS.tagAxisUnavailable).

<BugReportButton> — report a bug with the run attached, consent first

A small button for a debug UI. The dialog it opens shows every selectable unit of evidence with its size and counts, meters the selection live against a 24 MB ceiling (naming the unit to untick when it is over), and offers whichever of the three submit modes you configured — copy + download, sign in with GitHub, or file through your own endpoint. Needs agentfootprint 9.9+; on anything older it renders a version hint instead of itself. See Report a bug with this run.

<ArtifactPane> — render by ref, with #REF! taught manners

Takes one present call (presented) and an ArtifactResolver (resolver); heads the ref, then renders the component registered for the artifact's kind (registerArtifactComponent({ kind, component })) with { meta, data, presented } as props. 'dataset/rows' ships as a built-in table; every unregistered kind falls back to <ArtifactMetaCard> with the gap stated. An expired/missing ref renders its placeholder from the speak-time snapshot alone. The headless half — httpArtifactResolver, storeArtifactResolver, presentedFromEvents, readPresentedResult, artifactPlaceholder — is on agentfootprint-lens/core. See Render an artifact by its ref.

<AwaitingPane> — answer a paused run with a real component

Takes the awaiting payload a served agent replied with (awaiting), an optional ArtifactResolver (resolver, only needed when the ask ships a propsRef), and onDecision — which receives the person's STRUCTURED decision; your app posts it (decisionRequestBody formats the wire body). Renders the component registered for the ask's componentId (registerDecisionComponent({ componentId, component })) with { question, props, data, respond }; 'option-picker' ships as a built-in. Unknown id, expired ref, failed door and crashed component all state themselves and fall back to a live answer surface — never a dead end. After answering it renders the decision as one sentence (display only; the structured decision is the record). The headless half — readAwaitingComponent, approveDecision / declineDecision, decisionRequestBody, decisionSentence, isConsentAsk — is on agentfootprint-lens/core. See Answer a paused run with a real component.

selectSkillRoute({ log }) — why the skill graph went where it went

Routing already had a voice in Lens (the commentary line for every hop). This gives it a shape: one row per iteration, typed, so a view can group by skill, draw the graph, or answer "why was I refused" without re-reading prose.

import { selectSkillRoute } from 'agentfootprint-lens/core';

const route = selectSkillRoute({ log: recorder.selectEventLog() });

for (const hop of route.hops) {
  console.log(hop.iteration, hop.by, hop.from, '→', hop.to); // 2 'stay' triage → triage
}

// The model asked for a skill it could not reach — and the whole failure,
// on one row: what it asked for, what it was told, what it was reading when
// it asked, and the proof the cursor did not budge.
const [refusal] = route.hops.flatMap((h) => h.refusals);
refusal.requestedId;        // 'audit-log'
refusal.allowed;            // ['volume-lookup']  ← the reachable set
refusal.refusalText;        // the sentence the MODEL read back
refusal.cursorAfter;        // { iteration: 2, by: 'stay', moved: false }

hop.readSkillDescription is the read_skill menu verbatim, as the model saw it — the reachable list it was reading when it picked. route.nodes is the skill catalog (with visited), route.observedEdges the hops the cursor actually took, route.declaredEdges the ones the author drew, and route.turns the turn-start verdicts.

Two vocabularies, deliberately on two types: hop.by answers what moved the cursor (nine causes: entry, route, model-pick, tool-proposal, intent, continuity, decider, stay, none), while turn.by answers which tier decided where the turn started (menu lives only there — an offer is not a move). route.hasRouting is false on a run with no skills, so a view can say so instead of drawing an empty graph.

Headless core

agentfootprint-lens/core is React-free: LensRecorder, ChangeNotifier, observeRecording, the selectors and the graph adapters. Build a Vue / Angular / CLI view on the same primitives — see ChangeNotifier's JSDoc for adapter snippets.

The cursor is headless too: scrubAxisFor builds the ruler, openLensCursor moves along it through Foottrace's timeTravel() port, resolveNavigation turns an address into a step with every rung named, and stepForCommitIdx carries a position between the two axes. See Time travel through one port.


Why this design

The runner is the single source of truth. Agents fire events as they work. Lens subscribes to those events. Telemetry exporters subscribe to those events. CLI loggers subscribe to those events. Nobody owns the runner; everyone can watch it.

  • Three lines to integrate — lensRecorder() + observe() + <Lens>
  • Zero coupling — the agent doesn't know Lens exists
  • Composable — Lens + your telemetry + your logger all watch the same agent
  • Uniform — any runner works with any observer, live or replayed

The footprintjs ecosystem

The self-explaining stack — from backend pipelines to AI agents. → overview

Project Role
footprintjs the flowchart pattern (core engine)
agentfootprint build self-explaining AI agents
Explainable UI visualize a footprintjs run
Lens ← you are here debug an agentfootprint run
Thinking UI replay an agent run for non-devs

License

MIT

About

Why Lens: React components for inspecting recorded agentfootprint prompts, tool calls, context and decisions.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages