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/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:
| Field | Meaning |
|---|---|
iteration | Current 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)
| 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
Artifacts
The claim-check store and the data legs — tools check data in and the model routes ~26-char tickets. ctx.artifacts, wants (refs as tool arguments), the present tool, the placement threshold, typed lifecycle events.
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.
