Published on

Designing Tools for AI Agents — Schemas, Descriptions, and Error Handling

Authors

Introduction

The quality of AI agent tools directly impacts whether agents choose the right tools and successfully complete tasks. Many teams overlook tool design, leading to agents that misuse tools, pass wrong arguments, or ignore tools entirely. This post covers everything from schema engineering to error message design that agents can actually understand and recover from.

Tool Schema Design

A well-designed schema makes it obvious to the agent what a tool does and how to call it. Poor schemas cause agents to skip the tool or misuse it.

// BAD: Vague parameter names and descriptions
interface BadSchema {
  name: 'search';
  description: 'search';
  input_schema: {
    type: 'object';
    properties: {
      q: { type: 'string' }; // What does 'q' mean?
      limit: { type: 'number' }; // Default? Max?
      filter: { type: 'string' }; // What format?
    };
  };
}

// GOOD: Explicit, helpful schema
interface GoodSchema {
  name: 'web_search';
  description: 'Search the web for information about a topic. Returns up to 10 results with titles, snippets, and URLs. Use when you need current information not in your training data.';
  input_schema: {
    type: 'object';
    properties: {
      query: {
        type: 'string',
        description: 'The search query. Be specific: "how to tune PostgreSQL for write-heavy workloads" instead of "PostgreSQL".',
        minLength: 2,
        maxLength: 256,
      },
      max_results: {
        type: 'number',
        description: 'Number of results to return (1-10). Default is 5. Use 10 for broad topics that need comprehensive coverage.',
        minimum: 1,
        maximum: 10,
        default: 5,
      },
      domain_filter: {
        type: 'string',
        description: 'Optional: Restrict results to a domain (e.g., "github.com", "stackoverflow.com"). Leave empty to search all domains.',
        pattern: '^[a-z0-9.-]*$',
      },
    },
    required: ['query'],
  };
}

// EXCELLENT: With examples
interface ExcellentSchema {
  name: 'database_query';
  description: 'Execute a SQL query against the production PostgreSQL database. Safe for SELECT queries. Use for data lookup, analysis, and reporting.';
  input_schema: {
    type: 'object',
    properties: {
      sql: {
        type: 'string',
        description: 'The SQL query to execute. MUST be a SELECT statement. Only these tables are readable: users, orders, products, reviews. Examples: "SELECT COUNT(*) FROM orders WHERE created_at > NOW() - INTERVAL 7 days", "SELECT * FROM users WHERE id = $1 LIMIT 1"',
        pattern: '^\\s*SELECT\\s+',
      },
      timeout_seconds: {
        type: 'number',
        description: 'Query timeout in seconds (1-30). Default 10. Use higher values for complex aggregations.',
        minimum: 1,
        maximum: 30,
        default: 10,
      },
    },
    required: ['sql'],
  };
}

class ToolRegistry {
  private tools: Map<string, Tool> = new Map();

  registerTool(tool: Tool): void {
    // Validate schema before registering
    this.validateSchema(tool);
    this.tools.set(tool.name, tool);
  }

  private validateSchema(tool: Tool): void {
    if (!tool.name || tool.name.length === 0) {
      throw new Error('Tool name cannot be empty');
    }

    if (!tool.description || tool.description.length < 10) {
      throw new Error(
        `Tool "${tool.name}" description too short. Aim for 1-2 sentences explaining what the tool does and when to use it.`,
      );
    }

    // Ensure all required properties have descriptions
    const props = (tool.input_schema as any).properties || {};
    for (const [name, prop] of Object.entries(props)) {
      if (typeof prop === 'object' && !((prop as any).description)) {
        throw new Error(`Property "${name}" in tool "${tool.name}" missing description`);
      }
    }
  }
}

interface Tool {
  name: string;
  description: string;
  input_schema: Record<string, unknown>;
  execute: (input: Record<string, unknown>) => Promise<string>;
}

Good schemas explicitly state:

  • What the tool does and why an agent would use it
  • Constraints on parameters (min/max, patterns, allowed values)
  • Default values and example inputs
  • When NOT to use the tool

Description Engineering

A tool's description is the only thing telling the agent whether to use it. Invest time here.

