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

A provider tool and an active skill's tool can share a name, and they lose in opposite directions: the tools slot merges [static, provider, skill] first-wins, so the model reads the provider's description — while dispatch resolves the static registry first, which holds every skill tool and no provider tool, so the skill's implementation runs. The model reads one contract and calls another. Since 8.7.0 the run emits agentfootprint.tools.shadowed (ToolsShadowedPayload — names only, never args or results) every iteration it happens, plus one dev-mode console line. It is reported rather than refused because a provider's list is resolved per iteration: a dynamic provider can begin shadowing mid-run, long after build time.

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)
  • SkillsautoActivate uses the same gatedTools pattern under the hood
  • ToolsmcpClient / mockMcpClient ship in the same tool-providers subpath

On this page