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
Toolis a singleton — built once, shared by every run and every session your process serves. A session held in its closure is therefore shared too. In a standing agent, person B gets person A's files, environment and half-run state. That is not a memory leak; it is an isolation failure that no test with one user will ever show you.
Since 9.7.0 the context carries what a tool needs to do this properly.
What ctx tells you
| field | is | absent when |
|---|---|---|
ctx.runId | the run this call belongs to | there is no run — a call served over mcpServe is one call, not a turn |
ctx.sessionId | the hosting conversation, when bound to one | the run is not session-bound |
ctx.identity | the identity the CALLER passed | the caller passed none |
ctx.teardownScopes | which scopes this door can honour | never — [] means none will ever fire |
Every one is absent rather than invented when the fact is absent. That is load-bearing: a session keyed on a fabricated id is shared by everyone who gets the same fabrication.
ctx.identity is specifically what the caller passed, not the run's internal
runIdentity — that one always exists (it defaults to
{ conversationId: '<runId>' }), and handing a synthesized conversation to a
tool as "the identity" would let it isolate on a fiction.
Derive the key, register the cleanup
import { defineTool, toolSessionKey } from 'agentfootprint';
const query = defineTool<{ sql: string }, string>({
name: 'query',
description: 'Run SQL against the analytics warehouse',
execute: async ({ sql }, ctx) => {
const key = toolSessionKey(ctx, 'run');
if (!key) throw new Error("query: this door has no run — build with scope 'call'");
let session = live.get(key);
if (!session) {
session = await warehouse.connect();
live.set(key, session);
}
// Registering on EVERY call is the intended shape: the first cleanup wins
// (it holds the handle) and the repeat refreshes liveness.
ctx.onTeardown?.(() => { live.delete(key); return session.close(); }, { scope: 'run', key });
return session.run(sql);
},
});toolSessionKey is exported because the derivation is the isolation
boundary — one implementation, or two that disagree:
session → t=<tenant|_>/p=<principal|_>/s=<sessionId> requires sessionId
run → t=<tenant|_>/p=<principal|_>/r=<runId> requires runId
call → c=<toolCallId> always availableA sessionId alone never keys a live session. It is caller data: anyone who
can reach your host can put any string there, including someone else's. Tenant
and principal are in the key whenever they exist — and a deployment that has no
principal is thereby stating it is single-principal rather than assuming it.
When the facts a scope needs are missing, toolSessionKey returns undefined
rather than guessing, and you refuse. Do not silently narrow or widen: widening
is the cross-binding bug, and narrowing is a hidden 30× latency change nobody
sees until the bill.
When teardown fires
| scope | fires | available at |
|---|---|---|
'call' | when execute settles — resolve or throw | every door, including mcpServe |
'run' | when the turn reaches a terminal that is not a pause | any run |
'session' | when you call agent.closeToolSessions({ sessionId }) | any door |
'shutdown' | agent.shutdown() — including { stop: false } | always |
A pause is not a terminal. A checkIn on a consequential tool stops the run
so a person can decide; tearing down there destroys the state the resume needs,
and it fails quietly — as a resumed run that "just re-ran everything". Both
pause shapes are skipped. An error, by contrast, is a terminal: nobody is
coming back, and a resource held by a crashed run is the clearest kind of leak.
shutdown({ stop: false }) closing tool sessions is deliberate. stop governs
borrowed strategies — telemetry a host was handed and does not own. A session
is not borrowed: this runtime opened it, and nobody else holds a handle to close
it. Draining without closing would leak every sandbox on standingAgent's
default path.
Who says a session ended
Nothing in this library can know when a request/reply session is over. A
HostRequest carries a sessionId and no end; SessionLifecycle is
hydrate/persist by design; and managed backends do not tell you either — an
idle timeout is the reality. So the mechanism is the library's and the timing
is your composition root's, the same doctrine that stops shutdownOn from
grabbing signals by default. On the conversation door it is one line:
conversation.onClose(() => void agent.closeToolSessions({ sessionId }));Never calling it is survivable, not silent: sessions idle out on a lazy sweep
(no timers — a library that installs an interval keeps your process alive), a
bounded live count evicts the coldest, and shutdown() takes the rest.
The record it leaves
Four events on the existing agentfootprint.tools. stream:
agentfootprint.tools.session_started, agentfootprint.tools.session_reused
(with calls — how many calls have shared one start-up, which is the payoff
measured), agentfootprint.tools.session_closed (with reason), and
agentfootprint.tools.session_close_failed (with errorClass). Teardown never throws into your run — but
it is never silent either, and that last one is the difference: a vendor Stop
that failed leaves something you are still paying for.
Payloads carry a keyHash, never the key: the key composes tenant, principal
and sessionId, and publishing it would put a user identifier into every
exporter's payload.
A ready-made tool that does all of this — codeRunnerTool — is in
Tools and gateways.
The names
Everything here is on the main barrel.
| Name | What it is |
|---|---|
TeardownScope | The four scopes: 'call' · 'run' · 'session' · 'shutdown'. |
TeardownOptions | The second argument to ctx.onTeardown — scope, key, and the optional runnerId / label that ride the events. |
TeardownReason | Why a cleanup ran, as reported on session_closed: call-end · run-end · session-end · shutdown · idle · evicted. |
toolSessionKey(ctx, scope) | The one key derivation. undefined when the scope's facts are absent. |
hashSessionKey(key) | The digest the events carry. SHA-256 (12 hex chars) where node:crypto resolves, FNV-1a in a browser bundle — never reversible to the key. |
TOOL_TEARDOWN_TIMEOUT_MS | The 5000ms default behind AgentOptions.toolTeardownTimeoutMs. |
ToolTeardownTimeoutError | Raised inside the tier when a cleanup outruns its budget; surfaces as errorClass on session_close_failed, never into your run. |
agent.closeToolSessions({ sessionId, reason }) | Ends 'session'-scoped cleanups. Answers how many ran; 0 on a runner that holds none. |
For codeRunnerTool specifically: CodeRunnerToolOptions is its options bag,
CodeRunnerToolScope is the 'call' | 'run' | 'session' subset it accepts
('shutdown' is when everything goes, not a thing to key one session on), and
toolSessionsOf(tool) reads the live-session map back for a test or an
inspector. That map rides the tool under the TOOL_SESSIONS registry symbol
(the shape HoldsToolSessions describes) — invisible to the LLM and to Tool's
own shape, and a different symbol from flowchartAsTool's inner-record
registry, so one tool can carry both.
Typed tool effects — steering the run with data (9.19.0)
Before 9.19.0 a tool that wanted to steer the run had exactly one medium —
its result string — and any convention riding it ("ROUTE:billing") was
arbitrary text one prompt injection away from control authority. The typed
effects channel replaces the convention with data the framework validates.
The law: push mandatory procedure; pull optional knowledge; never let
arbitrary text promote itself into control authority.
A tool opts in by returning the result envelope — { content, effects, status? }. content is what the model reads (exactly what a bare return
would have shown); the rest is for the framework. The effects array is
required — it is the envelope marker itself: when only the status
matters, spell it { content, effects: [], status: 'denied' }. A
{ content, status } without effects is not an envelope (a domain
object could already have that shape) — it stays data byte-for-byte, and
dev mode warns you about the missing marker so the miss is never silent:
// A tool opts into the typed effects channel by RETURNING the envelope —// content for the model, status + effects for the framework. Plain// returns stay byte-identical; string conventions never become authority.const issueRefund = defineTool<Record<string, never>, unknown>({ name: 'issue_refund', description: 'Issue the refund for the looked-up order.', inputSchema: { type: 'object', properties: {} }, execute: () => ({ content: 'refund refused: order is outside the 30-day window (policy P-12)', // The declared OUTCOME — the `onToolStatus: 'denied'` edge below // routes on this, so a denial can never route like a success. status: 'denied', // Push the registered playbook into the NEXT call — the model reads // the denial with the playbook already in front of it. effects: [ { kind: 'require-instruction', instructionId: 'denial-playbook', deliveryLease: 'next-call', }, ], }),});// The pushed instruction must be REGISTERED — an unknown id is a// teaching refusal, recorded. It is inert on its own (`activeWhen`// false); only a granted lease ever delivers it.const denialPlaybook = defineInstruction({ id: 'denial-playbook', prompt: 'A refund was denied by policy. Explain WHICH policy, offer store credit, ' + 'and never promise an exception.', activeWhen: () => false,});Two effect kinds, deliberately:
{ kind: 'propose-transition', targetSkillId, reason }— the typed replacement for string routing markers. The graph decides: the target is reachability-checked against the graph's own law, an accepted proposal moves the cursor at the next evaluation (cursorMove.by: 'tool-proposal'), a refusal is teaching and recorded. Precedence is stated: a same-batch declared edge still wins (the author's determinism, reported asskill.reroute_superseded { source: 'tool-proposal' }), and a proposal outranks the model's ownread_skillpick (deterministic tool code over a model guess). Because proposals come from tools — code the author shipped — they are framework-tier evidence: admitted under every posture,railsincluded. Conflicting same-batch proposals reuse theroute_conflictlaw: first accepted in call order wins, the rest are suppressed on the record (source: 'tool-proposal').{ kind: 'require-instruction', instructionId, deliveryLease }— pushes a registered instruction (a skill body or a declared snippet) into the coming call(s).'next-call'serves exactly the next LLM call;'until-skill-exit'serves while the tenure that granted it holds — and when that tenure ends, the lease dies for good: on a cyclic graph the cursor may later re-enter the granting skill, and a dead lease does not come back with it (a fresh tenure needs a fresh grant).read_skillstays the pull door; this is the push door, and it serves the declared catalog only — an unknown id (or a'tool-only'body, whose declared channel cannot be pushed) is refused teachingly.
Outcome status, normalized. Beside the effects rides an optional
status: 'success' | 'failure' | 'denied' | 'invalid' | 'partial' | 'pending' — and route edges gain onToolStatus, the data form of "route
on meaning": .route(refund, escalation, { onToolReturn: 'issue_refund', onToolStatus: 'denied' }) fires only on a denied refund, never a
successful one. A result with no declared status can never match a status
edge (an undeclared outcome is not evidence). The status also rides
stream.tool_end and the toolResults batch, and route() refuses
when + onToolStatus together (code or data, never both).
Every judgment is a typed agentfootprint.tools.effect event —
accepted, refused (with the teaching sentence), or superseded — and
refusal notes join the model-visible result so the model can route around
them. Zero-cost when unused: recognition is strict (an envelope needs
content plus an effects array whose every element speaks the reserved
kind vocabulary; an empty effects: [] also needs one of the 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? }(effectsrequired — status-only iseffects: []).ProposedEffect— the two-kind union;ProposeTransitionEffectandRequireInstructionEffectare its arms, andInstructionDeliveryLeaseis the'next-call' | 'until-skill-exit'union behind the push lease.ToolResultStatus— the closed six-value outcome vocabulary;TOOL_RESULT_STATUSESis the same set as data.readToolResultEnvelope— the strict recognizer itself (exported so a custom runner or a test can apply exactly the framework's rule);ReadToolResultEnvelopeis its result: unwrappedcontent, the valideffects, thestatus, and themalformedrefusals.explainStatusOnlyNearMiss— the recognizer's teaching companion: given a value that is not an envelope, it returns the warning sentence when the shape is a status-only envelope missing itseffects: []marker ({ content, status: 'denied' }), andundefinedfor everything else. The framework calls it for its own dev-mode warning; it is exported so a custom runner can teach the same miss. Diagnosis only — it never changes what any value does.PendingToolTransition— the accepted proposal as it rides scope state (sharedState.pendingToolTransition): target, proposing tool, reason, and the granting iteration (one-shot by data).InstructionLease— one granted push as it ridessharedState.instructionLeases: the instruction, its lease, the tenure that granted it, and the granting call.
The result ceiling — refuse, never truncate (9.20.0)
A tool once returned ~191,000 characters. The tempting fix — truncate it — is the
fabrication trap: a truncated result reads as a complete one. The model cannot
tell the data ends where the cut happened, so it answers from the fragment as if it
were everything, confidently. resultCeiling is the tool author's contract that
prevents it: over maxChars, the model receives a teaching refusal instead of
data — the true size, the ceiling, the parameters to narrow by, and the sentence
that keeps it honest: "No data was returned." A clean retry follows, because the
refusal says exactly how to make one.
const exportOrders = defineTool<{ limit?: number }, string>({
name: 'export_orders',
description: 'Export orders as CSV rows. Pass limit to bound the export.',
inputSchema: { type: 'object', properties: { limit: { type: 'number' } } },
// Over 2 000 chars the model reads a refusal, not a truncation:
// "Result too large: export_orders returned 102442 chars, over its declared
// 2000-char ceiling. Narrow the request and call again — e.g. pass 'limit'.
// No data was returned."
resultCeiling: { maxChars: 2_000, narrowBy: ['limit'] },
execute: async ({ limit }) => fetchRows(limit),
});What the framework guarantees when the ceiling fires:
- The payload enters no channel. Not history, not
stream.tool_end, not any recorder — refused means refused everywhere. The record keeps the truth as the typedagentfootprint.tools.result_refusedevent:{ toolName, toolCallId, iteration, sizeChars, maxChars, narrowBy? }. - The result carries status
'invalid'— the closed-set member whose corrective action is "fix the call" (the tool itself did not fail, nothing partial was delivered, and no policy denied it) — so a skill-graph edge can route the overflow:.route(support, narrowDesk, { onToolStatus: 'invalid' }). - An effects envelope keeps its declared effects. When
{ content, effects, status }overflows its content, the content is refused but the declared effects are still judged — a tool that proposed a transition and overflowed its data does not lose the transition (the effects channel is validated data, not the channel that overflowed). The status the tool declared rides the event asdeclaredStatus; the delivered status is'invalid'. - A procedure step does not advance. The refusal's own instruction is to call again — a stepped skill's pointer holds.
- Every dispatch door refuses alike. The inline batch and every resumed dispatch (check-in approval, middleware ask, credential consent) measure at the same boundary.
- Zero-cost when unused. No
resultCeiling= nothing measured, no event, byte-identical results — including 9.19 envelope semantics.
The agent-level maxToolResultChars remains the other ceiling —
truncate with a verbatim head and a truncated marker — for operators capping tools
they did not write. Only the author knows which parameters make a retry smaller,
which is why narrowBy lives on the tool; the two compose (the refusal sentence is
far under any sane agent cap). The declared shape is the exported
ToolResultCeiling interface — { maxChars, narrowBy? }. A bad ceiling is refused
at defineTool — maxChars must be a positive whole number, and a narrowBy: []
that could suggest nothing is refused too (omit the field to say "no suggestions");
assertResultCeiling is exported for hand-built Tool objects.
With an artifact store attached there is a third dial in the
family: the placement threshold (artifacts: { store, placement: { maxInlineChars } }) — over it, the result is checked into the store and the model
reads a claim ticket. The stated precedence: the tool's resultCeiling first, then
placement, then maxToolResultChars last (which then measures the ticket).
wants — artifact refs as tool arguments (9.22.0)
The needs precedent applied to data. A tool declares which of its
arguments are claim tickets and what artifact kind each must redeem to; the model
passes the ~26-char art_… ref STRING; and at dispatch — before credentials,
before execute — the framework redeems the ref under the run's own scope and
kind-checks the meta. The handler receives the resolved data in args and the
tickets on ctx.wanted:
const transformReport = defineTool<{ dataset: string }, string>({
name: 'transform_report',
description: 'Aggregate a stored dataset. Pass the art_… ref from get_data.',
inputSchema: {
type: 'object',
properties: { dataset: { type: 'string' } }, // the model speaks the REF
required: ['dataset'],
},
wants: { dataset: 'dataset/rows' }, // …and the framework redeems it
execute: async (args, ctx) => {
const rows = args.dataset as unknown as Row[]; // the DATA, already resolved
const ticket = ctx.wanted?.dataset; // the ArtifactMeta behind it
return `total: ${total(rows)} (from ${ticket?.ref})`;
},
});What the framework guarantees:
- A stale, unknown, or wrong-kind ref never reaches the tool. The call is not
executed; the model reads a teaching refusal that lists the live refs of the
wanted kind in scope — correction by naming what can resolve. On the record:
agentfootprint.artifacts.refusedwithop: 'dispatch'; successful resolution ridesartifacts.resolved(via: 'get'). - Every dispatch door resolves alike — the inline batch and every resumed dispatch. Scope is the run's own (tenant/principal/conversation): a ref minted in another session resolves to nothing here.
- Declared honestly or refused at
defineTool: eachwantsargument must exist ininputSchema.propertiesastype: 'string'(assertToolWantsis exported for hand-builtToolobjects). An agent with a statically registeredwantstool and no store refuses at build;mcpServerefuseswantstools by name (that door has no store). - Zero-cost when unused. No
wants= nothing resolved,ctx.wantedabsent, byte-identical dispatch.
The full worked example — placement mints a 48k-row result, the ref rides a wants
argument, present hands the chart to the screen — is on the
Artifacts page and runnable as
examples/features/57-artifact-data-flow.ts.
Staging refs into a code session (9.26.0)
wants resolves a ref into an argument. For a code runner that is only half the
story: the resolved payload still has to reach the interpreter, and 9.22.0
stated the honest cut — the CodeSession port's only input was the code string,
so pushing a payload through it would mean inlining megabytes into an argv in
language-specific quoting.
CodeSession.stageInputs is the file-write verb that note was waiting for, and
codeRunnerTool({ wants }) is what uses it:
import { codeRunnerTool } from 'agentfootprint';
import { localCodeRunner } from 'agentfootprint/providers';
Agent.create({ provider, model, artifacts: { store } })
.tool(codeRunnerTool({
runner: localCodeRunner(),
language: 'python',
wants: { dataset: 'dataset/rows' },
}))
.build();The model passes the art_… ref as dataset; the framework resolves it under
the run's own scope (the same wants machinery, with the same teaching refusals
for a stale, unknown or wrong-kind ref); the tool writes the resolved payload
into the session as a file before the code runs. Data now flows both ways
without entering the context window: refs in as staged files, produced files out
as refs.
What the model's code reads
One environment variable, on every backend that stages. STAGED_INPUTS_ENV
(AF_STAGED_INPUTS) holds a JSON object keyed by argument name:
import json, os
path = json.loads(os.environ['AF_STAGED_INPUTS'])['dataset']
rows = json.load(open(path))The composed tool description says exactly that, with a one-line example in the
tool's own language, so a model needs nothing beyond the description. The
manifest key is the argument name (what the description told it to look up)
while the file gets an extension derived from the artifact's own media type —
CodeInput keeps name and fileName as separate fields precisely so the two
cannot drift. StagedCodeInput is what an adapter reports back: { name, path, bytes }.
Refused, never degraded
A runner whose sessions cannot stage — stageInputs absent, which is the honest
state of any backend that cannot write into its own session — makes a
wants-declaring code tool refuse by name at dispatch. Running the code
anyway would leave the model debugging a missing file, in a session that never
had the data, for a reason nothing in the conversation could reveal. Detect it
yourself with canStageCodeInputs(session).
Staged inputs live as long as the session and are released by stop(). On
localCodeRunner they land in a private temp directory, and a caller-supplied
name becomes one inert file-name segment — .. and separators arrive as data
and land as literals, the same law the artifact scope paths follow.
Zero-cost without wants: no schema properties are added, no session is ever
asked to stage, no filesystem module is loaded, and the description is the one
earlier releases composed.
The repeated-call nudge (9.26.0)
A traced production run: the model called one tool three times with byte-identical arguments and got a byte-identical result each time. The tool was doing its job — the arguments named a filter the backend silently ignored — and the model read the same rows as a fresh answer each iteration, concluded nothing had changed, and tried again. Three calls, three identical results, one wasted turn, and nothing in the loop that could say "you have done this".
That class of loop is invisible from inside the conversation: the history genuinely shows three separate calls that each returned data. The only party with the whole picture is the framework, which watched all three land. So on the second identical landing it appends one sentence to that result:
identical call: 'search' has now returned exactly this result 2 times this turn, for exactly these arguments. Calling it again will not change it — act on what you have, or change the arguments…
It is a note, not a refusal. The call ran, the result is unchanged beside the note, nothing errored, and a third identical call is not blocked. That restraint is the design: polling a job until its status changes is a loop of identical calls returning identical results on purpose, and only the model knows which it is doing.
Both halves are required. Identical arguments alone are not evidence — a "check status" call returning a different status is progress. It is the identical result that makes the repeat pointless, which is also what lets the note say something specific and true.
It fires once per distinct call, at the threshold landing exactly; a fourth identical call adds nothing further, because repeating the lesson every iteration would be the framework doing the very thing it is complaining about. It is applied at the batch dispatch loop only — the pause-resume paths deliver a call a person answered, and a note telling the model it has already done what a human just authorised would be the framework arguing with the human.
Set repeatedCallNudge: false on Agent.create to switch it off: nothing is
fingerprinted, no counter is kept, and even a repeating turn is byte-identical
to earlier releases. Worth doing when a deployment's tools are deliberately
polled.
Each note also lands on the record as agentfootprint.tools.repeated_call,
carrying { toolName, toolCallId, iteration, occurrences, argsFingerprint, resultFingerprint }. Fingerprints, never values — tool arguments routinely
carry the things redaction exists for, and a fingerprint answers the only
question this feature asks.
A turn that repeats nothing is byte-identical whether the dial is on or off
— same results, same events, same tracked state down to the key set. The
counters are held beside the dispatch loop, keyed by runId, and never written
to scope: a within-turn tally is not conversation state, and tracked state is
the commit log, the snapshot, the narrative and every recording. Upgrading
changes nothing until a call actually repeats. (A resume mints a fresh runId,
so a turn continued after a person answered starts counting again — the
framework only watched half of it.)
Anti-patterns
- Don't reach outside the args +
ctxyour tool is given.execute(args, ctx)receives the LLM-supplied args plus aToolExecutionContext— usectx.signalto honor cancellation, andctx.runId/ctx.sessionId/ctx.identityto isolate anything you hold, not ambient globals. (executemay return a value OR a Promise; the loopawaits either, so sync handlers are fine.) - Don't hold a session in a module-level map. It looks like a cache and behaves like one right up until two people use your agent at once. See Tool sessions below.
- Don't put validation logic in
executefor things JSON Schema can express (type,required,enum). The LLM honors well-formed schemas; redundant runtime checks are noise. - Don't make tool descriptions ambiguous. "Get data" is bad. "Look up an order by ID; returns status + amount" is good. The LLM picks tools by description.
Next steps
- Connect to an MCP server —
mcpClient({ transport }), per-request signing, and governing by server - Tool discovery — dynamic catalogs and MCP as an async tool source
- Serve your tools over MCP —
mcpServe(tools), the other direction - Tool providers — dynamic per-iteration tool catalogs (
staticTools,gatedTools,skillScopedTools) - Skills, explained — LLM-activated tool bundles
- Permission gating — restrict which tools an agent can call
- Tools and gateways — the
CodeRunnerport,localCodeRunner, and AgentCore Code Interpreter
Skills, explained
Skills are context engineering for instructions — abstracted so you don't do it by hand and get it wrong. A conceptual walk through what they actually are, and why the abstraction exists.
Artifacts
The claim-check store and the data legs — tools check data in and the model routes ~26-char tickets. ctx.artifacts, wants (refs as tool arguments), the present tool, the placement threshold, typed lifecycle events.
