Build

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 Tool is 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

fieldisabsent when
ctx.runIdthe run this call belongs tothere is no run — a call served over mcpServe is one call, not a turn
ctx.sessionIdthe hosting conversation, when bound to onethe run is not session-bound
ctx.identitythe identity the CALLER passedthe caller passed none
ctx.teardownScopeswhich scopes this door can honournever — [] 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 available

A 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

scopefiresavailable at
'call'when execute settles — resolve or throwevery door, including mcpServe
'run'when the turn reaches a terminal that is not a pauseany 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.

NameWhat it is
TeardownScopeThe four scopes: 'call' · 'run' · 'session' · 'shutdown'.
TeardownOptionsThe second argument to ctx.onTeardownscope, key, and the optional runnerId / label that ride the events.
TeardownReasonWhy 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_MSThe 5000ms default behind AgentOptions.toolTeardownTimeoutMs.
ToolTeardownTimeoutErrorRaised 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 as skill.reroute_superseded { source: 'tool-proposal' }), and a proposal outranks the model's own read_skill pick (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, rails included. Conflicting same-batch proposals reuse the route_conflict law: 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. The agentfootprint.tools.effect event carries the ordinary outcome: 'accepted' plus an additive stay: true, so an exhaustive switch over outcome in 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_skill stays 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? } (effects required — status-only is effects: []).
  • ProposedEffect — the two-kind union; ProposeTransitionEffect and RequireInstructionEffect are its arms, and InstructionDeliveryLease is the 'next-call' | 'until-skill-exit' union behind the push lease.
  • ToolResultStatus — the closed seven-value outcome vocabulary; TOOL_RESULT_STATUSES is 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); ReadToolResultEnvelope is its result: unwrapped content, the valid effects, the status, and the malformed refusals.
  • 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 its effects: [] marker ({ content, status: 'denied' }), and undefined for 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 rides sharedState.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 seventh ToolResultStatus, so an onToolStatus: 'absent' route edge can send "we found nothing" somewhere other than "the call broke";
  • agentfootprint.tools.absent files 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, the tryInstead sentence, and any extra key you attach to the envelope — a known_shares list 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_for is 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 hosts

The 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:

  • absent and coverage — 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.
  • AbsenceDeclaration and CoverageDeclaration — what you pass to each door.
  • ToolAbsence and CoveredResult — 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 in sharedState.coverageDeclared.
  • readAbsence, readCoverageLedger and readCoverageResult — the strict recognizers (exported so a custom runner or a test can apply exactly the framework's rule); CoverageReading is the last one's result: the delivered status and the statements found.
  • ABSENCE_MARKER and COVERAGE_MARKER — the reserved keys (af_absent, af_coverage) that make each shape recognizable on the tool-result wire.
  • ABSENCE_NOTE and COVERAGE_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.json

It 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) and headings (COVERAGE_BLOCK_HEADING). The same value is reachable as a TypeScript import from agentfootprint, 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 typed agentfootprint.tools.result_refused event: { 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 as declaredStatus; 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 defineToolmaxChars 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.refused with op: 'dispatch'; successful resolution rides artifacts.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: each wants argument must exist in inputSchema.properties as type: 'string' (assertToolWants is exported for hand-built Tool objects). An agent with a statically registered wants tool and no store refuses at build; mcpServe refuses wants tools by name (that door has no store).
  • Zero-cost when unused. No wants = nothing resolved, ctx.wanted absent, 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.progress is never undefined, so there is no optional-chaining dance. Doors with no event stream to file on — a call served over mcpServe, the offline callTraceTool context — 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 execute returns.
  • payload must survive structuredClone. 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_progress rows, 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

Since 9.54.0. Through 9.53.0 a report reached the record and stopped there — nothing projected it onto a surface a person watches, so consumers kept a hand-rolled side channel for the live middle of a long call. That side channel can go.

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 payloadthe 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
  • message is the one field read. A top-level string field named message is 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's total is 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 message does 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 (the message case), tool.progress.generic (everything else), or per tool with tool.<toolName>.progress / tool.<toolName>.progress.generic — the same map .thinkingTemplates(...) takes. Drop a key and the ladder falls through to your existing tool.<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 + ctx your tool is given. execute(args, ctx) receives the LLM-supplied args plus a ToolExecutionContext — use ctx.signal to honor cancellation, and ctx.runId / ctx.sessionId / ctx.identity to isolate anything you hold, not ambient globals. (execute may return a value OR a Promise; the loop awaits 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 execute for 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

On this page