All articles
AI Agents#tool calling#ai agents#security#api design

Tool calling done right: designing tools an LLM can use safely

Tools are where an agent touches the real world. How to design schemas the model understands, make actions idempotent, enforce permissions and put a human in the loop for risky operations.

FX

Codestria Engineering

Engineering team

5 min read

An LLM on its own produces text. Tools are what let it read your database, open a ticket, send an email or move money. That makes tool design the most important part of an agent, both for how well it works and for how badly it can fail.

A useful mental model: a tool is a public API whose caller is clever, fast, occasionally confused and potentially under someone else's control. You would not expose an endpoint to that caller without validation, authorisation, rate limits and an audit log. The same applies here.

Design the interface for the model

The model chooses tools based on names, descriptions and parameter schemas. Write them with the same care you would give public API docs. Good tool interfaces tend to share a few traits:

  • Task-shaped, not table-shaped. find_customer_by_email beats a generic query_database. Narrow tools are easier to choose correctly and easier to secure.
  • Clear boundaries. If two tools could plausibly do the same thing, merge them or explain in each description when to prefer which.
  • Constrained parameters. Use enums, formats, min and max values and required fields. Every constraint is one less way to be wrong.
  • Descriptions that include when not to use the tool, plus any preconditions such as “call get_order first to confirm the order belongs to this customer”.
tools/definitions.ts
import type Anthropic from "@anthropic-ai/sdk";

export const tools: Anthropic.Tool[] = [
  {
    name: "get_order",
    description:
      "Look up a single order for the current customer by its order number. " +
      "Use this before any refund or change to confirm the order exists and to read its status. " +
      "Returns status, items, total and whether it is eligible for a refund.",
    input_schema: {
      type: "object",
      properties: {
        order_number: {
          type: "string",
          pattern: "^[0-9]{4,10}$",
          description: "Numeric order number, without the # prefix.",
        },
      },
      required: ["order_number"],
    },
  },
  {
    name: "request_refund",
    description:
      "Request a refund for an order the customer owns. Only use after get_order shows refund_eligible = true. " +
      "Refunds above the automatic limit are sent to a human for approval; tell the customer it is pending.",
    input_schema: {
      type: "object",
      properties: {
        order_number: { type: "string", pattern: "^[0-9]{4,10}$" },
        amount: { type: "number", exclusiveMinimum: 0, description: "Amount in the order currency." },
        reason: { type: "string", enum: ["damaged", "not_received", "wrong_item", "other"] },
        note: { type: "string", maxLength: 500 },
      },
      required: ["order_number", "amount", "reason"],
    },
  },
];

Validate everything in code

The JSON schema guides the model, but you should not assume every call matches it. Parse arguments with a runtime validator before doing anything, and return validation failures to the model as a normal tool result so it can correct itself. Keep the schema and the validator in one place so they cannot drift apart. With zod, you can generate the JSON schema from the validator.

tools/registry.ts
import { z } from "zod";

type ToolContext = { tenantId: string; customerId: string; actor: "agent"; runId: string };
type ToolResult = { ok: true; output: unknown } | { ok: false; output: { error: string } };

type ToolDef<S extends z.ZodTypeAny> = {
  schema: S;
  risk: "read" | "write" | "high";
  handler: (input: z.infer<S>, ctx: ToolContext) => Promise<unknown>;
};

const registry = new Map<string, ToolDef<z.ZodTypeAny>>();

export function defineTool<S extends z.ZodTypeAny>(name: string, def: ToolDef<S>) {
  registry.set(name, def as ToolDef<z.ZodTypeAny>);
}

export async function runTool(name: string, rawInput: unknown, ctx: ToolContext): Promise<ToolResult> {
  const tool = registry.get(name);
  if (!tool) return { ok: false, output: { error: "Unknown tool: " + name } };

  const parsed = tool.schema.safeParse(rawInput);
  if (!parsed.success) {
    const issues = parsed.error.issues.map((i) => i.path.join(".") + ": " + i.message);
    return { ok: false, output: { error: "Invalid input. " + issues.join("; ") } };
  }

  try {
    const output = await tool.handler(parsed.data, ctx);
    await audit({ ...ctx, tool: name, input: parsed.data, ok: true });
    return { ok: true, output };
  } catch (err) {
    const message = err instanceof ToolError ? err.message : "Internal error. Try a different approach or escalate.";
    await audit({ ...ctx, tool: name, input: parsed.data, ok: false, error: String(err) });
    return { ok: false, output: { error: message } };
  }
}

export class ToolError extends Error {}
declare function audit(entry: Record<string, unknown>): Promise<void>;

