Build

Tool providers

staticTools + gatedTools — chainable tool dispatch primitives. Compose permission gating + per-skill tool filtering at the dispatch boundary, not inside each tool's execute.

A read-only support agent has 25 tools registered, but on a refund turn the LLM should only see the 3 billing tools. Putting the gating logic inside each tool's execute is wrong — the tool runs AFTER the LLM has already chosen it. The right place to gate is BEFORE the LLM sees the menu. That's what ToolProvider exists for.

What ToolProvider is

A ToolProvider answers ONE question per iteration: "what tools should the LLM see right now?" It's pure compute — given the current iteration context, return the visible tool set.

Three implementations ship:

  • staticTools(arr) — the 90% case. Wraps a fixed Tool[] list. What agent.tools(arr) does today, made composable.
  • gatedTools(inner, predicate) — the DECORATOR. Wraps any provider with an additional per-tool filter. Composes freely.
  • skillScopedTools(skillId, arr) — exposes a tool subset only while ctx.activeSkillId === skillId. Returns [] when a different skill (or no skill) is active. Compose with staticTools for the always-on baseline.

Plus tool sources from the same subpath:

  • mcpClient(opts) — connect to a real MCP server
  • mockMcpClient({ tools }) — in-memory MCP source for dev / tests

For runtime tool catalogs (Rube, Composio, MCP registries, per-tenant policy services) where list() needs to be a network call: see Tool discovery (async ToolProvider). Same ToolProvider interface; list() returns Promise<Tool[]>. Sync providers on this page stay sync — zero overhead.

staticTools — the simplest provider

export function staticTools(tools: readonly Tool[]): ToolProvider {  // Capture the input list once. `list()` returns a fresh array each  // call so the agent's reference-equality check always sees an update  // (matches the `gatedTools` decorator's per-call recomputation).  const captured = [...tools];  return {    id: 'static',    list(_ctx: ToolDispatchContext): readonly Tool[] {      return [...captured];    },  };}

That's the whole implementation. Captures the input list defensively (mutating the source array doesn't leak), returns a fresh array each list() call so reference-equality checks see the update.

gatedTools — the decorator

