Designing Tools for Function-Calling Agents

Carson Rodrigues

Carson Rodrigues / October 19, 2025

7 min read––– views

fdeai-agentsllm

When an agent misbehaves in production, everyone's first instinct is to blame the model or rewrite the system prompt. In my experience — across voice agents, MCP servers, and LLM pipelines that real customers depend on — the actual culprit, maybe seven times out of ten, is a badly designed tool.

I touched on this in my production agents field guide, but tool design deserves its own post, because it's the part of the stack you fully control. You can't retrain the model. You can redesign the tool. This is where the leverage is.


Your tool schema is a prompt

Here's the mental shift that changed how I build: the model never sees your code — it sees your tool's name, description, and parameter schema, and nothing else. Those three strings are the interface. They're read on every single request, which makes them the most-executed prompt in your system.

So write them like prompts, not like docstrings:

// Weak: the model has to guess everything
server.tool("search", { q: z.string() }, ...);

// Strong: the description does the prompting
server.tool(
  "search_knowledge_base",
  "Search the customer's internal docs. Use for questions about " +
  "their policies, products, or procedures. Returns up to 5 excerpts " +
  "with source URLs. Do NOT use for general knowledge questions.",
  {
    query: z.string().describe(
      "Full-sentence search query. Rephrase the user's question; " +
      "don't copy it verbatim."
    ),
    max_results: z.number().int().min(1).max(5).default(3),
  },
  ...
);

The second version tells the model when to call, when not to call, what comes back, and how to phrase the argument. Every one of those sentences prevents a category of failure I've watched happen in traces. When I audit a misfiring agent, I read the tool descriptions before I read the system prompt — and rewriting a description is the cheapest fix that ever works.


Narrow the inputs until it hurts

A tool's power should match the model's judgment, and the model's judgment is worse than yours. The classic example from my MCP post still holds: run_sql(query) is a liability, get_orders_by_customer(customer_id) is a feature. But the principle goes further than "don't expose raw SQL":

  • Enums over free strings. If a status can only be open, pending, or closed, say so in the schema. A free string invites "OPEN", "Open ", and "opened" — I've seen all three from the same model in one afternoon.
  • IDs over names. Force the model to look an entity up first and pass the ID. Name-based lookups fail on typos, homonyms, and hallucinated customers.
  • Bounded numbers. Every limit, offset, and amount gets a min and a max in the schema. The model will eventually ask for 10,000 rows; the schema should refuse before your database does.
  • No optional soup. A tool with nine optional parameters is nine chances to hallucinate. If two combinations of options mean two different jobs, that's two tools.

The narrower the input space, the fewer ways there are to be wrong. It feels restrictive when you write it. It feels like reliability when you read the traces.


Errors are a conversation, not an exception

The model can't read your logs. When a tool fails, the error message is the debugging session — and the model is the one debugging. This is one of the biggest quality gaps I see between demo agents and production ones.

Return errors that are structured, honest, and actionable:

{
  "ok": false,
  "error": "date_out_of_range",
  "message": "start_date must be within the last 90 days. You sent 2024-01-15. The earliest allowed date is 2026-04-03.",
  "retryable": true
}

Three rules I hold to on every project:

  1. Distinguish "empty" from "failed." A search that finds nothing and a search that timed out must return visibly different shapes. Otherwise the agent tells the user "you have no orders" when the truth was "the orders service was down" — the single most corrosive silent failure in agent systems.
  2. Say what to do next. "Invalid input" teaches nothing. "Expected ISO 8601, got '15/01/2026'" lets the model self-correct on the very next step, no human involved.
  3. Never leak stack traces. They burn hundreds of tokens, contain internal paths, and — worst — the model sometimes quotes them to the end user.

A good error message turns a failed run into a one-step recovery. A bad one turns it into a loop.


Make everything idempotent, because retries are coming

Agents retry. The loop retries on transient failures, the model retries when it's unsure a call landed, and your infrastructure retries on timeouts. If your tools aren't idempotent, retries become duplicates — duplicate emails, duplicate tickets, duplicate charges.

While building voice agents at VoiceQube, this bit us in the most classic way possible: a booking tool, a network timeout, a model that reasonably tried again, and a customer with two appointments. The fix is old, boring distributed-systems hygiene applied at the tool boundary:

  • Reads are naturally idempotent — make most of your tools reads, and mark them as such (MCP's read-only annotation exists for exactly this).
  • Writes take an idempotency key. Either the client loop generates one per logical action, or the tool derives one from the arguments and dedupes server-side.
  • Destructive actions get a two-step shape. prepare_refund returns a summary and a confirmation token; execute_refund(token) actually moves money. The model — or better, a human — confirms in between. That gap is where your safety lives.

Design every tool as if it will be called twice. In production, it will be.


When one tool should be three

There's constant tension between a short tool list (better selection accuracy) and narrow tools (better call accuracy). My rule of thumb after shipping a few of these: split a tool when the model needs to make a decision that the tool's parameters are hiding.

Signals that a tool is doing too many jobs:

  • The description contains "or" doing heavy lifting: "searches orders or invoices or shipments."
  • One parameter changes the meaning of the others (a mode flag is the classic tell).
  • You keep adding sentences to the description to explain which parameter combos are valid.

Split manage_calendar(action, ...) into list_events, create_event, cancel_event and the model's accuracy jumps — because tool selection is something models are genuinely good at, while inferring implicit parameter contracts is something they're genuinely bad at.

The inverse smell exists too. If the model always calls get_customer, then get_customer_orders, then get_customer_balance in sequence, collapse them into one get_customer_overview. Three round trips of latency for one logical question is a tax your users pay on every request — and for voice, where I've spent most of my time, that tax is fatal.


The checklist I actually use

Before a tool ships, I run it through this:

  • Does the description say when to use it and when not to?
  • Is every parameter constrained as tightly as the domain allows?
  • Does a failed call return a structured, actionable error — distinct from an empty result?
  • Is it safe to call twice with the same arguments?
  • Does it do exactly one job?
  • Have I read a real trace of the model using it, not just my own test call?

That last one is non-negotiable. The model will use your tool in ways you didn't imagine, and the traces are where you find out — before your customers do.


The takeaway

Tools are the part of an agent you can engineer with certainty, in a system that's otherwise probabilistic. Treat the schema as a prompt, narrow the inputs, make errors instructive, assume every call happens twice, and split tools along decision boundaries. None of it is glamorous. All of it compounds — and it's the difference between an agent you demo and an agent you defend in production.


Related reading

Available for senior AI / contract / FDE work

Building something with AI?

Voice agents, MCP servers, LLM pipelines, agentic workflows — pick a slot, drop a message, or send your email and I'll reply within a day.

or leave your email

Replies within ~24 hours · Remote-first · global · open to relocation