AgentBuilder

Class: AgentBuilder

Defined in: src/core/agent/AgentBuilder.ts:201

Fluent builder. tool() accepts any Tool<TArgs, TResult> and registers it by its schema.name. Duplicate names throw at build time.

Constructors

Constructor

new AgentBuilder(opts): AgentBuilder

Defined in: src/core/agent/AgentBuilder.ts:466

Parameters

opts

AgentOptions

Returns

AgentBuilder

Methods

act()

act(options): this

Defined in: src/core/agent/AgentBuilder.ts:831

Everything this agent DOES about its own loop, in one block.

Tools do the work. .act() decides about the work. watch remembers both — and nothing can act without being watched.

Five keys, one per moment of a turn, each optional and each the exact argument the individual door takes:

const agent = Agent.create({ provider, model })
  .act({
    input:      [scrubSSNs],        // the message, before the run commits it
    beforeTool: [refundCeiling],    // every call, before it is dispatched
    afterTool:  [hideRawPII],       // every result, before the model reads it
    window:     slidingWindow({ keepRecentTurns: 12 }),
    output:     [noInternalCodenames],
  })
  .build();

It is sugar, and provably so. Each key is forwarded to the door that already owned it — .messageMiddleware(), .toolMiddleware(), .window() — so the agent it builds sends the same request bytes and files the same records as the same rules spelled out one call at a time. That equivalence is pinned per key by tests, the way .compaction()'s is.

The keys cannot fall behind the loop. They are locked at compile time against LoopMoment, so a sixth moment cannot ship without a key here.

A rule speaks where its hooks say, not where you filed it. beforeTool and afterTool are one chain; an entry with both onToolCall and onToolResult runs at both moments whichever key you wrote it under — the KEYS are named for the moments, the HOOKS for what they receive — and an entry named under both keys is the same object attached once. A governance rule that silently did not run because it was written in the wrong bucket is exactly the failure this library exists to make impossible — so the bucket is checked for the hook it names, and the hooks decide the rest.

Call it once. A second .act() throws: two posture blocks means the answer to "what does this agent do at each moment?" is in two places, and the second one silently wins. Adding one piece to an agent somebody else built — a plugin, a policy pack — is what the individual doors are for, and they stay open for exactly that.

Parameters

options

ActOptions

One key per moment. input / output take message middleware, beforeTool / afterTool take tool middleware, window takes a WindowStrategy. Unknown keys throw.

Returns

this


answerLayer()

answerLayer(options?): this

Defined in: src/core/agent/AgentBuilder.ts:2074

Mount the ANSWER LAYER (honesty layer 4): every answer gets its standing, folded from the run's committed record — never from how sure the model sounded — and served as data.

The standing is one of five words: known (a supporting row: a passed .answerValidation() report for these exact bytes), consistent with the run's record (checks ran and none fired — never "verified"), not-sure (a reason fired: an assumed argument, an empty result that did not say what it searched, a declared gap, an unsupported value, a limit that cut the turn short, …), ask, or not-assessed (nothing on the record could be checked). It is the same fold as agent.assessment() and assessAnswer(recording), run once inside the run at the head of the final branch, so the in-run standing equals the one a later reader folds.

Armed, three things happen and no others:

  • turn_end.answerAssessment and one agentfootprint.answer.assessed event carry the value, its rendering, the reason kinds and the checks that ran — never a value from the answer or a quote;
  • the Route decider files two witness rows on the findings ledger, so the fold can read them: the evidence gate's clean pass (grounded — the names-and-numbers check RAN; it never supports "known") and an answer given before a skill's declared steps finished (steps-unfinished);
  • every row the findings ledger gets carries its conversation turn.

It is a LABEL: it never asks, never refuses and never changes the model's reply. The one exception is opt-in — { standingLine: true } appends ONE line to a prose answer (Not sure — window = "2h" was assumed by search_logs's rule, not given by you.), after the limits separator. Refused beside .answerValidation() (which judges the exact bytes delivered) and beside .outputSchema() (a typed answer is JSON, and JSON followed by prose is not) — through this door and AgentOptions.answerLayer alike; the standing still travels as data there. The line, like the limits block, is appended to the answer the run returns — never streamed on stream.token.

The in-run fold reads no app declarations: agent.assessment(declarations) and the answer account take rowsAt, and can say more than the run did.

Off → the chart, the committed rows, the events and the answer are byte-identical.

Parameters

options?
standingLine?

boolean

Returns

this

Examples

const agent = Agent.create({ provider, model })
    .tool(searchLogs)
    .answerLayer()                       // the standing as data
    .build();
  agent.on('agentfootprint.answer.assessed', (e) => console.log(e.payload.standing));
One line to the person, too
  Agent.create({ provider, model }).tool(searchLogs).answerLayer({ standingLine: true }).build();

answerValidation()

answerValidation<T>(options): this

Defined in: src/core/agent/AgentBuilder.ts:3235

Validate a JSON answer against host-owned evidence before delivering it. Requires outputSchema. Enforce (default) refuses failed or unverified checks; observe returns the answer with a recorded verdict. Both modes withhold draft tokens until this boundary. The callback cannot rewrite the answer; the schema's JSON-safe output is serialized once for delivery. .outputFallback() and .limitsTravelWithTheAnswer() are refused beside it in this version.

Type Parameters

T

T

Parameters

options

AnswerValidationOptions<T>

Returns

this


appName()

appName(name): this

Defined in: src/core/agent/AgentBuilder.ts:1129

Set the agent's display name — substituted as {{appName}} in commentary + thinking templates. Same place to brand a tenant ("Acme Bot"), distinguish multi-agent roles ("Triage" vs "Reviewer"), or localize ("Asistente"). Default: 'Chatbot'.

Parameters

name

string

Returns

this


build()

build(): Agent

Defined in: src/core/agent/AgentBuilder.ts:3334

Returns

Agent


checkIn()

checkIn(opts?): this

Defined in: src/core/agent/AgentBuilder.ts:3114

Parameters

opts?

CheckInBuilderOptions = {}

Returns

this


claims()

claims(contract): this

Defined in: src/core/agent/AgentBuilder.ts:1560

WHICH ANSWER FIELDS ARE CLAIMS ABOUT WHICH FACTS (9.61.0) — the claim seam's contract.

The evidence gate (.evidence()) grounds the answer's names and numbers: every value must appear in a tool result. Its own limit is stated in its docs — it cannot catch a false claim assembled from real values ("fc1/3 is healthy" when the data says the port is down uses entirely grounded tokens). This closes that hole for the facts you name: the run's semantic envelopes settle typed readings, and each declared field of the validated answer is compared against the one it claims to report.

DECLARED, NEVER INFERRED — the argumentsFrom precedent. The library does not guess that an answer field named nav_count is about nav(screen2); you say so, and the checker joins. Nothing is blocked: a disagreement files one agentfootprint.integrity.context_error at seam 'claim', and the answer is returned exactly as it was.

Requires .outputSchema() — without a validated answer object there is no typed stratum to read, and prose is never checked. Refused at build() rather than skipped, because a contract that silently checks nothing is worse than no contract.

Parameters

contract

Readonly<Record<string, { entity: string; field: string; }>>

Returns

this

Example

const agent = Agent.create({ provider, model })
    .tool(screenTool)                       // returns describedResult({ facts: [...], provenance })
    .outputSchema(AnswerSchema)
    .claims({ nav_count: { entity: 'screen2', field: 'nav' } })
    .build();

commentaryTemplates()

commentaryTemplates(templates): this

Defined in: src/core/agent/AgentBuilder.ts:1144

Override agentfootprint's bundled commentary templates. Spread on top of defaultCommentaryTemplates; missing keys fall back. Same Record<string, string> shape with {{vars}} substitution as the bundled defaults — see defaultCommentaryTemplates for the full key list.

Use cases: i18n ('agent.turn_start': 'El usuario...'), brand voice ("You: {{userPrompt}}"), per-tenant customization.

Parameters

templates

Readonly<Record<string, string>>

Returns

this


compaction()

compaction(options): this

Defined in: src/core/agent/AgentBuilder.ts:1014

Keep the live context window inside a token budget — without ever losing the record.

Sugar for .window(summarizeOldest(options)), and byte-for-byte the same agent. It keeps its own name because compaction is what the market calls this and it is the strategy most people want first.

