Tool Design Patterns for AI Agents

How to design effective tools for AI agents including parameter schemas, error handling, tool composition, and the patterns that make agents more reliable — with examples from Klivvr Agent.

technical6 min readBy Klivvr Engineering
Share:

An AI agent is only as capable as its tools. The language model provides reasoning — deciding what to do next, interpreting results, and communicating with the user. But tools provide action — querying databases, calling APIs, updating records, and interacting with external systems. Poorly designed tools produce unreliable agents regardless of how capable the underlying model is.

This article covers the tool design patterns used in Klivvr Agent, from parameter schema design to error handling to tool composition.

The Tool Interface

Every tool in Klivvr Agent implements a standard interface that separates the tool's definition (what the LLM sees) from its implementation (what actually executes).

import { z, ZodSchema } from "zod";
 
interface Tool<TInput = unknown, TOutput = unknown> {
  name: string;
  description: string;
  parameters: ZodSchema<TInput>;
  execute: (input: TInput) => Promise<ToolResult<TOutput>>;
}
 
interface ToolResult<T = unknown> {
  success: boolean;
  data?: T;
  error?: string;
  metadata?: Record<string, unknown>;
}
 
function defineTool<TInput, TOutput>(config: {
  name: string;
  description: string;
  parameters: ZodSchema<TInput>;
  execute: (input: TInput) => Promise<TOutput>;
}): Tool<TInput, TOutput> {
  return {
    name: config.name,
    description: config.description,
    parameters: config.parameters,
    execute: async (input) => {
      try {
        const validated = config.parameters.parse(input);
        const result = await config.execute(validated);
        return { success: true, data: result };
      } catch (error) {
        return {
          success: false,
          error: error instanceof Error ? error.message : "Unknown error",
        };
      }
    },
  };
}

Using Zod for parameter schemas provides runtime validation and automatic JSON Schema generation for the LLM. The LLM sees the JSON Schema in its tool definitions, and Zod validates the arguments before execution.

Writing Effective Descriptions

The tool description is the single most important factor in whether the LLM uses a tool correctly. Descriptions must be precise, operational, and include boundary conditions.

// Bad: vague description
const badTool = defineTool({
  name: "get_customer",
  description: "Gets customer information",
  // ...
});
 
// Good: precise, operational, includes constraints
const goodTool = defineTool({
  name: "lookup_customer",
  description:
    "Look up a customer by their unique ID, email address, or phone number. " +
    "Returns the customer profile including name, account status, KYC level, " +
    "account balance, and the 5 most recent transactions. " +
    "Returns a not_found error if no customer matches. " +
    "Phone numbers must include the country code (e.g., +20 for Egypt).",
  parameters: z.object({
    identifier: z.string().describe("Customer ID, email, or phone number"),
    identifierType: z
      .enum(["id", "email", "phone"])
      .describe("Type of identifier provided"),
  }),
  execute: async ({ identifier, identifierType }) => {
    const customer = await customerService.findBy(identifierType, identifier);
    if (!customer) {
      return { found: false, message: `No customer found` };
    }
    return {
      found: true,
      id: customer.id,
      name: customer.fullName,
      status: customer.status,
      kycLevel: customer.kycLevel,
      balance: customer.formattedBalance,
      recentTransactions: customer.transactions.slice(0, 5),
    };
  },
});

The good description tells the LLM exactly what the tool returns, what identifier formats are accepted, and what happens on failure. This reduces the model's guesswork and improves first-attempt accuracy.

Parameter Schema Design

Constrained parameters reduce errors. Every parameter should be as specific as possible — use enums instead of free-form strings, add validation rules, and provide clear descriptions.

const transferTool = defineTool({
  name: "initiate_transfer",
  description:
    "Initiate a money transfer between accounts. " +
    "Transfers over 50,000 EGP require additional verification and will be queued. " +
    "Both accounts must belong to the same customer.",
  parameters: z.object({
    fromAccountId: z
      .string()
      .regex(/^acc_[a-zA-Z0-9]{12}$/)
      .describe("Source account ID (format: acc_xxxxxxxxxxxx)"),
    toAccountId: z
      .string()
      .regex(/^acc_[a-zA-Z0-9]{12}$/)
      .describe("Destination account ID (format: acc_xxxxxxxxxxxx)"),
    amount: z
      .number()
      .positive()
      .max(500000)
      .describe("Transfer amount in EGP (max 500,000)"),
    currency: z
      .enum(["EGP", "USD", "EUR", "GBP"])
      .default("EGP")
      .describe("Currency code"),
    reference: z
      .string()
      .max(100)
      .optional()
      .describe("Optional transfer reference or memo"),
  }),
  execute: async (input) => {
    if (input.fromAccountId === input.toAccountId) {
      throw new Error("Source and destination accounts must be different");
    }
    return await transferService.initiate(input);
  },
});

