Skip to content

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.

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 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

Section titled “Example — agent + tool registered inline”
examples/core/02-agent-with-tools.ts (region: build)
const agent = Agent.create({
provider: provider ?? exampleProvider('feature', { respond: weatherRespond }),
model: 'mock',
maxIterations: 5,
})
.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.

Pull a whole MCP server’s tool surface into the agent in one call:

examples/context-engineering/08-mcp.ts (region: connect-and-attach)
// 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 .build() time — if an MCP-imported tool collides with a manually-defined tool, the build throws with a clear error.

For mock-first development of MCP integrations without spawning a subprocess, use mockMcpClient({ tools }) — same McpClient interface, in-memory implementation. See MCP integration for the full surface.

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.

  • Don’t make execute synchronous — it must return a Promise. The agent loop is async end-to-end.
  • Don’t put validation logic in execute for 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.