Sign in

Tools

advanced~35 minagentstoolsfunction-callingsecurity

Function calling, JSON-schema tool definitions, and why every model-supplied argument is untrusted input until your code says otherwise.

Tools

Overview

A model that can only produce text is limited to what it memorised during training. It cannot read your database, check today's price, or send anything.

Tool calling is the fix, and it works in a way people usually get backwards: the model never executes anything. It emits a name and some arguments, your code validates and runs them, and the result goes back into the conversation. This lesson is that round trip — plus why validating those arguments is a security boundary and not a formality.

Theory

The round trip. The model emits a tool name and JSON arguments. It cannot execute anything. Your code validates the arguments, runs the function, and returns the result as a tool message the model reads on its next turn. The model is asking, not doing — that distinction is the whole security model.

Tool definitions are JSON Schema. Name, description, and typed parameters. The description is read by the model, so it is a prompt: "the user's account id, a UUID" gets used correctly far more often than "id".

Validating arguments is a security boundary, not a formality. The model is an untrusted input source — it can hallucinate an id, a path, or a row that belongs to someone else. Parse against a schema and check the caller is allowed to touch that resource. A tool that trusts its arguments is an API with authentication removed.

Make tools idempotent where you can. Models retry, chain, and sometimes call the same thing twice from slightly different reasoning. If a repeat call is harmless, that is a non-event instead of a duplicate charge.

Return errors as results, not exceptions. A tool that throws ends the loop. A tool that returns { error: "no user with that id" } lets the model read it and try something sensible — which is usually what you wanted.

Visual Diagram

Model requests a callEmits a name and JSON arguments. It cannot run anything itself.
You validateParse against a schema and check permissions.
Invalid? Return the error as the result and let it try again.
You executeYour code hits the database or the API, under your credentials.
Result returns to contextAs a tool message the model reads on its next turn.
The model may chain another call from what it just learned.
The tool-calling round trip. Your code, not the model, does the work.

Draw it as a loop between two boxes. The left box is the model: it receives tool definitions plus conversation history, and emits either a text response or a tool-call request (name + arguments as text). The right box is your application: it receives that request, runs the arguments through schema validation, then through business-rule validation, then — only if both pass — executes the actual side effect. The result (success data, or a structured error) flows back into the left box's next input as a new message. The loop repeats until the model produces a final text response with no further tool call.

Code

A refund tool with schema validation, a re-check of business rules the schema can't express, an idempotency key to survive retries safely, and structured errors returned to the model instead of thrown.

import { generateText, tool, stepCountIs } from "ai";
import { z } from "zod";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";

const provider = createOpenAICompatible({
  name: "example-gateway",
  baseURL: "https://ai.gateway.lovable.dev/v1",
  headers: { "Lovable-API-Key": process.env.LOVABLE_API_KEY ?? "" },
});

// Stand-in for a real idempotency-key store (Redis, a database unique index).
const processedRefunds = new Set<string>();

const issueRefund = tool({
  description: "Issue a refund for a previously drafted, human-approved order.",
  // The schema is the contract the model must satisfy to be accepted at all —
  // it is NOT the security boundary. A schema-valid amountCents can still be
  // ten times the refund policy allows; that check has to happen below.
  inputSchema: z.object({
    orderId: z.string().min(1),
    amountCents: z.number().int().positive(),
    idempotencyKey: z.string().uuid(),
  }),
  execute: async ({ orderId, amountCents, idempotencyKey }) => {
    // Re-check business invariants the JSON Schema can't express. Every
    // field here already passed Zod — that only proves it's the right
    // *shape*, not that it's the right *value*.
    if (amountCents > 50_000) {
      return { error: `amountCents ${amountCents} exceeds the $500 refund cap` };
    }

    // Idempotency: a retried tool call (network blip, the model re-issuing
    // the same call) must not double-refund the customer.
    if (processedRefunds.has(idempotencyKey)) {
      return { status: "already_processed", idempotencyKey };
    }

    // Stand-in for a real payment-provider call. A failure here becomes
    // structured data returned to the model, not a thrown exception — the
    // model can then decide whether to retry, ask a clarifying question, or
    // hand off to a human, instead of the whole request just dying.
    if (!orderId.startsWith("ord_")) {
      return { error: `unknown order ID format: ${orderId}` };
    }

    processedRefunds.add(idempotencyKey);
    return { status: "refunded", orderId, amountCents };
  },
});

export async function handleRefundRequest(userMessage: string) {
  const { text } = await generateText({
    model: provider("gpt-4o-mini"),
    system: "Issue refunds only for orders a human reviewer has already approved.",
    prompt: userMessage,
    tools: { issueRefund },
    stopWhen: stepCountIs(4),
  });
  return text;
}

Notice the two separate validation passes: Zod's inputSchema runs automatically before execute is even called, rejecting anything the wrong shape — but the amountCents > 50_000 check inside execute runs after that, because "positive integer" and "within refund policy" are different questions. The idempotencyKey check runs before the side effect, not after, so a retried call is safe. And every failure path returns a plain object the model can read, instead of throwing.

Real Product Example