class ToolDescriptions {
  // ANTI-PATTERN: Generic description
  static badCalculator = {
    name: 'calculator',
    description: 'Do math', // Too vague!
  };

  // GOOD: Purpose-driven description
  static goodCalculator = {
    name: 'calculator',
    description: 'Perform arithmetic calculations: addition, subtraction, multiplication, division, exponents. Use for numerical answers where precision matters.',
  };

  // EXCELLENT: Includes when NOT to use
  static excellentCalculator = {
    name: 'calculator',
    description: `Perform precise arithmetic calculations: addition, subtraction, multiplication, division, exponents, square roots.
Use when you need exact numerical answers.
IMPORTANT: Do NOT use this for estimates or approximate math—your reasoning can handle that.
Examples of when to use: "2^10 = ?", "compound interest over 5 years at 3% APR", "convert 50 miles to kilometers".`,
  };

  // ANTI-PATTERN: Overlapping tools confuse agents
  static confusingTools = [
    {
      name: 'send_email',
      description: 'Send email',
    },
    {
      name: 'send_message',
      description: 'Send message',
    },
    {
      name: 'notify_user',
      description: 'Notify user',
    },
  ];

  // GOOD: Clear differentiation
  static clearTools = [
    {
      name: 'send_email',
      description:
        'Send an email via SMTP to one or more recipients. Supports HTML formatting and attachments. Use for formal communication or when you need a readable receipt.',
    },
    {
      name: 'send_slack_message',
      description:
        'Send a message to a Slack channel or user. Instant delivery, best for team notifications and alerts. Supports markdown formatting.',
    },
    {
      name: 'send_push_notification',
      description:
        'Send a push notification to user devices (iOS/Android). Immediate, intrusive—use sparingly for urgent information only.',
    },
  ];

  // ANTI-PATTERN: When to use is unclear
  static unclearTiming = {
    name: 'cache_get',
    description: 'Get value from cache',
  };

  // EXCELLENT: When to use and when not to
  static excellentTiming = {
    name: 'cache_get',
    description: `Retrieve a previously stored value from cache. MUCH faster than database queries (microseconds vs milliseconds).
Use for frequently accessed data like user preferences, configuration, recent query results.
Do NOT use if you need the latest data—cache may be stale (max 5 minutes old).
Returns undefined if key doesn't exist.`,
  };
}

Write descriptions as if explaining to a colleague. Include:

  • What the tool does (verb + what it operates on)
  • Why an agent would use it (performance, capability)
  • When NOT to use it (edge cases, limitations)
  • Real usage examples in comments

Input Validation Before Execution

Validate inputs before calling the tool, not after. This lets agents recover.

interface ValidationResult {
  valid: boolean;
  errors: Array<{
    field: string;
    message: string;
  }>;
}

class ValidatedTool {
  async executeWithValidation(toolName: string, input: Record<string, unknown>): Promise<string> {
    const validation = this.validate(toolName, input);

    if (!validation.valid) {
      const errorMessage = validation.errors
        .map((e) => `${e.field}: ${e.message}`)
        .join('. ');

      return `VALIDATION ERROR: ${errorMessage}. Please check your inputs and retry.`;
    }

    // Proceed with execution
    return this.execute(toolName, input);
  }

  private validate(toolName: string, input: Record<string, unknown>): ValidationResult {
    const errors: ValidationResult['errors'] = [];

    if (toolName === 'transfer_funds') {
      const amount = input.amount as number;
      const fromAccount = input.from_account as string;
      const toAccount = input.to_account as string;

      if (!amount || amount <= 0) {
        errors.push({
          field: 'amount',
          message: 'Must be a positive number',
        });
      }

      if (amount > 100000) {
        errors.push({
          field: 'amount',
          message: 'Exceeds maximum transfer limit of $100,000',
        });
      }

      if (fromAccount === toAccount) {
        errors.push({
          field: 'from_account',
          message: 'Cannot transfer to the same account',
        });
      }

      if (!this.accountExists(fromAccount)) {
        errors.push({
          field: 'from_account',
          message: `Account not found: ${fromAccount}`,
        });
      }

      if (!this.accountExists(toAccount)) {
        errors.push({
          field: 'to_account',
          message: `Account not found: ${toAccount}`,
        });
      }
    }

    return {
      valid: errors.length === 0,
      errors,
    };
  }

