DEV Community

Cover image for AI agent tool calling: designing tools the model will actually use correctly
Amit Shukla
Amit Shukla

Posted on Originally published at amitshuklabag.hashnode.dev

AI agent tool calling: designing tools the model will actually use correctly

You give your agent a handful of tools, write a decent system prompt and try a few requests. It works. Then real users show up. The agent calls search when it should have called get_order. It passes a customer name where an ID was expected. It retries the same failing call five times in a row. It dumps a 40,000 token API response into its own context and forgets what the user asked.

The usual reaction is to blame the model or pile more instructions into the system prompt. Most of the time the real problem is the tools themselves. The model only knows what you tell it: a name, a description and a schema. If those are vague, overlapping or noisy, the model guesses, and it guesses with confidence.

This article is about designing tools as an interface for a model, the same way you would design an API for another engineer who can't ask you questions.

How the model actually sees a tool

Whatever framework you use, a tool reaches the model as three things:

{
  "name": "get_order",
  "description": "Fetch a single order by its order ID...",
  "input_schema": {
    "type": "object",
    "properties": {
      "order_id": { "type": "string", "description": "..." }
    },
    "required": ["order_id"]
  }
}
Enter fullscreen mode Exit fullscreen mode

That is the whole contract. The model never sees your implementation, your database or the comment you left in the code. Every decision it makes about when to call this tool and what to pass comes from those few lines. Treat them as the most important documentation in your project, because for the agent they are.

Write descriptions for a new teammate

A good tool description answers four questions: what it does, when to use it, when not to use it, and what comes back.

Compare these two:

search: Searches for things.
Enter fullscreen mode Exit fullscreen mode
search_help_articles: Full text search over the public help center.
Use it for general "how do I" questions about product features.
Do not use it to look up a specific customer, order or invoice;
use get_order or get_customer for those.
Returns up to 5 articles with title, url and a short snippet.
Enter fullscreen mode Exit fullscreen mode

The second one is longer, and that is fine. Descriptions are cheap compared to a wrong call. The "do not use it for" line is the part most people skip, and it is the one that prevents the most mistakes, because it tells the model where the boundary sits between two similar tools.

Names matter too. search and lookup side by side are a coin flip. search_help_articles and get_order_by_id are not. Use a consistent pattern like verb_noun and avoid two names that could reasonably mean the same thing.

Fewer tools, with clear boundaries

Every tool you add makes the choice harder. An agent with 40 tools has to scan 40 descriptions on every step, and the chance that two of them overlap goes up fast.

A few rules that help:

  • Merge tools that differ only by a parameter. Instead of get_open_orders, get_closed_orders and get_cancelled_orders, have one list_orders with a status enum.
  • Don't mirror your REST API one to one. Your API was designed for frontend code that knows exactly what it needs. Agents work better with tools shaped around tasks, like find_customer that accepts an email or a name, than with five low level endpoints they have to chain correctly.
  • Split tools that do very different things. A single manage_user tool with an action field that can be read, update or delete hides a destructive operation inside a harmless sounding name. Make delete_user its own tool, so you can describe it clearly and require approval for it.

If you find yourself writing a long paragraph in the system prompt about which tool to use when, that is a sign the tools themselves need clearer boundaries.

Make the schema do the work

The input schema is your best defense against garbage arguments. Every constraint you put in the schema is one less thing the model has to remember from prose.

{
  "name": "list_orders",
  "description": "List a customer's orders, newest first. Use get_order if you already have an order ID.",
  "input_schema": {
    "type": "object",
    "properties": {
      "customer_id": {
        "type": "string",
        "description": "Internal customer ID, format cus_ followed by 14 characters. Get it from find_customer, never guess it."
      },
      "status": {
        "type": "string",
        "enum": ["open", "shipped", "delivered", "cancelled"],
        "description": "Optional filter. Omit to return all statuses."
      },
      "limit": {
        "type": "integer",
        "minimum": 1,
        "maximum": 20,
        "description": "How many orders to return. Default 5."
      }
    },
    "required": ["customer_id"]
  }
}
Enter fullscreen mode Exit fullscreen mode

What this gets right:

  • Enums instead of free text. The model can't invent a status called "pending" if the schema only allows four values.
  • Formats and sources in the description. "Get it from find_customer, never guess it" stops the model from making up an ID that looks plausible.
  • Only truly required fields are required. If the model has to fill in five required fields to make a simple call, it will invent values for the ones it doesn't know.
  • Sensible limits. A maximum on limit keeps one call from returning a thousand rows.

Then validate on your side anyway. The schema guides the model, it doesn't guarantee anything. Check arguments in the tool implementation and reject bad ones with a clear message.

Return what the model needs, not everything you have

