Skip to content

Tool Calling Guide

This guide covers practical patterns for implementing tools in your agent.

  1. Single responsibility: Each tool does one thing well
  2. Clear descriptions: The model knows when to use it
  3. Fail gracefully: Return errors the model can act on
  4. Validate inputs: Don’t trust the model’s arguments

Always validate inputs before acting:

const readFileTool: AgentTool = {
name: 'read_file',
label: 'Read File',
description: 'Reads a file from the filesystem',
parameters: {
type: 'object',
properties: {
path: { type: 'string' },
},
required: ['path'],
},
execute: async (_id, args) => {
const path = args.path as string;
// Validate path
if (!path || typeof path !== 'string') {
return { result: 'Error: path is required', isError: true };
}
// Prevent directory traversal
if (path.includes('..')) {
return { result: 'Error: path cannot contain ..', isError: true };
}
// Check file exists
if (!fs.existsSync(path)) {
return { result: `Error: file not found: ${path}`, isError: true };
}
const content = fs.readFileSync(path, 'utf-8');
return { result: content };
},
};

Large tool outputs can overwhelm the model’s context. Truncate when necessary:

execute: async (_id, args) => {
const result = await longOperation(args);
const MAX_LENGTH = 10000;
if (result.length > MAX_LENGTH) {
return {
result: result.slice(0, MAX_LENGTH) + '\n\n...(truncated, showing first 10000 chars)'
};
}
return { result };
}

Use the signal parameter to stop long-running operations:

execute: async (_id, args, signal) => {
const response = await fetch(args.url as string, { signal });
// For manual operations
if (signal?.aborted) {
return { result: 'Cancelled', isError: true };
}
return { result: await response.text() };
}

For long operations, send progress updates:

execute: async (_id, args, _signal, onUpdate) => {
const files = await listFiles(args.directory);
for (let i = 0; i < files.length; i++) {
onUpdate?.({
progress: `Processing ${i + 1}/${files.length}`
});
await processFile(files[i]);
}
return { result: `Processed ${files.length} files` };
}

The onUpdate callback emits tool.update events that you can handle in your UI.


execute: async (_id, args) => {
try {
return { result: await primaryMethod(args) };
} catch (primaryError) {
// Try fallback
try {
return { result: await fallbackMethod(args) };
} catch (fallbackError) {
// Now it's fatal
return {
result: `Error: ${fallbackError.message}`,
isError: true
};
}
}
}

Give the model actionable information:

execute: async (_id, args) => {
try {
return { result: await doThing(args) };
} catch (error) {
return {
result: JSON.stringify({
error: error.message,
suggestion: 'Try using a different file format',
alternatives: ['json', 'yaml'],
}),
isError: true,
};
}
}

For complex operations, compose multiple tools:

// Simple focused tools
const listDirTool: AgentTool = { /* ... */ };
const readFileTool: AgentTool = { /* ... */ };
const applyPatchTool: AgentTool = { /* ... */ };
// Let the model compose them
const stream = agentLoop(
[{ type: 'text', text: 'Find and summarize README files' }],
{
model: 'gemini-3-flash-preview',
tools: [listDirTool, readFileTool, applyPatchTool],
systemInstruction: 'You can use list_dir, read_file, and apply_patch to work with files.',
}
);

Let the model compose tools rather than creating a single complex tool. This is more flexible and easier to test.


Test tools in isolation before using with the agent:

tool.test.ts
import { describe, test, expect } from 'bun:test';
import { readFileTool } from './tools';
describe('readFileTool', () => {
test('reads existing file', async () => {
const result = await readFileTool.execute('test', { path: './package.json' });
expect(result.isError).toBeFalsy();
expect(result.result).toContain('name');
});
test('returns error for missing file', async () => {
const result = await readFileTool.execute('test', { path: './nonexistent.txt' });
expect(result.isError).toBe(true);
expect(result.result).toContain('Error');
});
});