  private execute(toolName: string, input: Record<string, unknown>): Promise<string> {
    return Promise.resolve('Executed');
  }

  private accountExists(account: string): boolean {
    return true; // Stub
  }
}

Validation errors should be constructive so the agent knows how to fix it. Return what went wrong AND how to fix it.

Error Messages Agents Can Understand

Error messages for agents differ from error messages for humans. Make them actionable.

// ANTI-PATTERN: Human-friendly but not actionable for agents
class BadErrorMessages {
  async search(query: string): Promise<string> {
    try {
      const result = await fetch(`https://api.search.com/search?q=${query}`);
      if (!result.ok) {
        throw new Error('Request failed'); // Too vague for agents to recover
      }
      return result.text();
    } catch (error) {
      return 'An error occurred';
    }
  }
}

// GOOD: Structured errors agents can parse
interface AgentError {
  code: string;
  message: string;
  recoveryAction?: string;
}

class GoodErrorMessages {
  async search(query: string): Promise<string | AgentError> {
    try {
      if (query.length < 2) {
        return {
          code: 'INVALID_QUERY',
          message: 'Search query must be at least 2 characters',
          recoveryAction: 'Try a more specific search term',
        };
      }

      if (query.length > 256) {
        return {
          code: 'QUERY_TOO_LONG',
          message: 'Search query cannot exceed 256 characters',
          recoveryAction: 'Break your query into smaller searches',
        };
      }

      const result = await fetch(`https://api.search.com/search?q=${encodeURIComponent(query)}`);

      if (result.status === 429) {
        return {
          code: 'RATE_LIMITED',
          message: 'Search API rate limit exceeded',
          recoveryAction: 'Try again in 60 seconds or use a more specific query to reduce requests',
        };
      }

      if (result.status === 401) {
        return {
          code: 'INVALID_CREDENTIALS',
          message: 'API credentials invalid or expired',
          recoveryAction: 'This is a server issue, not caused by your request. Contact support.',
        };
      }

      if (!result.ok) {
        return {
          code: `HTTP_${result.status}`,
          message: `Search API returned ${result.status}: ${result.statusText}`,
          recoveryAction: 'Try a different query or use a different tool',
        };
      }

      return result.text();
    } catch (error) {
      return {
        code: 'NETWORK_ERROR',
        message: `Network error: ${(error as Error).message}`,
        recoveryAction: 'The search service may be temporarily unavailable. Try again later.',
      };
    }
  }
}

// EXCELLENT: Human-readable + structured + contextual
class ExcellentErrorMessages {
  async queryDatabase(sql: string): Promise<string> {
    try {
      // Validate syntax first
      if (!sql.toUpperCase().startsWith('SELECT')) {
        return JSON.stringify({
          success: false,
          error: {
            code: 'INVALID_QUERY_TYPE',
            message: 'Only SELECT queries are allowed',
            hint: 'Your query starts with: ' + sql.substring(0, 20),
            example: "SELECT * FROM users WHERE id = $1 LIMIT 1",
          },
        });
      }

      const result = await this.executeQuery(sql);
      return JSON.stringify({
        success: true,
        data: result,
        metadata: {
          rowCount: result.length,
          executionTimeMs: 42,
        },
      });
    } catch (error) {
      const dbError = error as any;

      let errorCode = 'UNKNOWN_ERROR';
      let hint = '';

      if (dbError.message.includes('relation')) {
        errorCode = 'TABLE_NOT_FOUND';
        hint = 'Check the table name spelling. Available tables: users, orders, products';
      } else if (dbError.message.includes('timeout')) {
        errorCode = 'QUERY_TIMEOUT';
        hint = 'Your query took too long. Try using LIMIT to reduce rows, or simplify the query.';
      } else if (dbError.message.includes('permission')) {
        errorCode = 'PERMISSION_DENIED';
        hint = 'You cannot query this table. Only readable tables: users, orders, products';
      }

      return JSON.stringify({
        success: false,
        error: {
          code: errorCode,
          message: dbError.message,
          hint,
          retryable: errorCode === 'QUERY_TIMEOUT',
        },
      });
    }
  }

