Published on

Designing AI Agent Tools — Schema, Errors, and Idempotency for LLM Tool Use

Authors

Introduction

A tool is only as good as how well an LLM can use it. Poor tool design leads to agent failures, hallucinations, and retries. This post covers designing tools that LLMs understand intuitively: clear naming, precise schemas, idempotent operations, and error messages that guide the model toward recovery.

Characteristics of a Good Tool

A tool that an LLM can reliably use has these properties:

Clear name: Verb or verb-noun form (create_invoice, not invoice_ops) Precise description: One sentence explaining what the tool does Minimal parameters: Fewer parameters mean fewer mistakes Required fields: Explicitly mark what is required vs. optional Example usage: Show how the tool is typically called Atomic operations: One tool, one responsibility

interface ToolSchema {
  name: string;
  description: string;
  inputSchema: {
    type: 'object';
    properties: Record<string, unknown>;
    required: string[];
  };
}

// Good tool design
const goodTool: ToolSchema = {
  name: 'create_invoice',
  description: 'Create a new invoice for a customer',
  inputSchema: {
    type: 'object',
    properties: {
      customer_id: {
        type: 'string',
        description: 'The ID of the customer',
      },
      items: {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            product_id: { type: 'string' },
            quantity: { type: 'number' },
          },
        },
        description: 'Line items for the invoice',
      },
      due_date: {
        type: 'string',
        format: 'date-time',
        description: 'When payment is due (ISO 8601 format)',
      },
    },
    required: ['customer_id', 'items'],
  },
};

// Bad tool design (too many responsibilities)
const badTool: ToolSchema = {
  name: 'invoice_ops',
  description: 'Perform operations on invoices',
  inputSchema: {
    type: 'object',
    properties: {
      operation: {
        type: 'string',
        enum: ['create', 'update', 'delete', 'send', 'cancel'],
      },
      // ...many more fields
    },
  },
};

JSON Schema Best Practices

Write schemas that are unambiguous:

// Good: Clear property names and descriptions
const createOrderSchema = {
  type: 'object',
  properties: {
    customer_id: {
      type: 'string',
      pattern: '^[a-f0-9]{24}$', // MongoDB ObjectId format
      description: 'MongoDB ObjectId of the customer',
    },
    items: {
      type: 'array',
      minItems: 1,
      maxItems: 100,
      items: {
        type: 'object',
        properties: {
          product_id: {
            type: 'string',
            description: 'Product ID',
          },
          quantity: {
            type: 'integer',
            minimum: 1,
            maximum: 1000,
            description: 'Quantity to order',
          },
          price_override_cents: {
            type: 'integer',
            description: 'Optional fixed price in cents. If omitted, uses current price.',
          },
        },
        required: ['product_id', 'quantity'],
      },
      description: 'Array of items to order. Each item must have at least product_id and quantity.',
    },
    shipping_address: {
      type: 'object',
      properties: {
        street: { type: 'string' },
        city: { type: 'string' },
        postal_code: { type: 'string' },
        country: { type: 'string' },
      },
      required: ['street', 'city', 'postal_code', 'country'],
      description: 'Shipping address (required)',
    },
    notes: {
      type: 'string',
      maxLength: 500,
      description: 'Optional order notes (max 500 characters)',
    },
  },
  required: ['customer_id', 'items', 'shipping_address'],
  description: 'Create a new order for a customer',
};

Returning Structured Results

Tools should return results that other tools can consume:

interface ToolResult {
  success: boolean;
  data?: unknown;
  error?: string;
  metadata: {
    executedAt: string;
    durationMs: number;
  };
}

// Bad: returning plain string
async function lookupCustomer(customerId: string): Promise<string> {
  // Returns: "Customer: John Doe, Email: john@example.com"
  // LLM can''t parse this reliably
}

