Giving an Agent Tools

An agent is only as good as its tools. You can have the strongest model and a clean loop, and it will still fail if the tools are named badly, do too little, or trust whatever arguments the model hands them.

This article is about the part you actually control. The model picks tools and fills in their arguments; you decide what those tools are, what they say about themselves, and what happens when the model gets it wrong.

The model reads three things: name, description, schema

When the model decides what to do next in the loop, it is not looking at your code. It sees only what you expose: each tool’s name, its description, and its input schema. Those three fields are the entire interface. If they are vague, the model guesses, and it guesses wrong.

Treat them as prompt engineering, because that is what they are.

  • Name. Short and specific. search_orders beats query. cancel_subscription beats update.
  • Description. One or two sentences that say what the tool does, when to use it, and any limits. Write it for a competent stranger who will read nothing else.
  • Input schema. A typed JSON Schema for the arguments. Each field gets a type and a description. Constrain with enum, minimum, maxLength wherever the value has a real range.

A weak description like “gets data” tells the model nothing about when to reach for the tool. A description like “Search a customer’s past orders by email. Returns up to 50 most recent orders. Does not include refunds.” tells it exactly when this tool applies and when it does not.

flowchart LR
    M[Model] -->|reads| N[Name]
    M -->|reads| D[Description]
    M -->|reads| S[Input schema]
    N --> DEC{Which tool,<br/>what arguments?}
    D --> DEC
    S --> DEC
    DEC --> CALL[Tool call]

Design task-shaped tools, not thin wrappers

The tempting move is to expose your existing functions one for one: db_select, db_insert, http_get, http_post. This gives the model raw power and almost always makes it worse. Now the model has to compose several low-level calls correctly, in order, to accomplish one real thing. Every extra step is another chance to get it wrong.

Design tools around tasks, not around your internal API surface. Ask what the agent actually needs to do, then give it one tool per task.

  • Instead of db_select + db_join + format, give it get_customer_orders(email).
  • Instead of http_post to a payments endpoint with a hand-built body, give it issue_refund(order_id, amount).

A task-shaped tool hides the plumbing. It has a name the model understands, a small set of arguments, and one clear outcome. It also gives you one place to put validation and safety checks, which matters more than it sounds.

Why not just give the model a shell or raw SQL?

Because you inherit every failure mode of the most powerful tool you expose.

A raw shell or raw SQL tool can do anything: read secrets, drop a table, spend money. The model does not have to be malicious to cause damage; a plausible-looking wrong command is enough. And you have no natural place to validate intent, because the tool accepts everything.

Narrow, task-shaped tools invert this. Each tool can do exactly one thing, so the blast radius of a bad call is bounded by design. You trade some flexibility for a lot of safety, and for most agents that is the right trade.

Validate arguments before you touch anything

The model generates the arguments. That is worth repeating: the values passed to your tool are model output, not user input you can trust and not code you wrote. The model will sometimes invent an order_id that does not exist, pass a string where you expected a number, or set an amount of -500.

So validate every argument before the tool does anything with a side effect. The JSON Schema catches shape errors (wrong type, missing field, out-of-range) at the boundary. Your code catches the semantic errors the schema cannot: does this order exist, does it belong to this customer, is the amount within a sane limit.

When validation fails, do not throw an unhandled exception. Return a clear error message as the tool result and let the loop continue. The model reads that message and can correct itself, which is one of the things a loop is good at.

def issue_refund(order_id: str, amount: float) -> str:
    order = orders.get(order_id)
    if order is None:
        return f"Error: no order found with id {order_id!r}."
    if amount <= 0:
        return "Error: refund amount must be positive."
    if amount > order.total:
        return f"Error: refund {amount} exceeds order total {order.total}."
    # Only now do we do the thing with a side effect.
    refund = payments.refund(order_id, amount)
    return f"Refunded {amount} for order {order_id}. Refund id {refund.id}."

The shape (validate, return a readable error on failure, act only after every check passes) is the same for every tool that changes state.

Safety for destructive tools

Some tools delete data, spend money, or send messages to real people. Those need more than validation, because a validated call can still be the wrong call. The model decided to make it, and the model is not always right.

A few defaults worth applying to any destructive tool:

  • Prefer reversible operations. A soft delete you can undo beats a hard delete. Draft an email instead of sending it, when the workflow allows.
  • Scope narrowly. cancel_order(order_id) is safer than cancel_orders(filter), which could match everything.
  • Cap the damage. Enforce hard limits in code: a max refund amount, a max number of rows touched per call. Do not rely on the model to stay within bounds.
  • Require confirmation for the high-stakes ones. For irreversible or expensive actions, have the tool return a summary and require a second explicit call, or a human approval, before it commits.