  private executeQuery(sql: string): Promise<any[]> {
    return Promise.resolve([]);
  }
}

Good error messages for agents include:

  • Structured format (JSON, not free text)
  • Error code the agent can check
  • Human-readable message
  • Recovery suggestion ("retry", "use different tool", "contact support")
  • Is this retryable? (some errors won't change if retried)

Tool Result Formatting

How you return results matters as much as getting them right.

// ANTI-PATTERN: Inconsistent format
class InconsistentResults {
  async getUser(id: string): Promise<unknown> {
    // Sometimes returns string
    if (!id) return 'Invalid ID';

    // Sometimes returns object
    return {
      id,
      name: 'John',
      email: 'john@example.com',
    };
  }
}

// GOOD: Consistent envelope format
interface ToolResult<T> {
  success: boolean;
  data?: T;
  error?: {
    code: string;
    message: string;
  };
  metadata?: {
    executionTimeMs: number;
    itemsReturned?: number;
  };
}

class ConsistentResults {
  async getUser(id: string): Promise<ToolResult<{ id: string; name: string; email: string }>> {
    if (!id) {
      return {
        success: false,
        error: {
          code: 'INVALID_ID',
          message: 'User ID cannot be empty',
        },
      };
    }

    const startTime = Date.now();
    const user = await this.fetchUser(id);

    if (!user) {
      return {
        success: false,
        error: {
          code: 'NOT_FOUND',
          message: `User ${id} not found`,
        },
      };
    }

    return {
      success: true,
      data: user,
      metadata: {
        executionTimeMs: Date.now() - startTime,
      },
    };
  }

  private fetchUser(id: string): Promise<{ id: string; name: string; email: string } | null> {
    return Promise.resolve(null);
  }
}

// EXCELLENT: Summarized large results
class SmartResults {
  async listOrders(limit: number = 10): Promise<ToolResult<string>> {
    const orders = await this.fetchOrders(limit + 1); // Fetch one extra to detect more

    if (orders.length === 0) {
      return {
        success: true,
        data: 'No orders found',
        metadata: { itemsReturned: 0 },
      };
    }

    const hasMore = orders.length > limit;
    const displayOrders = orders.slice(0, limit);

    // Summarize results for the agent
    const summary = displayOrders
      .map(
        (o) =>
          `Order #${o.id}: $${o.amount.toFixed(2)} (${o.status}) on ${new Date(o.createdAt).toLocaleDateString()}`,
      )
      .join('\n');

    const moreInfo = hasMore
      ? `\n\n(+ ${orders.length - limit} more orders. Use limit parameter to see more.)`
      : '';

    return {
      success: true,
      data: summary + moreInfo,
      metadata: {
        itemsReturned: displayOrders.length,
        hasMore,
        executionTimeMs: 45,
      },
    };
  }

  private fetchOrders(limit: number): Promise<any[]> {
    return Promise.resolve([]);
  }
}

Format results for agent consumption:

  • Always use consistent envelope (success/data/error)
  • Summarize large results, don't dump raw JSON
  • Include metadata (count, execution time, "more available?")
  • Make results readable—agents read your output to humans

Idempotent vs Side-Effect Tools

Some tools can be safely called multiple times; others can't.

// IDEMPOTENT: Safe to call multiple times without side effects
class IdempotentTools {
  async getUser(id: string): Promise<ToolResult<any>> {
    // Reading data is idempotent
    return { success: true, data: { id, name: 'John' } };
  }

  async searchDocuments(query: string): Promise<ToolResult<any>> {
    // Search is idempotent
    return { success: true, data: [] };
  }

  async getWeather(city: string): Promise<ToolResult<any>> {
    // Reading current state is idempotent
    return { success: true, data: { temp: 72 } };
  }
}

// NON-IDEMPOTENT: Side effects occur
interface NonIdempotentTools {
  // Each call SENDS an email
  send_email: (to: string, subject: string, body: string) => Promise<ToolResult<any>>;

  // Each call CREATES an order
  create_order: (items: string[], quantity: number) => Promise<ToolResult<any>>;

  // Each call INCREMENTS a counter
  process_payment: (amount: number) => Promise<ToolResult<any>>;
}

// For non-idempotent tools, require idempotency keys
interface SafeNonIdempotentTool {
  name: 'process_payment';
  input_schema: {
    type: 'object';
    properties: {
      amount: { type: 'number' };
      idempotency_key: {
        type: 'string',
        description:
          'Unique key for this payment. If the same key is used twice, only one payment is processed. Format: uuid',
      };
      reason: {
        type: 'string',
        description: 'Why is this payment being processed?',
      };
    };
    required: ['amount', 'idempotency_key'];
  };
}

// Mark tools as idempotent for agent optimization
interface ToolMetadata {
  name: string;
  isIdempotent: boolean;
  isMutating: boolean;
  requiresApproval: boolean;
}

const toolMetadata: ToolMetadata[] = [
  {
    name: 'search_docs',
    isIdempotent: true,
    isMutating: false,
    requiresApproval: false,
  },
  {
    name: 'send_email',
    isIdempotent: false,
    isMutating: true,
    requiresApproval: true,
  },
  {
    name: 'create_order',
    isIdempotent: false,
    isMutating: true,
    requiresApproval: true,
  },
];

Tool Versioning

Tools change. Version them to avoid agent confusion.

interface VersionedTool {
  name: string;
  version: string; // semver
  deprecated: boolean;
  deprecatedInFavorOf?: string;
  changeLog: Array<{
    version: string;
    changes: string[];
  }>;
}

class ToolVersionManager {
  private tools: Map<string, VersionedTool[]> = new Map();

  registerTool(tool: VersionedTool): void {
    const key = tool.name;
    if (!this.tools.has(key)) {
      this.tools.set(key, []);
    }

    this.tools.get(key)!.push(tool);

    // Sort by version
    this.tools.get(key)!.sort((a, b) => this.compareVersions(a.version, b.version));
  }

  getLatestTool(name: string): VersionedTool | undefined {
    const versions = this.tools.get(name) || [];
    return versions[versions.length - 1];
  }

  listAvailableTools(): VersionedTool[] {
    const latest: VersionedTool[] = [];

    for (const versions of this.tools.values()) {
      if (versions.length > 0) {
        latest.push(versions[versions.length - 1]);
      }
    }

    return latest;
  }

  private compareVersions(a: string, b: string): number {
    const [aMajor, aMinor, aPatch] = a.split('.').map(Number);
    const [bMajor, bMinor, bPatch] = b.split('.').map(Number);

    if (aMajor !== bMajor) return aMajor - bMajor;
    if (aMinor !== bMinor) return aMinor - bMinor;
    return aPatch - bPatch;
  }
}

// Example: Old tool is deprecated
const oldSearchTool: VersionedTool = {
  name: 'search',
  version: '1.0.0',
  deprecated: true,
  deprecatedInFavorOf: 'web_search',
  changeLog: [
    {
      version: '1.0.0',
      changes: ['Initial release'],
    },
  ],
};

const newSearchTool: VersionedTool = {
  name: 'web_search',
  version: '2.0.0',
  deprecated: false,
  changeLog: [
    {
      version: '2.0.0',
      changes: [
        'Renamed from "search" to "web_search"',
        'Added domain_filter parameter',
        'Results now include relevance score',
        'Increased result limit to 10',
      ],
    },
  ],
};

Checklist

  • Schema: explicit constraints, examples, clear descriptions
  • Description: explain purpose, when to use, when NOT to use
  • Input validation: check before calling, return actionable errors
  • Error messages: structured, include recovery suggestions
  • Results: consistent format, summarized output, metadata
  • Idempotency: use keys for non-idempotent tools
  • Versioning: semantic versioning with deprecation path

Conclusion

Tool design is the foundation of agent reliability. Invest in clear descriptions, strict validation, and thoughtful error messages. When agents understand your tools, they use them correctly. When they misuse tools, they should get errors that guide them toward success.