- Published on
Designing AI Agent Tools — Schema, Errors, and Idempotency for LLM Tool Use
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
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
- JSON Schema Best Practices
- Returning Structured Results
- Error Messages for LLM Reasoning
- Idempotent Tools
- Tool Authentication and Authorization
- Rate Limiting Tools
- Logging and Audit Trail
- TypeScript Tool Registry Pattern
- Checklist
- Conclusion
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 > 60000) {
usage.minute = 0;
usage.minuteAt = now;
}
if (now - usage.hourAt > 3600000) {
usage.hour = 0;
usage.hourAt = now;
}
// Check limits
if (usage.minute >= this.config.requestsPerMinute) {
return false;
}
if (usage.hour >= 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.