export function gatedTools(inner: ToolProvider, predicate: ToolGatePredicate): ToolProvider {  return {    id: 'gated',    list(ctx: ToolDispatchContext): readonly Tool[] | Promise<readonly Tool[]> {      // Pull from the inner provider first; each recomputation sees      // the freshest state from any nested gates. Inner may be sync      // or async — we mirror what we get back so a sync chain stays      // sync (zero microtask overhead) and an async chain stays      // async (no premature `Promise.resolve` wrapping).      const innerResult = inner.list(ctx);      const filter = (innerTools: readonly Tool[]): readonly Tool[] =>        // Filter by predicate — tool name from `tool.schema.name`.        // Predicates throwing escape: a buggy predicate should crash        // loudly, not silently allow tools through. Per the        // permission-as-defense-in-depth principle.        innerTools.filter((t) => predicate(t.schema.name, ctx));      return innerResult instanceof Promise ? innerResult.then(filter) : filter(innerResult);    },  };}

Takes any inner ToolProvider and a per-tool predicate. Filters the inner output. Predicates that throw propagate — better to fail closed than silently allow tools through a broken policy.

Composition — read-only over skill-gated

The composability is the point. Two concerns, two layers, decorator-shaped:

import { staticTools, gatedTools } from 'agentfootprint/providers';

const isReadonly = (toolName: string) => toolName.startsWith('read_');
const skillToolMap: Record<string, readonly string[]> = {
  billing: ['read_billing', 'write_billing'],
  health: ['read_health'],
};

const provider = gatedTools(
  gatedTools(staticTools(allTools), isReadonly),                        // (1) read-only
  (name, ctx) =>                                                         // (2) skill-gated
    ctx.activeSkillId
      ? (skillToolMap[ctx.activeSkillId] ?? []).includes(name)
      : true,
);

Reads as: "static list of all tools, filtered by readonly policy, then further filtered by the active skill's tool set." Each gate is one concern; composition handles the rest.

When the LLM is on a billing turn (ctx.activeSkillId === 'billing'), the visible tool set becomes ['read_billing'] — both gates pass. When no skill is active, the read-only gate alone applies.

Wiring a provider into an agent

Pass the provider to .toolProvider(...) on the builder. The agent consults it every iteration via provider.list(ctx), with ctx = { iteration, activeSkillId, identity }. Each agent has at most one external provider — calling .toolProvider() twice throws.

import { Agent } from 'agentfootprint';
import { gatedTools, staticTools } from 'agentfootprint/providers';

const provider = gatedTools(staticTools(allTools), (name) => name.startsWith('read_'));

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

Tools the provider emits flow into the Tools slot alongside any static tools registered via .tool() / .tools(), and the tool-call dispatcher consults the provider too — so dynamic chains (gatedTools, skillScopedTools) dispatch correctly when their visible set changes mid-turn.

ToolDispatchContext — what predicates can inspect

Predicates receive a read-only context per iteration:

FieldMeaning
iterationCurrent ReAct iteration (1-based)
activeSkillId?The id of the currently-activated Skill, if any. Set by read_skill(id) — and only by it
activeSkillIds?Every skill active this iteration, however it got there: a read_skill call, an entry rule, a skillGraph() route, a tree leaf (8.7.0)
identity?Caller identity tuple — { tenant?, principal?, conversationId } for role checks
signal?Optional AbortSignal propagated from run({ env }). Async (discovery) providers MUST honor it; sync providers ignore it

Predicates MUST be pure — no side effects, no async. They run dozens of times per agent.run().

activeSkillId vs activeSkillIds — the distinction that bites

activeSkillId is the tail of activatedInjectionIds, which only read_skill writes. A skill the GRAPH activated — an entry rule matched, an edge routed into it — does not set it. So skillScopedTools('billing', …) returns [] on exactly the iterations billing is loaded, if billing arrived by routing rather than by the model asking. Since 8.7.0 that mismatch dev-warns from inside the provider instead of passing in silence.

activeSkillIds is the real active set, which is what to scope on when you want to follow the graph's POSITION:

const graphScoped = (id: string, tools: Tool[]): ToolProvider => ({
  id: `graph-scoped:${id}`,
  list: (ctx) => (ctx.activeSkillIds?.includes(id) ? tools : []),
});

Every skillScopedTools provider carries the id `${SKILL_SCOPED_TOOLS_ID_PREFIX}${skillId}`, and skillScopedToolsTarget(providerId) recovers the skill id from one — the convention the agent builder itself reads to warn when a scoped provider is aimed at a skill that already narrows its own tools with autoActivate: 'currentSkill'.

When two sources claim one tool name

The law (9.92.0): for every tool name on a call, exactly one party owns the OFFER and the same party owns the ANSWER — or the record names the disagreement.

A provider tool and a skill's tool can share a name. The tools slot merges [static, provider, skill, step] first-occurrence-wins, so the model reads the provider's description — and since 9.92.0 the provider's implementation is what answers it: dispatch follows the offer, resolved from the slot's own record of its merge rather than from a build-time map. (Until 9.92.0 the static registry was consulted first, so the skill's execute answered the provider's contract — an inactive skill's, even.) A name that is NOT on this call's wire — held out by a step, a park or a scoping, or named from a restored transcript — still dispatches, but only to the party the model last read it under (or its only holder when it was never served), and the run records it as agentfootprint.tools.answered_off_wire; a name whose last-served party can no longer answer (the provider withdrew it) is refused as a recorded tool result rather than handed to a party the model was never shown under that name.

The collision is still reported, not refused, because a provider's list is resolved per iteration and can begin colliding mid-run:

  • agentfootprint.tools.shadowed (ToolsShadowedPayload) — once per contested name per iteration when two contracts competed for the wire; schemaFrom and dispatchTo both name the wire's party and agree.
  • agentfootprint.tools.claim_swallowed (ToolsClaimSwallowedPayload) — once per iteration for every party whose claim is dead while somebody else holds the name: { toolName, lostBy, lostById?, wonBy, wonById?, iteration }. It fires whether the loser's contract competed (an active skill) or never reached the merge (the same skill while inactive, a provider tool whose name a static .tool() owns). Two skills sharing one Tool reference are one claim and draw nothing.
  • agentfootprint.tools.answered_off_wire (ToolsAnsweredOffWirePayload) — once per call the dispatcher answered although the name was not on that call's wire: { toolName, toolCallId, iteration, answeredBy, answeredById? }.

Both carry names only — never args, results or descriptions — plus one dev-mode console line per name. skip_step is a claimant like any other: the framework's schema merges last, so a provider serving that name is the one that answers, and the procedure does not advance on a call whose contract the model read from somebody else.

When to use this vs flat agent.tools(arr)

SituationUse
All tools always visibleagent.tools(arr) (top-level builder)
Permission filtering neededgatedTools(staticTools(arr), permissionPredicate)
Per-skill tool gating (autoActivate)skillScopedTools(skillId, arr), or gatedTools(staticTools(arr), skillPredicate)
Both stackedLayer two gatedTools calls — each one concern
Wire any of the above into an agentAgent.create(...).toolProvider(provider)

Anti-patterns

  • Don't put permission checks inside tool execute. The LLM has already chosen the tool by then. Gate at dispatch with gatedTools.
  • Don't make predicates async. They run per iteration, per tool. Async predicates multiply latency.
  • Don't make predicates have side effects. They run during the agent loop's hot path; mutation breaks composition.
  • Don't catch + ignore predicate exceptions. Failing closed is correct — a bug in your policy SHOULD crash, not silently allow tools through.

Next steps

  • Tool discovery (async) — the async Promise<Tool[]> path for runtime catalogs (Rube / MCP / per-tenant)
  • Permission policy — the natural consumer of gatedTools (data-driven role-based gating)
  • Skills — autoActivate uses the same gatedTools pattern under the hood
  • Tools — mcpClient / mockMcpClient ship in the same tool-providers subpath

On this page