Notice that unexpected exceptions return a generic message to the model while the full error goes to the audit log. Stack traces and SQL errors are noise to the model and can leak details you do not want echoed back to a user.

Permissions: the agent acts as someone

Every tool call should run with the identity and permissions of a real principal: the end user the agent is helping, or a narrowly scoped service account. Never give an agent a database superuser or an admin API key and trust the prompt to keep it in bounds.

  • Pass identity through a server-side context object, as with ctx above. Never accept customer_id or tenant_id as a model-supplied argument.
  • Check ownership inside the handler: the order must belong to ctx.customerId, whatever order number the model passes.
  • Give each agent only the tools its job needs. A support agent does not need delete_customer.
  • Rate-limit write tools per run and per user, so a loop cannot send 500 emails.

Idempotency and retries

Agents retry. The model may call the same tool twice because it did not trust the first result, your loop may retry after a timeout, and your job queue may redeliver a run after a crash. For read tools this is harmless. For write tools it means duplicate refunds and duplicate emails unless you design for it.

Derive an idempotency key from the run and the meaningful arguments, and store it with a unique constraint. A second call with the same key returns the original result instead of acting again.

tools/refund.ts
import { createHash } from "node:crypto";
import { z } from "zod";
import { defineTool, ToolError } from "./registry";
import { db } from "../db";

const AUTO_APPROVE_LIMIT = 50;

defineTool("request_refund", {
  risk: "high",
  schema: z.object({
    order_number: z.string().regex(/^[0-9]{4,10}$/),
    amount: z.number().positive(),
    reason: z.enum(["damaged", "not_received", "wrong_item", "other"]),
    note: z.string().max(500).optional(),
  }),
  async handler(input, ctx) {
    const order = await db.orders.findFirst({
      where: { number: input.order_number, customerId: ctx.customerId, tenantId: ctx.tenantId },
    });
    if (!order) throw new ToolError("Order not found for this customer.");
    if (input.amount > order.refundableAmount) {
      throw new ToolError("Amount exceeds the refundable balance of " + order.refundableAmount + ".");
    }

    const key = createHash("sha256")
      .update([ctx.runId, order.id, input.amount, input.reason].join("|"))
      .digest("hex");

    const existing = await db.refundRequests.findUnique({ where: { idempotencyKey: key } });
    if (existing) return { status: existing.status, refund_id: existing.id, duplicate: true };

    const status = input.amount <= AUTO_APPROVE_LIMIT ? "approved" : "pending_approval";
    const created = await db.refundRequests.create({
      data: { idempotencyKey: key, orderId: order.id, amount: input.amount, reason: input.reason, status, requestedBy: "agent:" + ctx.runId },
    });

    if (status === "approved") await enqueueRefund(created.id);
    else await notifyApprovers(created.id);

    return { status, refund_id: created.id };
  },
});

declare function enqueueRefund(id: string): Promise<void>;
declare function notifyApprovers(id: string): Promise<void>;

Human approval for high-impact actions

The refund tool above shows the pattern we use most: the agent never executes a high-impact action directly above a threshold. It creates a request in a pending_approval state and tells the user what will happen next. A person reviews the request in an internal tool with the full context (conversation, tool trace and the agent's reasoning summary) and approves or rejects it.

  • Decide approval rules per tool, based on reversibility and blast radius, not on how confident the model sounds.
  • Show reviewers exactly what will execute: the resolved record, the amount and the recipient, not just the model's description.
  • Make the approved action execute from the stored request, so nothing can change between review and execution.
  • Track approval and rejection rates. A tool that is always approved might be safe to automate further; one that is often rejected needs better design or a better prompt.

Shape outputs and errors for the next step

Tool results go straight back into the context window. Large raw payloads waste tokens and bury the useful fields. Return only what the agent needs to decide its next action, paginate lists and summarise long text. Errors should be specific and actionable: “Order not found for this customer” tells the model to ask for the order number again, while “500 Internal Server Error” tells it nothing.

Key takeaways

  • Design task-shaped tools with constrained schemas and descriptions written for the model.
  • Validate every input at runtime and return validation errors as tool results.
  • Run tools as a real principal; take identity from server context, never from model arguments.
  • Make write tools idempotent with keys and unique constraints.
  • Route high-impact actions through a human approval step that executes exactly what was reviewed.
  • Keep outputs compact and errors actionable, and audit every call.

We design and build agents with safe, well-scoped tools as part of our Agentic AI service. If you want a second pair of eyes on your tool layer, reach out.

Found this useful? Share it with your team.

LinkedInX

Need help with agentic ai?

Autonomous AI agents that reason, use your tools and finish multi-step work end to end.

Learn more