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.

Tool errors

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').
  • { 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 six 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 six-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.

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

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

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