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.

What execute returns — three helpers, one rule

Whatever execute returns is what the model reads next. Plain data is fine until it has limits the model must not lose: how old it is, what it did not cover, whether anything matched at all. Three helpers say those limits as data. None of them adds anything to the system prompt or to the tool's schema — each one is the tool's response.

helperuse it whenthe model readsthe record keeps
describedResult({ … })rows, a series or relationships from a system of record — or a question back (clarify)the data, grain, provenance, one not_covered line per gap, and a static note — never your checked listthe whole envelope on tools.semantics_declared, and its coverage in coverageDeclared
coverage(value, { … })any other value that has limits — a verdict, a sentence, an object you will not reshapeyour value under result, with the coverage lists and a static note under af_coveragetools.coverage_declared and coverageDeclared
absent({ … })nothing matchedwhat was looked for, the coverage of the search, that a retry returns the same, and a static notetools.absent, coverageDeclared, and the call's status 'absent'

The rule: rows from a system of record → describedResult(); any other value that has limits → coverage(); nothing matched → absent(). Never wrap one in another — describedResult() carries its own coverage. A person reading the final answer sees the same thing for all three: with .limitsTravelWithTheAnswer() on the agent, the coverage the tools declared is appended to the answer — provenance and grain never are.

All three also take a period — what time the READ covered — and absent() takes the provenance describedResult() carries, so "searched the 02:00 export — nothing" is data, not prose. See The period a result covered below. coverage() also takes inProgress, for items the read found still running, whose outcome is not known yet. See Still running.

One execute that uses all three:

const vmBackups = defineTool({  name: 'vm_backups',  description: 'Backup runs for one VM, or a one-line verdict with summary: true',  inputSchema: {    type: 'object',    properties: { vm: { type: 'string' }, summary: { type: 'boolean' } },    required: ['vm'],  },  execute: ({ vm, summary }: { vm: string; summary?: boolean }) => {    const { exportedAt, source, runs: all } = backupExport;    const runs = all.filter((r) => r.vm === vm);    // The ground every answer stands on — declared once, used by all three.    const ground = {      checked: [`every backup job in the export of ${exportedAt}`],      cannotCover: [{ what: 'PPDM', why: 'not collected on this install' }],    };    // Nothing matched → absent(): "I looked and there is nothing".    if (runs.length === 0) return absent({ what: `backup runs for ${vm}`, ...ground });    // A value that is not rows (a verdict, prose, someone else's object) → coverage().    if (summary === true) {      const ok = runs.filter((r) => r.ok === 1).length;      return coverage(`${vm}: ${ok} of ${runs.length} backup runs succeeded`, ground);    }    // Rows from the system of record → describedResult(), time taken from the data.    return describedResult({      facts: runs.map((r) => ({ entity: vm, day: r.day, ok: r.ok === 1 })),      provenance: { measuredAt: exportedAt, source },      coverage: ground,    });  },});

What the model read for each branch, captured from that example's run (pretty-printed here; the model reads each on one line):

describedResult — two rows found
{
  "facts": [
    { "entity": "vm-01", "day": "2026-08-18", "ok": true },
    { "entity": "vm-01", "day": "2026-08-19", "ok": true }
  ],
  "provenance": {
    "measured_at": "2026-08-19T10:12:00Z",
    "source": "Cohesity API export (4 clusters)"
  },
  "not_covered": ["PPDM — not collected on this install"],
  "note": "Typed data, not prose. `grain` and `provenance` are caveats that travel with the numbers: check `is_counter` before summing values from a series, read `measured_at` (and `age_seconds`) as how old the data is, and treat everything under `not_covered` as ground this result does NOT cover — a clean look here says nothing about it."
}
coverage — a one-line verdict
{
  "af_coverage": {
    "checked": [{ "what": "every backup job in the export of 2026-08-19T10:12:00Z" }],
    "cannot_cover": [{ "what": "PPDM", "why": "not collected on this install" }],
    "note": "This result covers only what `checked` lists. `not_checked` is ground the call this result answers did not look at, and `cannot_cover` is ground this tool can never see — a clean result here is NOT evidence about either, and no retry changes `cannot_cover`. Carry these limits into any answer you build on this result."
  },
  "result": "vm-01: 2 of 2 backup runs succeeded"
}
absent — nothing matched
{
  "af_absent": true,
  "outcome": "nothing_found",
  "looked_for": "backup runs for vm-99",
  "checked": [{ "what": "every backup job in the export of 2026-08-19T10:12:00Z" }],
  "cannot_cover": [{ "what": "PPDM", "why": "not collected on this install" }],
  "retry_returns_the_same": true,
  "note": "The search ran and matched nothing. This is an ANSWER, not an error: nothing failed, nothing was substituted for what was asked, and calling this tool again with the same arguments returns this same result. `checked` is the ground this answer covers; anything under `not_checked` or `cannot_cover` is ground it does NOT cover, and reaching that needs a different question, not a retry."
}

A helper that cannot honor its declaration throws inside execute, and the call's error result is its refusal: the model reads text that starts refused: in place of the data, and the run continues. Details: Described tool results, absent() and coverage() below.

Write both branches. A data list is never empty — "nothing matched" has exactly one helper — so describedResult({ facts: [] }) is refused, and the refusal names the one you meant:

refused: `facts` is empty — if nothing matched, return absent({ what, checked }) instead. (field: facts)

A tool without an empty branch passes every test that has rows and meets this on its first empty read in production, where the model reads the refusal instead of "nothing matched". So every execute that returns rows says both: rows.length ? describedResult({ … }) : absent({ … }) — the example above does exactly that.

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.

A procedure, or another agent, as one tool

Sometimes the thing the model should call is bigger than a function — a multi-step procedure, or a whole specialist agent.

  • A footprintjs flowchart → runbookAsTool wraps it as one tool, and every answer carries what the procedure did and did not check. (The older flowchartAsTool still works.)
  • Another agent → call its run() from a plain tool:
import { Agent, defineTool } from 'agentfootprint';
import { mock } from 'agentfootprint/providers';

const translator = Agent.create({ provider: mock({ reply: 'Hola, ¿cómo estás?' }), model: 'mock' })
  .system('Translate the message to Spanish. Reply with the translation only.')
  .build();

const translate = defineTool<{ text: string }, string>({
  name: 'translate',
  description: 'Translate text to Spanish. Returns the translation only.',
  inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] },
  execute: async ({ text }, ctx) => {
    const out = await translator.run(
      { message: text },
      {
        ...(ctx.signal && { env: { signal: ctx.signal } }),
        // The calling run's redaction policy, handed on like the signal.
        ...(ctx.redact && { redact: ctx.redact }),
      },
    );
    if (typeof out !== 'string') throw new Error('The translator paused; it cannot ask a person from inside a tool.');
    return out;
  },
});

Gotchas:

  • One run at a time per agent instance. A second run() on an agent that is already running throws RunInFlightError. If two callers can reach the same specialist at once, build one per caller.
  • The inner run is its own run. The outer trace records one translate call; what the translator did is on translator.getLastNarrativeEntries() and its own events.
  • Hand the redaction policy on. ctx.redact is the policy the calling run is covered by (its agent's redact); passing it as the inner run's redact keeps the same names out of the inner run's records. It can only add names to the inner agent's own policy. See Keep personal data out of the record.
  • A pause does not cross the tool boundary. Handle the non-string outcome, as above.
  • Hand-offs are a different shape. When the specialist should take over the conversation rather than answer one question, use swarm().
  • Remote agents. There is no built-in client for calling another team's agent over A2A; call its endpoint from execute like any other HTTP API. The other direction — letting other agents call yours over A2A — ships as agentCoreA2AHost (see AgentCore adapters).

Argument validation

The Agent checks inputSchema before dispatch: types, required fields, primitive enums, nested properties/items, explicit extra-field restrictions, and string pattern, minLength, and maxLength. This is a subset of JSON Schema. A rejected call returns a correction without executing.

Fields must be own properties, not inherited. additionalProperties: false permits no extra keys even without a properties map; explicitly declared names such as constructor remain valid. Malformed property maps and nonempty or malformed patternProperties defer extra-key checks, not required/type checks. Pattern properties need a full validator in the tool.

Owned schemas. read_skill accepts only id; run_code accepts code and the artifact argument names configured in codeRunnerTool({ wants }). Both declare additionalProperties: false. With default enforcement, remove previously tolerated extra fields from calls or middleware-added arguments. Declared artifact inputs remain optional; runtime-enabled _findings is still accepted and peeled before validation. The skill id enum remains the full catalog, so unreachable picks still receive the graph's teaching refusal.

Declare an identifier's format and explain it in description:

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'],
}

toolArgValidation selects 'enforce' (default refusal), 'warn' (execute and emit agentfootprint.validation.args_invalid with enforced: false), or 'off' (skip validation).

Stream integrity is separate from schema validation. With anthropic() or browserAnthropic(), malformed completed argument JSON, a non-object JSON value, or an unfinished tool block cannot execute—even in 'warn' or 'off' mode. The adapter retains a finite argumentsFailure on the attempted call; Agent emits the ordinary enforced argument-validation event and error result before middleware, argument fills, human questions, credentials, or tool execution. Raw invalid fragments are not included in this refusal. A complete {} remains valid; extra object keys remain governed by the tool schema.

On the next Anthropic request, that failed call and its marked error result are recovery text, not a fabricated empty tool_use. This is explicitly lossy projection, not exact replay; existing active signed-thinking safeguards still apply. The model may retry within the normal iteration budget. A witnessed max_tokens completion can carry this recoverable refusal, but a tool stream ending without message_stop is a provider failure, not a completed call. Forced structured output follows the existing output-contract retry/exhaustion path rather than accepting repaired {}. Non-streaming behavior is unchanged, and invokeModelGateway() keeps its stronger whole-response refusal.

Invalid regexes are ignored with a one-time warning. A pattern failure takes precedence over length failures. String-shape corrections include the description and at most 80 characters of the supplied value; structural issues name paths and types, not values. Telemetry projections omit values.

Other dispatch paths. Resumed Agent calls apply the same mode after middleware, before artifact resolution and credentials; warn may report again. ctx.tools.call and mcpServe always enforce the shared subset, throwing the correction or returning an MCP tool error before credentials or execution. Accepted args are unchanged; open schemas still allow extras. Direct tool.execute remains caller-governed. This is not full JSON Schema validation. Credential-consent checkpoints containing resolved artifact data instead of refs remain unsupported and may now fail schema admission first.

A default nobody chose — askOrAssume

A log search that defaults its look-back to 2 hours answers "any errors on checkout?" with "no errors" — and that means "no errors in a window nobody chose". Declare the default as a rule on the tool instead of burying it in execute, and the LIBRARY fills it, files it and admits it:

function searchLogsTool(ran: Record<string, unknown>[]) {  return defineTool({    name: 'search_logs',    description: 'Error lines for one service over a look-back period.',    inputSchema: {      type: 'object',      required: ['service', 'window'], // the author's contract — never edited      properties: {        service: { type: 'string', description: 'Service name.' },        window: { type: 'string', enum: ['1h', '2h', '24h', '7d'], description: 'Look-back.' },      },    },    // The default, declared: the LIBRARY fills it when a call leaves it out.    askOrAssume: { window: { assume: '2h' } },    // …and it is the argument that bounds the period the answer covers.    period: { argument: 'window', spelling: 'lookback' },    execute: async (args) => {      ran.push({ ...args });      return { service: args.service, window: args.window, errors: 0 };    },  });}async function ask(message: string, limitsTravel: boolean) {  const ran: Record<string, unknown>[] = [];  let builder = Agent.create({    provider: mock({      replies: [        // The model leaves `window` out — the omission this example is about.        { toolCalls: [{ id: 'call-1', name: 'search_logs', args: { service: 'checkout' } }] },        { content: 'No errors on checkout.' },      ],    }),    model: 'small-model',  }).tool(searchLogsTool(ran));  if (limitsTravel) builder = builder.limitsTravelWithTheAnswer();  const agent = builder.build();  const answer = await agent.run({ message });  return { agent, answer: String(answer), ran };}

When a call leaves window out:

  • the tool runs with window: '2h' — the library filled it, never the tool;
  • agent.findings() holds an argument row: source: 'default', the value in the tool's own argument view, the conversation turn;
  • agentfootprint.findings.argument fires (names, enums and counts only — never a value);
  • the model reads one past-tense note on the result: window was not in the search_logs call this result answers; the call ran with "2h", the value the tool's rule assumes — recorded as assumed, not as the person's;
  • agent.assessment() reads not sure, with the reason argument-assumed;
  • with .limitsTravelWithTheAnswer(), the answer ends with an Assumed (a tool's rule, not your words) block — or, on a typed answer, the same values come back as data, agent.answerCoverage().assumed.

A model that sends the default itself is filed default too (with the value it sent as proposed): copying a default from a description is not a choice. Any other value is filed model and the standing says the record cannot trace it (argument-unverified). The model is told the rule in the schema it is served — the ruled argument leaves required and its description says If left out, the tool's rule fills "2h", recorded as assumed. — while the schema you wrote, the one validateToolArgs judges and mcpServe serves, is never edited.

Arming. A registered tool that declares askOrAssume (through .tool(), a skill's tools, or an MCP tool registered on the builder) arms the layer by itself. A ruled tool that only a ToolProvider serves needs .inputsLayer() on the builder — without it, its calls are refused rather than run unruled, with one warning, and before any .toolMiddleware() link runs, so nobody is asked to approve a call that will not run. Nothing declared, nothing changes: the chart, the record and the requests are byte-identical.

Refused at definition (the same rule runs again at dispatch and at MCP ingest): a rule on an argument the schema does not offer, whose type is not exactly one of string, number, integer, boolean, or that is a wants argument; a value the property's own schema rejects; a period on an argument with no rule.

Asking the person instead — { ask: 'question', choices }: a value the call leaves out is asked of the person, once for the whole batch and before anything in it runs, through the typed input pause. See Collect missing inputs.

Where a value the model DID send came from — .inputsLayer({ argumentSources: true }): the model declares each value's source (the person's words, a result, an earlier answer, the app) and the library checks it before the call runs; an unchecked value on an ask argument is asked. A choice may carry words you vouch for ({ value: '7d', said: ['last week'] }). See Collect missing inputs.

Over MCP. askOrAssume and period ride _meta.agentfootprint beside the other declarations, and the client judges them against the listed tool's own input schema — a rule that fails is warned about once and dropped, and the tool still registers.

A middleware that rewrites a ruled argument after the layer checked it: its value is what ran, so it reads as assumed unless the middleware says where it came from — allow({ ...call.args, window }, 'from the receipt', { from: { window: 'person' } }). A filled value the middleware replaced is named nowhere: not in the note the model reads, not in the Assumed block.

The note is never read as the tool's words. The result message carries the length of the tool's own text, and every reader of a result reads only that part — the evidence gate, agent.assessment(), the answer account and the unsupported-argument / empty-lookup checks. So the "2h" in the note grounds nothing, and a bare [] after a note still reads not sure (empty-undeclared).

The names. AskOrAssume is the rules by argument name (Tool.askOrAssume); ToolPeriod says which argument sets the period and how its values are spelled (Tool.period — lookback 24h, signed-lookback -24h, or iso-range); and ArgumentRow is the row the layer files on the findings ledger — a member of FindingsRow, so a reader that switches over every row kind must skip one it does not know.

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.onTeardown — scope, 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.

absent() and coverage() refuse a key they do not read, rather than drop it — a declaration parsed from JSON with not_checked in it would otherwise lose that list without a word. The refusal names the spelling meant: for not_checked it asks whether you meant notChecked. A refusal thrown inside execute becomes the call's error result, which is what the model reads, so it starts refused: and never with the helper's name.

When the suggestion is another tool, name it as data (9.113.0). tryInstead is one sentence, for the model. When that sentence points at another tool, name the tool beside it with tryInsteadTool — a TryInsteadTool, { tool, why? }. A reader that wants to know which tool was suggested would otherwise have to parse the sentence, and this library never parses a tool name out of prose. The tool rides its own key, try_instead_tool, so try_instead stays the string it has always been, and the record carries both:

return absent({
  what: `FLOGI entries on ${port}`,
  checked: [`${sw}: the live fcns database`, 'window: the last 24h'],
  notChecked: [{ what: 'logins older than 24h', why: 'outside the window this tool keeps' }],
  tryInstead: 'Ask for a wider window, or check the archived FLOGI history.',
  tryInsteadTool: { tool: 'flogi_history', why: 'it keeps the archived FLOGI history' },
});

The name is not looked up — a provider may serve it on a later iteration — and it is recorded as declared (a name no provider would accept gets the dev-mode warning defineTool would give it). One tool, { tool, why? } and nothing else: a list, an unknown key or an empty why is refused where you typed it. Either field may come without the other.

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, and the suggestion as you declared it — tryInstead and tryInsteadTool;
  • 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, the tryInsteadTool's tool and why, 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.

Words for the person reading the report — short and kind. A coverage item's what is written for the model and for an engineer. Two optional fields on any item say the same ground for a person reading an account of the answer:

  • short — a short plain form of what (at most 80 characters, one line, never longer than what);
  • kind — on a notChecked or cannotCover item only: 'existence' when the item is whether the thing asked about exists at all, 'scope' when it is a population or source outside what the tool reaches.
return absent({
  what: 'a VM disk in the RVTools export attributed to the array asked',
  checked: [{ what: 'vDisk and vm_rdm_map: every VM disk in the RVTools export dated 2026-09-19',
              short: 'every VM disk in the RVTools export of 2026-09-19' }],
  notChecked: [
    { what: 'whether that name is a storage array, and which VM disks are on it',
      short: 'whether that name is a storage array', kind: 'existence', why: 'this tool places disks' },
    { what: 'hosts that are not VMware — AIX LPARs and physical servers',
      short: 'hosts that are not VMware', kind: 'scope', why: 'RVTools sees VMware only' },
  ],
});

Both are record-only: tools.absent, tools.coverage_declared and sharedState.coverageDeclared carry them, and the model never does — the framework removes them from a recognized envelope (an object: an absence, or a ledger and whatever it wraps) before the model or any onToolResult rule reads it, and the tool's resultCeiling measures that served value, so declaring them costs no request byte. stream.tool_end then keeps your envelope as result and what the model read as modelResult (under result placement both carry the one ticket). They are never evidence either: never put the caller's arguments in short — it restates what.

What is not stripped, because it is not a recognized envelope object: a result returned as JSON text (an mcpClient result in its default text mode is one), an af_absent whose checked is empty (that is ordinary data), and an absence buried inside your own domain object. mcpServe does not strip either, on purpose: it hands the tool's own answer to another host, and a client's model there WILL read the two fields unless that client is an agentfootprint agent reading structured results, whose own door strips them.

A tool in another language puts the same two keys on the items of its snake_case lists — and emits them only when the item has a value (a None is read as not declared, but it is still bytes the model reads):

# ABSENCE_NOTE and the marker come from canonical-notes.json (see below)
{"af_absent": True, "outcome": "nothing_found", "looked_for": "…",
 "checked": [{"what": "…", "short": "every VM disk in the RVTools export of 2026-09-19"}],
 "not_checked": [{"what": "…", "short": "whether that name is a storage array",
                  "kind": "existence", "why": "…"}],
 "retry_returns_the_same": True, "note": ABSENCE_NOTE}

short is one line of plain visible text: no control characters, no line or paragraph separators, no invisible format characters (bidi overrides, zero-width characters) — checked before the 80-character limit. absent() and coverage() refuse a bad value where you wrote it; an envelope built by hand is read, never repaired — a bad short or kind is dropped with one dev-mode warning per tool per process, which a Python author never sees, so check both where you build the envelope.

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.

What it rewrites, exactly. On a prose answer, the answer STRING you get back: the value agent.run() returns, turn_end's finalContent, and the answer the turn's memory write stores all carry the block. The block comes from every coverage statement of the run — absent(), coverage() and a describedResult()'s coverage field alike (sharedState.coverageDeclared is the list); a describedResult()'s provenance and grain never reach it.

  • Streamed answers. The stream.token events carry what the model wrote, as it wrote it; the block is appended after the stream ends, so a streaming UI that shows only the tokens never shows it. Render turn_end.finalContent (or the returned string) when the turn ends, or draw the limits yourself from sharedState.coverageDeclared.
  • Not the same "limits" as .answerValidation({ limits }). Those are resource bounds on answer validation — reads, bytes, checks, time. The limits here are the ground your tools declared they did and did not cover.

With an output schema, the limits come back as data

A typed answer is JSON, and JSON with a paragraph after it is not JSON. So when the agent has .outputSchema(), nothing is appended: the answer stays exactly what the model sent, agent.runTyped() parses it, and the same limits come back beside it as data.

const agent = Agent.create({ provider, model })
  .tool(replicationHealth)
  .outputSchema(Verdict)
  .limitsTravelWithTheAnswer()
  .build();

const verdict = await agent.runTyped('is replication healthy?');
// { healthy: true, summary: 'Replication is healthy.' }

agent.answerCoverage();
// {
//   checked:     [{ what: 'SRDF pair state on all 4 arrays (live query)' }],
//   notChecked:  [{ what: 'NDM migration sessions', why: 'the API timed out — ask again' }],
//   cannotCover: [{ what: 'host-side multipathing', why: 'no collector runs on the ESX hosts' }],
// }

agent.answerCoverage() holds the entries the block would have listed — duplicates said once, every entry kept — and is undefined when no tool declared a limit and nothing was assumed. When a tool's askOrAssume rule filled a value, the data also lists it — assumed: [{ toolName, argument, value, hidden }], the same values the prose answer's Assumed block prints, each in the tool's own argument view. The value's type is AnswerCoverage: a Coverage plus that optional list, so code written against the three lists keeps working. The same value rides turn_end as answerCoverage and sits in the snapshot as sharedState.answerCoverage, so a UI that shows the answer when the turn ends can draw its limits beside it. .answerValidation() still refuses this option at build time.

The coverage surface, named

All from agentfootprint:

  • absent and coverage — the two doors, described above. The third way a statement enters the same channel is the coverage field of a describedResult() — see Described tool results.
  • CoverageItem, CoverageInput, Coverage — one piece of ground ({ what, why?, short?, kind? }), 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.
  • TryInsteadTool — the tool a suggestion points at ({ tool, why? }), given as tryInsteadTool beside the tryInstead sentence.
  • 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.
  • DeclaredPeriod, periodVerdict, PeriodRow and PERIOD_WIRE — the period a result declares, the one verdict over it, the row the results layer files, and the period's wire spelling as data (below).

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 four groups — notes (ABSENCE_NOTE, COVERAGE_NOTE, SEMANTICS_NOTE), markers (the reserved keys), headings (COVERAGE_BLOCK_HEADING) and wire (PERIOD_WIRE, the period's keys and its one literal, published whole — below). 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 period a result covered — period

A backup search reads the nightly export, taken at 02:00, and is asked at 10:00 about "the last hour". It finds nothing — and "no failed backups" silently means "none in data that ends seven hours before the hour you asked about". The tool knows both times; the model does not. So a result says what time its read covered, on the result it already returns:

function backupFailures(queried: { from: string; to: string }) {  return defineTool({    name: 'backup_failures',    description: 'Failed backup jobs for one host, read from the nightly backup export.',    inputSchema: {      type: 'object',      required: ['host'],      properties: { host: { type: 'string', description: 'Host name.' } },    },    execute: async ({ host }: { host: string }) => {      const snap = nightlyExport;      // What the READ covered — computed by the tool from its own data, never parsed.      const period: DeclaredPeriod = {        queried, // the instants this read asked for        held: { from: snap.heldFrom, to: snap.exportedAt }, // what the store holds      };      const rows = snap.failures.filter((f) => f.host === host);      if (rows.length === 0) {        return absent({          what: `failed backup jobs for ${host}`,          checked: ['every backup job in the nightly export'],          provenance: { measuredAt: snap.exportedAt, source: 'nightly backup export' },          period,        });      }      return rows;    },  });}async function ask(queried: { from: string; to: string }, limitsTravel: boolean) {  let builder = Agent.create({    provider: mock({      replies: [        { toolCalls: [{ id: 'call-1', name: 'backup_failures', args: { host: 'host-103' } }] },        { content: 'No failed backups on host-103.' },      ],    }),    model: 'small-model',  })    .tool(backupFailures(queried))    // The tool declares a period only on its results, so the layer is armed here.    // A tool with a ToolPeriod (`period: { argument }`) arms it by itself.    .resultsLayer();  if (limitsTravel) builder = builder.limitsTravelWithTheAnswer();  const agent = builder.build();  const answer = await agent.run({ message: 'Any failed backups on host-103 in the last hour?' });  return { agent, answer: String(answer) };}
  • queried — the instants the READ asked for (of the read that produced this result, not of "this call");
  • held — what the store holds at the time of the read, or 'unknown', said out loud when the tool cannot vouch for it;
  • readAt (optional) — when the read ran; a cached answer is served minutes after the read it describes.

Every value is an ISO 8601 instant WITH a zone (2026-09-26T09:00:00Z, 2026-09-26T11:00:00+02:00): the library compares instants, and one with no zone is refused rather than guessed. It never parses "last hour" or "2h" — the tool computes the instants from its own data. The same period goes on coverage(value, { …, period }) (inside af_coverage, before result) and on describedResult({ …, period }) (a top-level field); the model reads it as you declared it, snake_case on the wire (read_at). absent() also takes provenance: { measuredAt, source } — the shape and the rules describedResult() uses — so the found branch and the not-found branch of one execute say their source and time the same way.

The results layer judges it. At the head of the loop, after every batch, it files ONE verdict per call on the findings ledger (kind: 'period'), by one pure rule over the instants you declared (periodVerdict, bounds inclusive):

verdictwhenthe answer's standing
coveredthe store holds every instant the read asked fornothing fires
partly-heldit holds some of themnot sure — period-partly-held
not-heldit holds none of themnot sure — period-not-held
unknownheld: 'unknown' — on a non-empty result toonot sure — period-unknown
undeclaredthe tool declares a period argument (period: { argument } on defineTool) and this result said nothing about its periodnot sure — period-undeclared

agentfootprint.findings.period fires per row with the verdict word only — never an instant. The layer serves the model nothing new (the period is already in the result it read).

Arming. A registered tool that declares a period argument — defineTool({ …, askOrAssume: { window: { assume: '2h' } }, period: { argument: 'window' } }) — arms the layer by itself: its results owe a period, and one that says nothing is recorded as silence (undeclared). For a tool that declares a period only on its results, or one a ToolProvider serves, add .resultsLayer() on the builder. Without the layer a declared period is still recorded (on sharedState.coverageDeclared and the tools.absent / tools.coverage_declared events) but never judged — and one dev-mode warning per tool says so. Nothing declared, nothing changes: the chart, the record and the requests are byte-identical.

For the person, under an existing .limitsTravelWithTheAnswer() only, one Period: line per declaring call, as the tool declared it:

Period:
- backup_failures queried 2026-09-26T09:00:00Z to 2026-09-26T10:00:00Z; the store holds 2026-08-27T02:00:00Z to 2026-09-26T02:00:00Z

In the person's zone. Arm the time layer's clock with .time() and every run declares when "now" is and which zone the person is in — run({ message, time: { now, zone } }), with .time({ zone }) as an optional fallback (a run with neither is refused; the server's zone is never used). The Period: lines are then shown in that zone, the zone named, each end as the tool declared it:

Period:
- backup_failures queried 2026-10-09 08:00–08:40 America/Los_Angeles (UTC-07:00); what the store holds is unknown

The clock is on the record: one clock row per turn on agent.findings() (now, nowSource: 'app' | 'default', zone, zoneSource: 'run' | 'builder', and a window set in a UI as time: { window }, recorded source: 'control'), one call row per dispatched call with dispatchedAt, and — when a resume passes a different time — a clock-on-resume row: the paused turn keeps its clock.

function desk(script: NonNullable<Parameters<typeof mock>[0]>['replies']) {  return Agent.create({ provider: mock({ replies: script }), model: 'small-model' })    .tool(backupFailures)    .tool(confirmRerun)    .time({ zone: 'America/Los_Angeles' }) // the fallback; each run's own zone wins    .limitsTravelWithTheAnswer()    .build();}

The person's words, read by your reader. .time({ reader, policy }) arms a TimeReader that returns zone-less parts (10/09/26 is three numbers, the order undecided) and a verbatim quote. The library resolves them against the clock into every candidate window — each date order, am and pm, both instants of a DST overlap — and the policy (dateOrder, year, both 'ask' by default) picks or leaves the choice open for the person. One time-reading row per mention records the quote, the candidates, how it settled and the reader's id and version; the reader runs once per message, only on a message a person wrote, and a resume reads the row instead.

/** A fixture reader: the parts a tokenizer returns for "10/09/26 8 AM to 8:40 AM". */const fixtureReader: TimeReader = {  id: 'example/fixture',  version: '1.0.0',  locale: 'en-US',  kind: 'rule',  read: (text) => {    const quote = '10/09/26 8 AM to 8:40 AM';    if (!text.includes(quote)) return { mentions: [] };    return {      mentions: [        {          quote,          parses: [            {              date: { kind: 'numeric', fields: [10, 9, 26] }, // the ORDER is not the reader's to decide              rangeOf: [                { wall: { h: 8, meridiem: 'am' } },                { wall: { h: 8, m: 40, meridiem: 'am' } },              ],            },          ],        },      ],    };  },};function desk(policy?: { dateOrder: 'MDY' }) {  return Agent.create({ provider: mock({ replies: [{ content: 'Done.' }] }), model: 'small-model' })    .time({ zone: 'America/Los_Angeles', reader: fixtureReader, ...(policy && { policy }) })    .build();}

The time ask. A requestInput field with a format — 'instant', 'time-range' (an ISO 8601 interval from/to) or 'zone' (an IANA name) — is checked before your app sees the answer. One that ends before it starts, has no offset, names no zone, or (under .time(), which knows the person's zone) writes a wall time the clocks skip is not taken: agent.resume returns the same ask with refused: { answer, reason } and repeat: { count }, and nothing runs. The reason is a sentence from defaultTimeAskMessages; .time({ messages }) rewords any key. labels name each choice, a time field's choices keep free entry open unless strict, and elicitationOf / answerFromElicitation carry the ask over MCP (a range as two date-time properties).

const collectWindow = defineTool({  name: 'collect_window',  description: 'Ask the person which window the search should cover.',  inputSchema: { type: 'object', properties: {} },  execute: () =>    requestInput({      id: 'search-window',      question: 'Which window should the search cover?',      fields: [{ id: 'window', type: 'string', format: 'time-range' }],    }),});const desk = Agent.create({  provider: mock({    replies: [      { content: '', toolCalls: [{ id: 'c1', name: 'collect_window', args: {} }] },      { content: 'No errors on checkout in that window.' },    ],  }),  model: 'small-model',})  .tool(collectWindow)  .time({    zone: 'America/Los_Angeles',    messages: { 'answer.out-of-order': 'That window ends ({{to}}) before it starts ({{from}}).' },  })  .build();

A typed answer (.outputSchema()) carries the same as data: agent.answerCoverage().periods — { toolName, toolCallId, queried, held, readAt? } per declaring call — and turn_end.answerCoverage.periods.

From another language, mint the same envelope from canonical-notes.json's wire.PERIOD_WIRE — every reserved key and the literal 'unknown', read rather than copied:

wire = json.load(open("node_modules/agentfootprint/canonical-notes.json"))["wire"]["PERIOD_WIRE"]

def period(queried_from, queried_to, held=None, read_at=None):
    held_value = wire["heldUnknown"] if held is None else {wire["from"]: held[0], wire["to"]: held[1]}
    out = {wire["queried"]: {wire["from"]: queried_from, wire["to"]: queried_to}, wire["held"]: held_value}
    if read_at is not None:
        out[wire["readAt"]] = read_at
    return out   # put it under wire["key"] on af_absent / af_coverage / af_semantics

Across versions. A reader older than this release serves a period on af_absent or af_coverage to the model as tool knowledge and files nothing, and refuses a whole af_semantics envelope that carries one (it stays plain data) — so upgrade the reading side (and the lens) before tools mint periods. An mcpClient in its default text mode hands envelopes over as JSON text, which is not recognized at the door at all — use resultMode: 'structured' for tools that return envelopes.

Still running — inProgress

A backup that is two hours into its run has not failed, and it has not succeeded either. A replication session that is still synchronizing is the same. A vendor's states have at least three outcomes: settled-ok, in progress and failed. A tool whose test is "equals the success value" counts the middle one as a failure, and someone gets paged about an array with no unhealthy sessions.

Only your tool knows which of its vendor's states are still running. When the vendor also gives its own verdict field, trust that field over a state name. So the tool classifies each item, and says which ones are still running on the coverage() it already returns:

const replicationSessions = defineTool({  name: 'replication_sessions',  description: 'Replication sessions on one array: settled, still transferring, and failed.',  inputSchema: { type: 'object', properties: { array: { type: 'string' } }, required: ['array'] },  execute: ({ array }: { array: string }) => {    // The TOOL classifies: the vendor's own verdict decides a failure; a healthy    // session that is still transferring is in progress — never "non-OK".    const failed = sessions.filter((s) => !s.healthy);    const running = sessions.filter((s) => s.healthy && s.state === 'SYNCING');    return coverage(      { failed: failed.length, settled: sessions.length - failed.length - running.length },      {        checked: [`every replication session on ${array} (live query)`],        inProgress: running.length          ? [{ what: 'sessions still synchronizing', count: running.length, short: 'syncing' }]          : [],      },    );  },});
  • Each entry is a string or { what, why?, short?, count? }. count says how many things the entry stands for, so write "96 sessions" as one entry, not 96. short is for the record only, as on every coverage item.
  • The model reads in_progress inside af_coverage, followed by one extra sentence after the note: the outcome is not known yet, so it is neither a success nor a failure. That sentence is served only when the list is non-empty. A tool that declares nothing is served the same bytes as before.
  • The record keeps it. tools.coverage_declared and the coverageDeclared row gain inProgress. With .limitsTravelWithTheAnswer(), a prose answer gains an In progress (outcome not known yet): section with one line per item. A typed answer gains answerCoverage.inProgress instead.
  • The answer's standing does not change. The declaration is a label, not a reason. Whether an answer called an in-flight item "failed" is a reading of the answer's words, and the library does not read words to decide a standing.

Gotchas. inProgress needs checked beside it, even when the list is empty, because an item still running is part of what the call read. An empty list is simply omitted, so running.length ? [...] : [] needs no other branch. absent() does not take it: an absence promises that a retry returns the same result, and an item still running promises the opposite. describedResult() does not take it yet. A tool in another language mints the key from canonical-notes.json (IN_PROGRESS_WIRE).

The names. The entry types are InProgressItem (one entry) and InProgressInput (what you may write: a string or an InProgressItem). The sentence the model is served is IN_PROGRESS_CLAUSE. The section heading a reader matches on is IN_PROGRESS_SECTION_LABEL. One declaring call's entry in a typed answer's limits is AnswerInProgress.

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 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.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