// Good: returning structured JSON
async function lookupCustomer(customerId: string): Promise<ToolResult> {
  const start = Date.now();

  try {
    const customer = await db.customers.findById(customerId);

    if (!customer) {
      return {
        success: false,
        error: `Customer ${customerId} not found`,
        metadata: { executedAt: new Date().toISOString(), durationMs: Date.now() - start },
      };
    }

    return {
      success: true,
      data: {
        id: customer.id,
        name: customer.name,
        email: customer.email,
        plan: customer.plan,
        created_at: customer.created_at,
      },
      metadata: {
        executedAt: new Date().toISOString(),
        durationMs: Date.now() - start,
      },
    };
  } catch (error) {
    return {
      success: false,
      error: (error as Error).message,
      metadata: {
        executedAt: new Date().toISOString(),
        durationMs: Date.now() - start,
      },
    };
  }
}

Error Messages for LLM Reasoning

When a tool fails, the error message should guide recovery:

// Bad error messages
async function chargeCard(cardToken: string, amountCents: number): Promise<ToolResult> {
  try {
    const result = await paymentGateway.charge(cardToken, amountCents);
    return { success: true, data: result, metadata: {...} };
  } catch (error) {
    // Unhelpful error
    return {
      success: false,
      error: 'Payment failed',
      metadata: {...},
    };
  }
}

// Good error messages (specific, actionable)
async function chargeCard(cardToken: string, amountCents: number): Promise<ToolResult> {
  try {
    const result = await paymentGateway.charge(cardToken, amountCents);
    return { success: true, data: result, metadata: {...} };
  } catch (error) {
    const errorMsg = (error as Error).message;

    if (errorMsg.includes('insufficient_funds')) {
      return {
        success: false,
        error: `Card declined: insufficient funds. Requested amount: $${(amountCents / 100).toFixed(2)}. Use a different card or reduce the amount.`,
        metadata: {...},
      };
    }

    if (errorMsg.includes('expired')) {
      return {
        success: false,
        error: 'Card expired. Request a card token for a different, valid card.',
        metadata: {...},
      };
    }

    if (errorMsg.includes('rate_limit')) {
      return {
        success: false,
        error: 'Too many payment attempts. Wait 60 seconds and try again.',
        metadata: {...},
      };
    }

    // Generic fallback with context
    return {
      success: false,
      error: `Payment failed: ${errorMsg}. Contact support if this persists.`,
      metadata: {...},
    };
  }
}

Idempotent Tools

Idempotent tools can be called multiple times safely:

// Non-idempotent: calling twice creates duplicate invoice
async function createInvoice(customerId: string): Promise<ToolResult> {
  const invoice = {
    customer_id: customerId,
    amount: 100,
    created_at: new Date(),
  };

  const saved = await db.invoices.insert(invoice);
  return { success: true, data: saved, metadata: {...} };
}

// Idempotent: uses idempotency key to prevent duplicates
async function createInvoice(
  customerId: string,
  idempotencyKey: string
): Promise<ToolResult> {
  // Check if we''ve already processed this key
  const existing = await redis.get(`idempotency:${idempotencyKey}`);
  if (existing) {
    return {
      success: true,
      data: JSON.parse(existing),
      metadata: {
        executedAt: new Date().toISOString(),
        durationMs: 0,
        cached: true,
      },
    };
  }

  const invoice = {
    customer_id: customerId,
    amount: 100,
    created_at: new Date(),
  };

  const saved = await db.invoices.insert(invoice);

  // Cache the result
  await redis.setex(
    `idempotency:${idempotencyKey}`,
    3600,
    JSON.stringify(saved)
  );

  return { success: true, data: saved, metadata: {...} };
}

Tool Authentication and Authorization

Prevent agents from accessing restricted tools:

interface AuthContext {
  userId: string;
  roles: string[];
  permissions: Set<string>;
}