flowchart TB
    CALL[Model calls<br/>destructive tool] --> VAL{Args valid?}
    VAL -->|no| ERR[Return error,<br/>loop continues]
    VAL -->|yes| RISK{High stakes?}
    RISK -->|no| DO[Execute]
    RISK -->|yes| CONF[Require confirm<br/>or human approval]
    CONF --> DO

None of this is exotic. It is the same instinct you already apply to an untrusted API caller, pointed at the model instead.

Where MCP fits

Everything above describes tools inside one codebase. But you will often want the same tool available to more than one agent, or to a model running in a different app entirely: your IDE, a chat client, someone else’s product.

That is what the Model Context Protocol (MCP) solves. MCP is a standard way to expose tools to a model, so you write and host a tool once and any MCP-aware client can call it. Instead of re-implementing get_customer_orders in every app, you run it as an MCP server and each app connects to it.

The design rules in this article carry over directly. An MCP tool still has a name, a description, and a JSON input schema, and it still needs argument validation and safety. MCP standardizes the wire format and discovery; it does not change what makes a tool good.

For the concept, read what MCP is. For how tools, resources, and prompts are defined as MCP primitives, read MCP tools, resources, and prompts.

A clean tool definition

Here is a single tool defined with the fields the model reads. The name is specific, the description says when to use it and its limits, and the schema constrains every argument.

{
  "name": "issue_refund",
  "description": "Refund a customer for a specific order. Use only after confirming the order exists and belongs to the customer. Amount must be positive and cannot exceed the order total. Refunds are irreversible.",
  "input_schema": {
    "type": "object",
    "properties": {
      "order_id": {
        "type": "string",
        "description": "The order to refund, e.g. 'ord_8123'."
      },
      "amount": {
        "type": "number",
        "description": "Amount to refund in the order's currency.",
        "minimum": 0.01
      },
      "reason": {
        "type": "string",
        "description": "Short reason for the refund, shown to the customer.",
        "maxLength": 200
      }
    },
    "required": ["order_id", "amount"]
  }
}

Read it as the model would. It knows what the tool is for, when not to use it, that refunds cannot be undone, and the exact shape of every argument. That clarity is what turns a correct-but-vague tool into one the model calls correctly.

Common beginner mistakes

  • Vague names and descriptions: query or “gets data” gives the model nothing to decide on. Be specific about what and when.
  • Thin wrappers over low-level calls: exposing db_select and http_post forces the model to compose plumbing. Give it task-shaped tools instead.
  • Trusting model arguments: the arguments are model output. Validate shape with the schema and meaning in code before any side effect.
  • Throwing instead of returning errors: a readable error string lets the loop self-correct; an unhandled exception just kills the run.
  • No cap on destructive tools: never rely on the model to stay under a limit. Enforce max amounts and narrow scope in code.
  • Too many tools: a huge tool list confuses the model. Expose the few it needs for the task, not everything you have.

Questions you will face in production

“How many tools is too many?” There is no hard number, but more tools means more chances to pick the wrong one, and long tool lists eat context. Start with the smallest set that covers the task. If two tools are easy to confuse, that is a naming or scoping problem; fix the descriptions or merge them.

“Should I let the model retry a failed tool call?” Yes, as long as the failure is returned as a readable message rather than a crash. The model can read “no order found with id X” and try a different id. What you want to avoid is the model retrying the exact same broken call in a loop; a clear error that points at the fix helps it move on.

“Do I need MCP to give an agent tools?” No. Tools defined directly in your code work fine for a single app. Reach for MCP when you want the same tool used across multiple apps or clients without reimplementing it each time.

What to remember

  • The model decides using only the tool’s name, description, and input schema; write all three as carefully as a prompt
  • Design tools around tasks, not as thin wrappers over your low-level functions
  • Tool arguments are model output; validate shape and meaning before any side effect
  • Return errors as readable strings so the loop can self-correct
  • Add real safety rails for destructive tools: reversibility, narrow scope, hard caps, confirmation
  • MCP is the standard way to expose a tool once and use it across apps; it does not change what makes a tool good

What to study next

You can now define tools an agent uses well. The next problem is memory: the loop keeps every result in the conversation, and that context grows until it does not fit. Read state and memory for how agents track what they have done without drowning in it.

If you want to expose these tools beyond one app, what MCP is and MCP tools, resources, and prompts cover the standard way to do it.

Further reading

Where this article comes from. This is a synthesis of common practice in AI engineering as of 2026, not a citation of any single paper. The sources above are where the mechanics come from. If you find an error or have a better source for a claim, the article gets fixed within a day, send me a note.


Auto-marks when you reach the end. Click to toggle.

If this helped, buy me a coffee

Everything is free. Tips keep me writing the rest.

Buy me a coffee →