Monitor

Archive a run

persistRecording wraps a recorded run in a RecordingEnvelope — the versioned contract that says which run it is, how much of it this is, who produced it and under what privacy policy. Every field is derived from the run's own events or stated by the caller, and a fact it cannot honour is refused by name rather than guessed.

A recording you can hand to a viewer is not yet a recording you can put on disk.

recordRun freezes a run into snapshot, events and structure, and that shape is exactly right for rendering the run in the same process. It is not enough to store: it carries no format marker, no producer version, no statement of which run it is, and no statement of whether it is the whole run. So every consumer that wanted to archive a run, attach one to a ticket, or feed one to an analysis tool invented its own wrapper — and each wrapper made a different guess about the same missing facts.

A RecordingEnvelope is the producer-owned answer: one format string, and every field either a fact the library can prove or a fact you stated.

The narrow waist

The envelope is the contract; the other two shapes a finished run leaves in are presentations over it.

ShapeForWhat it is
RecordingEnvelopemachinesthe versioned contract — identity, completeness and drop-count facts, stamped as truths or refused. Archives, ingestion, cross-run analysis.
Tracehumans and UIsa redacted domain-event projection of the same run — see Replay a saved run
exportBugReporthumans filing issuesa zip whose evidence is an envelope, wrapped in consent machinery — a presentation for one workflow

If you are choosing: archive the envelope, and derive the other two from it. The bug-report zip already does — its envelope.json is built by the same buildRecordingEnvelope this page describes, so the producer versions are stamped once and read the same way in both places.

Save one run

persistRecording builds the envelope and hands it to a sink. fileRecordingSink is the reference sink — one JSON file per run, in a directory you can ls, cat and tar.

// A recording is collected AS the run happens — start before run().const recorder = recordRun(agent);const answer = await agent.run({ message: input });const { id, uri } = await persistRecording(recorder, {  sink: fileRecordingSink({ directory }),  // `complete` has no default and no derivation: a frozen recording looks  // identical whether it was taken after the run or from a crash handler,  // so the API asks rather than guessing.  run: { complete: true },});recorder.stop();

The whole example — round trip, absent identity, and both refusals — is examples/features/62-recording-envelope.ts, runnable on the mock provider with no key:

npm run example examples/features/62-recording-envelope.ts

Pass the handle from recordRun(agent) rather than the recording it made wherever you can: RecordingSource accepts either, but only the live handle knows how many events the cap discarded. PersistRecordingOptions is the sink, the run facts, an optional privacy statement and an optional configuration override; run is a RecordingRunFacts — the half of the truth the library cannot derive.

Save a long run: pack it

A plain recording repeats the conversation once per place that saw it, so its JSON grows with the square of the iteration count. With 1,000-row tool results it is 67 MB at 10 iterations, 808 MB at 40, and past JSON's string limit before 80, where it cannot be saved at all. packRecording writes every repeated value once and refers to it by index. The same runs pack to 1.3 MB, 5.6 MB and 12.6 MB at 80, and four times the iterations cost about four times the bytes.

const recorder = recordRun(agent);await agent.run({ message: input });recorder.stop();const recording = recorder.toRecording();const plain = JSON.stringify(recording);const packed = JSON.stringify(packRecording(recording)); // what you store or send// Reading it back: one call reads either shape.const readBack = unpackRecording(JSON.parse(packed));

unpackRecording reads both shapes, so call it wherever a recording is read: a plain recording comes back untouched, and a packed one reads back to the plain recording's JSON, byte for byte. A packed format it does not know is refused with PackedRecordingError, never half-read. A packed recording is a PackedRecording — { format, values, recording } — whose format is PACKED_RECORDING_FORMAT; isPackedRecording(value) tells the two shapes apart without expanding anything. openRecording and the answer-account op already read through unpackRecording.

A packed recording can stand for far more JSON than it holds: a value referred to ten times, by values each referred to ten times, is a few KB packed and billions of bytes unpacked. So unpackRecording expands one only when the plain recording it stands for fits a bound: maxBytes in its UnpackRecordingOptions, counted in UTF-8 bytes of the plain JSON. The default, DEFAULT_UNPACK_MAX_BYTES (512 MiB), is about the largest plain recording a JavaScript string can hold. The size is measured over the packed form before anything is built, and a recording over it is refused with PackedRecordingTooLargeError. A host that reads recordings other people send should pass the same ceiling it puts on a plain one, as the answer-account op does with its maxRecordingBytes. openRecording takes the default; open a larger recording you trust with openRecording(unpackRecording(packed, { maxBytes })).

