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/tool-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/tool-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)
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().

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