Blueprint/Chapter 15
Chapter 15

Tool Design & Function Calling

By Zavier SandersSeptember 21, 2025

Design robust, composable tools that extend agent capabilities safely and reliably.

Prefer something you can ship today? Start with theQuickstart: Ship One Agent with Mastra— then come back here to deepen the concepts.

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

  1. Single responsibility - Each tool does one thing well
  2. Strong schemas - Zod validation prevents misuse
  3. Security first - Authorization, audit logs, data access controls
  4. Error handling - Consistent error responses, graceful failures
  5. 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.