// A host that explains recordings other people send holds a packed one to the// ceiling it puts on a plain one — measured over the packed form, before// anything is expanded.const ceiling = 16 * 1024 * 1024;let refused = false;try {  unpackRecording(JSON.parse(packed), { maxBytes: ceiling });} catch (err) {  refused = err instanceof PackedRecordingTooLargeError; // ~36 MB plain: over it}

An agent that files its recordings as artifacts packs them with artifacts: { recordings: { packed: true } }. It is off by default: switch it on once every reader of those artifacts (the Lens, your own viewer) calls unpackRecording.

Gotcha: an unpacked recording SHARES one object wherever the packed one referred to the same value. Treat a recording as read-only, as every viewer does.

What an envelope holds

fieldtypewhat it says
formatRECORDING_ENVELOPE_FORMATthe marker for this envelope shape — v1 today. Compare against the exported constant rather than a copied string: it is bumped only for a change an older reader could not survive, so a reader that does not recognise it must refuse the file rather than half-read it.
producerRecordingProducerthe agentfootprintVersion and footprintjsVersion that made the bytes, or 'unknown' when a manifest is unreadable
runRecordingRunwhich run, and how much of it: runId, optional sessionId / principal / tenant, startedAt, optional endedAt, complete, droppedEvents
configurationRecordingConfigurationthe agentId and the run's own run_configured manifest — names and ids only, by law, which is what makes it safe to archive
privacyRecordingPrivacythe mode and a matchable policyId — what was done to the bytes before they were stored
recordingthe recordingsnapshot, events and structure, unmodified

It is plain JSON by construction — no Dates, no Maps, no live handles — and an absent optional is an absent key rather than a key holding undefined. That is what makes JSON.parse(JSON.stringify(envelope)) the same envelope, deep-equal, rather than one that quietly changed shape on the way to disk.

The rule: never stamp a fact you had to guess

An archive is read by people and tools that were not there when the run happened. That makes every field a claim — and a claim that turns out to be a guess is worse than a missing field, because a missing field sends the reader to look and a wrong one stops them looking. So each field has a stated source:

fieldwhere it comes from
runId, sessionId, principal, tenantthe event meta, or your explicit statement. Never synthesized.
startedAt / endedAtevent wall clocks, but only where the stream can honestly supply them
completeyour statement, always
droppedEventsthe live recordRun handle, which counts them
configurationthe run's own agentfootprint.agent.run_configured manifest
producerthe package manifests, at runtime

Where a fact is neither derivable nor supplied, the builder refuses. The refusals below are the feature, not the rough edges around it.

complete is asked for, never defaulted

A frozen recording looks identical whether it was taken after the run or from a crash handler mid-run, and there is no run-terminal event to check. Defaulting it to true would make every crash dump claim to be whole, so complete is required — say false for a recording frozen from a timeout, a crash handler, or mid-stream.

An incomplete recording gets no endedAt unless you state one: the last retained event is just where watching stopped, and a run that had not finished has no end time to report. RecordingTimestamp lets you state either edge as an ISO 8601 string, epoch milliseconds or a Date; anything unreadable is refused rather than silently becoming an invalid date.

Identity is inherited, never invented

principal and tenant come from the event meta, whose own law is that they are stamped only from an explicit run(input, { identity }) — never from the run's internal identity and never from a session id, because a conversation id is not an actor. The envelope inherits that guarantee whole: an anonymous run produces an envelope with no principal key at all, not a placeholder and not a session id wearing an actor's name.

droppedEvents: 0 means none were dropped

Only the live handle counts what the maxEvents cap discarded; a bare Recording is a plain object that carries no count. Enveloping one without stating the count raises IndeterminateRunFactError with field: 'droppedEvents' rather than reporting a comfortable zero — the difference between "none were dropped" and "we did not look" is the difference between a timeline that starts at the run's beginning and one that merely appears to.

The same error, with field: 'startedAt', fires when the cap has dropped events: the cap discards the oldest, so the earliest retained event is not the run's first, and a start time read off it would report the moment recording overflowed. State run.startedAt, or record with a larger maxEvents.

One envelope names one run

A run id is minted per run() and per resume(), so a recorder left attached across both sees two. Filing that recording under the first id it saw would put a label on an archive that is wrong for some of the events inside it — so it is refused, naming both ids, and you either split the recording or state which run.runId the archive is filed under. Two principals or two sessionIds in one stream refuse the same way.

Privacy: v1 is 'full' only, and says so

