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.
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.
The data legs (9.22.0) — refs the model routes, not just holds
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). - 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 — 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.
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, deliberately unvalidated: the component registry that would check it is the frontend phase.- 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…]). Entries without data stay described-only, exactly as before. 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. Both ship as contract-shaped and tested; awaiting field use — no live call has been made from this repository. 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
Five typed events (domain wildcard: agent.on('agentfootprint.artifacts.*', …)):
| event | payload | fires when |
|---|---|---|
agentfootprint.artifacts.minted | ArtifactMintedPayload — ref, kind, bytes, origin, parents | a tool checked a payload in (or placement did it for one) |
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 |
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.
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 component registry that validatesas(the lens leg) remains a later phase.
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.
Tools
defineTool — flat builder for the Tool interface. JSON schema in, async execute out. Drop into agent.tool() or pull a whole MCP server's surface via agent.tools().
Tool providers
staticTools + gatedTools — chainable tool dispatch primitives. Compose permission gating + per-skill tool filtering at the dispatch boundary, not inside each tool's execute.