The regex patterns on account IDs prevent the LLM from generating malformed identifiers. The max constraint on amount prevents accidental large transfers. The enum on currency limits values to supported currencies. These constraints catch errors at the schema level before they reach business logic.

Compound Tools

Some operations require multiple steps that should be atomic from the agent's perspective. Rather than forcing the agent to orchestrate multiple tool calls, compound tools encapsulate multi-step workflows.

const resolveDisputeTool = defineTool({
  name: "resolve_dispute",
  description:
    "Resolve a transaction dispute by reviewing the transaction, " +
    "applying the resolution (refund, reject, or escalate), " +
    "and notifying the customer. This is an atomic operation.",
  parameters: z.object({
    disputeId: z.string().describe("The dispute case ID"),
    resolution: z
      .enum(["full_refund", "partial_refund", "reject", "escalate"])
      .describe("Resolution decision"),
    partialAmount: z
      .number()
      .positive()
      .optional()
      .describe("Amount for partial refund (required if resolution is partial_refund)"),
    reason: z.string().min(10).describe("Explanation for the resolution decision"),
  }),
  execute: async (input) => {
    // Step 1: Validate dispute exists and is open
    const dispute = await disputeService.get(input.disputeId);
    if (!dispute) throw new Error(`Dispute ${input.disputeId} not found`);
    if (dispute.status !== "open")
      throw new Error(`Dispute is already ${dispute.status}`);
 
    // Step 2: Apply resolution
    const resolution = await disputeService.resolve({
      disputeId: input.disputeId,
      type: input.resolution,
      amount: input.partialAmount,
      reason: input.reason,
      resolvedBy: "agent",
    });
 
    // Step 3: Process refund if applicable
    if (input.resolution.includes("refund")) {
      await refundService.process({
        transactionId: dispute.transactionId,
        amount: input.partialAmount ?? dispute.transactionAmount,
        reason: `Dispute resolution: ${input.reason}`,
      });
    }
 
    // Step 4: Notify customer
    await notificationService.send({
      customerId: dispute.customerId,
      template: "dispute_resolved",
      data: {
        disputeId: input.disputeId,
        resolution: input.resolution,
        reason: input.reason,
      },
    });
 
    return {
      disputeId: input.disputeId,
      resolution: input.resolution,
      refundProcessed: input.resolution.includes("refund"),
      customerNotified: true,
    };
  },
});

Tool Registries

As the number of tools grows, managing them requires a registry that supports dynamic tool selection. Not every conversation needs every tool — a customer asking about their balance does not need access to the dispute resolution tool.

class ToolRegistry {
  private tools = new Map<string, Tool>();
  private categories = new Map<string, Set<string>>();
 
  register(tool: Tool, categories: string[] = []) {
    this.tools.set(tool.name, tool);
    for (const category of categories) {
      if (!this.categories.has(category)) {
        this.categories.set(category, new Set());
      }
      this.categories.get(category)!.add(tool.name);
    }
  }
 
  getByCategory(category: string): Tool[] {
    const names = this.categories.get(category) ?? new Set();
    return [...names]
      .map((name) => this.tools.get(name))
      .filter((t): t is Tool => t !== undefined);
  }
 
  getForIntent(intent: string): Tool[] {
    const intentToolMap: Record<string, string[]> = {
      balance_inquiry: ["lookup_customer", "get_account_balance"],
      transfer: ["lookup_customer", "get_account_balance", "initiate_transfer"],
      dispute: ["lookup_customer", "get_transaction", "resolve_dispute"],
      general: ["lookup_customer", "search_faq"],
    };
 
    const toolNames = intentToolMap[intent] ?? intentToolMap["general"];
    return toolNames
      .map((name) => this.tools.get(name))
      .filter((t): t is Tool => t !== undefined);
  }
}

Conclusion

Tool design is the engineering discipline that separates demo agents from production agents. Precise descriptions guide the LLM to use tools correctly. Constrained parameter schemas catch errors before they reach business logic. Compound tools encapsulate multi-step workflows into atomic operations. And tool registries enable context-appropriate tool selection. In Klivvr Agent, these patterns ensure that every tool call is validated, predictable, and safe — because in a fintech agent, a malformed tool call is not just a bug, it is a potential financial error.

Related Articles

business

AI Agents in Fintech Operations

How AI agents automate fintech operational workflows including compliance monitoring, fraud detection, dispute resolution, and regulatory reporting — with insights from Klivvr Agent deployments.

7 min read
business

Human-in-the-Loop Patterns for AI Agents

How to design effective human-in-the-loop workflows for AI agents, covering escalation policies, approval workflows, the autonomy ladder, and trust-building strategies.

7 min read
technical

Multi-Agent Systems in TypeScript

Architecture patterns for multi-agent systems including supervisor topologies, agent-to-agent communication, task delegation, and shared state management in Klivvr Agent.

6 min read