Tool outputs go straight into the model's context. A tool that returns a full API response, with nested metadata, internal IDs and 200 fields per record, burns tokens and buries the useful part.

Shape the output for the next decision the model has to make:

def get_order(order_id: str) -> dict:
    order = orders_api.get(order_id)
    return {
        "order_id": order["id"],
        "status": order["status"],
        "placed_at": order["created_at"][:10],
        "items": [f'{i["qty"]} x {i["name"]}' for i in order["line_items"]],
        "total": f'{order["total"] / 100:.2f} {order["currency"]}',
        "tracking_url": order.get("shipment", {}).get("tracking_url"),
    }
Enter fullscreen mode Exit fullscreen mode

Human readable values like "2 x Blue T shirt" and "49.90 EUR" are easier for the model to reason about and to repeat back to a user than raw cents and nested arrays. For list tools, return a short summary per item plus a way to fetch details, and say in the output when results were truncated, for example "note": "showing 5 of 132, narrow with status". Otherwise the model assumes it has seen everything.

Errors are instructions

When a tool fails, the error message is the only thing the model has to decide what to do next. A stack trace or a bare 400 Bad Request gives it nothing to work with, so it retries the same call or gives up.

Write errors the way you would write them for a junior engineer:

def get_order(order_id: str) -> dict:
    if not order_id.startswith("ord_"):
        return {
            "error": "invalid_order_id",
            "message": f"'{order_id}' is not an order ID. Order IDs start with ord_. "
                       "If you only have the customer's email, call find_customer, "
                       "then list_orders.",
        }
    order = orders_api.get(order_id)
    if order is None:
        return {
            "error": "not_found",
            "message": f"No order {order_id}. Ask the user to confirm the ID. Do not retry.",
        }
    ...
Enter fullscreen mode Exit fullscreen mode

Two things make this work. The message says what went wrong in plain words, and it says what to do instead. "Do not retry" alone cuts out a whole class of loops where the agent hammers a tool that will never succeed.

Return these as normal tool results the model can read, not as exceptions that crash your agent loop. Save real exceptions for things the model can't fix, like your database being down.

Design for the mistakes that will happen

Even with great tools, the model will sometimes call the wrong one or call the right one twice. Plan for it:

  • Make write operations idempotent where you can. Accept an idempotency key on create_refund so a repeated call doesn't refund twice.
  • Separate reads from writes. Read tools can run freely. Write tools that change money, data or anything external should go through an approval step or at least a confirmation field.
  • Put hard limits in code, not in the prompt. "Never refund more than 100 EUR" in the system prompt is a suggestion. The same check inside create_refund is a rule.

Test tools like you test code

You can't tell if a tool is well designed by reading it. You find out by watching the agent use it.

Build a small set of realistic requests, 20 to 50 is enough to start, each with the tool calls you expect. Run the agent on them and log every tool call with its arguments. Then look for patterns:

  • Wrong tool chosen: the descriptions overlap, or a "do not use for" line is missing.
  • Invented arguments: a field is required that shouldn't be, or the description doesn't say where the value comes from.
  • Repeated failing calls: the error messages don't tell the model what to do next.
  • Long, wandering runs: outputs are too big, or the agent needs too many small tools to finish one task.

Change one tool at a time and rerun the set. It is the same loop as any other test suite, and it is the fastest way to turn "the agent is flaky" into specific fixes.

Takeaway

The model can only be as precise as the tools you hand it. Clear names, descriptions that say when not to use a tool, schemas with enums and real constraints, compact outputs and errors that explain the next step will fix more agent bugs than any amount of prompt tweaking. Design your tools like an API for someone who can't ask you questions, because that is exactly who is using them.


Keep reading

Let's stay in touch

I'm Amit Shukla, a software engineer working across DevOps, cloud and applied AI. Behind the Stack is where I write up the things I learn the hard way, with working code and honest tradeoffs.

If this saved you some time, a reaction or a comment helps more people find it.

Top comments (1)

Collapse
 
autenai profile image
Auten •

The "errors are instructions" section matches what we keep seeing on the Auten team (we build a computer-use MCP server). One addition for tools whose side effect is a click instead of an API call: the click almost never errors. It "succeeds" and nothing happens, because a dialog took focus or the button was still disabled. Returning what changed after the action (the new window title, the field's new value, or an explicit "no visible change") instead of "ok" lets the model tell a silent failure from a success without another screenshot round trip.

The overlap point applies across tool types too: put a screen tool next to a shell or fetch tool and the model reaches for the cheaper one, even for a page that only renders after login. A "use this only when the content exists on screen and nowhere else" line did more than any system prompt paragraph.

Have you found a good way to test the "wrong tool chosen" case when both tools could technically work and only one gives the right answer?