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().
Your agent needs to look up an order. The LLM doesn't know your order database — it knows what you tell it via the tool's JSON schema. Tools are the contract between LLM intent ("call this with these args") and your code ("here's what happens"). Get the schema right and the LLM uses the tool well; get it wrong and you debug schema-vs-args mismatches at 2 AM.
What a tool is
A Tool has two parts:
{
schema: { name, description, inputSchema }, // what the LLM sees
execute: async (args) => result, // what runs when LLM calls it
}The inputSchema is a JSON Schema describing the args the LLM should produce. It's the LLM's contract — it generates well-formed args based on that shape.
defineTool — the flat builder
defineTool is a flatter helper that puts name + description + inputSchema at the top level instead of nested under schema:
import { defineTool } from 'agentfootprint';
const lookup = defineTool<{ orderId: string }, string>({
name: 'lookup_order',
description: 'Look up an order by ID',
inputSchema: {
type: 'object',
properties: { orderId: { type: 'string' } },
required: ['orderId'],
},
execute: async ({ orderId }) => `Order ${orderId}: shipped, $299`,
});
const agent = Agent.create({ provider }).tool(lookup).build();Type parameters <TArgs, TResult> flow into execute so your handler is fully typed.
Example — agent + tool registered inline
const agent = Agent.create({ provider: provider ?? exampleProvider('feature', { respond: weatherRespond }), model: 'mock', maxIterations: 5, // reactMode: 'dynamic-grouped' wraps the LLM turn in an sf-llm-call subflow, // so Lens renders the agent's reasoning as an LLM group with its context // slots (system-prompt / messages / tools) nested inside — the SAME shape // the LLMCall primitive shows — instead of a bare "Final · RUNNER" card. reactMode: 'dynamic-grouped',}) .system('You answer weather questions using the `weather` tool.') .tool({ schema: { name: 'weather', description: 'Get current weather for a city.', inputSchema: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'], }, }, execute: async (args) => `${(args as { city: string }).city}: sunny, 72°F`, }) .build();.tool(...) accepts both shapes (flat {schema, execute} or defineTool output) — the agent doesn't care which.
Bulk-register from MCP
Pull a whole MCP server's tool surface into the agent in one call:
// Connect once at startup. In production: use a real transport.const fileServer = await mcpClient({ name: 'file-server', transport: { transport: 'stdio', command: 'npx', args: ['fake-mcp'] }, _client: fakeServer, // ← test injection; remove for real MCP});// Agent picks up all the server's tools at once.const agent = Agent.create({ provider: provider ?? mock({ reply: '/tmp/notes.md and /tmp/todo.txt are present.' }), model: 'mock', maxIterations: 1,}) .system('You answer file-system questions using the MCP tools provided.') .tools(await fileServer.tools()) .build();agent.tools(arr) is the bulk-register companion to agent.tool(t). Tool-name uniqueness is validated at registration time — .tool() (and .tools(), which calls it per entry) throws Agent.tool(): duplicate tool name '<name>' the moment a collision is registered, so an MCP-imported tool clashing with a manually-defined one fails fast. (Collisions between static tools and Skill-injected tools are caught later, at .build().)
For mock-first development of MCP integrations without spawning a subprocess, use mockMcpClient({ tools }) — same McpClient interface, in-memory implementation. See Tool discovery for the full MCP surface.
Tool errors
When execute throws, the framework catches it, reports it to the LLM as a tool error, and continues the loop. The LLM sees the error message and can decide to retry with different args, try a different tool, or surface to the user. See Error handling for the typed error contract + retry decorators.
Anti-patterns
- Don't reach outside the args +
ctxyour tool is given.execute(args, ctx)receives the LLM-supplied args plus aToolExecutionContext({ toolCallId, iteration, signal? }) — usectx.signalto honor cancellation, not ambient globals. (executemay return a value OR a Promise; the loopawaits either, so sync handlers are fine.) - Don't put validation logic in
executefor things JSON Schema can express (type,required,enum). The LLM honors well-formed schemas; redundant runtime checks are noise. - Don't make tool descriptions ambiguous. "Get data" is bad. "Look up an order by ID; returns status + amount" is good. The LLM picks tools by description.
Next steps
- Tool discovery — connect to any MCP server with
mcpClient({ transport }) - Tool providers — dynamic per-iteration tool catalogs (
staticTools,gatedTools,skillScopedTools) - Skills, explained — LLM-activated tool bundles
- Permission gating — restrict which tools an agent can call
Skills, explained
Skills are context engineering for instructions — abstracted so you don't do it by hand and get it wrong. A conceptual walk through what they actually are, and why the abstraction exists.
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.