RecordingPrivacyMode names three modes; v1 implements one. Asking for 'redacted' or 'structure-only' raises UnsupportedPrivacyModeError, and it refuses before the sink is reached, so nothing is ever stored under a label the producer could not honour.

That looks strict until you follow what the label does downstream: an archive browser decides what to show from it, a retention rule decides how long to keep it, and a triage tool decides who may open it. An envelope stamped 'redacted' over un-redacted bytes would be handled with less care than one that admits it is raw — so silently storing the raw bytes under the nicer label is the worse outcome, not the convenient one.

Redact before persisting instead:

  • recordRun(agent, { boundaryDetail: 'lean' }) removes captured content from the BoundaryEvents bundle only; its event tail and engine state can still contain raw content;
  • serializeTrace and redactContent redact their own projection at the serialize boundary, not every field of an arbitrary recording (see Replay a saved run);
  • Agent.create({ redact }) — with conversationRedaction() from agentfootprint/security — keeps what it names out of every record of the agent's runs, the recording included: state where it is written, every event at its source (see Keep personal data out of the record). The run's output — the answer as the chart returns it — still rides run.exit.

Review every exported channel, including errors, custom emits and recorder metadata. A content-minimized Trust Boundary bundle does not sanitize the surrounding recording either. A recording made under an agent's redact is still enveloped as 'full' in v1: the label never claims more than the envelope can check, and the conservative label is handled with more care, not less.

A 'full' envelope carries FULL_PRIVACY_POLICY_ID as its policyId when you name none, so a retention rule can match on a stable string rather than on prose.

The file name is a key

fileRecordingSink takes FileRecordingSinkOptions — a directory, created if missing — and maps one run id to one file name with recordingFileName, which is exported so you can drive the mapping directly.

That mapping is a key, so it is asserted rather than hoped for: two runs landing on one name means one archive silently overwrites another with nothing raised anywhere. Ids outside the safe set raise UnsafeRecordingIdError before anything is serialized.

refusedwhy
uppercasemacOS and Windows fold case, so run-A and run-a are two ids and one file
/ or \a separator inside a field silently becomes a directory hop
a leading . or -one hides the archive from ls, the other reads as a flag to every CLI tool
. and ..path navigation, not names
con, nul, com1, …Windows device names, with or without an extension: con.json opens the console
over 200 charactersNAME_MAX is 255 and the suffixes add to it

The ids this library mints all satisfy it; the assertion is there for the ones you state. Two other things the reference sink gets right and a hand-rolled one usually does not: the write is atomic (a temp file, then a rename within the same directory), so a crash leaves a .tmp nobody reads rather than a truncated .json that parses as far as it got — a half-written archive is the one failure a bug report cannot survive, because it looks like evidence. And writing the same run id twice replaces the archive: the run id is the archive's identity, so a second envelope for one run is a newer version of one archive (a partial crash dump later superseded by the finished run), not a second archive.

Writing your own sink

A RecordingSink is one method, so a bucket, a table or an HTTP endpoint is a few lines. Return an id — your own handle for what you stored — and a uri when your destination has a meaningful address.

import { persistRecording, type RecordingEnvelope, type RecordingSink } from 'agentfootprint/observe';

const archived: RecordingEnvelope[] = [];

const memorySink: RecordingSink = {
  write(envelope) {
    archived.push(envelope);
    return Promise.resolve({ id: envelope.run.runId });
  },
};

await persistRecording(recorder, { sink: memorySink, run: { complete: true } });

Swap the push for a bucket put, an INSERT or a POST, and return the key you stored it under — plus a uri when your destination has a meaningful address. Serialize with JSON.stringify: the envelope is plain JSON, and a RecordingEnvelope that came back through JSON.parse is deep-equal to the one that went out.

When the envelope goes somewhere this library should not know about — a request body, a queue message, a test assertion — use buildRecordingEnvelope, which is the same builder without a destination. It takes BuildRecordingEnvelopeOptions: PersistRecordingOptions minus the sink.

import { buildRecordingEnvelope } from 'agentfootprint/observe';

const envelope = buildRecordingEnvelope(recorder, { run: { complete: true } });
await fetch('/api/runs', { method: 'POST', body: JSON.stringify(envelope) });

Reading an archive back

Check format first and refuse a string you do not recognise. Then envelope.recording is exactly what the viewers consume — hand it straight to observeRecording() as described in Replay a saved run.

Explain an answer in plain words