At each ReAct iteration boundary, compaction compares the LAST call's adapter-reported input tokens against thresholdTokens. Over budget, it folds the oldest foldable span of the conversation into one summary message and sends that instead. Counted, never guessed: a provider that reports no usage gets a named refusal (CompactionUnmeasurableError) rather than an invented number.

The fold edits the window, not the record. The turns it folds stay in the run's commit log byte-identical — footprintjs's log is append-only, so a fold cannot erase them even in principle. The summary enters as its own recorded step naming every runtimeStageId it folded, plus what was measured and what refused to fold. A compacted run is still a provable run: the lens draws a fold seam, not a hole.

Never folded: the system envelope, the last keepRecentTurns turns, and any turn holding something unresolved — an unanswered tool call, a paused tool, a pending check-in. Folding an unanswered question would destroy the referent of the answer that has not arrived yet, so those refuse by name and the fold takes the next oldest instead.

Omit .compaction() and nothing changes: no stage, no extra keys, the same request bytes as before.

Parameters

options

CompactionOptions

Returns

this

Example

const agent = Agent.create({ provider: anthropic(), model: 'claude-sonnet-4-5' })
  .compaction({
    thresholdTokens: 120_000,
    summarizer: anthropic(),
    model: 'claude-haiku-4-5',   // the cheap one writes the summary
  })
  .build();

configure()

configure(fn): this

Defined in: src/core/agent/AgentBuilder.ts:764

Decide this run's model and/or system prompt when the run starts.

An agent is built once and run many times, but not every run wants the same model or the same instructions: a long message may deserve the bigger model, a tenant may have its own house rules, a canary may want last week's prompt. Rebuilding the whole agent per request works and is wasteful; reaching in and mutating one is worse, because the trace then describes an agent that no longer exists.

The resolver runs ONCE per run(), at the start of the run, and what it returns is committed to the trace — resolvedModel and resolvedInstructions land in the run's commit log before the first LLM call, and the LLM call reads them from there. So the recording says which model actually answered instead of which model the agent was built with.

Return {} (or nothing) to keep the defaults; ctx.defaults carries them, so a resolver can decide relative to what was built rather than restating it. Omit .configure() entirely and every run behaves — and records — exactly as it did before.

This is the RUN axis only. Tools are the iteration axis and already have an owner: .toolProvider(), consulted every iteration.

Throws if called more than once (same rule as .toolProvider() — a silently-overridden resolver is a config that lies).

Parameters

fn

RunConfigFn

Returns

this

Examples

Bigger model for a bigger question
  const agent = Agent.create({ provider, model: 'small-model' })
    .system('You answer support questions.')
    .configure(({ message, defaults }) =>
      message.length > 500 ? { model: 'big-model' } : {},
    )
    .build();
Per-tenant house rules
  const agent = Agent.create({ provider, model })
    .system('You answer support questions.')
    .configure(({ identity, defaults }) => ({
      instructions: `${defaults.instructions}\n\n${rulesFor(identity?.tenant)}`,
    }))
    .build();

fact()

fact(injection): this

Defined in: src/core/agent/AgentBuilder.ts:1631

Register a Fact — developer-supplied data the LLM should see. User profile, env info, computed summary, current time, … Distinct from Skills (LLM-activated guidance) and Steering (always-on rules) in INTENT — the engine treats them all alike.

Parameters

injection

Injection

Returns

this


findings()

findings(options?): this

Defined in: src/core/agent/AgentBuilder.ts:2342

The findings ledger (9.101.0) — the model's OWN standings on its tool results, on the record, at zero extra calls.

WHY. A long tool-using run serves every result back in full on every call, and what the model already stood on, left open, ruled out or found to be noise is nowhere but in its head. With this door every served tool schema carries a reserved optional _findings argument: the model declares a basis for each call before the result exists ('direct' when it expects the answer, 'exploratory' when it is looking) and, on its next tool call or as a top-level _findings.previous on a JSON answer, the STANDING of each previous result by its tool_result id — 'fact' with the assertions it stands on, 'open' with what would settle it, 'ruled-out' with one line, 'noise' with nothing. The library peels the argument off before anything else reads the call (middleware, validation, execute and every pause carrier see the call as if the key were never there), files one flat append-only findingsLedger on the run's state through ONE writer, and leaves history verbatim. Read it back with Agent.findings. Nothing infers: a call with no declaration files no row, and a result nobody named has no standing — never 'open'.

WHAT THE ANSWER TURN IS SERVED. Once the model has declared a standing, every later call is served a request-only system piece composed from the folded ledger (findings/serve.ts · findingsLedgerPiece — facts, limitations, evidenceRefs and nextSteps, each marked "declared by the model"; results nobody named listed as undeclared, never open), and on the wire only — history never changes — a result the model judged noise or ruled-out is replaced by a ticket (collapseJudged). serve chooses how much of the pile stays: 'ledger-and-facts' (the default) keeps fact, open and undeclared results verbatim beside the piece; 'ledger-only' collapses fact results too and is BENCH-GATED — shipped so bench/findings-shuffle.mjs can measure it on a real model, not a recommendation, never a default until that run shows the answer does not drift under shuffled evidence. answerAsk (9.103.0) appends the answer-turn ask (findings/reserved.ts · FINDINGS_ANSWER_ASK) to that piece under 'quote-facts' — BENCH-GATED the same way, against the fidelity dip the real-model page's fourth run measured; 'none' (the default) writes no key and serves the piece exactly as before. keepLedgerFacts is the ceiling of fact turns the WINDOW holds under standing-aware eviction (9.102.0) — the same dial as AgentOptions.keepLedgerFacts (that field says why and what it costs), this door winning when both are given, resolved once by Agent at build and threaded to the window stage on an armed agent with a window strategy, where stages/window.ts · buildWindowStage spends it as the 'ledger-fact' pin ceiling; a negative or non-integer value is refused here.

argumentSources: true (honesty layer 2, declared sources) — on a tool that declares argument rules (askOrAssume), the reserved argument also carries from, first and required: the model says where each argument value came from (the person's words as a quote, a result's id, an earlier answer, the app, or 'assumed'), and the inputs layer CHECKS each claim before the batch runs and files the verdict on the call's argument rows — under an ask rule, a value the checks do not trace is asked of the person, and the rule's sentence names _findings.from. No instruction line is added: from explains itself in its own property. It needs the inputs layer (a registered ruled tool, or .inputsLayer()) and is refused at build without it. The same arm WITHOUT this ledger: .inputsLayer({ argumentSources: true }). Off (the default): nothing is served, read or written.

Once per agent (a second call is refused, the .window() grammar). Registers the always-on findings-ledger instruction — the byte-for-byte twin of outputSchema's piece, hashed per piece on every receipt, so a reworded ask is a different hash a bench can name. An agent that never calls this is byte-identical to one built before the ledger existed: no decoration, no peel, no key, no piece, no event.

Parameters

options?
answerAsk?

"none" | "quote-facts"

Whether the served piece ends with the answer-turn ask (FINDINGS_ANSWER_ASK): 'none' (default) or 'quote-facts' (bench-gated).

argumentSources?

boolean

DECLARED SOURCES (honesty layer 2) — the model says where each argument value of a ruled call came from, in _findings.from (the person's words as a quote, a result's id, an earlier answer, the app, or 'assumed'), and the library CHECKS each claim before the batch runs (arguments/checks.ts · checkSource) and files the verdict on the call's argument rows. Under an ask rule, a present value the checks do not trace to a source is asked of the person. Requires the inputs layer (a registered tool that declares askOrAssume, or .inputsLayer()) — refused at build without it. Without the ledger: inputsLayer: { argumentSources: true }. Default off: no from property, no check, byte-identical.

judge?

Classifier

A calibrated classifier judging every tool result as a second source (JudgmentRow), never served in the model's place. Default none.

keepLedgerFacts?

number | false

The ceiling of fact turns the window holds beyond keepRecentTurns, or false (= 0) for no hold — the same dial as AgentOptions.keepLedgerFacts; this door wins when both are given. Default 4 under .findings() with a window strategy.

maxContextBytes?

number

Hard UTF-8 byte ceiling for the request-only findings DATA frame. Whole entries are omitted with counts; identifiers are never clipped. Default 16384; valid integers 1024–262144.

serve?

"ledger-and-facts" | "ledger-only"

What the model is served from the ledger: the default keeps fact results verbatim beside the piece; 'ledger-only' collapses them too (bench-gated).

Returns

this

Example