// Tool wrapper with auth checks
async function withAuth<T>(
  tool: (context: AuthContext, ...args: unknown[]) => Promise<T>,
  context: AuthContext,
  requiredPermission: string,
  ...args: unknown[]
): Promise<ToolResult> {
  if (!context.permissions.has(requiredPermission)) {
    return {
      success: false,
      error: `Permission denied: requires '${requiredPermission}' permission. Current permissions: ${Array.from(context.permissions).join(', ')}`,
      metadata: {
        executedAt: new Date().toISOString(),
        durationMs: 0,
      },
    };
  }

  try {
    const result = await tool(context, ...args);
    return {
      success: true,
      data: result,
      metadata: {
        executedAt: new Date().toISOString(),
        durationMs: 0,
      },
    };
  } catch (error) {
    return {
      success: false,
      error: (error as Error).message,
      metadata: {
        executedAt: new Date().toISOString(),
        durationMs: 0,
      },
    };
  }
}

// Usage
const authContext: AuthContext = {
  userId: 'user-123',
  roles: ['user'],
  permissions: new Set(['read:invoices', 'create:invoices']),
};

const result = await withAuth(
  (ctx, customerId) => createInvoice(ctx, customerId),
  authContext,
  'create:invoices',
  'customer-456'
);

Rate Limiting Tools

Prevent abuse and runaway loops:

interface RateLimitConfig {
  requestsPerMinute: number;
  requestsPerHour: number;
}

class RateLimitedTool {
  private limits: Map<
    string,
    { minute: number; hour: number; minuteAt: number; hourAt: number }
  > = new Map();

  private config: RateLimitConfig;

  constructor(config: RateLimitConfig) {
    this.config = config;
  }

  async checkLimit(userId: string): Promise<boolean> {
    const now = Date.now();
    let usage = this.limits.get(userId);

    if (!usage) {
      usage = { minute: 0, hour: 0, minuteAt: now, hourAt: now };
      this.limits.set(userId, usage);
    }

    // Reset windows if expired
    if (now - usage.minuteAt &gt; 60000) {
      usage.minute = 0;
      usage.minuteAt = now;
    }

    if (now - usage.hourAt &gt; 3600000) {
      usage.hour = 0;
      usage.hourAt = now;
    }

    // Check limits
    if (usage.minute &gt;= this.config.requestsPerMinute) {
      return false;
    }

    if (usage.hour &gt;= this.config.requestsPerHour) {
      return false;
    }

    usage.minute += 1;
    usage.hour += 1;

    return true;
  }
}

async function executeToolWithRateLimit(
  toolName: string,
  userId: string,
  execute: () => Promise<ToolResult>
): Promise<ToolResult> {
  const limiter = new RateLimitedTool({
    requestsPerMinute: 10,
    requestsPerHour: 100,
  });

  const allowed = await limiter.checkLimit(userId);

  if (!allowed) {
    return {
      success: false,
      error: `Rate limit exceeded for ${toolName}. Max 10 requests per minute, 100 per hour.`,
      metadata: {
        executedAt: new Date().toISOString(),
        durationMs: 0,
      },
    };
  }

  return execute();
}

Logging and Audit Trail

Maintain complete records of tool calls:

interface ToolCallLog {
  id: string;
  toolName: string;
  userId: string;
  agentId: string;
  input: unknown;
  output: unknown;
  success: boolean;
  executedAt: Date;
  durationMs: number;
  error?: string;
}

class ToolAuditLogger {
  private db: any;

  async logToolCall(log: ToolCallLog): Promise<void> {
    await this.db.toolLogs.insert({
      ...log,
      createdAt: new Date(),
    });
  }

  async getToolCallsByUser(
    userId: string,
    limit: number = 100
  ): Promise<ToolCallLog[]> {
    return this.db.toolLogs.find({ userId }).limit(limit).sort({ executedAt: -1 });
  }

  async getToolCallsByAgent(agentId: string): Promise<ToolCallLog[]> {
    return this.db.toolLogs.find({ agentId }).sort({ executedAt: -1 });
  }

