Artifacts
The claim-check store and the data legs — tools check data in and the model routes ~26-char tickets. ctx.artifacts, wants (refs as tool arguments), the present tool, the placement threshold, typed lifecycle events.
A production agent once hauled 879,073 tokens through its context window because two tools needed the same data and the only road between them ran through the model. The model read six megabytes it never needed, paraphrased it (imperfectly) back out, and the bill arrived anyway. Enterprise messaging solved this decades ago and named the law the claim check: the expensive channel carries a ticket plus enough description to decide — the payload never rides the channel. Your context window is the most expensive channel you own. Artifacts are that law, built in.
This page is the feature surface: the adapters, the option shapes, the events, the runnable examples. The mechanism behind them — the port's refusals, the three dispatch laws, what a ref is not allowed to move, every claim carrying a status, and one worked refusal captured from a real run — is Artifact architecture.
The idea in one run
import { Agent, defineTool, inMemoryArtifacts } from 'agentfootprint';
const getSales = defineTool({
name: 'get_sales',
description: 'Fetch Q3 sales and check them into the artifact store.',
execute: async (_args, ctx) => {
// Imagine 48,000 rows — 6 MB — coming back from your warehouse:
const rows = Array.from({ length: 48_000 }, (_, i) => ({ order: i, amount: (i % 97) + 1 }));
const meta = await ctx.artifacts.put({
kind: 'dataset/rows',
mediaType: 'application/json',
data: rows,
label: 'Q3 sales by region',
});
// The model reads THIS — one line, ~30 tokens:
return `stored ${meta.ref} [${meta.kind} · ${meta.bytes} bytes]`;
},
});
const summarize = defineTool({
name: 'summarize',
description: 'Total a stored dataset. Pass the art_… ref from get_sales.',
inputSchema: { type: 'object', properties: { ref: { type: 'string' } }, required: ['ref'] },
execute: async ({ ref }: { ref: string }, ctx) => {
const head = await ctx.artifacts.head(ref); // decide from meta — pay nothing
if (head === null) return `nothing under ${ref} — expired or never stored`;
const got = await ctx.artifacts.get(ref); // now pay for the bytes
const rows = got?.data as ReadonlyArray<{ amount: number }>;
return `total: ${rows.reduce((sum, row) => sum + row.amount, 0)}`;
},
});
const agent = Agent.create({ provider, model, artifacts: inMemoryArtifacts() })
.tool(getSales)
.tool(summarize)
.build();The model routes the ticket (art_h7Kq…) between tools. The window spent on six megabytes of freight: two metadata lines. That is the whole feature; everything below is the law behind it.
Runnable version: examples/features/56-artifacts.ts — mint in one tool, redeem in another, a derived artifact, and expiry stating itself.
Open selected details of a stored proof
Use artifactDetailTool when a model needs a small section of an already stored
JSON result. A reference alone does not make the result readable: mount the tool
and supply a trusted admit(ref, ctx) callback. Every navigation call repeats that
callback. The helper never reads a store automatically or caches an admission.
const openProof = artifactDetailTool({ name: 'open_synthetic_proof', admit: admitProof, context: () => ({ demo: { provenance: 'LOCAL SEED', scope: 'retained synthetic samples', inventoryMapping: fixture.missing.inventoryMapping, sourceOperationWindow: 'unknown', physicalCauseEstablished: false, claimAuthority: 'unchanged', }, }),});ArtifactDetailOptions configures the trusted host callbacks; model request
fields use ArtifactDetailArgs. Responses use ArtifactDetailResult, whose
child pages contain ArtifactDetailChild entries with ArtifactDetailAction
follow-ups that the mounted tool can execute.
The model sends a complete artifact ref, a JSON-pointer path (empty opens the
root), and optional offset/limit. A small section returns mode: 'value' with
the actual JSON. A larger object or array returns mode: 'children', child actions
for the same tool, and exact shown/total/complete/nextOffset fields. Follow
those actions or request the next page. An oversized scalar returns
mode: 'scalar-omitted' and complete: false; identifiers and values are never
split into partial strings. An omitted scalar cannot be recovered by pagination.
Admission belongs to the host. The example admits only its producer's issued
reference in the same caller scope and native run. It checks kind, producer call,
size and digest identity, then uses fresh scoped head/get reads. The configured
store owns payload integrity. Adapt these checks to your application's source,
permissions, query/window and evidence contracts; the viewer does not certify any
of them. Authenticate the caller before supplying its run identity. Opening a
section grants no additional authority to attribute a cause or make a claim.
Host context(root, path) receives the detached frozen root and returns caveats
nested under the result's context. Keep provenance, window limitations and unknown
causes there; context cannot override navigation fields. Results travel through
the existing tool-message channel. This is source-data framing, not a global
taint flag or a guarantee of prompt-injection immunity.
The fixed limits are 1 MiB of admitted ordinary JSON (depth 100), 4 KiB of host
context, 8 KiB per inline value, and 12 KiB UTF-8 for the whole response including
context. Pages contain at most 20 direct children; pointers have at most 16
segments and 2,048 UTF-16 code units, and offsets are at most 1,000,000. Escape
~ as ~0 and / as ~1; prototype keys and malformed pointers are refused.
Getters, cycles, non-finite numbers, negative zero and unsupported JSON values are refused.
These bounds do not limit transport deserialization or preempt synchronous host
callbacks or arbitrary JavaScript proxy traps.
Direct callers can inspect ArtifactDetailError.code (ArtifactDetailErrorCode): malformed requests,
failed admission/unavailable sections, and cancellation use
ERR_ARTIFACT_DETAIL_INVALID, ERR_ARTIFACT_DETAIL_UNAVAILABLE, and
ERR_ARTIFACT_DETAIL_CANCELLED with fixed messages. An agent serves failures
through its existing tool-result handling; it does not promise a structured
error-code envelope to the model. Callback error text and cancellation reasons
are not echoed by the viewer.
Cancellation stops waiting and prevents late delivery; the host still owns
underlying I/O cancellation and cleanup.
Runnable example: examples/artifacts/artifact-detail.ts
uses a real agent, in-memory artifacts and a scripted mock. It follows two sample
pages into an exact stored source value with five fresh admissions and one
synthetic source read. Its evidence-shaped fixture is not a causal-analysis API.
For an actual recorded cause result, a host can explicitly
retain a complete traceToolpack root_cause JSON result and admit it through
this viewer, preserving its gaps and interpretation. Existing cause-response
overflow still returns an explicit truncated gap and stores no reference;
the viewer cannot recover that discarded finding. Automatic overflow storage,
lifetime and admission remain a consumer integration, and cause caps are unchanged.
The data legs (9.22.0) — refs the model routes, not just holds
Declare datasets once, independently of transport
withDatasetArtifacts wraps a local, HTTP-backed or MCP tool with a
DatasetResultAdapter. The adapter's describe(result, args) returns a
DatasetResultPlan: explicit datasets plus a projection that turns storage
receipts into the compact result the model receives. Returning undefined
leaves a non-dataset result unchanged. The library does not guess which arrays
are tables or invent their field meanings.
Each DatasetArtifactInput has a unique key, an artifact declaration and an
optional source artifact. DatasetPublication contains the key and a
DatasetArtifactReceipt for each declared artifact. A receipt says either
stored with its metadata, or unavailable with a reason. Source references
join the dataset's existing parent references. stageDatasetArtifacts exposes
the same publication step to pipelines that already own tool execution and
projection; the wrapper delegates to it.
import { withDatasetArtifacts } from 'agentfootprint';
const referencedTool = withDatasetArtifacts(fetchSales, {
describe(result) {
return {
datasets: [{
key: 'sales',
artifact: { kind: 'dataset/rows', mediaType: 'application/json', data: result.rows },
source: { kind: 'evidence/source', mediaType: 'application/json', data: result.source },
}],
project([publication]) {
if (publication.artifact.status !== 'stored') {
return { available: false, reason: publication.artifact.reason };
}
return {
dataset: publication.artifact.meta,
source: publication.source,
rowCount: result.rows.length,
};
},
};
},
});Required for references: configure Agent.create({ artifacts: store })
and supply the producer adapter. A built-in store is sufficient; implementing
a custom store or exposing storage as an LLM tool is optional. The dispatcher
provides the scope-bound ctx.artifacts; a follow-up tool can redeem a dataset
through wants without refetching its source. Without a store, this wrapper
does not call the adapter and returns the original tool result. A host that
requires reference-only context must enforce its normal result projection or
refusal policy as well.
A frontend is optional. A chatbot can complete the whole flow on the
backend. When a UI is present, its configured artifact resolver calls the host,
which checks the same authenticated scope. The reference is neither a storage
URL nor authorization by itself. Existing httpArtifactResolver reads an
artifact through the host; it is not a pagination or database query API.
Shared deployments must configure identity verification: session IDs alone
are not authentication.
Writes are independent, not a transaction. A failed source write can leave a readable dataset with explicitly unavailable provenance; a failed dataset write never produces a ticket. Tickets are rechecked after the batch to detect eviction, but may still expire later. The projection decides how to disclose each unavailable receipt. Declaration shapes are validated before writing; the store retains responsibility for serialization, budgets and parent proofs.
These helpers accept materialized results. They do not turn a 2 GB download into a bounded-memory query. Streaming storage and server-side query providers are separate capabilities; an MCP resource URI is not automatically a local artifact reference.
Runnable chatbot-only example, including a second-turn calculation:
examples/artifacts/dataset-result.ts.
The storage design follows the established
claim-check pattern.
Declare which column is time
A viewer handed rows has to guess whether they are a time series: ts holding
1726300800 is epoch seconds to the tool that wrote it and a large number to
everything else. Guessing draws some series and leaves the rest as tables. So the
producer says it, on the ticket: timeAxis names the time column, how its values
are written ('iso', 'epoch-s', 'epoch-ms'), the IANA zone when ISO values
are wall-clock, and how each row summarises its interval.
const hourly = { rows: [ { ts: 1_726_300_800, avg_iops: 410, peak_iops: 1_220 }, { ts: 1_726_304_400, avg_iops: 380, peak_iops: 990 }, { ts: 1_726_308_000, avg_iops: 455, peak_iops: 1_410 }, ],};// Epoch SECONDS under a column called `ts`: nothing a viewer could guess safely.const timeAxis: DatasetTimeAxis = { column: 'ts', unit: 'epoch-s', interval: '1h', aggregate: { avg_iops: 'avg', peak_iops: 'max' },};const adapter: DatasetResultAdapter<Record<string, unknown>, typeof hourly, { ref: string }> = { describe(result) { return { datasets: [{ key: 'rows', artifact: { kind: 'dataset/rows', mediaType: 'application/json', data: result.rows, timeAxis }, }], project([publication]) { if (publication?.artifact.status !== 'stored') throw new Error('The series could not be retained.'); return { ref: publication.artifact.meta.ref }; }, }; },};The declaration rides ArtifactMeta.timeAxis through every store and the
artifacts.minted event. A viewer reads it with readTimeAxis(meta) and titles the
chart with describeTimeAxis(axis), the one wording every screen shares:
// The viewer's side: the ticket says what the rows are, so nothing is guessed.const [ticket] = (await store.list(scope)).artifacts;const reading = readTimeAxis(ticket);const title = reading.status === 'declared' ? `IO profile — ${describeTimeAxis(reading.axis) ?? 'time series'}` : 'IO profile (table)';The declaration's type is DatasetTimeAxis. Its unit is a TimeAxisUnit and each
summary word is a TimeAxisAggregate; the closed sets are exported as
TIME_AXIS_UNITS and TIME_AXIS_AGGREGATES. readTimeAxis returns a
TimeAxisReading: absent, declared, or malformed with its issues, for a ticket
written by a foreign store. timeAxisIssues(value) is the judge the store itself
uses. It returns every problem in words, and an empty list means the declaration
is well-formed.
To compare times, ask for the view. normaliseInstants(rows, axis) turns the
declared column into UTC instants at one precision, sorted, each with the row it came
from, so comparing two spellings as text compares them in time. It only reads the
rows; the stored bytes never change.
// Comparing needs instants, not spellings: the view turns the declared column into// UTC at one precision, sorted, and leaves the stored rows exactly as they were.const record = ticket === undefined ? null : await store.get(scope, ticket.ref);const view = reading.status === 'declared' && record !== null ? normaliseInstants(record.data as unknown[], reading.axis) : undefined;const first = view?.status === 'instants' ? view.points[0] : undefined;// A wall-clock string with no offset and no declared zone is never read as UTC.const unzoned = normaliseInstants( [{ t: '2026-09-14T10:00:00Z' }, { t: '2026-09-14T11:00:00' }], { column: 't', unit: 'iso' },);A string with no offset under an axis with no zone could be any of 24 hours, so the
view never reads it as UTC. It counts it and returns status: 'naive-values' (or,
with { naive: 'refuse' }, 'refused' and no points). Under a declared zone the
values are wall times. In the hour the clocks go back, one wall time names two
instants, so the view places them by the rows' order and notes each one
({ kind: 'dst-overlap', resolvedBy: 'row-order' }). A lone 01:30, or rows not in
time order, cannot be placed that way and is counted as dstAmbiguous. A wall time
the spring change skips is counted as dstGap.
Gotchas. A malformed declaration is refused at mint, never repaired. That covers
an unknown key, a zone that is not an IANA name (an abbreviation such as PST or a
bare offset is refused), a zone on an epoch, a summary with no interval, or an
interval with no aggregate ('raw' says nothing was reduced). stageDatasetArtifacts
judges it before its first write. The store never scans rows, so a declared column
missing from the rows is for the viewer to report as an error, not to hide. An
undeclared dataset is unchanged, and the viewer keeps whatever heuristic it had.
The store alone still leaves the model doing the redeeming by hand (ctx.artifacts.get inside the tool). Three legs make the ref a first-class citizen of dispatch itself.
wants — refs as tool arguments, resolved before execute
A tool declares which arguments are claim tickets, and what kind they must redeem to:
import { Agent, defineTool, inMemoryArtifacts } from 'agentfootprint';
// 1. get_data returns 48,000 rows. It doesn't even call the store —
// the placement threshold (below) checks the result in for it, as
// kind 'tool-result/get_data', and the model reads the ticket.
const getData = defineTool({
name: 'get_data',
description: 'Fetch the Q3 sales rows (large).',
execute: () => Array.from({ length: 48_000 }, (_, i) => ({ order: i, amount: (i % 97) + 1 })),
});
// 2. transform_report DECLARES its ref argument. The model passes the
// art_… STRING; the framework resolves it at dispatch — the handler
// receives the DATA, and the claim ticket rides ctx.wanted.
const transformReport = defineTool<{ dataset: string }, string>({
name: 'transform_report',
description: 'Aggregate a stored dataset. Pass the art_… ref from get_data.',
inputSchema: {
type: 'object',
properties: { dataset: { type: 'string' } },
required: ['dataset'],
},
wants: { dataset: 'tool-result/get_data' },
execute: async (args, ctx) => {
const rows = JSON.parse(args.dataset) as ReadonlyArray<{ amount: number }>; // the data, already redeemed
const meta = ctx.wanted?.dataset; // the ticket (ArtifactMeta)
return `total: ${rows.reduce((s, r) => s + r.amount, 0)} (from ${meta?.ref})`;
},
});
const agent = Agent.create({
provider,
model,
artifacts: { store: inMemoryArtifacts(), placement: { maxInlineChars: 2_000 } },
})
.tool(getData)
.tool(transformReport)
.build();The declaration's shape is the exported ToolWants type — Readonly<Record<argName, kind>>. The law, verb for verb the needs (credentials) precedent:
- Resolution happens at dispatch, before
execute, before credentials — a call whose data cannot be delivered never acquires a secret and never runs. - A stale, unknown, or wrong-kind ref never reaches the tool. The model reads a teaching refusal that lists the live refs of the wanted kind in scope — correction by naming what can resolve, never just what can't. On the record:
agentfootprint.artifacts.refusedwithop: 'dispatch'(reasonmissing-or-expired,kind-mismatch,invalid-input, orno-store); successful resolution rides the existingartifacts.resolved(via: 'get'). - Kind check is exact.
'dataset/rows'matches only'dataset/rows'— no wildcards, no hierarchy. - Declared honestly or refused at
defineTool: awantsargument must exist ininputSchema.propertiesand betype: 'string'(the model speaks the ref, never the bytes). An agent with a statically registeredwantstool and no store refuses at build — configuration that lies otherwise.mcpServerefuseswantstools by name (no store at that door). - A declared ref the tool's own schema marks
requiredis refused by name when the model omits it (9.38.0) — at both dispatch doors, including the resume door a human approval comes back through, and independently of the agent-widetoolArgValidationdial. An optional declared argument the model omits is a legitimate choice: the call runs andctx.wantedsimply has no entry for it. The full before/after — and why the dial used to decide this — is in Artifact architecture. - Zero-cost: a tool without
wantstakes the exact path it always took, andctx.wantedis absent (absent ≠ empty).
The placement threshold — oversized results become tickets automatically
The object form of the option (the exported AgentArtifactsOptions — { store, placement? }) carries the operator's dial, an ArtifactPlacement: artifacts: { store, placement: { maxInlineChars: N } }. Any tool result whose finalized text exceeds N is checked into the store as kind tool-result/<toolName> — the exported placedResultKind(toolName) composes that vocabulary, so a wants declaration can name it without spelling the prefix, and a tool that declares its own resultKind (below) is minted under that instead — with label <toolName> result (the payload is the exact text the model would have read), and every channel — history, stream.tool_end, recorders — carries the one-shape PlacedToolResult substitute instead:
{ "placed": true, "ref": "art_h7Kq…", "kind": "tool-result/get_data",
"mediaType": "application/json", "bytes": 5000000,
"reason": "get_data returned 5000000 chars, over the 2000-char placement threshold, so the full result was stored as artifact 'art_h7Kq…' … Route the ref: pass it to a tool whose argument wants 'tool-result/get_data', or call present({ ref, as: … })." }Branch on .placed (isPlacedToolResult); the decision is never silent — the mint lands as artifacts.minted with origin: { runId, toolCallId }.
Precedence with the other two ceilings, stated and tested:
- the tool's own
resultCeiling(the author's refusal) is judged first, at the execute boundary — a refused result is short, so placement never sees the payload; - placement (the operator's ref-ing) is judged next, on what the after-tool governance chain let through;
- the agent-level
maxToolResultCharstruncation net runs last — with placement on, it measures the ticket and should rarely fire.
A placed result is a ticket, not a refusal: declared tool effects are still judged and procedure steps advance normally — the result arrived; it just travels by reference. Errored/denied/refused calls are never placed (tool-result/<name> must never claim an error is the tool's result). Placement cannot be spelled without a store — the option's own shape refuses it.
One trap to know before you pick a number: the substitute replaces the result string everywhere downstream — history, lastToolResult, toolResults — which is exactly where skill-graph when edges and rule triggers look, so turning placement on or moving maxInlineChars can change which edge fires for a graph that matches on result text. It is the intended layering (routing judges what the model was told), and the way to be immune to it is to key edges on onToolReturn or onToolStatus. Reasoned through in Artifact architecture.
resultKind — a placed result the wants rail can spend (9.70.0)
Read the two rules above together and a gap falls out. The wants kind check
is exact — no wildcards, no hierarchy — and placement mints under
tool-result/<toolName>. So an agent whose consuming tool declares
wants: { dataset: 'dataset/rows' } refuses the very ticket placement just
minted, as a kind mismatch. The framework hands out a ticket its own rail
will not take, and the honest workaround was to re-mint by hand at the seam.
Tool.resultKind closes it from the producing end. A tool declares the
artifact kind its placed result is minted under, in its consumers' vocabulary:
const getRows = defineTool({
name: 'get_rows',
description: 'Fetch the Q3 sales rows (large).',
resultKind: 'dataset/rows', // ← what a wants argument names
execute: () => bigArrayOfRows,
});
const chart = defineTool<{ dataset: string }, string>({
name: 'chart',
description: 'Chart a stored dataset. Pass the art_… ref from get_rows.',
inputSchema: { type: 'object', properties: { dataset: { type: 'string' } }, required: ['dataset'] },
wants: { dataset: 'dataset/rows' }, // ← resolves now
execute: async (args) => summarize(JSON.parse(args.dataset)),
});- The matcher is untouched. Nothing here loosens
wants; exact match is what makes a ticket a promise, and a rail that guessed at families would hand a tool the wrong parcel. What changes is the mint, which now speaks the author's vocabulary instead of the framework's. - Declared, never inferred — the
capabilities/resultClasslaw. Only the tool's author knows what it produces; a kind guessed from a tool name would be a promise the library cannot keep. - Refused at
defineToolif it could never be redeemed. A blank or whitespace-only kind throws by name (the exportedassertResultKindis the same check, for consumers assemblingToolobjects by hand) — a kind is what a ticket is matched against, and a blank one matches nothing. - Zero-cost when omitted: the mint is
tool-result/<toolName>, byte for byte what it always was.placedResultKind(toolName, declared?)is the one place that decision is made, and it is exported so awantsdeclaration can compose the default without spelling the prefix. - It only ever moves the KIND. The ref, the payload, the ticket's shape,
the
artifacts.mintedevent and the precedence order are all unchanged — the kind simply travels throughmeta.kindinto every one of them.
present — hand a ref to the screen, with a durable snapshot
When a store is attached, the framework auto-attaches one more tool (the read_skill seam — the name is the exported PRESENT_TOOL_NAME constant, and it is reserved exactly then): present({ ref, as, label? }). It heads the ref under the run's scope — never get; the screen pays for bytes later, under its own identity — and the result the model (and the transcript) keeps is a PresentedResult carrying the description snapshot (a PresentSnapshot):
{ "presented": true, "ref": "art_9mTw…", "as": "bar-chart",
"snapshot": { "kind": "chart/spec", "mediaType": "application/json",
"bytes": 41210, "label": "Q3 sales by region" } }asis consumer vocabulary ('bar-chart','table','image'…) — stored as data, never validated. The shipped renderer picks a component from the artifact'skind(the producer's word, stamped at mint), not fromas, so the model is not the one choosing a renderer at all — see Artifact architecture.- The snapshot lives inside the tool result — the one thing provider message history keeps typed and durable — so a conversation reloaded after the artifact expired can still render an honest placeholder from history alone: "Chart — 'Q3 sales by region' (bar-chart, 41 KB) — expired; re-run to regenerate." Never a blank pane.
- A miss is a teaching refusal listing the live refs in scope,
error: trueon the call, andartifacts.refused(op: 'dispatch') on the record. A hit emits the typedagentfootprint.artifacts.presented{ ref, as, snapshot }. - The model never serializes what the screen will show.
The code leg — CodeResult.artifacts gets the store behind it
With a store attached, files a code run hands back in-band (CodeResult.artifacts[].data — an additive field an adapter fills when it can return the bytes) are minted under the run's scope — kind file/<ext> from the producer's own filename, mediaType from the adapter's statement, a well-known-extension table, or the payload's shape — and the rendered result names the ref ([artifact: report.csv, 29 bytes, stored as art_… (file/csv) — route this ref…]). Since 9.38.0 each such mint also carries parentRefs stamped from the input refs this same call resolved (absent, never [], when none did). Entries without data stay described-only, exactly as before. No runner shipped here fills that field yet — localCodeRunner leaves it absent rather than guessing which files in the caller's own working directory were outputs; what a runner owes to produce them is spelled out in Artifact architecture. Since 9.26.0 the leg runs both ways: codeRunnerTool({ wants }) stages the resolved payload INTO the session as a file through CodeSession.stageInputs, so a dataset reaches the interpreter without one byte of it entering the prompt. See Staging refs into a code session.
Runnable version of the whole flow: examples/features/57-artifact-data-flow.ts — placement mints the 48k-row result, the ref rides a wants argument, and present finishes with the snapshot, end-to-end on the mock provider.
The HITL leg (9.24.0) — an ask's big props ride the store, not the checkpoint
A human ask can carry a typed component: { componentId, props?, propsRef? } — which registered screen component collects the answer. propsRef is this store doing for questions what wants does for tool arguments: a 200-option picker's options are minted via ctx.artifacts.put(...) BEFORE the ask is raised, the ask carries the ~26-char ticket, and the screen redeems it through the artifact wire under the session's own scope — the checkpoint and every stored session envelope stay lean. The ref is validated to resolve at raise time, so a dangling ticket refuses at its source instead of reaching the person answering. See Pause / Resume — typed asks and Check-in; runnable: examples/features/58-typed-hitl-component.ts.
Recordings as artifacts (9.26.0) — the Lens over the wire
A finished run already has a shape every viewer reads: { snapshot, events, structure }, produced by recordRun. What nobody shipped was the boring half
in between — where a completed run's recording goes so a screen can ask for it
later. Every deployment that wanted the Lens over the wire wrote the same twenty
lines, and each one wrote a slightly different, slightly wrong version of
retention and scoping.
All four of those problems already had an answer here. A recording is simply an artifact:
const agent = Agent.create({
provider, model,
artifacts: { store: sqliteArtifacts({ file: './artifacts.db' }), recordings: true },
}).build();
await agent.run({ message: 'Q3 by region?' });
// → one artifact of kind 'recording/run', in the run's own scope,
// with origin.runId joining it back to the trace.The frontend redeems it with the operation it already speaks:
{ op: 'artifact-get', ref } returns the recording, and
observeRecording(JSON.parse(text)) draws it. Zero new wire operations were
needed. Retention rides the store, so recordings age out under the same ttl
and byte budget everything else does, and a sweep reports itself on
agentfootprint.artifacts.expired exactly as any other eviction does.
recordings: true takes the default naming; AgentRecordingsOptions —
{ label } — names them yourself, and the label is used verbatim. A static
label repeats on every run on purpose: what distinguishes two recordings is the
ref and origin.runId, and a library that decorated your label to make it
unique would be overruling the name you chose.
What it costs, stated rather than discovered
Recording a run means an event tail and a boundary recorder for its duration,
and the mint is one store write on the way out of run(). The answer is
fully composed before the write begins and the write can never change it — but
run() does return after it rather than before. That is deliberate: a
fire-and-forget write is a recording lost whenever the container exits with the
reply, which is exactly the deployment that wants this most.
A mint that fails degrades to the old behaviour: the answer is returned
unchanged and the reason lands on the record as
agentfootprint.artifacts.refused. A run never fails because its recording
could not be filed. Nothing is minted for a run that paused (the turn is not
over — the resume mints its own) or threw.
The payload is the recording's JSON text — the same bytes
JSON.stringify(recorder.toRecording()) produces. recordRun states that
snapshot and structure are the runner's own objects held by reference, and
an in-process store handed those would keep a live view into a finished run's
state; serializing detaches it, once. A recording JSON cannot carry (a cyclic
snapshot) is refused at the mint by UnserializableRecordingError rather than
at whatever tried to read it.
Unset — the default — no recorder is attached, no events are captured, nothing is minted, and the run is byte-identical to every earlier release.
For custom harnesses the pure half is public: recordingPutInput(recording, facts) turns a recording into the PutArtifactInput that stores it, taking
RecordingMintFacts ({ runId?, label? }); RECORDING_ARTIFACT_KIND
('recording/run') and RECORDING_MEDIA_TYPE ('application/json') are the
constants a consumer branches on.
ctx.artifacts — shaped exactly like ctx.credentials
Every tool's execute context carries ctx.artifacts (a ToolArtifacts) and ctx.hasArtifacts:
- Always present, fail-closed. With no store attached, every method throws a teaching refusal naming the fix (
Agent.create({ ..., artifacts })) — a missing store can never read as an empty one. Branch onctx.hasArtifactsfor an intentional degraded mode. - Scope is composed by the framework, never by the tool. The capability closes over the run's own tenant/principal/conversation tuple (the same
ArtifactScope— an alias of the memory identity tuple — that memory scopes on). A tool cannot name, widen, or replace the scope it resolves under. originis stamped from the run's facts. Every mint carries{ runId, toolCallId }(ArtifactOrigin) — the join to the causal record. A caller-supplied origin is discarded.- Zero-cost when unused. No store attached ⇒ byte-identical behavior and events. Nothing fires unless a tool actually calls the capability.
The port — five verbs, no more
ArtifactStore is the vendor-neutral port every adapter implements. Scope is always the first argument (the MemoryStore constitution):
| verb | returns | law |
|---|---|---|
put(scope, input) | ArtifactPutResult — the minted ArtifactMeta + every SweptArtifact retention evicted | validates input (PutArtifactInput), proves parentRefs, measures bytes, stamps expiresAt |
head(scope, ref) | ArtifactMeta | null | the ticket without the bytes — the render-by-ref decision |
get(scope, ref) | ArtifactRecord | null | meta + payload; verifies digest when present |
delete(scope, ref) | void | removing an absence is agreement, not an error |
list(scope, options) | ArtifactListResult | cursored pages (ArtifactListOptions), newest first, meta only |
get and head return null for missing or expired — the same deliberate ambiguity as memory: "no data" is the only actionable fact, and distinguishing the two would let a caller probe another scope's contents. The record still tells the truth (see events below).
The refusal, written down so it can be cited: the port will never grow query() or transform(). The moment it does, this is a database. Compute over artifacts belongs to generated code (a later phase); five verbs is the ceiling.
…and two OPTIONAL streaming members (9.25.0)
A claim check exists so a 6 MB result never rides the conversation. It does not follow that it should ride the process: putStream and getStream let a payload move without either side holding it whole.
They are optional members, not a sixth and seventh verb — a store that cannot honor the promise leaves them absent rather than faking it, and you feature-detect with narrowing guards:
import { canPutArtifactStream, canGetArtifactStream, canStreamArtifacts } from 'agentfootprint';
if (canPutArtifactStream(store)) {
await store.putStream(scope, { kind: 'report/csv', mediaType: 'text/csv', bytes: 4_096 }, body);
}Calling an undetected member is a type error, not a runtime surprise. Each guard narrows: canPutArtifactStream proves the store really has putStream, canGetArtifactStream proves it has getStream, and canStreamArtifacts narrows to StreamingArtifactStore when you need the round trip. After a guard the call compiles with no ! and no cast, so the honest branch is also the comfortable one.
| store | streams? | why |
|---|---|---|
fileArtifacts | yes | bytes go to a sibling <ref>.bin as they arrive |
s3Artifacts / gcsArtifacts | yes | the SDKs' own streaming upload/download |
sqliteArtifacts | no | node:sqlite reads and writes a BLOB whole — a "stream" here would buffer everything and call it a stream |
inMemoryArtifacts | no | it holds payloads whole under a byte budget; streaming into it would defeat both |
A streamed put (ArtifactStreamPutInput) costs you exactly two things, both stated rather than silently traded:
bytesis required — it is stated, not measured. Retention has to plan an eviction and an object store has to declare a content length before the first chunk arrives. A payload that does not match what it declared is refused, never stored under a meta that misdescribes it.- there is no
digest. A digest covers the whole canonical payload, computed with the one primitive every adapter shares; a store that never holds the payload cannot produce one, and an incremental hash computed a second way would be a different promise wearing the same field name.getremains the verifying read —getStream(anArtifactStreamRecord) does not re-verify, because verification needs the whole payload, which is the thing streaming exists to avoid.
ctx.artifacts does not grow streaming this phase — a decision, not an omission. A tool receives its artifacts pre-scoped and answers a model; the model cannot hold a stream. A tool that genuinely moves gigabytes holds the store (it already constructed it) and calls it directly, while ctx.artifacts stays the small, scope-bound surface a model-driven tool can be trusted with.
The ref is minted, never derived
An ArtifactRef is art_ + 22 crypto-random chars (~26 total — ARTIFACT_REF_PREFIX, mintArtifactRef, isArtifactRef). It is never content-addressed: the same bytes stored by two tenants must be two objects, and a digest can't name two generations of "the current dataset". The content digest (sha-256, computed at put when you ask) is metadata — it buys integrity checking on get (a mismatch throws ArtifactIntegrityError rather than delivering corrupt bytes as whole) — but it is never the key.
Security, day one
A ref alone opens nothing. Resolution takes the ref and the caller's scope. A ref pasted into a log, a model response, or a bug report is safe by construction — under any other session's scope it resolves to null. An anonymous run scopes to its own runId (the descriptor table dies with the process); a session-bound run to its sessionId (turn two of the session redeems turn one's tickets); an identity-carrying run to the caller's tenant/principal.
Bytes never appear in events. Every agentfootprint.artifacts.* payload carries metadata only.
Five adapters
import { inMemoryArtifacts, fileArtifacts, sqliteArtifacts } from 'agentfootprint';
import { s3Artifacts, gcsArtifacts } from 'agentfootprint';
inMemoryArtifacts() // dev/tests — bounded, drop-counting
fileArtifacts({ directory: './artifacts' }) // one legible JSON file per artifact
sqliteArtifacts({ file: './data/artifacts.db' })// one SQLite file — pairs with sqliteSessions
s3Artifacts({ bucket: 'my-agent-artifacts' }) // a fleet, in an S3 bucket
gcsArtifacts({ bucket: 'my-agent-artifacts' }) // a fleet, in a Cloud Storage bucketOne ladder, one contract: a tool that stored a dataset against the in-memory store stores it in a bucket by swapping one constructor. The five run the same contract suite — a cloud column that drifts from the port fails the shipped adapters' own tests.
inMemoryArtifacts(anInMemoryArtifacts) is always bounded: 32 MiB and 256 artifacts per scope by default (DEFAULT_IN_MEMORY_ARTIFACT_RETENTION, dials viaInMemoryArtifactsOptions), least-recently-used evicted first (reads refresh recency), and it counts its drops (store.dropped) — "we kept the last 200 of 340" is an answer; a silently missing ref is not.fileArtifacts(FileArtifactsOptions) partitions scopes into percent-encoded directories, so..and/in a tenant name arrive as data and land as literal directory names — never as navigation. A file that exists but cannot be read is refused by name (UnreadableArtifactFileError), never reported as absent.s3Artifacts(S3ArtifactsOptions) andgcsArtifacts(GcsArtifactsOptions) put artifacts in somebody else's bucket — the third rung of the same ladder. Both partition scopes into percent-encoded key segments using the same encoding law as the directory adapter (a tenant of literally..is a name), carry the ticket as one object-metadata entry of ASCII JSON, store the payload as the canonical bytes of the object body (so a stored report is downloadable with the vendor's own console and is the report), and answernullonly for a missing object — a denied, throttled, or misdirected call is an error, never "no data". A not-found is only "no data" when the call asked about one object: a 404 from a write or a listing means something the caller never asked about is wrong (most often that the bucket does not exist), so it raises with the SDK's own text withheld rather than being swallowed as an empty scope. Their statuses differ and are stated separately rather than averaged:s3Artifactsis contract-shaped and tested — no live call has been made against S3 from this repository — whilegcsArtifactsis field-validated, an independent field trial having run nine live checks against a real bucket in 2026-08 (scope isolation, cursor pagination, native streaming, TTL expiry, budget eviction and the sanitized 404). One caveat travels with that:@google-cloud/storageis an optional peer whose tree carries five transitive moderate advisories, and thenpm audit fix --forceresolution is a major DOWNGRADE this library will not take — read the advisory on Google Cloud before you inherit it. See AWS and Google Cloud for keys, costs, and how to align the operator's lifecycle rules.sqliteArtifacts(aSqliteArtifacts,SqliteArtifactsOptions) follows thesqliteSessionslaws line for line: lazynode:sqlite(no install; refused by name on Node without it), WAL with the journal mode read back, STRICT tables you can inspect from thesqlite3command line,':memory:'refused, and a file that is somebody else's schema — or a newer one — refused asUnreadableArtifactStoreErrorrather than half-read. Point it at a file beside yoursqliteSessionsstore and artifacts live exactly as long as the conversations that can speak their refs.
Retention — expiry is stated, never sprung
ArtifactRetention is three per-scope dials on any adapter: ttlMs, maxBytesPerScope, maxCountPerScope (ArtifactSweepReason names which one fired). The law: expiry is stated at mint — a ttl stamps expiresAt on the meta, so a consumer can reason about lifetime instead of discovering it. The store may tighten a caller's own expiresAt, never extend it. Budget evictions are reported by the put that forced them and land on the record; a payload larger than the whole byte budget is refused (InvalidArtifactError) instead of emptying the scope to admit it.
Derivation is a fact, not an engine
parentRefs on a mint records what an artifact was computed from — validated at mint, so a parent that does not resolve in the same scope is refused (UnknownParentRefError): a foreign key that cannot dangle at birth. There is deliberately no lineage-graph engine — walking parents is a fold over head(), and the moment your question becomes "why does this chart disagree with that dataset," that is causation, which the trace already owns.
The lifecycle, on the record
Six typed events (domain wildcard: agent.on('agentfootprint.artifacts.*', …)):
| event | payload | fires when |
|---|---|---|
agentfootprint.artifacts.minted | ArtifactMintedPayload — ref, kind, bytes, origin, parents, tool? | a tool checked a payload in (or placement did it for one); tool is absent when no tool did — the run's own recording, or a host filing for its turn through HostReply.turnArtifacts |
agentfootprint.artifacts.resolved | ArtifactResolvedPayload — ref, via: 'head' | 'get' | a ticket was redeemed — by a tool, or by dispatch resolving a wants argument |
agentfootprint.artifacts.expired | ArtifactExpiredPayload — ref, reason (ttl / max-bytes / max-count) | retention swept one |
agentfootprint.artifacts.refused | ArtifactRefusedPayload — op, reason, ref? | a verb refused — no store, missing-or-expired, unknown parent, digest mismatch, invalid input, kind mismatch; op: 'dispatch' marks the framework's own door (wants / present) |
agentfootprint.artifacts.presented | ArtifactPresentedPayload — ref, as, snapshot | the model handed a ref to the screen |
agentfootprint.artifacts.hand_over_failed | ArtifactHandOverFailedPayload — cause (hook / operation / timeout / abort / expired), op?, errorClass?, errorCode? | a host's own filing for a turn (HostReply.turnArtifacts) did not go cleanly — class only, stamped with its session; the reply was delivered regardless |
This is what separates the store from plain "where are the bytes" systems: every mint, hop and refusal lands on the same causal record as the rest of the run, so "where did this number come from?" is a query, not an archaeology dig.
For custom wiring outside the Agent (your own harness, tests), the binding layer is public: bindArtifacts(store, scope, options) (a BindArtifactsOptions with origin + an ArtifactEventSink receiving each ArtifactEventFact — op vocabulary ArtifactOp, refusal vocabulary ArtifactRefusalReason, put shape ToolArtifactPutInput) and unconfiguredArtifacts(), the fail-closed teacher itself. origin is the binding's field on every path: a caller-supplied origin on a put is dropped, and the binding stamps its own — or none, when it was given none.
Decisions worth knowing
- One store per agent, attached at construction (
Agent.create({ ..., artifacts })— bare store, or{ store, placement }) — there is no second door, so "one per agent" is a fact of the type. - No snapshot coupling.
getSnapshot()is unchanged; the store's own accounting islistplus the adapters' counters. Stated so the absence reads as a decision. - The hosting half of render-by-ref is live (9.23.0): a served agent answers
artifact-head/artifact-geton its invoke path, so a screen redeems tickets under the requesting session's identity — see Redeeming claim tickets. - The reference architecture is complete as of 9.25.0: typed HITL payloads landed in 9.24.0, and the cloud adapters, the optional streaming leg and the skill artifact vocabularies landed here. Since 9.26.0 the data legs run both ways: staging refs INTO code sessions landed with
CodeSession.stageInputs, and run recordings are artifacts the existing wire ops serve. The lens leg shipped too, in a separate package —agentfootprint-lens0.34.0 carries the resolver, a component registry keyed on the artifact'skind, and a pane that renders an honest placeholder when a ref no longer resolves. Nothing validatesas, because nothing selects on it; the names and the reasoning are in Artifact architecture.
Skill vocabularies — which kinds a skill makes and needs
An artifact kind is a consumer vocabulary. As of 9.25.0 a skill can declare which kinds it produces and consumes, so the data legs of a run are checkable before the run:
defineSkill({
id: 'charting',
consumes: ['dataset/rows'], // what must have arrived
produces: ['chart/spec'], // what it leaves behind
steps: [{ tool: 'render', note: 'draw it', consumes: ['dataset/rows'] }],
});These are declarations, not machinery — nothing at run time reads them, and a skill that declares none is byte-identical to one that never heard of them. What they buy is a build-time warning (artifact-kind-unsatisfied) when a consumer needs a kind nothing on the agent claims to make, and a fact a lens can draw. The full rule — and the honest statement of what a static check cannot see — is on the Skills page.
Findings reference admission
Refuse citations that the accepted model request did not serve, without using another model.
Artifact architecture
The five-verb port, the three dispatch legs, and what a ref is allowed to move — every claim carrying a status, three named gaps, and one worked refusal taken from a real run.