A recording can be read back for a person, not only for an engineer. accountForAnswer takes one answer's recording and returns an account in seven rows — You asked · It understood · It checked · It did not check · It found · How sure · Anything wrong — plus a one-line summary. No model writes it: every line comes from a fixed, versioned template, carries its template id, and names who vouches for it — the person, the library's own record, a tool's declaration, the model, or the app. When the record does not hold a fact the line says "not recorded", and a check the record cannot support is listed as one that could not run, never as "nothing wrong".

import { accountForAnswer } from 'agentfootprint/observe';

const account = accountForAnswer(recording, {
  // Data the app declares when the report is made — vouched "app", never the record's.
  skills: { 'array-inventory': { label: 'array estate report' } },
  tools: { lookup_volumes: { rowsAt: 'volumes' } },
});

account.summary.sentence.text; // the one-liner
account.rows.map((row) => row.heading.text); // ['You asked', 'It understood', …]

It reads only the answer's own run (by the agentfootprint run id on the recording, or the one you pass as { runId }), it is pure — the same recording gives the same bytes — and it stays under 128 KB whatever the run held.

The result is an AnswerAccount: the seven rows, the typed facts they were filled from (each with its voucher and a pointer to the leaf of the record it came from), the signals, the checks that could not run, and the summary. The second argument is an AnswerAccountDeclarations — a skill's plain label, where an object-shaped result keeps its rows, whether the app decides skills itself — validated when you pass it. A server that serves the account — the answer-account wire op does, so a browser never downloads the recording — also serves "show me" values as AnswerAccountShownLeafs, keyed by answerAccountPointerKey(pointer): an allow-listed leaf of the record, a derived row count (never the rows), or the reason it is withheld.

How far an answer stands

The account's "How sure" row — and anything else that needs one word for an answer — reads its standing from what the run committed, never from how sure the model sounded:

StandingWhen
knowna check tied the answer to a settled fact — today, a passed enforce answer check for these exact bytes
consistentchecks ran and none fired. Consistent with the record — never "verified"
not-surea reason fired; each names the row it rests on
askthe run paused on a question still waiting for a person — a typed input, an askHuman, a check-in or a middleware ask
not-assessednothing the record holds could be checked

await agent.assessment() folds the last run; assessAnswer(recording) from agentfootprint/observe folds a saved one — the same fold over the same committed rows, so the two agree:

async function standingOf(execute: () => unknown, message: string) {  const agent = Agent.create({    provider: mock({      replies: [        { toolCalls: [{ id: 'call-1', name: 'list_down_ports', args: { switch: 'A' } }] },        { content: 'No ports on switch A are down.' },      ],    }),    model: 'small-model',  })    .tool(      defineTool({        name: 'list_down_ports',        description: 'Ports that are down on one switch',        inputSchema: { type: 'object', properties: { switch: { type: 'string' } } },        execute,      }),    )    .build();  const recorder = recordRun(agent);  await agent.run({ message });  const saved = JSON.parse(JSON.stringify(recorder.toRecording()));  recorder.stop();  const live = (await agent.assessment())!; // the last run, folded from its committed record  const later = assessAnswer(saved); // …and the same fold over the saved recording  check(JSON.stringify(live) === JSON.stringify(later), 'the live and the saved folds to agree');  return live;}

The reasons, each read from one committed row: a tool declared ground it did not check (coverage-gap); a tool declared that nothing matched (declared-absent, which includes an empty rowset inside a coverage() boundary); an empty rowset that said nothing about what it searched (empty-undeclared — silence recorded as silence); a result's store does not hold the period its read asked about (period-not-held), holds only part of it (period-partly-held), cannot say (period-unknown), or the tool declares a period argument and its result said nothing about its period (period-undeclared) — the results layer's verdicts, see The period a result covered; two results the model stood on disagree (sources-conflict); names or numbers in the answer appear in no tool result (value-unsupported, value-survived-revision); a limit cut the turn short (stopped-early); the answer came before a skill's declared steps finished (steps-unfinished, on an agent with the answer layer — below); the app's answer checks failed (answer-check-failed) or could not reach a verdict (check-unreachable); the run paused and a person's reply is still awaited (asked). checked lists what actually ran, as counts (tool-coverage 1/3 — one of three calls declared what it covered). Pass the same declarations object the account takes to say where an object result keeps its rows.

The result is an AnswerAssessment: assessment (the value — known, unrefuted, unknown, not-applicable), standing (the word above), reasons (each with its layer and a pointer to every row it rests on), support (the row "known" stands on, when one does), checked, and turnFrom (whether the turn could be found after the last message a person said). The family is reached by indexed access — AnswerAssessment['reasons'][number]['reason'].

Gotchas:

  • It reads committed state, never events. A verdict that exists only as an event — the .claims() dispositions, and on an agent without the answer layer the evidence gate's clean pass — is not on the committed record, so it neither supports nor refutes.
  • A declaration the record lost is still read. When a call has no committed coverage row — a history restored by resumeOnError, a trimmed recording — the envelope the model read is read from the result itself, never as silence. The same holds for an envelope a tool returned as JSON text that the run never recognized, so the standing can say more there than the account's "It found".
  • This turn only. It reads the results after the last message a person said, so an earlier turn's empty result is not held against today's answer.
  • A paused run reads ask from its record alone — every kind of pause, from the committed state the pause leaves. No checkpoint is needed.
  • No answer, no standing. After a run that threw, or whose answer a rule refused, agent.assessment() resolves to undefined, and the account's "How sure" says the record shows no answer. assessAnswer cannot tell a crash from the committed state, so check how the run ended before you fold its recording.
  • It measures claims within the record, not truth. A tool that misstates what it searched produces an honest-looking standing.

The standing, in the run — .answerLayer()

.answerLayer() runs the same fold INSIDE the run — as the first stage of the final branch, after every check has filed its verdict — and serves the result the moment the answer exists:

  • turn_end.answerAssessment and one agentfootprint.answer.assessed event carry the value, its word, the reason kinds and the checks that ran — never a value from the answer or a quote;
  • the evidence gate's clean pass and an answer given before a skill's declared steps finished are committed as rows on the findings ledger — a GroundedRow and a StepsUnfinishedRow, members of FindingsRow that agent.findings() returns, each stamped with its conversation turn (the gate's pass is the check having RUN — it lifts "not assessed" to "consistent", never to "known");
  • { standingLine: true } appends one line to a prose answer, after the separator. It is refused beside .answerValidation() (the checks judge the exact bytes) and beside .outputSchema() (a typed answer is JSON) — the standing still travels as data there.

The in-run standing and a later assessAnswer(recording) are one fold over the same committed rows, so they agree:

function portsAgent(execute: () => unknown, answer: string) {  return Agent.create({    provider: mock({      replies: [        { toolCalls: [{ id: 'call-1', name: 'list_down_ports', args: { switch: 'A' } }] },        { content: answer },      ],    }),    model: 'small-model',  })    .tool(      defineTool({        name: 'list_down_ports',        description: 'Ports that are down on one switch',        inputSchema: { type: 'object', properties: { switch: { type: 'string' } } },        execute,      }),    )    .namesAndNumbersFromEvidence({ posture: 'assist' }) // the gate whose clean pass is a witness    .answerLayer({ standingLine: true }) // the standing as data — and one line for the person    .build();}async function answerWithStanding(execute: () => unknown, answer: string, question: string) {  const agent = portsAgent(execute, answer);  let inRun: unknown;  agent.on('agentfootprint.answer.assessed', (e) => {    const { assessment, standing, reasons, checked } = e.payload;    inRun = { assessment, standing, reasons, checked }; // the value, its word, the reason kinds, the checks that ran  });  const recorder = recordRun(agent);  const text = (await agent.run({ message: question })) as string;  const saved = JSON.parse(JSON.stringify(recorder.toRecording()));  recorder.stop();  // The same fold, read afterwards from the saved recording, says the same thing.  const after = assessAnswer(saved);  check(    JSON.stringify(inRun) ===      JSON.stringify({        assessment: after.assessment,        standing: after.standing,        reasons: after.reasons.map((r) => r.reason),        checked: after.checked.map(({ layer, check, ran, of }) => ({ layer, check, ran, of })),      }),    'the in-run standing to equal the read-after fold',  );  return { text, standing: after.standing, reasons: after.reasons.map((r) => r.reason) };}

Gotchas:

  • It is a label. It never asks, never refuses and never edits the model's words — the line comes after the separator, and the data rides beside the answer.
  • One event per answer. A draft sent back for a revision or a schema retry is not assessed; the answer that stands is.
  • In a composition (an agent mounted as a Sequence step), the standing reaches the composition on the agent's turn_end; the answer.* event stays on the agent's own dispatcher.
  • A turn's rows stay that turn's. While a layer is armed a checkpoint carries the turn it ended on (turnNumber), and the next run continues from it — so a continued conversation never folds an earlier turn's rows as its own, even when a window strategy trims the stored history.
  • The line is not streamed. Like the limits block, it is appended to the answer the run returns; stream.token carries the model's text.
  • Off by default. Without .answerLayer() the chart, the committed rows, the events and the answer are exactly what they were.

Next steps

On this page