  async searchToolCalls(query: {
    toolName?: string;
    success?: boolean;
    fromDate?: Date;
    toDate?: Date;
  }): Promise<ToolCallLog[]> {
    const filter: any = {};

    if (query.toolName) filter.toolName = query.toolName;
    if (query.success !== undefined) filter.success = query.success;

    if (query.fromDate || query.toDate) {
      filter.executedAt = {};
      if (query.fromDate) filter.executedAt.$gte = query.fromDate;
      if (query.toDate) filter.executedAt.$lte = query.toDate;
    }

    return this.db.toolLogs.find(filter).sort({ executedAt: -1 });
  }
}

async function executeToolWithAudit(
  toolName: string,
  userId: string,
  agentId: string,
  input: unknown,
  execute: () => Promise<unknown>
): Promise<unknown> {
  const startTime = Date.now();
  const logger = new ToolAuditLogger();

  try {
    const output = await execute();

    await logger.logToolCall({
      id: crypto.randomUUID(),
      toolName,
      userId,
      agentId,
      input,
      output,
      success: true,
      executedAt: new Date(),
      durationMs: Date.now() - startTime,
    });

    return output;
  } catch (error) {
    await logger.logToolCall({
      id: crypto.randomUUID(),
      toolName,
      userId,
      agentId,
      input,
      output: null,
      success: false,
      executedAt: new Date(),
      durationMs: Date.now() - startTime,
      error: (error as Error).message,
    });

    throw error;
  }
}

TypeScript Tool Registry Pattern

Centralize and type-check tools:

interface ToolDefinition {
  name: string;
  description: string;
  inputSchema: Record<string, unknown>;
  execute: (args: unknown) => Promise<ToolResult>;
}

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

  register(tool: ToolDefinition): void {
    if (this.tools.has(tool.name)) {
      throw new Error(`Tool '${tool.name}' already registered`);
    }
    this.tools.set(tool.name, tool);
  }

  get(name: string): ToolDefinition | undefined {
    return this.tools.get(name);
  }

  getAll(): ToolDefinition[] {
    return Array.from(this.tools.values());
  }

  async execute(name: string, args: unknown): Promise<ToolResult> {
    const tool = this.get(name);
    if (!tool) {
      return {
        success: false,
        error: `Tool '${name}' not found. Available tools: ${Array.from(this.tools.keys()).join(', ')}`,
        metadata: {
          executedAt: new Date().toISOString(),
          durationMs: 0,
        },
      };
    }

    return tool.execute(args);
  }
}

// Register tools
const registry = new ToolRegistry();

registry.register({
  name: 'lookup_customer',
  description: 'Find customer by ID',
  inputSchema: {
    customer_id: { type: 'string' },
  },
  execute: async (args: unknown) => {
    const { customer_id } = args as { customer_id: string };
    return await lookupCustomer(customer_id);
  },
});

registry.register({
  name: 'create_invoice',
  description: 'Create a new invoice',
  inputSchema: {
    customer_id: { type: 'string' },
    items: { type: 'array' },
  },
  execute: async (args: unknown) => {
    const { customer_id, items } = args as any;
    return await createInvoice(customer_id, items);
  },
});

Checklist

  • Design tools with clear, verb-based names
  • Write unambiguous JSON schemas with examples
  • Return structured, parseable results
  • Write error messages that guide LLM reasoning
  • Make tools idempotent where possible
  • Implement auth checks and permissions
  • Add rate limiting to prevent abuse
  • Log all tool calls for audit trails
  • Build a centralized tool registry

Conclusion

Well-designed tools are the difference between reliable agents and unreliable ones. Invest in clear naming, precise schemas, helpful errors, and idempotency. Build a centralized tool registry, add comprehensive logging, and make decisions to guard against abuse. As your agent fleet grows, these patterns ensure tools remain trustworthy and maintainable.