const agent = Agent.create({ provider, model })
  .tool(search)
  .findings()
  .build();
await agent.run({ message: 'which port is down?' });
const ledger = agent.findings() ?? [];
const facts = ledger.filter((r) => r.kind === 'standing' && r.standing === 'fact');

injection()

injection(injection): this

Defined in: src/core/agent/AgentBuilder.ts:1182

Register any Injection. Use this for power-user / custom flavors; for built-in flavors use the typed sugar (.skill, .steering, .instruction, .fact).

An Injection carrying inject.messages is ROUTED here, not refused (7.19.1 refused it; 7.21.0 delivers it). What still gets refused is the pair the wire cannot take: a role: 'tool' message has no tool call to answer, so it is rejected here, at the declaration, on every provider. A role the ATTACHED provider cannot carry is a different question — it depends on the provider, which this builder does not have — so it is refused at run start instead, by name. This is the one funnel every flavor passes through, so a hand-built Injection cannot go around the checks the named factories make.

Parameters

injection

Injection

Returns

this


inputsLayer()

inputsLayer(options?): this

Defined in: src/core/agent/AgentBuilder.ts:1954

Mount the INPUTS LAYER (honesty layer 2) for ruled tools the build cannot see — tools a ToolProvider serves.

A tool that declares askOrAssume (defineTool({ …, askOrAssume: { window: { assume: '2h' } } })) arms the layer by itself when it is REGISTERED on the agent (.tool(), a skill's tools, an MCP tool registered here): the library fills a value the call leaves out, files it on the findings ledger as default, tells the model in a note on the result, and the answer's standing says the value was assumed. A ToolProvider's list is only known per iteration, so the build cannot see its ruled tools — and a ruled tool met on an agent without the layer is REFUSED rather than run unruled, because configured-and-inert looks exactly like configured-and-working. This one line mounts the layer so those tools' rules apply.

argumentSources: true arms DECLARED SOURCES without the findings ledger: each ruled tool's served schema carries the reserved _findings argument with from alone — where each argument value came from (the person's words as a quote, a result's id, an earlier answer, the app, or 'assumed') — and the layer CHECKS each claim before the batch runs; under an ask rule a value the checks do not trace is asked of the person. The same checks and rows as .findings({ argumentSources: true }), without the ledger's _findings schema on every tool (about 2,500 characters each) — .findings() beside it serves both, exactly as that door does.

Off → the chart is byte-identical (nothing mounted, decorated, read or written).

Parameters

options?
argumentSources?

boolean

Returns

this

Examples

const agent = Agent.create({ provider, model })
    .toolProvider(fleetTools)   // lists a tool that declares askOrAssume
    .inputsLayer()
    .build();
// The model says where each ruled value came from; the library checks it.
  const agent = Agent.create({ provider, model })
    .tool(searchLogs)           // declares askOrAssume: { window: { ask, choices } }
    .inputsLayer({ argumentSources: true })
    .build();

instruction()

instruction(injection): this

Defined in: src/core/agent/AgentBuilder.ts:1609

Register an Instruction — rule-based system-prompt guidance. Predicate runs each iteration. Use for context-dependent rules including the "Dynamic ReAct" on-tool-return pattern.

Parameters

injection

Injection

Returns

this


instructions()

instructions(injections): this

Defined in: src/core/agent/AgentBuilder.ts:1620

Bulk-register many instructions at once. Convenience for consumer code that organizes its instruction set in a flat array (const instructions = [outputFormat, dataRouting, ...]). Each element is registered via .instruction() so duplicate-id checks still fire per-entry.

Parameters

injections

readonly Injection[]

Returns

this


limitsTravelWithTheAnswer()

limitsTravelWithTheAnswer(): this

Defined in: src/core/agent/AgentBuilder.ts:1896

Make the limits of an answer travel WITH the answer (this release).

A tool that returns coverage(verdict, { checked, notChecked, cannotCover }) — or absent({ what, checked, … }) — declares the ground its result stands on. With this on, the run's declarations are folded into one block and appended to the final answer, so a reader learns whether "everything looks fine" means verified or unexamined. (A typed answer gets the same limits as data instead — see below.)

Why appended, and not asked for

A limit the model is asked to carry is a limit the model can drop, and dropping it is invisible: an answer with no caveat and an answer whose caveat was omitted read identically. The block is therefore composed by the framework from what the tools declared and concatenated onto the answer — the model does not write it, so the model cannot drop it. The price is that it changes the bytes of the answer, which is why it is off by default.

What it is not. It does not judge whether the model stated the limits in its own prose, and it does not refuse an answer that did not. Both would need a second model to decide what counts as "stated", which is the one thing this library will not put in a guard (see .namesAndNumbersFromEvidence() and core/agent/evidence/README.md).

Off → byte-identical: nothing is appended and the final branch mounts the stage function it has always mounted. The RECORDING half runs either way (agentfootprint.tools.coverage_declared / .absent, and coverageDeclared in the snapshot), so you can measure how often your tools declare limits before you decide to ship them.

With an output schema, the limits come back as data

A typed answer (.outputSchema()) is JSON, and JSON with a block of prose after it is not JSON — so nothing is appended to it. The answer stays exactly what the model sent, runTyped() parses it, and the same limits the block would have listed come back beside it: agent.answerCoverage() ({ checked, notChecked, cannotCover }), turn_end.answerCoverage, and answerCoverage in the snapshot.

Returns

this

Examples

const agent = Agent.create({ provider, model })
    .tool(replicationHealth)   // returns coverage(verdict, { … })
    .limitsTravelWithTheAnswer()
    .build();

A typed answer — the limits as data

const typed = Agent.create({ provider, model })
  .tool(replicationHealth)
  .outputSchema(Verdict)
  .limitsTravelWithTheAnswer()
  .build();
const verdict = await typed.runTyped({ message: 'is replication healthy?' });
const limits = typed.answerCoverage(); // undefined when no tool declared any

maps()

maps(options?): this

Defined in: src/core/agent/AgentBuilder.ts:1509

Mount the maps kernel (9.58.0) — the layer that owns ENGAGEMENT, the axis orthogonal to every map's own cursor.

A mounted map (today: the skill map; the screen map is the next tenant) keeps sole ownership of its position. What the kernel owns is whether that map's contributions — prompt fragment and tools — ride the next call. An engagement founded on a GUESS (an entry regex, a classifier) is renewed only by concrete evidence: the map's own tool called, a declared route fired, the model asking by name. Without corroboration for renewalGrace consecutive passes the map is PARKED — its cursor stays exactly where the map put it, its contribution stops riding, and explicit or structural evidence re-engages it (an accepted read_skill pick is the recovery door). Every standing change is a typed event: agentfootprint.map.engaged / agentfootprint.map.parked.

Why: in a recorded 30-call turn, an entry regex matched the word "zone" inside "find the most recent zone redundancy run" — a noun the person wanted to FIND, not a task. The turn stood on an audit skill for all 30 calls; its 4 tools were never called; ~7k characters of the wrong map rode every call of a 359k-token turn. Under the kernel that map parks on call four.

Requires a mounted skill map — refused at build() otherwise. Zero-delta when absent: no scope key, no events, byte-identical evaluation.

Parameters

options?

MapsOptions = {}

Returns

this

Example

const agent = Agent.create({ provider, model })
    .skillGraph(myMap)
    .maps({ renewalGrace: 3 })
    .build();

maxIterations()

maxIterations(n): this

Defined in: src/core/agent/AgentBuilder.ts:1084

Override the ReAct iteration cap set via Agent.create({ maxIterations }). Convenience for builder-style code that prefers fluent setters over constructor opts. Last call wins.

Throws if n is not a positive integer or exceeds the hard cap (clampIterations's upper bound).

Parameters

n

number

Returns

this


memory()

memory(definition): this

Defined in: src/core/agent/AgentBuilder.ts:1658

Register a Memory subsystem — load/persist conversation context, facts, narrative beats, or causal snapshots across runs.

The MemoryDefinition is produced by defineMemory({ type, strategy, store }). Multiple memories layer cleanly via per-id scope keys (memoryInjection_${id}):

Agent.create({ provider })
  .memory(defineMemory({ id: 'short', type: MEMORY_TYPES.EPISODIC,
                         strategy: { kind: MEMORY_STRATEGIES.WINDOW, size: 10 },
                         store }))
  .memory(defineMemory({ id: 'facts', type: MEMORY_TYPES.SEMANTIC,
                         strategy: { kind: MEMORY_STRATEGIES.EXTRACT,
                                     extractor: 'pattern' }, store }))
  .build();

The READ subflow runs at the configured timing (default MEMORY_TIMING.TURN_START) and writes its formatted output to the memoryInjection_${id} scope key for the slot subflows to consume.

Parameters

definition

MemoryDefinition

Returns

this


messageMiddleware()

messageMiddleware(...middleware): this

Defined in: src/core/agent/AgentBuilder.ts:3090

Wrap the message boundary in a governance chain — the input before the model sees it, the output before the caller receives it.

Same verbs as toolMiddleware minus one: there is no ask here, and the type says so. Tool dispatch runs inside a pausable stage, so it has a checkpoint to suspend on; the message boundary is a plain stage, and inventing a second pause to give it one would be a worse answer than not offering it.

The 'input' half runs at the very top of the run, BEFORE the message is committed. That placement is the point: everything downstream reads scope.history — the window strategies, the injections, all three slots, the bytes on the wire and every slice taken afterwards — so the transformed text is what the whole run agrees was said. The 'output' half runs before final capture. Configuring this door withholds provider draft tokens and stream.llm_end.content (marked contentWithheld: true). An accepted run emits its captured final answer once; a refused output emits no answer or turn_end and makes no final memory write. The API boundary emits error.fatal so public streams can close.

This door may inspect both phases. For input-only rules with immediate draft streaming, declare .act({ input: [rule] }). Output policy is not audit erasure: raw model content and rewrite evidence remain in the run. Coverage/standing text is composed after the output rule; policy approval is not answer validation.

deny(reason) at either phase raises a MessageDeniedError rather than returning. At 'input' there is no model to tell; at 'output' the middleware has just refused to release an answer, and handing the caller a string in its place is the one substitution they must never make without noticing. The error carries the reason, the phase and the middleware's name — never the refused content.

Parameters

middleware

...readonly MessageMiddleware[]

Returns

this

Example

import { Agent, allow } from 'agentfootprint';

const agent = Agent.create({ provider, model })
  .messageMiddleware({
    name: 'mask-card-numbers',
    onMessage: (msg) => {
      const clean = msg.content.replace(/\b(?:\d[ -]?){13,16}\b/g, '[card]');
      return clean === msg.content ? allow() : allow(clean, 'masked a card number');
    },
  })
  .build();

namesAndNumbersFromEvidence()

namesAndNumbersFromEvidence(opts?): this

Defined in: src/core/agent/AgentBuilder.ts:1825

Require every name and number in the final answer to appear in a tool result the run actually read (9.35.0). If one does not, the model typed it rather than read it.

What it is — and what it provably is not

It is a fabrication detector, not a correctness judge. It catches invented values. It CANNOT catch a false claim assembled from real values: "fc1/3 is healthy" when the data says the port is down uses entirely grounded tokens, and this check passes it without a murmur. Anyone who reads it as a hallucination check will trust it for the one thing it cannot do. It is also conservative by design (small numbers, all-letters names and units are not examined), because a false accusation costs a real turn and can refuse a good answer.

The check is deterministic — set membership over normalized tokens. No second model, no embedding, no judge. A guard that needed a bigger model to police a smaller one would invert this library's whole thesis and would fail exactly where the small model is deployed.

The three postures

Same three words as .skillGraph({ strictness }), deliberately — and a SEPARATE setting, because routing authority and evidence discipline are different decisions:

• 'assist' (default) — record and flag. The answer goes out unchanged; you learn how often it happens before you act on it. • 'guard' — the unsupported values are named back to the model, which gets ONE more ordinary turn (tools still on the wire, so it can go and fetch what it guessed). Survivors ship flagged. This is the recommended posture for a weaker model. • 'rails' — the same one revision, then run() raises UnsupportedValuesError rather than return an answer that still carries them.

Values the USER supplied — this turn's message, the conversation, the system prompt and skill bodies — are exempt without being declared: the user gave them, so they were not invented.

Every judgement lands on the emit channel as agentfootprint.agent.evidence_checked, whatever the posture, so a debugger can show the answer, the values and whether the revision fixed them. The terminal verdict is readable after the run with agent.unsupportedValues().

nudge: true adds the staged-refs nudge — the recency half of grounded numbers. When an iteration's context holds tool results staged by reference (artifacts.placement tickets) and a served tool declares wants over one of their kinds, ONE late line is appended to that request naming the refs and the spender: derived numbers come from the tool, not from mental arithmetic. Composed from declarations only, request-only (never history), recorded as agentfootprint.agent.grounding_nudged. Off by default.

Parameters

opts?

NamesAndNumbersOptions

Returns

this

Example

const agent = Agent.create({ provider, model })
    .tool(showInterface)
    .namesAndNumbersFromEvidence({
      posture: 'guard',
      // The default extractor guesses from digits and punctuation; teach
      // it your domain's shapes and they are checked by name.
      shapes: [{ name: 'wwn', match: /(?:[0-9a-f]{2}:){7}[0-9a-f]{2}/ }],
      exempt: ['v9.35.0'],
      // Name staged dataset refs + the compute tool late in the context,
      // where recency works FOR the instruction.
      nudge: true,
    })
    .build();

ontology()

ontology(ontology, options?): this

Defined in: src/core/agent/AgentBuilder.ts:2588

The declared ontology (9.106.0) — a MAP of what exists and where, never a way to fetch it.

WHY. When a tool result does not hold what a question needs, the model has nothing to say about where the need WOULD be met — it guesses a source, or reports the absence as if nothing anywhere held the term. An ontology lets the application declare, once, what each term IS (its meaning and unit — the context contract's domainDefinitions), how terms RELATE, which SOURCE holds a term with the author's own coverage sentence, and which registered TOOL reads it from there. The model is served that map as data on every call and asked to name the source, the tool or the neighbouring node a need points at — as a proposal. The library executes nothing through the map, fetches nothing, and never decides that a node "has no data": a node with no declared source is served as known, not held here, which is what the declaration says.

WHAT IT DOES. Takes the frozen Ontology defineOntology returned (agentfootprint/ontology); at .build() every tool name a node's via names is checked against the agent's tool registry and a name no registry carries is refused, naming the ontology and the tool. At run seed writes the whole spec once as the run constant AgentState.ontology, every model call is served ONE request-only system piece composed from that key (ontology/serve.ts · ontologyPiece, joined after the recovery piece and before the findings piece, hashed on the receipt, rebuilt byte-equal by servedAt) and agentfootprint.ontology.served fires per call with identities and counts only. Registers the always-on ontology instruction (ONTOLOGY_INSTRUCTION, the outputSchema() twin: a system piece on the receipt, never an injected turn).

Once per agent (a second call is refused, the .findings() grammar). An agent that never calls this is byte-identical to one built before the map existed: no key, no piece, no instruction, no event.

options.ask (9.107.0, the answerAsk grammar — a named value, never a boolean): 'use-the-map' (the default) registers the versioned ONTOLOGY_INSTRUCTION — read the question in the map's terms, look where a source and its tool are declared, walk a relation from a term held to a term needed, and when a need is unmet name where it would be met, never a value off a definition, never the map itself to the person. 'none' serves the map as data and registers no ask of the library's, for an application that writes its own through .instruction() — the map is served either way; only the ask changes, and the served view and the receipt show which (the ontology injection present or absent).

Parameters

ontology

Ontology

options?
ask?

NonNullable<OntologyAsk | undefined>

Returns

this

Example

import { defineOntology } from 'agentfootprint/ontology';

const map = defineOntology({
  id: 'fleet',
  version: '1',
  sources: { inventory: { meaning: 'the switch inventory export', configured: true } },
  nodes: {
    port: { meaning: 'a physical switch port', sources: [{ source: 'inventory', via: ['lookup_port'] }] },
    port_error_rate: { meaning: 'CRC errors per minute on a port', unit: 'errors/min' },
  },
  edges: [{ from: 'port_error_rate', to: 'port', relation: 'measured-on' }],
});
const agent = Agent.create({ provider, model }).tool(lookupPort).ontology(map).build();

outputFallback()

outputFallback<T>(options): this

Defined in: src/core/agent/AgentBuilder.ts:2693

3-tier degradation for output-schema validation failures. Pairs with .outputSchema() — an agent that has one and not the other is refused at .build(), in either call order.

Three tiers:

  1. Primary — LLM emitted schema-valid JSON. Caller gets it.
  2. Fallback — OutputSchemaError thrown. The async fallback(error, raw) runs; its return is re-validated.
  3. Canned — static safety-net value. NEVER throws when set.

canned is validated against the schema at .build() — fail-fast on misconfig (a canned that doesn't validate would defeat the fail-open guarantee at the exact moment it is needed).

The tiers run at the TYPED boundary — run() does not reach them

runTyped() and parseOutputAsync() engage the chain. run() does not, and cannot: these tiers produce a typed value T, and run() resolves to the raw answer string — substituting a fallback there would hand a caller a different answer than the model gave, invisibly. So an agent consumed through run() (a server route, a queue worker, standingAgent) gets NO fallback, and until 8.18.0 nothing said so. Now the unmet-contract warning and agentfootprint.agent.output_contract_unmet both carry fallbackConfigured: true — the signal that a safety net exists and this caller is not standing under it.

Two typed events fire on tier transitions for observability:

  • agentfootprint.resilience.output_fallback_triggered
  • agentfootprint.resilience.output_canned_used — carries retriesSpent, and warns when the canned value lands after re-asks that were billed. With canned set, runTyped() is structurally unable to throw, so nothing else would report that spend.

Type Parameters

T

T

Parameters

options

OutputFallbackOptions<T>

Returns

this

Example

import { z } from 'zod';
const Refund = z.object({ amount: z.number(), reason: z.string() });

const agent = Agent.create({...})
  .outputSchema(Refund)
  .outputFallback({
    fallback: async (err, raw) => ({ amount: 0, reason: 'manual review' }),
    canned:   { amount: 0, reason: 'unable to process' },
  })
  .build();

outputSchema()

outputSchema<T>(parser, opts?): this

Defined in: src/core/agent/AgentBuilder.ts:2234

Type Parameters

T

T

Parameters

parser

OutputSchemaParser<T>

opts?

OutputSchemaOptions

Returns

this


rag()

rag(definition): this

Defined in: src/core/agent/AgentBuilder.ts:1694

Register a RAG retriever — semantic search over a vector-indexed corpus. Identical plumbing to .memory() (RAG resolves to a MemoryDefinition produced by defineRAG()); this alias exists so the consumer's intent reads clearly:

agent
  .memory(shortTermConversation)   // remembers what the USER said
  .rag(productDocs)                // retrieves what the CORPUS says
  .build();

Both end up as memory subflows, but the alias separates "user conversation memory" from "document corpus retrieval" in code intent, ids, and Lens chips.

Parameters

definition

MemoryDefinition

Returns

this


recipe()

recipe(recipe, options?): this

Defined in: src/core/agent/AgentBuilder.ts:613

Apply a recipe — a named, versioned composition over the builder methods below (9.48.0).

Every capability an agent needs already ships; what did not was a declared, versioned, inspectable unit of CONFIGURATION. So an agent's setup lived as prose in an example, was copy-pasted into an app, drifted there, and afterwards nothing on the run could say which composition produced the agent that answered. A recipe is that missing noun, and each applied one puts an { id, version } row on the run manifest.

configure runs SYNCHRONOUSLY and immediately — at the position in the chain where you wrote .recipe(), so declaration order is application order and a later call still wins the way it always has. There is no deferred phase, nothing to close and nothing registered anywhere: see AgentRecipe for why that limit is deliberate.

Conflicts. A tool name or injection id a recipe introduces that is already taken refuses right here, naming BOTH sources — which recipe, or the app itself. 'error' is the only policy ({ conflict }); anything else is refused by name rather than approximated.

Parameters

recipe

AgentRecipe

options?

RecipeOptions

Returns

this

Example

an app composing two published recipes

import { defineAgentRecipe } from 'agentfootprint/recipes';

const agent = Agent.create({ provider, model })
  .recipe(supportDesk)   // system prompt + order lookup
  .recipe(housePolicy)   // the steering every agent here carries
  .tool(escalate)        // and one tool this app adds itself
  .build();

reliability()

reliability(config): this

Defined in: src/core/agent/AgentBuilder.ts:2800

Wire rules-based reliability around every CallLLM execution. The framework wraps the LLM call in a retry/fallback/fail-fast loop driven by preCheck and postDecide rules.

Decision verbs the rules can emit (see ReliabilityDecision for the full list):

• continue — pre-check OK, proceed to the call • ok — post-call OK, commit and return • retry — re-call same provider (bumps attempt) • retry-other — advance to next provider in providers[] • fallback — invoke config.fallback(req, lastError) • fail-fast — throw ReliabilityFailFastError at agent.run()

Streaming + reliability semantics — first-chunk arbitration: Pre-first-chunk failures (connection/headers/breaker-open) honor the full rule set (retry, retry-other, fallback, fail-fast). Post-first-chunk failures (mid-stream) honor only ok and fail-fast; rules wanting retry/retry-other/fallback are escalated to fail-fast with kind 'mid-stream-not-retryable'. This matches LangChain's RunnableWithFallbacks pattern and the prevailing industry default — see the streaming + reliability design memo for the full discussion.

Throws if called more than once on the same builder.

Parameters

config

ReliabilityConfig

Returns

this

Example

import { Agent } from 'agentfootprint';
  import { ReliabilityFailFastError } from 'agentfootprint/resilience';

  const agent = Agent.create({ provider, model: 'mock' })
    .system('Triage support tickets.')
    .reliability({
      postDecide: [
        { when: (s) => s.errorKind === '5xx-transient' && s.attempt < 3,
          then: 'retry', kind: 'transient-retry' },
        { when: (s) => s.error !== undefined,
          then: 'fail-fast', kind: 'unrecoverable' },
      ],
      circuitBreaker: { failureThreshold: 3 },
    })
    .build();

  try {
    await agent.run({ message: 'help' });
  } catch (e) {
    if (e instanceof ReliabilityFailFastError) {
      console.log(e.kind, e.reason);
    }
  }

resultsLayer()

resultsLayer(): this

Defined in: src/core/agent/AgentBuilder.ts:2010

Mount the RESULTS LAYER (honesty layer 3) for periods the build cannot see coming — a ToolPeriod on a tool a ToolProvider serves, or a period a tool declares only on its results.

A result says what time its read covered: absent({ …, period }), coverage(value, { …, period }) or describedResult({ …, period }) — the instants it queried, and what the store held (or 'unknown', said out loud). The layer runs at the loop head, reads the batch just run, and files ONE verdict per call on the findings ledger (kind: 'period'): covered, partly-held, not-held, unknown — or undeclared, when the tool declares a ToolPeriod and the result said nothing about what its read covered. The answer's standing (agent.assessment()) reads "not sure" for every verdict but covered. It serves the model nothing: the period is already in the result the model read.

A REGISTERED tool that declares a ToolPeriod (defineTool({ …, period: { argument: 'window' } })) arms the layer by itself. Without the layer, a declared period is still recorded (coverageDeclared, the events, the Period: line under .limitsTravelWithTheAnswer()) — but no verdict is filed, and one dev warning per tool says so.

Off → the chart is byte-identical (nothing mounted, read or written).

Returns

this

Example

const agent = Agent.create({ provider, model })
    .tool(backupRuns)   // returns absent({ …, period }) / describedResult({ …, period })
    .resultsLayer()
    .build();
  await agent.run({ message: 'Any failed backups in the last hour?' });
  (await agent.assessment())?.reasons; // [{ reason: 'period-not-held', layer: 3, … }]

selfExplain()

selfExplain(opts?): this

Defined in: src/core/agent/AgentBuilder.ts:3122

Parameters

opts?

SelfExplainOptions = {}

Returns

this


skill()

skill(injection): this

Defined in: src/core/agent/AgentBuilder.ts:1209

Register a Skill — LLM-activated, system-prompt + tools. Auto-attaches the read_skill activation tool to the agent. Skill stays active for the rest of the turn once activated.

Parameters

injection

Injection

Returns

this


skillGraph()

skillGraph(graph, options?): this

Defined in: src/core/agent/AgentBuilder.ts:1254

Mount a declarative skill graph (proposal 002) — each skill carries a graph-derived trigger (entry → always/rule, deterministic route → rule / on-tool-return), so dynamic token-efficient loading becomes declared and drawable. Pure sugar over .injection() — graph.toMermaid() renders the topology.

The optional second argument (SG-C, 9.17.0) sets the MOUNT's routing posture and cursor span — see SkillGraphOptions. Omitted, the agent behaves byte-for-byte as it always has.

Parameters

graph
deferredBodyContract?

{ mode: "warn" | "throw"; }

The graph's note that it deferred its body-contract checks to agent build (built without knownTools — see SkillGraph.deferredBodyContract). Optional for forward-compat; absent → the checks already ran at graph build (or were off), so this agent never re-runs them. Library-built graphs also stamp the note on each compiled skill's metadata, which build() prefers — this field is the fallback for a structurally-typed graph without the per-skill stamps (skills found by both are deduped by id).

deferredBodyContract.mode

"warn" | "throw"

edges?

readonly object[]

The declared edges. Read for ONE thing: which skills the graph wires, so the read_skill gate can tell a skill the graph routes from one it never mentions (see openSkillIds in build()). Optional for forward-compat with graphs built before edges existed; absent → the graph wires nothing.

entrySelection?

"scorer" | "model-read" | "classify"

How the graph picks a turn's starting entry (SG-C). Read for one refusal: strictness: 'rails' cannot honor 'model-read'.

explainNextSkill?

(ctx) => CursorMove

The same cursor resolver, reporting the clause that won (8.5.0). Optional for forward-compat; absent → no cursorMove on context.evaluated.

nextSkill

(ctx) => string | undefined

nodes?

readonly object[]

The drawn nodes. Read for TWO things: a predicate node means this graph is a decision tree() (the gate's refusal says so out loud), and the node-id set is what a continuity cursor is validated against (droppedResume). Derived here rather than added to SkillGraph as a mode field — the shape is already public, and one fact should not be declared twice.

reachableSkills?

(currentSkillId?) => readonly string[]

scoreEntries?

(ctx, signal?) => Promise<EntryScoring>

skills

readonly Injection[]

supersededEntries?

(ctx) => readonly string[]

The entries the cursor law superseded this iteration (8.15.0). Optional for forward-compat; absent → no supersededIds on context.evaluated.

turnRouting?

TurnRoutingPlan

The graph's turn-routing plan (SG-C) — tier-1 rules, intent candidates, the classifier and the resolved tie policy. Optional for forward-compat with graphs built before it existed; absent → the cascade cannot run (classify needs it; continuity degrades to nothing rather than guess).

options?

SkillGraphOptions

Returns

this

Examples

const graph = skillGraph()
    .entry(triage)
    .route(triage, sfp, { when: (r) => r.toolName === 'get_counters' && JSON.parse(r.result).crc > 0 })
    .build();
  Agent.create({ provider }).skillGraph(graph).build();
// The conversation keeps its place across turns, and the model may
  // route only when the router declared ambiguity:
  Agent.create({ provider })
    .skillGraph(graph, { continuity: 'conversation', strictness: 'guard' })
    .build();

skills()

skills(registry): this

Defined in: src/core/agent/AgentBuilder.ts:1224

Bulk-register every Skill in a SkillRegistry. Use for shared skill catalogs across multiple Agents — register skills once on the registry; attach the same registry to every consumer Agent.

Parameters

registry
list

Returns

this

Example

const registry = new SkillRegistry();
  registry.register(billingSkill).register(refundSkill);
  const supportAgent = Agent.create({ provider }).skills(registry).build();
  const escalationAgent = Agent.create({ provider }).skills(registry).build();

steering()

steering(injection): this

Defined in: src/core/agent/AgentBuilder.ts:1600

Register a Steering doc — always-on system-prompt rule. Use for invariant guidance: output format, persona, safety policies.

Parameters

injection

Injection

Returns

this


system()

system(prompt, options?): this

Defined in: src/core/agent/AgentBuilder.ts:512

Set the base system prompt.

Parameters

prompt

string

The system prompt text. Stable per-turn.

options?

Optional config. cache controls how the CacheDecision subflow treats this prompt block:

  • 'always' (default) — cache the base prompt as a stable prefix anchor. Highest cache-hit rate; recommended for production agents whose system prompt rarely changes.
  • 'never' — skip caching. Use if the prompt contains volatile content (timestamps, per-request user IDs).
  • 'while-active' — semantically equivalent to 'always' for the base prompt (it's always active by definition).
  • { until } — conditional invalidation (e.g., flush after iter 5).
cache?

CachePolicy

Returns

this


thinking()

thinking(opts): this

Defined in: src/core/agent/AgentBuilder.ts:2888

v2.14+ — REQUEST-side thinking activation. Tells the provider to emit reasoning blocks alongside its response.

What this does: every LLM call carries LLMRequest.thinking = { budget } — the intent. The adapter sends the shape the MODEL takes, from what it declares per model (LLMProvider.thinkingMode):

  • 'budget' (Claude 4.6 and earlier): thinking: { type: 'enabled', budget_tokens: budget } — a target for the thinking, not a hard cap (max_tokens is the cap);
  • 'adaptive' (Claude 4.7 and later, Mythos Preview, and any id the table does not know): thinking: { type: 'adaptive', display: 'summarized' } — the model decides whether and how much to think and the budget is NOT sent (dev mode says so once per model); it only keeps max_tokens above it. An adapter's thinkingMode option changes the answer for one id (an alias, a profile ARN, or Opus 4.6 wanted adaptive);
  • 'none' (Claude 3.x except 3.7 Sonnet): refused here at build() with an UnsupportedThinkingError.

Distinct from .thinkingHandler():

  • .thinking({ budget }) = ASK the model to think (request side)
  • .thinkingHandler(h) = NORMALIZE the response (response side)

Most consumers want both; auto-wired handler covers the response side automatically when .thinking() is set on a thinking-capable provider. Setting .thinking() without .thinkingHandler(null) is the typical happy path.

Provider compatibility:

  • Anthropic: per model, as above. A budget model needs a whole budget of at least 1024, and no thinking model takes a temperature other than the default — both are refused with an UnsupportedThinkingError before the request is sent.
  • OpenAI: ignores. o1/o3 reasoning is selected at the model id level; this field is a no-op for OpenAIProvider.

max_tokens: thinking counts toward it. When the request's maxTokens (default 4096 on the Anthropic adapters) is not above the budget, the adapter raises it to budget + 1024.

Calling twice throws — same shape as .reliability() / .outputSchema().

Parameters

opts
budget

number

Returns

this

Example

Agent.create({ provider: anthropic({...}), model: 'claude-opus-5-5' })
    .system('You are a careful reasoning agent.')
    .thinking({ budget: 5000 })   // adaptive on Opus 5.5; budget_tokens on Haiku 4.5
    .build();

thinkingHandler()

thinkingHandler(handler): this

Defined in: src/core/agent/AgentBuilder.ts:2827

Wire a thinking handler (v2.14+). Omitted, the agent uses the handler the provider declares (LLMProvider.thinkingHandler — anthropic() declares one, and a wrapper that forwards the field keeps it). Pass a handler to override it, or null to mount no thinking stage at all. Calling twice throws.

Parameters

handler

ThinkingHandler | null

Returns

this

Examples

// Custom handler that redacts thinking content
  Agent.create({...}).thinkingHandler(myRedactingHandler).build();
// Opt out of thinking parsing entirely
  Agent.create({ provider: anthropic({...}), model: '...' })
    .thinkingHandler(null)
    .build();

thinkingTemplates()

thinkingTemplates(templates): this

Defined in: src/core/agent/AgentBuilder.ts:1156

Override agentfootprint's bundled thinking templates. Same contract shape as commentary; different vocabulary — first-person status the chat bubble shows mid-call. Per-tool overrides go via tool.<toolName> keys (e.g., 'tool.weather': 'Looking up the weather…'). See defaultStatusTemplates for the full key list.

Parameters

templates

Readonly<Record<string, string>>

Returns

this


time()

time(options?): this

Defined in: src/core/agent/AgentBuilder.ts:2150

Arm the TIME LAYER's clock: every run declares when "now" is and which zone the person is in, and the run records it.

  • The zone is per run. run({ message, time: { zone } }) names the person's zone; zone here is an optional FALLBACK for a run that names none. With neither, the zone is UNKNOWN — the server's zone is never used and none is guessed: instants are spelled in UTC and said to be, the person is asked their zone before any time they wrote is read, and the zone they answer holds for the next turns. A zone is an IANA name ('America/Los_Angeles', 'UTC'); an abbreviation (PST) or a bare offset names no zone and is refused.
  • now is the app's (time: { now } — the message's time), else the turn's start, recorded as a default (nowSource: 'default').
  • The model is told the date. Every request ends with the library's one time line, opening with the turn's clock ("This turn's time: Friday 2026-10-09 08:40 America/Los_Angeles (UTC-07:00).").
  • Recorded once per turn. Seed files one clock row on the ledger (agent.findings()): now, nowSource, zone, zoneSource, and a window set in a UI (time: { window }, recorded source: 'control'). Each dispatched call files a call row with dispatchedAt.
  • Frozen across a pause. resume(checkpoint, answer, { time }) keeps the paused turn's clock; a time that differs is recorded as a clock-on-resume row, never applied.
  • The limits in the person's zone. Under .limitsTravelWithTheAnswer(), every Period: line is rendered in the clock's zone with the zone named (2026-10-09 08:00–08:40 America/Los_Angeles (UTC-07:00)); the typed record keeps the instants as declared.
  • The person's words, read by YOUR reader (reader, no default). A TimeReader returns zone-less parts ('10/09/26' → three numbers, the order undecided); the library resolves them against the clock into every candidate window and files one time-reading row per mention — once per turn, only on a message a person wrote, never re-read on a resume or a retry. policy picks among the candidates: dateOrder ('ask' default, or 'MDY' / 'DMY' / 'YMD') and year ('ask' default, or 'current'); a pick is recorded as assumed. A kind: 'model' reader's window is never taken as the person's words: it waits for the person to confirm it.
  • The time ask checks the answer (a requestInput field with a format: 'instant', 'time-range' or 'zone'). A resume whose answer has no offset, ends before it starts, names no zone, or — under .time(), which knows the person's zone — writes a wall time the clocks skip is not taken: the ask comes back with refused: { answer, reason } and repeat: { count }, and nothing runs. The reason is a catalog sentence (defaultTimeAskMessages); messages overrides any key.

Off → nothing is filed, read or rendered differently, and time on a run is refused (a door that ignored it would look configured and do nothing).

Parameters

options?

TimeOptions

Returns

this

Example

const agent = Agent.create({ provider, model })
    .tool(searchLogs)
    .time({ zone: 'America/Los_Angeles' }) // the fallback; each run's own zone wins
    .limitsTravelWithTheAnswer()
    .build();
  await agent.run({ message, time: { now: sentAt, zone: session.zone } });

  // With a reader: the words are read once per turn, and recorded.
  Agent.create({ provider, model }).time({ reader: myReader, policy: { dateOrder: 'MDY' } });

tool()

tool<TArgs, TResult>(tool): this

Defined in: src/core/agent/AgentBuilder.ts:534

Type Parameters

TArgs

TArgs

TResult

TResult

Parameters

tool

Tool<TArgs, TResult>

Returns

this


toolChoice()

toolChoice(options): this

Defined in: src/core/agent/AgentBuilder.ts:2470

Tool choice by classifier (9.105.0) — a SECOND READING of which tool answers the current step, filed beside the model's own call, and optionally a NARROWING of what is served.

At every model call the tools slot asks classifier one choice question over the tools it is about to serve — the merged wire minus the always-served doors, each by its own description — and files a ToolChoiceRow under AgentState.toolChoices: the provider's ranking (its distribution as sent, an unscored tool absent), its pick, its confidence, its cost, and the names actually served. After the reply callLLM files a ToolChoiceOutcomeRow: the tools the model called, whether the first is the pick (firstAgrees), and any miss. The model's call is never overridden; a failed classifier call is a ToolChoiceErrorRow and the full set is served (fail open).

serve: 'all' (the default) is advisory: the wire is byte for byte what it was. serve: { top: N } narrows the served list to the top-N plus the doors (read_skill, list_skills, skip_step, present, and alwaysServe), and serves the full wire with the reason on the row when the classifier failed or scored fewer than N (unavailable), fewer than N + 1 candidates were offered (too-few), the previous call missed (after-miss) or the call is the wrap-up (wrap-up). A miss — the model naming a narrowed-away tool — is recorded, answered by the dispatcher's off-wire path as before, and the next call serves the full wire.

Once per agent (a second call is refused, the .findings() grammar); reactMode: 'classic' is refused at build. An agent that never calls this is byte-identical to one built before the option existed: no classifier call, no key, no event.

Parameters

options
alwaysServe?

readonly string[]

The app's own always-served tool names — never narrowed away, never offered as candidates.

classifier

Classifier

The classifier asked which tool answers the current step.

serve?

"all" | { top: number; }

'all' (default): advisory only. { top: N }: serve the top-N plus the doors.

Returns

this

Example

import { mockClassifier } from 'agentfootprint/classify';

const agent = Agent.create({ provider, model })
  .tool(lookup)
  .tool(charge)
  .toolChoice({ classifier: typesafe(), serve: { top: 2 } })
  .build();
await agent.run({ message: 'refund order 42' });
agent.getSnapshot()?.sharedState.toolChoices; // pick → served → called, per call

toolMiddleware()

toolMiddleware(...middleware): this

Defined in: src/core/agent/AgentBuilder.ts:3008

Wrap every tool dispatch in a governance chain.

Each middleware answers with one of three verbs — allow(), deny(reason) or ask({ question }) — and there is deliberately no fourth. In particular there is no way to return a result: whatever the chain decides, the answer the model finally reads is the real tool's output or a refusal. A rule cannot quietly become the tool.

  • allow() passes the call through. allow(args, why) replaces the args and the run commits BOTH versions with your why beside them, so a slice taken later can find the moment they changed and who changed them.
  • deny(reason) refuses. The reason reaches the model verbatim, as the tool result, and the loop continues — the agent adapts in-flight. A denial is data, not a crash.
  • ask({ question }) suspends the run for a person, on the same checkpoint machinery checkIn and askHuman use. The answer is a DECISION, not a result: approve and the chain resumes and the real tool runs; decline and it becomes a denial the model reads.

Order is call order, and each middleware sees the previous one's output. The first non-allow answer wins and the rest of the chain does not run. A middleware that throws is a denial carrying the error as its reason — never a silent pass.

A link may also carry an onToolResult hook, which decides about the RESULT once the tool has run and before the model reads it — allow(), allow(value, why) or deny(reason), and no ask, because the tool has already run and there is nothing left for a person to prevent. That half of the chain is walked BACKWARDS, so the first-declared rule has the first word about the call and the last word about the answer. A link with only onToolResult takes no part in dispatch at all.

.act({ beforeTool, afterTool }) is the same chain, named by moment.

An existing PermissionChecker still decides FIRST: it is not part of this chain, it runs ahead of it, so a call it denies never reaches a middleware. gatedTools is a different layer again — it decides which tools the model can SEE; this decides what happens when one is called.

Omit this and nothing changes: no chain walk, no committed ledger key, the same request bytes.

Parameters

middleware

...readonly ToolMiddleware[]

Returns

this

Example

import { Agent, allow, deny } from 'agentfootprint';

const agent = Agent.create({ provider, model })
  .toolMiddleware({
    name: 'no-prod-writes',
    onToolCall: (call) =>
      call.args.env === 'prod' ? deny('writes to prod need a change ticket') : allow(),
  })
  .build();

toolProvider()

toolProvider(provider): this

Defined in: src/core/agent/AgentBuilder.ts:711

Wire a chainable ToolProvider (from agentfootprint/providers) as the agent's per-iteration tool source.

The provider is consulted EVERY iteration via provider.list(ctx) with ctx = { iteration, activeSkillId, identity }. Tools the provider emits flow into the Tools slot alongside any static tools registered via .tool() / .tools(). The tool-call dispatcher also consults the provider so dynamic chains (gatedTools, skillScopedTools) dispatch correctly when their visible-set changes mid-turn.

Throws if called more than once on the same builder (avoids silent override surprises).

Parameters

provider

ToolProvider

Returns

this

Example

Permission-gated baseline
  import { gatedTools, staticTools } from 'agentfootprint/providers';
  import { PermissionPolicy } from 'agentfootprint/security';

  const policy = PermissionPolicy.fromRoles({
    readonly: ['lookup', 'list_skills', 'read_skill'],
    admin:    ['lookup', 'list_skills', 'read_skill', 'delete'],
  }, 'readonly');

  const provider = gatedTools(
    staticTools(allTools),
    (toolName) => policy.isAllowed(toolName),
  );

  const agent = Agent.create({ provider: llm, model })
    .system('You answer.')
    .toolProvider(provider)
    .build();

tools()

tools(tools): this

Defined in: src/core/agent/AgentBuilder.ts:672

Register many tools at once. Convenience for tool sources that return a list (e.g., await mcpClient(...).tools()). Each tool is registered via .tool() so duplicate-name validation still fires per-entry.

Parameters

tools

readonly Tool<Record<string, unknown>, unknown>[]

Returns

this


toolsFromActiveSkill()

toolsFromActiveSkill(): this

Defined in: src/core/agent/AgentBuilder.ts:2219

Offer a skill's tools only while that skill is active (9.36.0). One line, for every skill on the agent.

What it fixes

By default a skill's tools go into the agent's STATIC tool list at build time, so the model can see and call them from iteration 1 — activated or not. Narrowing that was a per-skill field (defineSkill({ autoActivate: 'currentSkill' })) you had to remember on every skill; the one you forgot kept its tools on the wire for the life of the agent, and nothing said so. This says it once, for all of them.

With it on, a skill's tools enter the request on the iterations where the skill is active — through the same readmission path autoActivate has used since v2.5 — and nowhere else. Everything else is untouched: read_skill, list_skills, your .tool() registry, provider tools and every other active skill's tools stay offered, because a scoped agent still has to handle the input nobody imagined.

Not a posture dial, and why

.skillGraph({ strictness }) and .namesAndNumbersFromEvidence({ posture }) take three values because there is a real middle there — record it, revise it, refuse it. The wire has no middle: a tool's schema is either in the request or it is not, and "record that we sent it" is just sending it. A three-value dial here would ship one behaviour under two names.

What it does NOT do

It governs the OFFER, not dispatch. A tool stays resolvable by name so an active skill's call lands — the split autoActivate has always had. If you need execution itself gated (an inactive skill's tool refused even when the model names it from a restored transcript), that is a PermissionChecker or a gatedTools provider, and it is a different question: authority to run, not what the model was shown.

Interaction with the per-skill flag and with scopeTools

All three stamp the same field, and none can contradict another: autoActivate has one legal value, so a skill can ask to be scoped and can never ask to be exempt. A skill that declared its own keeps it; the graph's scopeTools: true fills in the skills it wires; this fills in the rest. Turning it on can only remove tools from the static list, never add one.

Opt-in. The default is unchanged — an agent that never calls this keeps additive tool visibility. Version 10 retires old APIs without changing this active routing policy.

Returns

this

Example

const skills = await skillsFromDir('./skills', { tools: [lookupOrder, issueRefund] });
  const agent = Agent.create({ provider, model })
    .skills({ list: () => skills })
    .toolsFromActiveSkill()   // billing's tools appear when billing does
    .build();

watch()

watch(...observers): this

Defined in: src/core/agent/AgentBuilder.ts:1118

Watch this agent. .act() says what the agent may do; .watch() says who is looking while it does it.

Every observer handed here is attached before build() returns, so it sees every event from the very first run — there is no window where the agent has run and nobody was watching.

Variadic, because observers come in sets:

const agent = Agent.create({ provider, model })
  .watch(toolChoiceRecorder(), routeRecorder())
  .act({ beforeTool: [budgetGuard] })
  .build();

Build time, not run time. This returns the builder; agent.attach(o) attaches to a live agent and returns an Unsubscribe you own. Same mechanism underneath — .watch() replays through agent.attach() at the end of build() — so mixing the two is fine and order is preserved.

Called more than once, the sets concatenate in call order. Nothing is de-duplicated here; footprintjs's executor dedupes by recorder id at run time, so the same observer handed in twice still fires once.

Parameters

observers

...readonly CombinedRecorder[]

Returns

this


window()

window(strategy): this

Defined in: src/core/agent/AgentBuilder.ts:904

Choose how the live context window is kept inside its budget.

This is the general door; the strategy decides everything about WHEN it acts and WHAT leaves. Three ship, and they share one turn segmentation and one refusal engine, so a refusal reason means the same thing under all of them:

summarizeOldest({ thresholdTokens, summarizer, ... }) fold the oldest span into one summary message. .compaction() is this, spelled shorter. slidingWindow({ keepRecentTurns }) keep the last N turns and drop older ones. No summarizer, no LLM call, no usage requirement — it runs on any provider. tokenBudget({ thresholdTokens }) the counted-token trigger, dropping instead of summarizing.

Never removed by any of them: the system envelope, the recent turns, and any turn holding something unresolved — an unanswered tool call, a paused tool, a pending check-in. Those refuse BY NAME in the record and the strategy takes the next oldest instead. Removing an unanswered question would destroy the referent of the answer that has not arrived yet, and splitting a tool_use from its tool_result produces a request the vendor rejects.

Whatever leaves the window stays in the ledger. footprintjs's commit log is append-only, so the turns were committed before the strategy ran and remain byte-identical; every strategy files its own recorded step naming the runtimeStageIds whose messages left, and emits one context.evicted per message. Removing is not forgetting.

Exactly one strategy per agent. Omit this (and .compaction()) and nothing changes: no stage, no extra committed key, the same request bytes.

Parameters

strategy

WindowStrategy

Returns

this

Example

import { Agent, slidingWindow } from 'agentfootprint';

const agent = Agent.create({ provider, model })
  .window(slidingWindow({ keepRecentTurns: 12 }))
  .build();

On this page

Class: AgentBuilderConstructorsConstructorParametersoptsReturnsMethodsact()ParametersoptionsReturnsanswerLayer()Parametersoptions?standingLine?ReturnsExamplesanswerValidation()Type ParametersTParametersoptionsReturnsappName()ParametersnameReturnsbuild()ReturnscheckIn()Parametersopts?Returnsclaims()ParameterscontractReturnsExamplecommentaryTemplates()ParameterstemplatesReturnscompaction()ParametersoptionsReturnsExampleconfigure()ParametersfnReturnsExamplesfact()ParametersinjectionReturnsfindings()Parametersoptions?answerAsk?argumentSources?judge?keepLedgerFacts?maxContextBytes?serve?ReturnsExampleinjection()ParametersinjectionReturnsinputsLayer()Parametersoptions?argumentSources?ReturnsExamplesinstruction()ParametersinjectionReturnsinstructions()ParametersinjectionsReturnslimitsTravelWithTheAnswer()Why appended, and not asked forWith an output schema, the limits come back as dataReturnsExamplesmaps()Parametersoptions?ReturnsExamplemaxIterations()ParametersnReturnsmemory()ParametersdefinitionReturnsmessageMiddleware()ParametersmiddlewareReturnsExamplenamesAndNumbersFromEvidence()What it is — and what it provably is notThe three posturesParametersopts?ReturnsExampleontology()Parametersontologyoptions?ask?ReturnsExampleoutputFallback()The tiers run at the TYPED boundary — run() does not reach themType ParametersTParametersoptionsReturnsExampleoutputSchema()Type ParametersTParametersparseropts?Returnsrag()ParametersdefinitionReturnsrecipe()Parametersrecipeoptions?ReturnsExamplereliability()ParametersconfigReturnsExampleresultsLayer()ReturnsExampleselfExplain()Parametersopts?Returnsskill()ParametersinjectionReturnsskillGraph()ParametersgraphdeferredBodyContract?deferredBodyContract.modeedges?entrySelection?explainNextSkill?nextSkillnodes?reachableSkills?scoreEntries?skillssupersededEntries?turnRouting?options?ReturnsExamplesskills()ParametersregistrylistReturnsExamplesteering()ParametersinjectionReturnssystem()Parameterspromptoptions?cache?Returnsthinking()ParametersoptsbudgetReturnsExamplethinkingHandler()ParametershandlerReturnsExamplesthinkingTemplates()ParameterstemplatesReturnstime()Parametersoptions?ReturnsExampletool()Type ParametersTArgsTResultParameterstoolReturnstoolChoice()ParametersoptionsalwaysServe?classifierserve?ReturnsExampletoolMiddleware()ParametersmiddlewareReturnsExampletoolProvider()ParametersproviderReturnsExampletools()ParameterstoolsReturnstoolsFromActiveSkill()What it fixesNot a posture dial, and whyWhat it does NOT doInteraction with the per-skill flag and with scopeToolsReturnsExamplewatch()ParametersobserversReturnswindow()ParametersstrategyReturnsExample