Tools & Toolkits
Tools are functions that agents can call to interact with the world. They're how agents search databases, call APIs, process files, and take actions.
Anatomy of a Tool
Every tool has three parts:
const myTool = {
// 1. Identity: Name and description for the LLM
name: "get_weather",
description: "Get current weather for a location",
// 2. Schema: What arguments the tool accepts
schema: Schema.objectValue({
location: Schema.stringValue("City name").isRequired(),
unit: Schema.enumValue("Temperature unit", ["celsius", "fahrenheit"])
.defaultTo("celsius"),
}),
// 3. Implementation: What the tool actually does
execute: async (args: Record<string, unknown>) => {
const { location, unit } = args;
const weather = await fetchWeather(location, unit);
return weather;
},
};
The ITool Interface
interface ITool {
name: string; // Unique identifier
description: string; // What the tool does (for LLM)
schema?: Schema; // Input validation
execute(args: Record<string, unknown>): unknown; // Implementation
}
Tool Names
- Use
snake_casefor consistency - Be descriptive:
search_productsnotsearch - Avoid generic names:
get_datais too vague
Descriptions
The description tells the LLM when to use this tool:
// ❌ Too vague
description: "Search things"
// ✅ Clear and actionable
description: `Search the product catalog by name, category, or price range.
Use this when users ask about products, availability, or prices.
Returns matching products with name, price, and stock status.`
Schema Definition
Schemas define what arguments your tool accepts and validate LLM outputs.
Basic Types
import { Schema } from "@shuttl-io/core";
Schema.stringValue("A text value")
Schema.numberValue("A numeric value")
Schema.booleanValue("A true/false value")
Schema.enumValue("Pick one", ["option1", "option2", "option3"])
Modifiers
// Required field
Schema.stringValue("User's email").isRequired()
// Default value
Schema.numberValue("Results limit").defaultTo(10)
// Combine them
Schema.enumValue("Priority", ["low", "medium", "high"])
.isRequired()
.defaultTo("medium")
Object Schema
Combine fields into an object:
schema: Schema.objectValue({
query: Schema.stringValue("Search query").isRequired(),
filters: Schema.objectValue({
category: Schema.stringValue("Product category"),
minPrice: Schema.numberValue("Minimum price"),
maxPrice: Schema.numberValue("Maximum price"),
inStock: Schema.booleanValue("Only show in-stock items").defaultTo(true),
}),
limit: Schema.numberValue("Max results").defaultTo(20),
})
Implementing execute()
The execute function receives validated arguments and returns results.
Basic Implementation
execute: async (args: Record<string, unknown>) => {
const query = args.query as string;
const limit = args.limit as number;
const results = await database.search(query, { limit });
return results;
}
Return Values
Return any JSON-serializable value:
// Object
return { temperature: 72, condition: "sunny" };
// Array
return [{ id: 1, name: "Product A" }, { id: 2, name: "Product B" }];
// Primitive
return "Success!";
// Structured response with metadata
return {
success: true,
data: results,
count: results.length,
hasMore: results.length === limit,
};
Error Handling
Throw errors for the LLM to handle:
execute: async (args) => {
const user = await db.findUser(args.userId);
if (!user) {
throw new Error(`User ${args.userId} not found`);
}
return user;
}
The LLM receives the error message and can:
- Try a different approach
- Ask the user for clarification
- Report the error appropriately
Toolkits
Toolkits group related tools together for organization and reuse.
Creating a Toolkit
import { Toolkit } from "@shuttl-io/core";
const weatherToolkit = new Toolkit("weather", "Tools for weather information");
// Add tools to the toolkit
weatherToolkit.addTool({
name: "get_current_weather",
description: "Get current weather conditions",
schema: Schema.objectValue({
location: Schema.stringValue("City name").isRequired(),
}),
execute: async ({ location }) => {
return await weatherApi.current(location);
},
});
weatherToolkit.addTool({
name: "get_forecast",
description: "Get weather forecast for upcoming days",
schema: Schema.objectValue({
location: Schema.stringValue("City name").isRequired(),
days: Schema.numberValue("Number of days").defaultTo(5),
}),
execute: async ({ location, days }) => {
return await weatherApi.forecast(location, days);
},
});
Using Toolkits with Agents
export const weatherAgent = new Agent({
name: "WeatherBot",
systemPrompt: "You help users with weather information.",
model: Model.openAI("gpt-4", Secret.fromEnv("KEY")),
toolkits: [weatherToolkit], // All tools in toolkit are available
});
Combining Toolkits
export const assistantAgent = new Agent({
name: "Assistant",
systemPrompt: "You're a helpful assistant.",
model: Model.openAI("gpt-4", Secret.fromEnv("KEY")),
toolkits: [weatherToolkit, calendarToolkit, emailToolkit],
tools: [customTool], // Can also add individual tools
});
Tool Patterns
API Wrapper
Wrap an external API:
const slackTool = {
name: "send_slack_message",
description: "Send a message to a Slack channel",
schema: Schema.objectValue({
channel: Schema.stringValue("Channel name or ID").isRequired(),
message: Schema.stringValue("Message content").isRequired(),
thread_ts: Schema.stringValue("Thread timestamp for replies"),
}),
execute: async ({ channel, message, thread_ts }) => {
const response = await slack.chat.postMessage({
channel,
text: message,
thread_ts,
});
return {
success: true,
messageId: response.ts,
channel: response.channel,
};
},
};
Database Query
Query your database safely:
const searchUsersTool = {
name: "search_users",
description: "Search users by name or email",
schema: Schema.objectValue({
query: Schema.stringValue("Search term").isRequired(),
role: Schema.enumValue("Filter by role", ["admin", "user", "guest"]),
limit: Schema.numberValue("Max results").defaultTo(10),
}),
execute: async ({ query, role, limit }) => {
let queryBuilder = db.users
.where("name", "ilike", `%${query}%`)
.orWhere("email", "ilike", `%${query}%`)
.limit(limit);
if (role) {
queryBuilder = queryBuilder.andWhere("role", role);
}
const users = await queryBuilder.select("id", "name", "email", "role");
return { users, count: users.length };
},
};
File Operations
Work with files:
const readFileTool = {
name: "read_file",
description: "Read contents of a file",
schema: Schema.objectValue({
path: Schema.stringValue("File path").isRequired(),
encoding: Schema.enumValue("File encoding", ["utf8", "base64"])
.defaultTo("utf8"),
}),
execute: async ({ path, encoding }) => {
// Validate path is within allowed directory
const safePath = resolveSafePath(path);
const content = await fs.readFile(safePath, encoding);
return { content, path: safePath };
},
};
Multi-step Operations
Combine multiple operations:
const createOrderTool = {
name: "create_order",
description: "Create a new order with items",
schema: Schema.objectValue({
customerId: Schema.stringValue("Customer ID").isRequired(),
items: Schema.stringValue("JSON array of {productId, quantity}").isRequired(),
}),
execute: async ({ customerId, items }) => {
const parsedItems = JSON.parse(items);
// 1. Validate customer
const customer = await db.customers.find(customerId);
if (!customer) throw new Error("Customer not found");
// 2. Check inventory
for (const item of parsedItems) {
const product = await db.products.find(item.productId);
if (product.stock < item.quantity) {
throw new Error(`Insufficient stock for ${product.name}`);
}
}
// 3. Create order
const order = await db.orders.create({
customerId,
items: parsedItems,
status: "pending",
});
// 4. Update inventory
for (const item of parsedItems) {
await db.products.decrement(item.productId, "stock", item.quantity);
}
return { orderId: order.id, status: "created" };
},
};
Best Practices
1. Keep Tools Focused
One tool, one action:
// ❌ Too many responsibilities
const userTool = {
name: "manage_user",
execute: async ({ action, userId, data }) => {
if (action === "create") { ... }
if (action === "update") { ... }
if (action === "delete") { ... }
}
};
// ✅ Separate, focused tools
const createUserTool = { name: "create_user", ... };
const updateUserTool = { name: "update_user", ... };
const deleteUserTool = { name: "delete_user", ... };
2. Return Useful Information
Help the LLM understand what happened:
// ❌ Minimal response
return true;
// ✅ Rich response
return {
success: true,
orderId: "ORD-123",
itemCount: 3,
totalAmount: 149.99,
estimatedDelivery: "2025-01-10",
};
3. Handle Errors Gracefully
Provide actionable error messages:
execute: async ({ email }) => {
const user = await db.users.findByEmail(email);
if (!user) {
// ❌ Generic error
throw new Error("Not found");
// ✅ Helpful error
throw new Error(
`No user found with email "${email}". ` +
`Try searching by name instead.`
);
}
return user;
}
4. Validate Dangerous Operations
Add confirmation for destructive actions:
const deleteAccountTool = {
name: "delete_account",
description: "Permanently delete a user account. Requires confirmation.",
schema: Schema.objectValue({
userId: Schema.stringValue("User ID").isRequired(),
confirmPhrase: Schema.stringValue(
"Must be exactly 'DELETE MY ACCOUNT'"
).isRequired(),
}),
execute: async ({ userId, confirmPhrase }) => {
if (confirmPhrase !== "DELETE MY ACCOUNT") {
throw new Error(
"Confirmation phrase doesn't match. " +
"User must type exactly: DELETE MY ACCOUNT"
);
}
await db.users.delete(userId);
return { deleted: true, userId };
},
};
Testing Tools
Test your tools in isolation:
import { describe, it, expect } from "vitest";
import { searchTool } from "./tools/search";
describe("searchTool", () => {
it("returns matching results", async () => {
const result = await searchTool.execute({
query: "pricing",
limit: 5,
});
expect(result.found).toBe(true);
expect(result.articles.length).toBeGreaterThan(0);
});
it("handles no matches gracefully", async () => {
const result = await searchTool.execute({
query: "xyznonexistent123",
limit: 5,
});
expect(result.found).toBe(false);
expect(result.suggestion).toBeDefined();
});
});
Next Steps
- ⚡ Add Triggers - Control when your agent runs
- 📤 Configure Outcomes - Route responses
- 🔍 See Examples - Real-world tool implementations