Tool Design Principles
Well-designed tools are the foundation of capable agents.
What Makes a Good Tool?
1. Single Responsibility
// ❌ Bad: Does too much const badTool = createTool({ name: 'manageCustomer', description: 'Create, update, delete, or search customers', // Too many responsibilities! }); // ✅ Good: Focused purpose const getCustomer = createTool({ name: 'getCustomer', description: 'Retrieve customer details by ID', }); const updateCustomer = createTool({ name: 'updateCustomer', description: 'Update customer information', });
2. Clear, Descriptive Names
// ❌ Bad: Vague const getData = createTool({ name: 'getData' }); const doStuff = createTool({ name: 'doStuff' }); // ✅ Good: Explicit const fetchCRMContactById = createTool({ name: 'fetchCRMContactById' }); const sendSlackMessage = createTool({ name: 'sendSlackMessage' });
3. Comprehensive Descriptions
// ❌ Bad: Minimal const badTool = createTool({ name: 'search', description: 'Search', }); // ✅ Good: Detailed const searchTool = createTool({ name: 'searchWebWithTavily', description: `Search the web using Tavily API. Use this tool when you need: - Current information (news, events, trends) - Factual data not in your training - Multiple perspectives on a topic - Real-time data Returns: Array of search results with title, URL, snippet, and relevance score. Example uses: - "What are the latest AI developments?" - "Current weather in San Francisco" - "Recent news about [company]"`, });
4. Predictable Behavior
// ✅ Tool should behave consistently const tool = createTool({ name: 'calculateDiscount', execute: (price: number, percent: number) => { // Always returns the same output for same inputs return price * (1 - percent / 100); }, }); // ❌ Avoid side effects that aren't obvious const badTool = createTool({ name: 'getPrice', execute: (productId: string) => { // Hidden side effect: logs analytics event analytics.track('price_viewed', { productId }); return getProductPrice(productId); }, });
5. Idempotency (When Possible)
// ✅ Safe to call multiple times const getCustomer = createTool({ name: 'getCustomer', execute: async (id: string) => { // Same result no matter how many times called return await db.customer.findUnique({ where: { id } }); }, }); // ⚠️ Not idempotent - document this clearly const createCustomer = createTool({ name: 'createCustomer', description: 'Creates a NEW customer. Do NOT call multiple times for the same customer.', execute: async (data: CustomerData) => { return await db.customer.create({ data }); }, });
Schema Definition
Schemas make tool calls reliable and type-safe.
Zod Schemas for Tools
// src/mastra/tools/crm-tools.ts import { createTool } from '@mastra/core'; import { z } from 'zod'; const GetCustomerSchema = z.object({ customerId: z.string().describe('The unique customer ID'), }); export const getCustomer = createTool({ id: 'get-customer', description: 'Retrieve customer details from CRM', inputSchema: GetCustomerSchema, execute: async ({ context, runId }, { customerId }) => { const customer = await db.customer.findUnique({ where: { id: customerId }, include: { orders: true, supportTickets: true, }, }); if (!customer) { return { success: false, error: `Customer ${customerId} not found`, }; } return { success: true, data: { id: customer.id, name: customer.name, email: customer.email, totalOrders: customer.orders.length, openTickets: customer.supportTickets.filter(t => t.status === 'open').length, lifetimeValue: customer.orders.reduce((sum, o) => sum + o.total, 0), }, }; }, });
Complex Schemas
const CreateSupportTicketSchema = z.object({ customerId: z.string().describe('Customer ID'), subject: z.string().min(5).max(200).describe('Ticket subject line'), description: z.string().min(20).describe('Detailed description of the issue'), priority: z.enum(['low', 'medium', 'high', 'urgent']).describe('Ticket priority level'), category: z.enum(['bug', 'feature', 'question', 'billing']).describe('Issue category'), tags: z.array(z.string()).optional().describe('Optional tags for categorization'), }); export const createSupportTicket = createTool({ id: 'create-support-ticket', description: `Create a new support ticket for a customer. Use when: - Customer reports a bug - Feature request - Billing question - General support inquiry IMPORTANT: - Always include detailed description (minimum 20 characters) - Set appropriate priority (urgent only for critical issues) - Choose correct category for routing`, inputSchema: CreateSupportTicketSchema, execute: async ({ context, runId }, args) => { // Validation is automatic via Zod const ticket = await db.supportTicket.create({ data: { ...args, status: 'open', createdAt: new Date(), }, }); // Notify support team await notifySupport(ticket); return { success: true, ticketId: ticket.id, message: `Created ticket #${ticket.id}`, }; }, });
Optional vs Required Parameters
const SearchProductsSchema = z.object({ // Required query: z.string().min(1).describe('Search query (required)'), // Optional with defaults limit: z.number().min(1).max(100).default(10).describe('Number of results (default: 10)'), // Optional without defaults category: z.string().optional().describe('Filter by category (optional)'), minPrice: z.number().optional().describe('Minimum price filter (optional)'), maxPrice: z.number().optional().describe('Maximum price filter (optional)'), // Optional with validation sortBy: z.enum(['relevance', 'price-asc', 'price-desc', 'newest']) .optional() .describe('Sort order (optional, default: relevance)'), }); export const searchProducts = createTool({ id: 'search-products', inputSchema: SearchProductsSchema, execute: async ({ context, runId }, args) => { const { query, limit = 10, category, minPrice, maxPrice, sortBy = 'relevance', } = args; // Build query const where: any = { OR: [ { name: { contains: query, mode: 'insensitive' } }, { description: { contains: query, mode: 'insensitive' } }, ], }; if (category) where.category = category; if (minPrice) where.price = { gte: minPrice }; if (maxPrice) where.price = { ...where.price, lte: maxPrice }; const products = await db.product.findMany({ where, take: limit, orderBy: this.getOrderBy(sortBy), }); return { success: true, results: products, count: products.length, }; }, });
Validation and Error Handling
Robust validation prevents agent misuse and improves reliability.
Input Validation
const SendEmailSchema = z.object({ to: z.string().email().describe('Recipient email address'), subject: z.string().min(1).max(200).describe('Email subject'), body: z.string().min(1).describe('Email body content'), cc: z.array(z.string().email()).optional().describe('CC recipients'), }); export const sendEmail = createTool({ id: 'send-email', inputSchema: SendEmailSchema, execute: async ({ context, runId }, args) => { // Additional business logic validation const errors: string[] = []; // Check if sender is authorized if (!context.user.canSendEmail) { errors.push('User not authorized to send emails'); } // Check rate limits const recentEmails = await getRecentEmails(context.user.id, '1h'); if (recentEmails.length >= 50) { errors.push('Email rate limit exceeded (50/hour)'); } // Check recipient allowlist (prevent spam) const allowedDomains = ['@company.com', '@partner.com']; const recipientDomain = args.to.split('@')[1]; if (!allowedDomains.some(d => recipientDomain.endsWith(d))) { errors.push(`Recipient domain ${recipientDomain} not in allowlist`); } if (errors.length > 0) { return { success: false, errors, message: 'Email validation failed', }; } // Validation passed - send email try { const result = await emailService.send(args); return { success: true, messageId: result.id, message: `Email sent to ${args.to}`, }; } catch (error) { return { success: false, error: (error as Error).message, }; } }, });
Error Response Patterns
// Consistent error response structure type ToolResult<T> = | { success: true; data: T; message?: string } | { success: false; error: string; code?: string; retryable?: boolean }; export const robustTool = createTool({ id: 'robust-tool', inputSchema: z.object({ /* ... */ }), execute: async ({ context, runId }, args): Promise<ToolResult<Data>> => { try { const result = await performOperation(args); return { success: true, data: result, message: 'Operation completed successfully', }; } catch (error) { // Categorize errors if (error instanceof ValidationError) { return { success: false, error: error.message, code: 'VALIDATION_ERROR', retryable: false, }; } if (error instanceof RateLimitError) { return { success: false, error: 'Rate limit exceeded. Please try again later.', code: 'RATE_LIMIT', retryable: true, }; } if (error instanceof NetworkError) { return { success: false, error: 'Network error. Please retry.', code: 'NETWORK_ERROR', retryable: true, }; } // Unknown error return { success: false, error: 'An unexpected error occurred', code: 'UNKNOWN_ERROR', retryable: false, }; } }, });
Human-in-the-Loop Validation
export const deleteCustomer = createTool({ id: 'delete-customer', description: `Delete a customer account (REQUIRES APPROVAL). CRITICAL: This action is IRREVERSIBLE. - All customer data will be permanently deleted - Active subscriptions will be cancelled - Support tickets will be archived This tool requires human approval before executing.`, inputSchema: z.object({ customerId: z.string(), reason: z.string().min(20).describe('Detailed reason for deletion (required)'), }), execute: async ({ context, runId }, args) => { // Check if this is a sensitive operation const customer = await db.customer.findUnique({ where: { id: args.customerId }, include: { subscriptions: true, orders: true }, }); if (!customer) { return { success: false, error: 'Customer not found' }; } // Calculate impact const hasActiveSubscription = customer.subscriptions.some(s => s.status === 'active'); const lifetimeValue = customer.orders.reduce((sum, o) => sum + o.total, 0); // Require approval for high-value or active customers if (hasActiveSubscription || lifetimeValue > 10000) { // Request human approval const approval = await requestApproval({ action: 'delete_customer', customerId: args.customerId, reason: args.reason, impact: { lifetimeValue, hasActiveSubscription, orderCount: customer.orders.length, }, }); if (!approval.approved) { return { success: false, error: 'Deletion rejected by human reviewer', reason: approval.rejectionReason, }; } } // Approval granted or not required - proceed await db.customer.delete({ where: { id: args.customerId } }); return { success: true, message: `Customer ${args.customerId} deleted`, }; }, });
Security and Authorization
Protect sensitive operations with proper security controls.
Role-Based Access Control
// lib/tool-permissions.ts export enum Permission { READ_CUSTOMER = 'read:customer', WRITE_CUSTOMER = 'write:customer', DELETE_CUSTOMER = 'delete:customer', SEND_EMAIL = 'send:email', ACCESS_BILLING = 'access:billing', } export const rolePermissions: Record<string, Permission[]> = { viewer: [Permission.READ_CUSTOMER], agent: [Permission.READ_CUSTOMER, Permission.WRITE_CUSTOMER, Permission.SEND_EMAIL], admin: Object.values(Permission), // All permissions }; export function hasPermission(user: User, permission: Permission): boolean { const userPermissions = rolePermissions[user.role] || []; return userPermissions.includes(permission); } // Use in tools export const updateCustomer = createTool({ id: 'update-customer', inputSchema: UpdateCustomerSchema, execute: async ({ context, runId }, args) => { // Check authorization if (!hasPermission(context.user, Permission.WRITE_CUSTOMER)) { return { success: false, error: 'Unauthorized: write:customer permission required', code: 'UNAUTHORIZED', }; } // Proceed with update const customer = await db.customer.update({ where: { id: args.customerId }, data: args.updates, }); return { success: true, data: customer }; }, });
Data Access Controls
export const getCustomer = createTool({ id: 'get-customer', inputSchema: GetCustomerSchema, execute: async ({ context, runId }, args) => { // Row-level security: users can only access their assigned customers const customer = await db.customer.findFirst({ where: { id: args.customerId, // User can only access customers in their territory territory: context.user.territory, }, }); if (!customer) { return { success: false, error: 'Customer not found or access denied', }; } // Field-level security: redact sensitive fields based on permissions const sanitized = { ...customer, ssn: hasPermission(context.user, Permission.ACCESS_BILLING) ? customer.ssn : '***-**-****', creditCard: hasPermission(context.user, Permission.ACCESS_BILLING) ? customer.creditCard : '****-****-****-' + customer.creditCard.slice(-4), }; return { success: true, data: sanitized }; }, });
Audit Logging
export const auditedTool = createTool({ id: 'audited-operation', inputSchema: OperationSchema, execute: async ({ context, runId }, args) => { // Log attempt const auditId = await db.auditLog.create({ data: { userId: context.user.id, action: 'audited-operation', input: args, timestamp: new Date(), runId, }, }); try { const result = await performOperation(args); // Log success await db.auditLog.update({ where: { id: auditId }, data: { status: 'success', output: result, completedAt: new Date(), }, }); return { success: true, data: result }; } catch (error) { // Log failure await db.auditLog.update({ where: { id: auditId }, data: { status: 'failed', error: (error as Error).message, completedAt: new Date(), }, }); throw error; } }, });
Tool Composition
Combine simple tools into powerful capabilities.
Sequential Composition
export const enrichCustomerProfile = createTool({ id: 'enrich-customer-profile', description: 'Get comprehensive customer profile with all related data', inputSchema: z.object({ customerId: z.string(), }), execute: async ({ context, runId }, args) => { // Call multiple tools sequentially const [customer, orders, tickets, interactions] = await Promise.all([ getCustomer.execute({ context, runId }, { customerId: args.customerId }), getCustomerOrders.execute({ context, runId }, { customerId: args.customerId }), getCustomerTickets.execute({ context, runId }, { customerId: args.customerId }), getCustomerInteractions.execute({ context, runId }, { customerId: args.customerId }), ]); // Combine results return { success: true, data: { profile: customer.data, stats: { totalOrders: orders.data.length, lifetimeValue: orders.data.reduce((sum, o) => sum + o.total, 0), openTickets: tickets.data.filter(t => t.status === 'open').length, lastInteraction: interactions.data[0]?.timestamp, }, recent: { orders: orders.data.slice(0, 5), tickets: tickets.data.slice(0, 3), interactions: interactions.data.slice(0, 10), }, }, }; }, });
Conditional Composition
export const smartSearch = createTool({ id: 'smart-search', description: 'Intelligently search across multiple sources', inputSchema: z.object({ query: z.string(), sources: z.array(z.enum(['web', 'docs', 'crm'])).optional(), }), execute: async ({ context, runId }, args) => { const sources = args.sources || ['web', 'docs', 'crm']; const results: any = {}; // Conditionally call tools based on requested sources if (sources.includes('web')) { results.web = await searchWeb.execute( { context, runId }, { query: args.query } ); } if (sources.includes('docs')) { results.docs = await searchDocs.execute( { context, runId }, { query: args.query } ); } if (sources.includes('crm')) { results.crm = await searchCRM.execute( { context, runId }, { query: args.query } ); } // Merge and rank results const merged = this.mergeResults(results); return { success: true, data: merged, sources: Object.keys(results), }; }, });
Error Recovery Composition
export const resilientFetchData = createTool({ id: 'resilient-fetch-data', description: 'Fetch data with automatic fallbacks', inputSchema: z.object({ dataId: z.string(), }), execute: async ({ context, runId }, args) => { // Try primary source try { const result = await primaryAPI.execute({ context, runId }, args); if (result.success) { return result; } } catch (error) { console.warn('Primary API failed:', error); } // Fallback to secondary source try { const result = await secondaryAPI.execute({ context, runId }, args); if (result.success) { return { ...result, message: 'Retrieved from fallback source', }; } } catch (error) { console.warn('Secondary API failed:', error); } // Last resort: cache try { const cached = await cache.get(args.dataId); if (cached) { return { success: true, data: cached, message: 'Retrieved from cache (may be stale)', }; } } catch (error) { console.warn('Cache lookup failed:', error); } return { success: false, error: 'All data sources failed', }; }, });
Production Tool Checklist
// ✅ Complete production-ready tool example export const productionTool = createTool({ // 1. Clear identification id: 'send-customer-notification', // 2. Comprehensive description description: `Send a notification to a customer via their preferred channel. Use when: - Order status updates - Important account changes - Response to customer inquiry IMPORTANT: - Checks customer preferences for channel - Respects quiet hours (9am-9pm local time) - Rate limited to 5 notifications per customer per day Returns: Notification ID and delivery status`, // 3. Strong schema validation inputSchema: z.object({ customerId: z.string().uuid(), subject: z.string().min(5).max(100), message: z.string().min(20).max(1000), priority: z.enum(['low', 'normal', 'high']).default('normal'), }), // 4. Proper execution execute: async ({ context, runId }, args) => { // Authorization if (!hasPermission(context.user, Permission.SEND_NOTIFICATION)) { return { success: false, error: 'Unauthorized' }; } // Business logic validation const rateLimit = await checkRateLimit(args.customerId); if (!rateLimit.allowed) { return { success: false, error: `Rate limit exceeded. Customer can receive ${rateLimit.remaining} more notifications today.`, }; } // Get customer preferences const customer = await getCustomer({ customerId: args.customerId }); const channel = customer.preferredChannel || 'email'; // Check quiet hours if (!isWithinQuietHours(customer.timezone)) { // Queue for later await queueNotification(args, customer.timezone); return { success: true, message: 'Notification queued (outside quiet hours)', scheduledFor: getNextAllowedTime(customer.timezone), }; } // Send notification try { const result = await sendViaChannel(channel, { to: customer[channel], subject: args.subject, message: args.message, }); // Audit log await logNotification({ customerId: args.customerId, channel, success: true, notificationId: result.id, }); return { success: true, notificationId: result.id, channel, message: `Notification sent via ${channel}`, }; } catch (error) { // Error handling await logNotification({ customerId: args.customerId, channel, success: false, error: (error as Error).message, }); return { success: false, error: `Failed to send notification: ${(error as Error).message}`, }; } }, });
Key Takeaways
- Single responsibility - Each tool does one thing well
- Strong schemas - Zod validation prevents misuse
- Security first - Authorization, audit logs, data access controls
- Error handling - Consistent error responses, graceful failures
- Composition - Combine simple tools for complex capabilities
Well-designed tools make agents reliable, secure, and powerful!
What's Next
Now that you understand tool design principles, see them in action:
- Chapter 13: Pattern 5 - Vision-Based Agents - Complete example of vision tools (
analyzeImageTool
,detectObjectsTool
) designed for production use with Moondream - Chapter 16: Error Handling & Reliability - How to make your tools resilient to failures
- Chapter 17: The Agentic Stack - Where tools fit in your overall agent architecture
Get chapter updates & code samples
We’ll email diagrams, code snippets, and additions.