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
executeis 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 whatToolProviderexists 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 fixedTool[]list. Whatagent.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 whilectx.activeSkillId === skillId. Returns[]when a different skill (or no skill) is active. Compose withstaticToolsfor the always-on baseline.
Plus tool sources from the same subpath:
mcpClient(opts)— connect to a real MCP servermockMcpClient({ 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). SameToolProviderinterface;list()returnsPromise<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:
| Field | Meaning |
|---|---|
iteration | Current 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)
| Situation | Use |
|---|---|
| All tools always visible | agent.tools(arr) (top-level builder) |
| Permission filtering needed | gatedTools(staticTools(arr), permissionPredicate) |
| Per-skill tool gating (autoActivate) | skillScopedTools(skillId, arr), or gatedTools(staticTools(arr), skillPredicate) |
| Both stacked | Layer two gatedTools calls — each one concern |
| Wire any of the above into an agent | Agent.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 withgatedTools. - 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 —
autoActivateuses the samegatedToolspattern under the hood - Tools —
mcpClient/mockMcpClientship in the sametool-providerssubpath
Tools
defineTool — flat builder for the Tool interface. JSON schema in, async execute out. Drop into agent.tool() or pull a whole MCP server's surface via agent.tools().
Tool discovery (async ToolProvider)
Runtime tool catalogs over hubs, MCP registries, and per-tenant indexes. Async ToolProvider.list(ctx) with TTL caching, AbortSignal propagation, and discovery_started/completed/failed events — no library API additions required.