Claude Code — the coding agent this lesson's content was itself built with tooling from — is a concrete, observable example of tool-call validation as a security boundary. It exposes tools like file read/write and shell execution to the model as JSON-schema functions, the same mechanism described in this lesson. Before a potentially destructive tool call executes — a shell command, a file write outside the working directory — it runs through a permission layer that checks the specific arguments the model produced against configured rules, and can require explicit human confirmation before proceeding. A shell command the model requests isn't trusted because it came from a well-behaved model; it's checked against an allow/deny policy the same way this lesson's refund example re-checks amountCents against a policy the schema can't express. When a tool fails — a file doesn't exist, a command exits non-zero — the error text is returned to the model as a tool result, not thrown as an exception that would kill the whole turn, letting the model see what happened and adjust its next action, exactly the error-surfacing pattern described above.

Common Mistakes

Treating schema validation as the whole security boundary

Passing Zod validation proves an argument has the right shape — the right types, the required fields present. It proves nothing about whether the value is safe: an amount within policy, an ID the caller is actually allowed to touch, a path that doesn't escape a sandboxed directory. Every tool that has a side effect needs a second layer of business-rule validation inside execute, after the schema has already passed.

Throwing exceptions from inside a tool's execute function

A thrown exception inside execute typically kills the whole model call rather than giving the model a chance to react. For failures the model could plausibly recover from — a not-found record, a validation failure, a downstream timeout — return a structured error object instead. Reserve real exceptions for failures that genuinely need to abort the request, like the tool store itself being unreachable.

Writing a tool description that's too vague for the model to use correctly

A description like "handles orders" tells the model almost nothing about when to call this tool versus another one, and you'll see it called at the wrong times, with the wrong tool chosen for a task, or not called when it should be. Write descriptions the way you'd write a docstring for a coworker who has never seen your codebase: what it does, when to use it, and what it explicitly does not do.

Skipping idempotency because 'retries are rare'

Retries aren't rare in a networked system with an LLM in the loop — timeouts, provider-side retries, and a model re-issuing a call it thinks failed are all routine. A tool without an idempotency key looks fine in testing (where you rarely trigger a retry) and causes duplicate side effects in production the first time a request actually gets retried, which is exactly the kind of bug that's expensive to have shipped.

Senior Tips

Senior tip

Write the tool description for the model the way you'd write API docs for a new hire, including what NOT to call it for. "Looks up a customer's order history. Does not modify orders — use issueRefund for that." explicit negatives reduce mis-calls far more than you'd expect, because the model otherwise has to infer scope from the name alone.

Senior tip

Keep the list of active tools small and scoped to the current task, not "everything the app can do." A model choosing between three well-described, clearly-scoped tools calls the right one far more reliably than one choosing between thirty. If your app has many capabilities, group them and only expose the relevant subset per agent or per conversation stage, rather than handing the model your entire API surface every time.

Senior tip

Log the raw tool-call arguments before validation runs, not just after. When a model produces a malformed or suspicious argument, the pre-validation payload is what tells you whether it was a model mistake, a prompt-injection attempt from content it read, or a schema that's ambiguous enough to invite bad values. Logging only the post-validation, already-clean data throws away the evidence you'd need to diagnose which of those it was.

Senior tip

Design tools around the smallest safe capability, not the most convenient one. A runShellCommand tool is easy to build and terrifying to secure; three narrow tools — listFiles, readFile, runTests — cover most of the same ground with a validation surface you can actually reason about. The convenience of one general-purpose tool is rarely worth the blast radius when a model (or content it's processing) produces an argument you didn't anticipate.

Exercises

Exercise

Take the issueRefund tool from the Code section. List two more business-rule checks (beyond the $500 cap) you'd want before a real payment provider is called, and explain why a JSON Schema alone couldn't express either one.

Exercise

Describe a prompt-injection scenario for an agent that reads incoming emails and has access to a forwardEmail tool: what malicious content in an email body could cause the tool to be called with attacker-chosen arguments, and what validation would stop it even if the model itself decided to forward the email?

Exercise

Pick one tool you'd need for a "book a meeting room" agent. Write its name, description, and Zod inputSchema, then write one business-rule check that isn't expressible in that schema (for example: the room isn't already booked for that time).

Mini Project

Validation boundary review. Take one tool from any project you've built earlier in this path — or write a new small one if you don't have one handy — and retrofit it with the two-layer validation from this lesson.

  1. Write down every argument the tool accepts and, for each, one business rule that a type-level schema cannot express (a range, an ownership check, a rate limit).
  2. Add those checks inside execute, after schema validation, each returning a structured error object rather than throwing.
  3. Add an idempotency key parameter if the tool has any side effect, and a check that rejects a repeated key without repeating the side effect.
  4. Write a short test (or a manual trace, if you don't have a test harness yet) that calls the tool twice with the same idempotency key and confirms the side effect only happened once.

Resources

  • Vercel AI SDK — Tool Calling: the tool() API, input schemas, multi-step tool-calling loops, and error handling used throughout this lesson's code.
  • Anthropic — Tool Use Overview: the full round trip for defining tools, handling tool calls, and the pricing implications of tool definitions taking up input tokens on every call.