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().
Your agent needs to look up an order. The LLM doesn't know your order database — it knows what you tell it via the tool's JSON schema. Tools are the contract between LLM intent ("call this with these args") and your code ("here's what happens"). Get the schema right and the LLM uses the tool well; get it wrong and you debug schema-vs-args mismatches at 2 AM.
What a tool is
A Tool has two parts:
{
schema: { name, description, inputSchema }, // what the LLM sees
execute: async (args) => result, // what runs when LLM calls it
}The inputSchema is a JSON Schema describing the args the LLM should produce. It's the LLM's contract — it generates well-formed args based on that shape.
defineTool — the flat builder
defineTool is a flatter helper that puts name + description + inputSchema at the top level instead of nested under schema:
import { defineTool } from 'agentfootprint';
const lookup = defineTool<{ orderId: string }, string>({
name: 'lookup_order',
description: 'Look up an order by ID',
inputSchema: {
type: 'object',
properties: { orderId: { type: 'string' } },
required: ['orderId'],
},
execute: async ({ orderId }) => `Order ${orderId}: shipped, $299`,
});
const agent = Agent.create({ provider }).tool(lookup).build();Type parameters <TArgs, TResult> flow into execute so your handler is fully typed.
Example — agent + tool registered inline
const agent = Agent.create({ provider: provider ?? exampleProvider('feature', { respond: weatherRespond }), model: 'mock', maxIterations: 5, // reactMode: 'dynamic-grouped' wraps the LLM turn in an sf-llm-call subflow, // so Lens renders the agent's reasoning as an LLM group with its context // slots (system-prompt / messages / tools) nested inside — the SAME shape // the LLMCall primitive shows — instead of a bare "Final · RUNNER" card. reactMode: 'dynamic-grouped',}) .system('You answer weather questions using the `weather` tool.') .tool({ schema: { name: 'weather', description: 'Get current weather for a city.', inputSchema: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'], }, }, execute: async (args) => `${(args as { city: string }).city}: sunny, 72°F`, }) .build();.tool(...) accepts both shapes (flat {schema, execute} or defineTool output) — the agent doesn't care which.
Bulk-register from MCP
Pull a whole MCP server's tool surface into the agent in one call:
// Connect once at startup. In production: use a real transport.const fileServer = await mcpClient({ name: 'file-server', transport: { transport: 'stdio', command: 'npx', args: ['fake-mcp'] }, _client: fakeServer, // ← test injection; remove for real MCP});// Agent picks up all the server's tools at once.const agent = Agent.create({ provider: provider ?? mock({ reply: '/tmp/notes.md and /tmp/todo.txt are present.' }), model: 'mock', maxIterations: 1,}) .system('You answer file-system questions using the MCP tools provided.') .tools(await fileServer.tools()) .build();agent.tools(arr) is the bulk-register companion to agent.tool(t). Tool-name uniqueness is validated at registration time — .tool() (and .tools(), which calls it per entry) throws Agent.tool(): duplicate tool name '<name>' the moment a collision is registered, so an MCP-imported tool clashing with a manually-defined one fails fast. (Collisions between static tools and Skill-injected tools are caught later, at .build().)
For mock-first development of MCP integrations without spawning a subprocess, use mockMcpClient({ tools }) — same McpClient interface, in-memory implementation. See Tool discovery for the full MCP surface.
The other direction is mcpServe(tools) — expose the tools you already wrote AS an MCP server, so another team's agent or a desktop MCP host can call them.
Argument validation — a declared shape is an enforced shape (9.64.0)
Before a call is dispatched, its arguments are checked against the tool's own
inputSchema: types, required fields, enum values — and, since 9.64.0, the
three string-shape keywords pattern, minLength and maxLength.
Why. In a recorded triage turn, a tool result ended with an offer — "I can also map these WWNs to volume names" — and the person answered "yes please". The model bound those two words AS THE WWN ARGUMENT and dispatched. The tool refused it correctly, but only after a round trip that told the person their word was not a WWN. The tool's schema knew exactly what a WWN looks like; the validator just never read that part. Now it does — the call dies at the door, and the refusal teaches:
Invalid arguments for tool 'lookup_wwn' — the call was not executed.
- 'wwn': expected a string matching ^[0-9a-f]{2}(:[0-9a-f]{2}){7}$, got "yes please"
'wwn' is described as: A world-wide name: eight colon-separated hex pairs.
Fix the arguments to match the tool's input schema and call it again.The parameter's own description rides the refusal — that sentence is where
you write what a valid value LOOKS like, and it is precisely the correction the
model needs. Declare shapes for anything identifier-like:
inputSchema: {
type: 'object',
properties: {
wwn: {
type: 'string',
pattern: '^[0-9a-f]{2}(:[0-9a-f]{2}){7}$',
description: 'A world-wide name: eight colon-separated hex pairs.',
},
},
required: ['wwn'],
}The dial. String-shape enforcement rides the existing toolArgValidation
option, identically to type/enum/required: 'enforce' (the default — the call
is refused and the model reads the teaching message), 'warn' (the
agentfootprint.validation.args_invalid event fires with enforced: false
and the call proceeds), 'off' (nothing runs, byte-identical to before).
If you already declare patterns and run the default dial, 9.64.0 starts
enforcing them — that is the fix, stated loudly.
Three edges, stated: a pattern that is not a valid regex is treated as
unconstrained with a one-time dev warning (a schema typo must never crash
dispatch); when both pattern and a length bound would complain, only the
pattern speaks (one mistake, one sentence); and the refusal quotes the
offending value capped at 80 characters — the value is the model's own
argument, already verbatim in the same history, while structural errors
(wrong type, missing field) echo nothing, and telemetry projections stay
value-free.
When a tool throws
When execute throws, the framework catches it, reports it to the LLM as a tool error, and continues the loop. The LLM sees the error message and can decide to retry with different args, try a different tool, or surface to the user. See Error handling for the typed error contract + retry decorators.
Tool sessions — holding something across calls
Some tools are not one operation. A managed code interpreter, a headless browser, a leased database connection: they are Start → Invoke ×N → Stop, and the middle is where all the value is. Starting a fresh session per call can cost seconds; keeping one in a module-level variable is fast and wrong.
Why it is wrong. A
Toolis a singleton — built once, shared by every run and every session your process serves. A session held in its closure is therefore shared too. In a standing agent, person B gets person A's files, environment and half-run state. That is not a memory leak; it is an isolation failure that no test with one user will ever show you.
Since 9.7.0 the context carries what a tool needs to do this properly.
What ctx tells you
| field | is | absent when |
|---|---|---|
ctx.runId | the run this call belongs to | there is no run — a call served over mcpServe is one call, not a turn |
ctx.sessionId | the hosting conversation, when bound to one | the run is not session-bound |
ctx.identity | the identity the CALLER passed | the caller passed none |
ctx.teardownScopes | which scopes this door can honour | never — [] means none will ever fire |
Every one is absent rather than invented when the fact is absent. That is load-bearing: a session keyed on a fabricated id is shared by everyone who gets the same fabrication.
ctx.identity is specifically what the caller passed, not the run's internal
runIdentity — that one always exists (it defaults to
{ conversationId: '<runId>' }), and handing a synthesized conversation to a
tool as "the identity" would let it isolate on a fiction.
Derive the key, register the cleanup
import { defineTool, toolSessionKey } from 'agentfootprint';
const query = defineTool<{ sql: string }, string>({
name: 'query',
description: 'Run SQL against the analytics warehouse',
execute: async ({ sql }, ctx) => {
const key = toolSessionKey(ctx, 'run');
if (!key) throw new Error("query: this door has no run — build with scope 'call'");
let session = live.get(key);
if (!session) {
session = await warehouse.connect();
live.set(key, session);
}
// Registering on EVERY call is the intended shape: the first cleanup wins
// (it holds the handle) and the repeat refreshes liveness.
ctx.onTeardown?.(() => { live.delete(key); return session.close(); }, { scope: 'run', key });
return session.run(sql);
},
});toolSessionKey is exported because the derivation is the isolation
boundary — one implementation, or two that disagree:
session → t=<tenant|_>/p=<principal|_>/s=<sessionId> requires sessionId
run → t=<tenant|_>/p=<principal|_>/r=<runId> requires runId
call → c=<toolCallId> always availableA sessionId alone never keys a live session. It is caller data: anyone who
can reach your host can put any string there, including someone else's. Tenant
and principal are in the key whenever they exist — and a deployment that has no
principal is thereby stating it is single-principal rather than assuming it.
When the facts a scope needs are missing, toolSessionKey returns undefined
rather than guessing, and you refuse. Do not silently narrow or widen: widening
is the cross-binding bug, and narrowing is a hidden 30× latency change nobody
sees until the bill.
When teardown fires
| scope | fires | available at |
|---|---|---|
'call' | when execute settles — resolve or throw | every door, including mcpServe |
'run' | when the turn reaches a terminal that is not a pause | any run |
'session' | when you call agent.closeToolSessions({ sessionId }) | any door |
'shutdown' | agent.shutdown() — including { stop: false } | always |
A pause is not a terminal. A checkIn on a consequential tool stops the run
so a person can decide; tearing down there destroys the state the resume needs,
and it fails quietly — as a resumed run that "just re-ran everything". Both
pause shapes are skipped. An error, by contrast, is a terminal: nobody is
coming back, and a resource held by a crashed run is the clearest kind of leak.
shutdown({ stop: false }) closing tool sessions is deliberate. stop governs
borrowed strategies — telemetry a host was handed and does not own. A session
is not borrowed: this runtime opened it, and nobody else holds a handle to close
it. Draining without closing would leak every sandbox on standingAgent's
default path.
Who says a session ended
Nothing in this library can know when a request/reply session is over. A
HostRequest carries a sessionId and no end; SessionLifecycle is
hydrate/persist by design; and managed backends do not tell you either — an
idle timeout is the reality. So the mechanism is the library's and the timing
is your composition root's, the same doctrine that stops shutdownOn from
grabbing signals by default. On the conversation door it is one line:
conversation.onClose(() => void agent.closeToolSessions({ sessionId }));Never calling it is survivable, not silent: sessions idle out on a lazy sweep
(no timers — a library that installs an interval keeps your process alive), a
bounded live count evicts the coldest, and shutdown() takes the rest.
The record it leaves
Four events on the existing agentfootprint.tools. stream:
agentfootprint.tools.session_started, agentfootprint.tools.session_reused
(with calls — how many calls have shared one start-up, which is the payoff
measured), agentfootprint.tools.session_closed (with reason), and
agentfootprint.tools.session_close_failed (with errorClass). Teardown never throws into your run — but
it is never silent either, and that last one is the difference: a vendor Stop
that failed leaves something you are still paying for.
Payloads carry a keyHash, never the key: the key composes tenant, principal
and sessionId, and publishing it would put a user identifier into every
exporter's payload.
A ready-made tool that does all of this — codeRunnerTool — is in
Tools and gateways.
The names
Everything here is on the main barrel.
| Name | What it is |
|---|---|
TeardownScope | The four scopes: 'call' · 'run' · 'session' · 'shutdown'. |
TeardownOptions | The second argument to ctx.onTeardown — scope, key, and the optional runnerId / label that ride the events. |
TeardownReason | Why a cleanup ran, as reported on session_closed: call-end · run-end · session-end · shutdown · idle · evicted. |
toolSessionKey(ctx, scope) | The one key derivation. undefined when the scope's facts are absent. |
hashSessionKey(key) | The digest the events carry. SHA-256 (12 hex chars) where node:crypto resolves, FNV-1a in a browser bundle — never reversible to the key. |
TOOL_TEARDOWN_TIMEOUT_MS | The 5000ms default behind AgentOptions.toolTeardownTimeoutMs. |
ToolTeardownTimeoutError | Raised inside the tier when a cleanup outruns its budget; surfaces as errorClass on session_close_failed, never into your run. |
agent.closeToolSessions({ sessionId, reason }) | Ends 'session'-scoped cleanups. Answers how many ran; 0 on a runner that holds none. |
For codeRunnerTool specifically: CodeRunnerToolOptions is its options bag,
CodeRunnerToolScope is the 'call' | 'run' | 'session' subset it accepts
('shutdown' is when everything goes, not a thing to key one session on), and
toolSessionsOf(tool) reads the live-session map back for a test or an
inspector. That map rides the tool under the TOOL_SESSIONS registry symbol
(the shape HoldsToolSessions describes) — invisible to the LLM and to Tool's
own shape, and a different symbol from flowchartAsTool's inner-record
registry, so one tool can carry both.
Typed tool effects — steering the run with data (9.19.0)
Before 9.19.0 a tool that wanted to steer the run had exactly one medium —
its result string — and any convention riding it ("ROUTE:billing") was
arbitrary text one prompt injection away from control authority. The typed
effects channel replaces the convention with data the framework validates.
The law: push mandatory procedure; pull optional knowledge; never let
arbitrary text promote itself into control authority.
A tool opts in by returning the result envelope — { content, effects, status? }. content is what the model reads (exactly what a bare return
would have shown); the rest is for the framework. The effects array is
required — it is the envelope marker itself: when only the status
matters, spell it { content, effects: [], status: 'denied' }. A
{ content, status } without effects is not an envelope (a domain
object could already have that shape) — it stays data byte-for-byte, and
dev mode warns you about the missing marker so the miss is never silent:
// A tool opts into the typed effects channel by RETURNING the envelope —// content for the model, status + effects for the framework. Plain// returns stay byte-identical; string conventions never become authority.const issueRefund = defineTool<Record<string, never>, unknown>({ name: 'issue_refund', description: 'Issue the refund for the looked-up order.', inputSchema: { type: 'object', properties: {} }, execute: () => ({ content: 'refund refused: order is outside the 30-day window (policy P-12)', // The declared OUTCOME — the `onToolStatus: 'denied'` edge below // routes on this, so a denial can never route like a success. status: 'denied', // Push the registered playbook into the NEXT call — the model reads // the denial with the playbook already in front of it. effects: [ { kind: 'require-instruction', instructionId: 'denial-playbook', deliveryLease: 'next-call', }, ], }),});// The pushed instruction must be REGISTERED — an unknown id is a// teaching refusal, recorded. It is inert on its own (`activeWhen`// false); only a granted lease ever delivers it.const denialPlaybook = defineInstruction({ id: 'denial-playbook', prompt: 'A refund was denied by policy. Explain WHICH policy, offer store credit, ' + 'and never promise an exception.', activeWhen: () => false,});Two effect kinds, deliberately:
-
{ kind: 'propose-transition', targetSkillId, reason }— the typed replacement for string routing markers. The graph decides: the target is reachability-checked against the graph's own law, an accepted proposal moves the cursor at the next evaluation (cursorMove.by: 'tool-proposal'), a refusal is teaching and recorded. Precedence is stated: a same-batch declared edge still wins (the author's determinism, reported asskill.reroute_superseded { source: 'tool-proposal' }), and a proposal outranks the model's ownread_skillpick (deterministic tool code over a model guess). Because proposals come from tools — code the author shipped — they are framework-tier evidence: admitted under every posture,railsincluded. Conflicting same-batch proposals reuse theroute_conflictlaw: first accepted in call order wins, the rest are suppressed on the record (source: 'tool-proposal').A proposal naming the cursor's own skill is a STAY (9.86.0). The tool asked for the state the run is already in, so there is nothing to move and nothing to refuse: it is accepted as a no-op — no cursor move, no pending transition, and no
[tool effect refused: …]suffix on the result. Theagentfootprint.tools.effectevent carries the ordinaryoutcome: 'accepted'plus an additivestay: true, so an exhaustive switch overoutcomein your own code keeps compiling. Before this it was refused as unreachable, because the graph's successor set excludes the cursor — right for a move, wrong for a stay. -
{ kind: 'require-instruction', instructionId, deliveryLease }— pushes a registered instruction (a skill body or a declared snippet) into the coming call(s).'next-call'serves exactly the next LLM call;'until-skill-exit'serves while the tenure that granted it holds — and when that tenure ends, the lease dies for good: on a cyclic graph the cursor may later re-enter the granting skill, and a dead lease does not come back with it (a fresh tenure needs a fresh grant).read_skillstays the pull door; this is the push door, and it serves the declared catalog only — an unknown id (or a'tool-only'body, whose declared channel cannot be pushed) is refused teachingly.
Outcome status, normalized. Beside the effects rides an optional
status: 'success' | 'failure' | 'denied' | 'invalid' | 'partial' | 'pending' — and route edges gain onToolStatus, the data form of "route
on meaning": .route(refund, escalation, { onToolReturn: 'issue_refund', onToolStatus: 'denied' }) fires only on a denied refund, never a
successful one. A result with no declared status can never match a status
edge (an undeclared outcome is not evidence). The status also rides
stream.tool_end and the toolResults batch, and route() refuses
when + onToolStatus together (code or data, never both).
Every judgment is a typed agentfootprint.tools.effect event —
accepted, refused (with the teaching sentence), or superseded — and
refusal notes join the model-visible result so the model can route around
them. Zero-cost when unused: recognition is strict (an envelope needs
content plus an effects array whose every element speaks the reserved
kind vocabulary; an empty effects: [] also needs one of the seven
statuses to say anything — { content } alone is data), so every shape
tools return today keeps its exact bytes and fires no new events.
The effects surface, named
All from agentfootprint:
ToolResultEnvelope— what a tool returns to opt in:{ content, effects, status? }(effectsrequired — status-only iseffects: []).ProposedEffect— the two-kind union;ProposeTransitionEffectandRequireInstructionEffectare its arms, andInstructionDeliveryLeaseis the'next-call' | 'until-skill-exit'union behind the push lease.ToolResultStatus— the closed seven-value outcome vocabulary;TOOL_RESULT_STATUSESis the same set as data.readToolResultEnvelope— the strict recognizer itself (exported so a custom runner or a test can apply exactly the framework's rule);ReadToolResultEnvelopeis its result: unwrappedcontent, the valideffects, thestatus, and themalformedrefusals.explainStatusOnlyNearMiss— the recognizer's teaching companion: given a value that is not an envelope, it returns the warning sentence when the shape is a status-only envelope missing itseffects: []marker ({ content, status: 'denied' }), andundefinedfor everything else. The framework calls it for its own dev-mode warning; it is exported so a custom runner can teach the same miss. Diagnosis only — it never changes what any value does.PendingToolTransition— the accepted proposal as it rides scope state (sharedState.pendingToolTransition): target, proposing tool, reason, and the granting iteration (one-shot by data).InstructionLease— one granted push as it ridessharedState.instructionLeases: the instruction, its lease, the tenure that granted it, and the granting call.
An absence that names its own coverage — absent()
A tool that finds nothing returns something: an empty array, a null, a
sentence. From any of those a model cannot tell "I looked and there is
nothing" from "I could not look" — and the confusion is not symmetric. A
nothing-found misread as an outage sends an engineer to investigate a healthy
collector: expensive, and self-correcting. An outage misread as
nothing-found declares a system healthy that was never checked: cheap,
silent, and wrong in the direction that hurts.
Both this and the coverage ledger below came from FIELD USE — a production triage agent had invented them because the framework had no answer for either.
import { absent, defineTool } from 'agentfootprint';
export const flogiForPort = defineTool({
name: 'flogi_for_port',
description: 'FLOGI entries for one interface',
inputSchema: {
type: 'object',
properties: { switch: { type: 'string' }, port: { type: 'string' } },
required: ['switch', 'port'],
},
execute: ({ switch: sw, port }) => {
const rows = fcns.flogi(sw, port);
if (rows.length > 0) return rows;
return absent({
what: `FLOGI entries on ${port}`,
checked: [`${sw}: the live fcns database`, 'window: the last 24h'],
notChecked: [{ what: 'the archived FLOGI history', why: 'older than the 24h window' }],
cannotCover: [
{ what: 'the peer fabric', why: 'this collector is scoped to one fabric' },
],
tryInstead: 'Ask for a different interface, or query the peer fabric by name.',
});
},
});An AbsenceDeclaration needs what (what was looked for) and a non-empty
checked: an absence that names no coverage is a null with extra steps, and
it is refused where you typed it. notChecked and cannotCover take the same
entries, a CoverageInput — a bare string or a CoverageItem
({ what, why }). Every cannotCover entry needs a why, because a permanent
blind spot is a claim about capability and a reader cannot act on, escalate or
disprove one with no reason.
What the framework does with it. The returned ToolAbsence is
recognized, not merely conventional — a shape the framework does not
understand cannot stop a retry loop or keep a value out of the evidence corpus:
- the delivered status becomes
'absent', the seventhToolResultStatus, so anonToolStatus: 'absent'route edge can send "we found nothing" somewhere other than "the call broke"; agentfootprint.tools.absentfiles the coverage on the record;- the model reads a frame that says, in prose and as data, that a retry with the same arguments returns the same result — the loop this ends is a model re-asking an identical question because a mismatch looked like a fluke;
- and the evidence gate indexes everything the tool authored except
looked_for. The coverage lists, thetryInsteadsentence, and any extra key you attach to the envelope — aknown_shareslist of the real names on the filer, say — are the tool speaking about the world, so an answer that takes the absence's own advice is grounded by it.looked_foris the one field whose job is to quote what was asked for, which in practice quotes the arguments the model passed; indexing that would ground an invented identifier through the one operation that proves nothing about it.
Nothing else changes: no error: true, no retry, no refusal, no gate flag.
What a clean result does not rule out — coverage()
"Everything looks fine" arrives with no way to tell whether fine means verified or unexamined. A ledger is three lists the tool knows and the model does not — and only the tool knows the third:
import { coverage, defineTool } from 'agentfootprint';
export const replicationHealth = defineTool({
name: 'replication_health',
description: 'Replication health across the estate',
inputSchema: { type: 'object', properties: {} },
execute: async () => {
const { verdict, ndmTimedOut } = await checkReplication();
return coverage(verdict, {
checked: ['SRDF pair state on all 4 arrays (live query)'],
notChecked: ndmTimedOut
? [{ what: 'NDM migration sessions', why: 'the API timed out — ask again' }]
: [],
cannotCover: [
{ what: 'host-side multipathing', why: 'no collector runs on the ESX hosts' },
],
});
},
});It is the sibling of the evidence gate: the gate catches invented values, a
ledger catches unstated limits. The CoverageDeclaration you pass must say
something — all three lists empty is refused, because a ledger that declares
no boundary looks like one while telling a reader nothing. The CoveredResult
it returns puts the boundary first and leaves your own answer untouched under
result; agentfootprint.tools.coverage_declared files it on the record, and
sharedState.coverageDeclared accumulates every DeclaredCoverage the run
produced — from ledgers and absences alike.
Making the limits survive — .limitsTravelWithTheAnswer()
A ledger the model can drop is worthless, and dropping it is invisible: an answer with no caveat and an answer whose caveat was omitted read identically. So this option does not ASK the model to state its limits — it appends them:
const agent = Agent.create({ provider, model })
.tool(replicationHealth)
.limitsTravelWithTheAnswer()
.build();
await agent.run('is replication healthy?');
// Replication is healthy. Everything looks fine.
//
// ---
//
// Coverage of this answer — declared by the tools that produced it, not by the model:
//
// Checked:
// - SRDF pair state on all 4 arrays (live query)
//
// Not checked:
// - NDM migration sessions — the API timed out — ask again
//
// Cannot cover:
// - host-side multipathing — no collector runs on the ESX hostsThe framework composes the block from what the tools declared, so the model cannot drop what it never wrote. It is not a check on the model's prose: it does not judge whether the answer stated its limits and does not refuse one that did not — both would need a second model to decide what counts as "stated". Duplicate entries fold, so five tools naming one missing collector say it once.
Off by default, because it changes the bytes of your answer; the RECORDING half runs either way, so you can measure how often your tools declare limits before you ship them to readers.
The coverage surface, named
All from agentfootprint:
absentandcoverage— the two doors, described above.CoverageItem,CoverageInput,Coverage— one piece of ground ({ what, why? }), what you may write in a list, and the normalized three-list shape everything downstream reads.AbsenceDeclarationandCoverageDeclaration— what you pass to each door.ToolAbsenceandCoveredResult— what each door returns, i.e. the exact values your tool hands back.DeclaredCoverage— one statement as the RUN recorded it, with the tool, the call and the iteration; this is the row type insharedState.coverageDeclared.readAbsence,readCoverageLedgerandreadCoverageResult— the strict recognizers (exported so a custom runner or a test can apply exactly the framework's rule);CoverageReadingis the last one's result: the delivered status and the statements found.ABSENCE_MARKERandCOVERAGE_MARKER— the reserved keys (af_absent,af_coverage) that make each shape recognizable on the tool-result wire.ABSENCE_NOTEandCOVERAGE_NOTE— the static sentences each frame carries, exported because tests and readers match on them.COVERAGE_BLOCK_HEADING— the appended block's opening line, for anything that has to find or strip it downstream.
Reading the canonical notes from another language (9.70.0)
Not every tool is written in JavaScript. A sidecar in Python, Go or Rust that
mints one of these shapes has to reproduce the note and the marker byte for
byte, or the recognizer will not take its envelope and the note on the wire
will not be the sentence the docs promise. Field report: a Python sidecar did
the only thing the package made possible and regex-scraped the compiled ESM
under node_modules/agentfootprint/dist/esm at import time. Reading the value
rather than copying it was the right instinct; the door was wrong, and the
wrong door was ours.
The package now publishes the strings as data, at a stable path inside the installed package:
node_modules/agentfootprint/canonical-notes.jsonIt is also an exports entry (agentfootprint/canonical-notes.json), so a Node
script can hand a sidecar the resolved path rather than guessing at a layout:
const path = require.resolve('agentfootprint/canonical-notes.json');The whole scrape becomes one read:
import json, pathlib
notes = json.load(open("node_modules/agentfootprint/canonical-notes.json"))
ABSENCE_NOTE = notes["notes"]["ABSENCE_NOTE"]
ABSENCE_MARKER = notes["markers"]["ABSENCE_MARKER"] # "af_absent"
def absent(checked, not_checked=(), cannot_cover=()):
return {ABSENCE_MARKER: {
"checked": list(checked),
"not_checked": list(not_checked),
"cannot_cover": list(cannot_cover),
"note": ABSENCE_NOTE,
}}Three things are worth knowing about the file:
- Keys are the exported constant names, in three groups —
notes(ABSENCE_NOTE,COVERAGE_NOTE,SEMANTICS_NOTE),markers(the reserved keys) andheadings(COVERAGE_BLOCK_HEADING). The same value is reachable as a TypeScript import fromagentfootprint, so the two sides of a polyglot system quote one source. - It is generated at build time from the package's own compiled exports, never hand-maintained, so it cannot disagree with the code that ships. The test suite pins both directions: every value byte-equals its constant, and the packed tarball really carries the file.
- It holds no version. Read
package.json(also an exports entry) if you need one — a version copied into this file could be a release stale.
What is deliberately not in it: model-facing prose the library injects for itself, like the out-of-budget wrap-up instruction. No foreign process mints those, and publishing them would turn an interop contract into an inventory.
The result ceiling — refuse, never truncate (9.20.0)
A tool once returned ~191,000 characters. The tempting fix — truncate it — is the
fabrication trap: a truncated result reads as a complete one. The model cannot
tell the data ends where the cut happened, so it answers from the fragment as if it
were everything, confidently. resultCeiling is the tool author's contract that
prevents it: over maxChars, the model receives a teaching refusal instead of
data — the true size, the ceiling, the parameters to narrow by, and the sentence
that keeps it honest: "No data was returned." A clean retry follows, because the
refusal says exactly how to make one.
const exportOrders = defineTool<{ limit?: number }, string>({
name: 'export_orders',
description: 'Export orders as CSV rows. Pass limit to bound the export.',
inputSchema: { type: 'object', properties: { limit: { type: 'number' } } },
// Over 2 000 chars the model reads a refusal, not a truncation:
// "Result too large: export_orders returned 102442 chars, over its declared
// 2000-char ceiling. Narrow the request and call again — e.g. pass 'limit'.
// No data was returned."
resultCeiling: { maxChars: 2_000, narrowBy: ['limit'] },
execute: async ({ limit }) => fetchRows(limit),
});What the framework guarantees when the ceiling fires:
- The payload enters no channel. Not history, not
stream.tool_end, not any recorder — refused means refused everywhere. The record keeps the truth as the typedagentfootprint.tools.result_refusedevent:{ toolName, toolCallId, iteration, sizeChars, maxChars, narrowBy? }. - The result carries status
'invalid'— the closed-set member whose corrective action is "fix the call" (the tool itself did not fail, nothing partial was delivered, and no policy denied it) — so a skill-graph edge can route the overflow:.route(support, narrowDesk, { onToolStatus: 'invalid' }). - An effects envelope keeps its declared effects. When
{ content, effects, status }overflows its content, the content is refused but the declared effects are still judged — a tool that proposed a transition and overflowed its data does not lose the transition (the effects channel is validated data, not the channel that overflowed). The status the tool declared rides the event asdeclaredStatus; the delivered status is'invalid'. - A procedure step does not advance. The refusal's own instruction is to call again — a stepped skill's pointer holds.
- Every dispatch door refuses alike. The inline batch and every resumed dispatch (check-in approval, middleware ask, credential consent) measure at the same boundary.
- Zero-cost when unused. No
resultCeiling= nothing measured, no event, byte-identical results — including 9.19 envelope semantics.
The agent-level maxToolResultChars remains the other ceiling —
truncate with a verbatim head and a truncated marker — for operators capping tools
they did not write. Only the author knows which parameters make a retry smaller,
which is why narrowBy lives on the tool; the two compose (the refusal sentence is
far under any sane agent cap). The declared shape is the exported
ToolResultCeiling interface — { maxChars, narrowBy? }. A bad ceiling is refused
at defineTool — maxChars must be a positive whole number, and a narrowBy: []
that could suggest nothing is refused too (omit the field to say "no suggestions");
assertResultCeiling is exported for hand-built Tool objects.
With an artifact store attached there is a third dial in the
family: the placement threshold (artifacts: { store, placement: { maxInlineChars } }) — over it, the result is checked into the store and the model
reads a claim ticket. The stated precedence: the tool's resultCeiling first, then
placement, then maxToolResultChars last (which then measures the ticket).
That ticket is minted under a kind, and Tool.resultKind (9.70.0) is where the
tool author names it: resultKind: 'dataset/rows' makes the placed result
redeemable by a consuming tool that declares wants: { dataset: 'dataset/rows' },
where the framework's default tool-result/<toolName> would have been refused as a
kind mismatch — wants matches kinds exactly, by law. Declared, never inferred; a
blank kind is refused at defineTool (assertResultKind); omitted → the default
kind, byte-identical. The full argument is in
Artifacts.
wants — artifact refs as tool arguments (9.22.0)
The needs precedent applied to data. A tool declares which of its
arguments are claim tickets and what artifact kind each must redeem to; the model
passes the ~26-char art_… ref STRING; and at dispatch — before credentials,
before execute — the framework redeems the ref under the run's own scope and
kind-checks the meta. The handler receives the resolved data in args and the
tickets on 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' } }, // the model speaks the REF
required: ['dataset'],
},
wants: { dataset: 'dataset/rows' }, // …and the framework redeems it
execute: async (args, ctx) => {
const rows = args.dataset as unknown as Row[]; // the DATA, already resolved
const ticket = ctx.wanted?.dataset; // the ArtifactMeta behind it
return `total: ${total(rows)} (from ${ticket?.ref})`;
},
});What the framework guarantees:
- A stale, unknown, or wrong-kind ref never reaches the tool. The call is not
executed; the model reads a teaching refusal that lists the live refs of the
wanted kind in scope — correction by naming what can resolve. On the record:
agentfootprint.artifacts.refusedwithop: 'dispatch'; successful resolution ridesartifacts.resolved(via: 'get'). - Every dispatch door resolves alike — the inline batch and every resumed dispatch. Scope is the run's own (tenant/principal/conversation): a ref minted in another session resolves to nothing here.
- Declared honestly or refused at
defineTool: eachwantsargument must exist ininputSchema.propertiesastype: 'string'(assertToolWantsis exported for hand-builtToolobjects). An agent with a statically registeredwantstool and no store refuses at build;mcpServerefuseswantstools by name (that door has no store). - Zero-cost when unused. No
wants= nothing resolved,ctx.wantedabsent, byte-identical dispatch.
The full worked example — placement mints a 48k-row result, the ref rides a wants
argument, present hands the chart to the screen — is on the
Artifacts page and runnable as
examples/features/57-artifact-data-flow.ts.
Staging refs into a code session (9.26.0)
wants resolves a ref into an argument. For a code runner that is only half the
story: the resolved payload still has to reach the interpreter, and 9.22.0
stated the honest cut — the CodeSession port's only input was the code string,
so pushing a payload through it would mean inlining megabytes into an argv in
language-specific quoting.
CodeSession.stageInputs is the file-write verb that note was waiting for, and
codeRunnerTool({ wants }) is what uses it:
import { codeRunnerTool } from 'agentfootprint';
import { localCodeRunner } from 'agentfootprint/providers';
Agent.create({ provider, model, artifacts: { store } })
.tool(codeRunnerTool({
runner: localCodeRunner(),
language: 'python',
wants: { dataset: 'dataset/rows' },
}))
.build();The model passes the art_… ref as dataset; the framework resolves it under
the run's own scope (the same wants machinery, with the same teaching refusals
for a stale, unknown or wrong-kind ref); the tool writes the resolved payload
into the session as a file before the code runs. Data now flows both ways
without entering the context window: refs in as staged files, produced files out
as refs.
What the model's code reads
One environment variable, on every backend that stages. STAGED_INPUTS_ENV
(AF_STAGED_INPUTS) holds a JSON object keyed by argument name:
import json, os
path = json.loads(os.environ['AF_STAGED_INPUTS'])['dataset']
rows = json.load(open(path))The composed tool description says exactly that, with a one-line example in the
tool's own language, so a model needs nothing beyond the description. The
manifest key is the argument name (what the description told it to look up)
while the file gets an extension derived from the artifact's own media type —
CodeInput keeps name and fileName as separate fields precisely so the two
cannot drift. StagedCodeInput is what an adapter reports back: { name, path, bytes }.
Refused, never degraded
A runner whose sessions cannot stage — stageInputs absent, which is the honest
state of any backend that cannot write into its own session — makes a
wants-declaring code tool refuse by name at dispatch. Running the code
anyway would leave the model debugging a missing file, in a session that never
had the data, for a reason nothing in the conversation could reveal. Detect it
yourself with canStageCodeInputs(session).
Staged inputs live as long as the session and are released by stop(). On
localCodeRunner they land in a private temp directory, and a caller-supplied
name becomes one inert file-name segment — .. and separators arrive as data
and land as literals, the same law the artifact scope paths follow.
Zero-cost without wants: no schema properties are added, no session is ever
asked to stage, no filesystem module is loaded, and the description is the one
earlier releases composed.
The repeated-call nudge (9.26.0)
A traced production run: the model called one tool three times with byte-identical arguments and got a byte-identical result each time. The tool was doing its job — the arguments named a filter the backend silently ignored — and the model read the same rows as a fresh answer each iteration, concluded nothing had changed, and tried again. Three calls, three identical results, one wasted turn, and nothing in the loop that could say "you have done this".
That class of loop is invisible from inside the conversation: the history genuinely shows three separate calls that each returned data. The only party with the whole picture is the framework, which watched all three land. So on the second identical landing it appends one sentence to that result:
identical call: 'search' has now returned exactly this result 2 times this turn, for exactly these arguments. Calling it again will not change it — act on what you have, or change the arguments…
It is a note, not a refusal. The call ran, the result is unchanged beside the note, nothing errored, and a third identical call is not blocked. That restraint is the design: polling a job until its status changes is a loop of identical calls returning identical results on purpose, and only the model knows which it is doing.
Both halves are required. Identical arguments alone are not evidence — a "check status" call returning a different status is progress. It is the identical result that makes the repeat pointless, which is also what lets the note say something specific and true.
It fires once per distinct call, at the threshold landing exactly; a fourth identical call adds nothing further, because repeating the lesson every iteration would be the framework doing the very thing it is complaining about. It is applied at the batch dispatch loop only — the pause-resume paths deliver a call a person answered, and a note telling the model it has already done what a human just authorised would be the framework arguing with the human.
Set repeatedCallNudge: false on Agent.create to switch it off: nothing is
fingerprinted, no counter is kept, and even a repeating turn is byte-identical
to earlier releases. Worth doing when a deployment's tools are deliberately
polled.
Each note also lands on the record as agentfootprint.tools.repeated_call,
carrying { toolName, toolCallId, iteration, occurrences, argsFingerprint, resultFingerprint }. Fingerprints, never values — tool arguments routinely
carry the things redaction exists for, and a fingerprint answers the only
question this feature asks.
A turn that repeats nothing is byte-identical whether the dial is on or off
— same results, same events, same tracked state down to the key set. The
counters are held beside the dispatch loop, keyed by runId, and never written
to scope: a within-turn tally is not conversation state, and tracked state is
the commit log, the snapshot, the narrative and every recording. Upgrading
changes nothing until a call actually repeats. (A resume mints a fresh runId,
so a turn continued after a person answered starts counting again — the
framework only watched half of it.)
Progress from inside a long tool — ctx.progress (9.52.0)
A tool call is atomic on the record. agentfootprint.stream.tool_start fires,
your handler runs for as long as it runs, and agentfootprint.stream.tool_end
carries the result. For a tool that finishes in 200ms that is the whole story.
For a twelve-hop graph walk that takes forty seconds it is one long silence —
and from outside, a tool that is working and a tool that has hung look exactly
the same.
ctx.progress(payload) breaks the silence:
const walkGraph = defineTool({
name: 'walk_graph',
description: 'Walk the dependency graph from a root',
inputSchema: { type: 'object', properties: { root: { type: 'string' } }, required: ['root'] },
execute: async (args, ctx) => {
const hops = await plan(args.root as string);
for (const [i, hop] of hops.entries()) {
await visit(hop);
ctx.progress({ done: i + 1, total: hops.length, hop: hop.id }); // "hop 3 of 12"
}
return summarize(hops);
},
});Each call files one agentfootprint.stream.tool_progress event, in call
order, always between that call's tool_start and its tool_end:
agent.on('agentfootprint.stream.tool_progress', (e) => {
const { toolName, toolCallId, iteration, payload } = e.payload;
console.log(`${toolName} [${toolCallId}] →`, payload);
});Because the event name starts with agentfootprint.stream., it reaches a
browser with no extra wiring — toSSE(agent) already carries it, and so does
agent.on('agentfootprint.stream.*').
The framework stamps the identity; you own the payload. toolCallId,
toolName and iteration come from the dispatch the framework is already
holding, so a report can never claim to be from another call, and a UI can
correlate reports to the call they belong to without trusting the tool. Your
payload is forwarded verbatim — an object, a string, whatever shape your
consumer wants.
It is telemetry, not a result. Progress never enters the tool result, the conversation history, or anything the model reads; the model still sees exactly one result, at the end, as it always did. (This is the house rule that keeps retries, streaming and token counts on the emit channel too.)
The rules that make it safe to call from anywhere:
- Always present.
ctx.progressis neverundefined, so there is no optional-chaining dance. Doors with no event stream to file on — a call served overmcpServe, the offlinecallTraceToolcontext — supply a no-op, so the same handler is safe inside an Agent and outside one. - Never fatal. With nothing listening the report is dropped. It never
throws, never blocks (nothing is awaited), and never changes what
executereturns. payloadmust survivestructuredClone. It rides into every event sink and every recording, so plain data only — no class instances, no live handles, no functions.- Zero-cost when unused. A tool that never calls it produces exactly the
event stream it produced before this existed: no
tool_progressrows, nothing else moved.
The reports land in recordings with everything else, so "where did that forty seconds go?" is answerable from an archive months later, not only from a terminal someone happened to be watching.
What a person sees
The same report now moves the live status line — the one sentence
agent.enable.liveStatus(...) hands to whatever renders your chat bubble:
agent.enable.liveStatus({
strategy: chatBubbleLiveStatus({ onLine: (line) => setStatus(line) }),
});Because payload is yours and typed unknown, the display contract is narrow
and literal — one rule, no guessing:
| your payload | the line a person reads |
|---|---|
{ message: 'Hop 3 of 12' } | Hop 3 of 12 — your sentence, verbatim |
{ done: 3, total: 12 } | `walk_graph` reported progress (3 so far)… |
'a bare string' | the same generic line |
messageis the one field read. A top-level string field namedmessageis shown to the person verbatim, trimmed, and cut at 120 characters with the cut stated (… (+N more)). It is the field MCP's own progress notification uses, so a tool already speaking that protocol needs no second vocabulary. Nothing else in your payload is read — one tool'stotalis hops and the next one's is bytes, and a status line that said "3 of 12" about the wrong unit would be worse than one that said nothing.- Everything else gets the generic line. No
message, a non-string one, an empty one, a bare string payload: the person sees the tool's name, that it reported, and how many times. Your payload is never pretty-printed into that sentence. A status line is prose, and a tool's JSON is not a sentence anyone wrote. - One call, two faces. The structured payload rides to the record untouched
either way. Adding
messagedoes not remove your numbers — it adds the half a person can read. - Parallel calls stay apart. The line is keyed by
toolCallId: two calls running at once each keep their own count, the newest report wins the line and names the call that made it, and one call ending never closes its sibling. - Override the wording by template key.
tool.progress(themessagecase),tool.progress.generic(everything else), or per tool withtool.<toolName>.progress/tool.<toolName>.progress.generic— the same map.thinkingTemplates(...)takes. Drop a key and the ladder falls through to your existingtool.<toolName>line, so a template map written before 9.54.0 renders exactly what it always did.
Recordings replay the middle too: the commentary voice narrates each report as
"The walk_graph tool reported progress while it was still running." — the
fact, never the payload, which is the same split the
Lens teaching view keeps.
Anti-patterns
- Don't reach outside the args +
ctxyour tool is given.execute(args, ctx)receives the LLM-supplied args plus aToolExecutionContext— usectx.signalto honor cancellation, andctx.runId/ctx.sessionId/ctx.identityto isolate anything you hold, not ambient globals. (executemay return a value OR a Promise; the loopawaits either, so sync handlers are fine.) - Don't hold a session in a module-level map. It looks like a cache and behaves like one right up until two people use your agent at once. See Tool sessions below.
- Don't put validation logic in
executefor things JSON Schema can express (type,required,enum). The LLM honors well-formed schemas; redundant runtime checks are noise. - Don't make tool descriptions ambiguous. "Get data" is bad. "Look up an order by ID; returns status + amount" is good. The LLM picks tools by description.
Next steps
- Connect to an MCP server —
mcpClient({ transport }), per-request signing, and governing by server - Tool discovery — dynamic catalogs and MCP as an async tool source
- Serve your tools over MCP —
mcpServe(tools), the other direction - Tool providers — dynamic per-iteration tool catalogs (
staticTools,gatedTools,skillScopedTools) - Skills, explained — LLM-activated tool bundles
- Permission gating — restrict which tools an agent can call
- Tools and gateways — the
CodeRunnerport,localCodeRunner, and AgentCore Code Interpreter
Mounted maps
An agent can be fed by more than one walkable map. Each map owns its cursor; the mount kernel owns only engagement — whether a map's prompt fragment and tools ride the next call at all.
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.
