teachyou.ai academy
← All posts
AI Agents

Tool Design for AI Agents: Writing Schemas Agents Actually Use Well

Pramod Dutta · May 2, 2026 · 16 min read

Why your agent keeps calling the right tool the wrong way

You built an agent, wired it to five tools, watched it fail on the third one, and spent an afternoon adding "IMPORTANT: always include the timezone" to a description string. If that sounds familiar, you are not bad at prompting. You are running into the single most underrated skill in agent engineering: tool design.

Most teams treat tool schemas as plumbing. They export a function signature, slap a JSON Schema wrapper around it, write a one-line description, and move on to "real" work like the system prompt or the retrieval pipeline. Then they wonder why the agent calls search_orders with a made-up customer ID, or passes a date as "tomorrow" instead of an ISO string, or calls three tools when one would have done.

The uncomfortable truth is that a tool schema is not documentation for a compiler. It is documentation for a language model that is guessing, from limited context, what shape of input will make the function succeed. Every ambiguous field name, every optional parameter that should have been required, every enum you left as a free-text string is a small tax the model pays in hallucination. Multiply that across a multi-step agent loop and the tax compounds into wrong answers, wasted tokens, and users who stop trusting the product.

This article is about closing that gap. We will walk through what actually makes a tool schema "agent-legible," compare real good-versus-bad JSON schemas, and cover the failure modes that show up once you move past a toy demo into a system with ten, thirty, or a hundred tools. If you are building anything beyond a single-tool chatbot, this is the layer where reliability is won or lost.

Tools are prompts, not APIs

The first mental shift: a tool definition is read by the model at inference time, alongside your system prompt and the conversation history. It competes for the model's attention with everything else in context. That means the same principles that make a good prompt work also make a good tool schema work — clarity, specificity, and removing ambiguity before the model has to resolve it on its own.

Traditional API design optimizes for a human reading documentation once and then writing code against it forever. Tool design for agents optimizes for a model reading the schema fresh on every single call, with no memory of "oh right, that field means X." This is why patterns that are perfectly fine in a REST API — vague field names, overloaded parameters, silent defaults — become expensive in an agent context. The model cannot check Stack Overflow. It can only work with what is in the schema and the description text around it.

Consider a tool for looking up a user. A backend engineer might write this without a second thought.

{
  "name": "get_user",
  "description": "Gets a user.",
  "parameters": {
    "type": "object",
    "properties": {
      "id": {
        "type": "string"
      },
      "type": {
        "type": "string"
      }
    },
    "required": ["id"]
  }
}

This compiles. It also fails constantly in practice. What is id — a UUID, an email, a username, an internal database key? What does type mean, and what happens if it is omitted? A model facing this schema in the middle of a multi-turn conversation has to guess, and it will guess inconsistently across calls, which is worse than guessing wrong consistently because now your error handling can't even detect a pattern.

Here is the same tool designed for an agent to actually use well.

{
  "name": "get_user_by_email",
  "description": "Look up a single user account by their email address. Returns the user's id, display name, plan tier, and account status. Use this before calling update_user or cancel_subscription, since those tools require the numeric user_id returned here, not an email.",
  "parameters": {
    "type": "object",
    "properties": {
      "email": {
        "type": "string",
        "description": "The user's email address exactly as stored, e.g. 'jane@acme.com'. Case-insensitive."
      }
    },
    "required": ["email"],
    "additionalProperties": false
  }
}

Notice what changed. The tool name encodes the lookup key directly, so there is no ambiguity about what identifier to pass. There is exactly one parameter instead of an overloaded pair. The description tells the model not just what the tool does but when to use it relative to other tools in the same toolset, which is often the actual source of agent confusion — not "what does this tool do" but "which of my five tools should I call right now."

Split one tool from doing five things

A pattern that shows up constantly in early-stage agent codebases: one giant tool that takes an action enum and branches internally, because it felt efficient to expose fewer functions. This is almost always a mistake.

{
  "name": "manage_ticket",
  "description": "Manages support tickets.",
  "parameters": {
    "type": "object",
    "properties": {
      "action": {
        "type": "string",
        "enum": ["create", "update", "close", "reopen", "assign", "comment"]
      },
      "ticket_id": { "type": "string" },
      "data": { "type": "object" }
    },
    "required": ["action"]
  }
}

The data field here is the tell. It is a bag of unknown shape whose valid contents depend entirely on which action was chosen, and none of that is expressed in the schema. The model has to infer, from training data and context, what data should contain for assign versus comment. It will sometimes get this right and sometimes fabricate a field name that looks plausible but doesn't exist in your backend. You will not find this bug in testing because it only surfaces on the action-and-field combinations you didn't happen to try.

Split it into one tool per action, each with its own fully-specified parameters.

{
  "name": "assign_ticket",
  "description": "Assign an existing support ticket to a team member. The ticket must already exist and be in an open or reopened state.",
  "parameters": {
    "type": "object",
    "properties": {
      "ticket_id": {
        "type": "string",
        "description": "The ticket identifier, formatted like 'TCK-1042'."
      },
      "assignee_email": {
        "type": "string",
        "description": "Email of the team member to assign. Must be an active agent, not a customer."
      }
    },
    "required": ["ticket_id", "assignee_email"],
    "additionalProperties": false
  }
}
{
  "name": "close_ticket",
  "description": "Close a support ticket and mark it resolved. Use add_comment first if you need to leave a resolution note — closing does not accept a message.",
  "parameters": {
    "type": "object",
    "properties": {
      "ticket_id": {
        "type": "string",
        "description": "The ticket identifier, formatted like 'TCK-1042'."
      },
      "resolution_code": {
        "type": "string",
        "enum": ["fixed", "duplicate", "wont_fix", "customer_resolved"],
        "description": "Why the ticket is being closed. Required for internal reporting."
      }
    },
    "required": ["ticket_id", "resolution_code"],
    "additionalProperties": false
  }
}

Yes, this is more tools. That is fine. Models are considerably better at picking the correct tool from a well-differentiated list than at correctly populating a polymorphic parameter bag. Tool selection is a discrimination problem, which language models are good at. Guessing the shape of an untyped object is a generation problem under uncertainty, which is where they fail silently. Every time you're tempted to add an action or mode enum with a matching free-form params object, that's a sign to split the tool instead.

Naming is not cosmetic

Names carry semantic weight for a model the same way they do for a human reading code for the first time. get_user versus get_user_by_email isn't a style preference — it's information. The name should tell the model, without opening the parameter list, what input it needs to already have on hand.

A few concrete naming rules that consistently reduce misuse:

  • Prefer verb_noun_by_key over verb_noun when there is more than one plausible lookup key (get_order_by_id vs get_order_by_confirmation_number), rather than one tool with an ambiguous identifier field.
  • Avoid overloaded verbs like process or handle — they tell the model nothing about the actual effect. refund_payment beats process_payment with a type: "refund" field.
  • Make destructive tools sound destructive. delete_project should not be named remove_project_reference, because a model under-weighting risk on a softly-named tool is exactly how you get an agent that deletes production data while trying to "clean up references."
  • Keep tool names consistent in tense and structure across your whole toolset. If ten tools use verb_noun and one uses noun_verb, that one tool will get called less reliably simply because it breaks the pattern the model has inferred from the others.

The same discipline applies to parameter names. date is worse than due_date_iso8601. limit is worse than max_results. filter is worse than status_filter with an explicit enum. None of this is pedantry — each of these is a place where an underspecified name forces the model to make an assumption, and assumptions are where errors are born.

Descriptions do the work examples used to do

Function docstrings for humans can lean on tribal knowledge — "oh yeah, everyone knows that endpoint wants cents not dollars." Agents don't have tribal knowledge. Every constraint that matters has to be in the schema or the description, every time.

The description field is not a summary of the function name. It's the place to put the three or four things that would otherwise cause a wrong call:

  • Units and formats (amount_cents as an integer, not amount as an ambiguous number)
  • Preconditions ("the user must already have an active subscription")
  • What the tool does *not* do, when that's a common confusion (close_ticket doesn't accept a message, so tell the model to call add_comment first)
  • Ordering relative to other tools ("call this before update_user, since it returns the id that tool requires")

Here's a before-and-after on a payments tool, which is exactly the kind of surface where vague schemas turn into real financial bugs.

{
  "name": "issue_refund",
  "description": "Issues a refund.",
  "parameters": {
    "type": "object",
    "properties": {
      "order_id": { "type": "string" },
      "amount": { "type": "number" }
    },
    "required": ["order_id", "amount"]
  }
}
{
  "name": "issue_refund",
  "description": "Issue a partial or full refund for a completed order. Refunds are irreversible once submitted. If amount_cents equals the order total, this triggers a full refund and cancels any pending shipment. Only call this after confirming the refund reason with the user — do not call speculatively.",
  "parameters": {
    "type": "object",
    "properties": {
      "order_id": {
        "type": "string",
        "description": "Order identifier, e.g. 'ORD-88213'. Get this from get_order_by_confirmation_number if you only have the customer's confirmation number."
      },
      "amount_cents": {
        "type": "integer",
        "minimum": 1,
        "description": "Refund amount in integer cents (e.g. 1999 for $19.99). Must not exceed the order's remaining refundable balance."
      },
      "reason": {
        "type": "string",
        "enum": ["damaged", "wrong_item", "customer_changed_mind", "duplicate_charge", "other"]
      }
    },
    "required": ["order_id", "amount_cents", "reason"],
    "additionalProperties": false
  }
}

The second version fixes a subtle financial bug class before it happens — passing 19.99 as a float amount into a system that expects cents is one of the most common real-world agent tool bugs, and it's entirely preventable by making the unit explicit in both the field name and the type. amount_cents as an integer closes off an entire category of rounding and unit errors that amount as a number leaves wide open. The irreversibility warning and the "don't call speculatively" instruction also matter — they're doing the job a human code reviewer would do by catching a risky call before it ships, except here the reviewer is the model itself reading its own tool list.

Constrain the input space, don't just describe it

A recurring mistake is writing an accurate description of a constraint instead of encoding the constraint in the schema. If a field only accepts five values, use an enum. If a field is a date, don't accept a bare string — either constrain the pattern or, better, accept separate year, month, day integers if downstream parsing is fragile. JSON Schema gives you real validation primitives: enum, pattern, minimum/maximum, minLength/maxLength, format. Use them. Every constraint you encode structurally is a constraint the model doesn't have to infer from prose, and it's also a constraint your calling code can validate before it ever reaches a downstream system.

# Bad: the constraint lives only in a comment a model may or may not read carefully
def set_priority(ticket_id: str, priority: str):
    """priority should be low, medium, high, or urgent"""
    ...
# Good: the constraint is structural, not advisory
from enum import Enum
from pydantic import BaseModel, Field

class Priority(str, Enum):
    low = "low"
    medium = "medium"
    high = "high"
    urgent = "urgent"

class SetPriorityInput(BaseModel):
    ticket_id: str = Field(..., description="Ticket identifier, e.g. 'TCK-1042'.")
    priority: Priority = Field(..., description="New priority level for the ticket.")

def set_priority(input: SetPriorityInput):
    ...

When you generate the JSON Schema from a Pydantic model (or a Zod schema, or whatever your language's equivalent is) instead of hand-writing loose JSON, you get this discipline for free — it becomes awkward to leave a field as a bare string when the type system is sitting right there asking you to be specific. That friction is a feature. It's pushing you toward the schema a model actually needs.

The same logic applies to additionalProperties: false. Leaving it unset lets the model attach extra fields you never defined, which some function-calling implementations will silently pass through into your handler. Locking it down means malformed calls fail fast and visibly, which is a much better failure mode than a partially-processed request that silently ignores an unrecognized field.

Return values are half the interface

Tool design conversations obsess over input schemas and mostly ignore what comes back. That's backwards for multi-step agents, because the return value is what the model reads to decide its *next* action. A tool that returns a raw database row or an opaque success boolean gives the model nothing to reason with on the following turn.

Compare a create_order tool that returns {"success": true} against one that returns {"order_id": "ORD-88213", "status": "pending_payment", "total_cents": 4999, "next_action": "Call charge_payment_method with this order_id to complete the purchase."}. The second response tells the model exactly what changed and exactly what to do next, which measurably reduces follow-up tool-selection errors — the model doesn't have to remember three turns back what the workflow requires, because the tool result just told it.

This matters even more for error returns. Don't let a tool fail with a bare stack trace or an HTTP status code. Return a structured error the model can act on.

{
  "error": true,
  "code": "INSUFFICIENT_REFUND_BALANCE",
  "message": "Requested refund of 5000 cents exceeds the order's remaining refundable balance of 2000 cents.",
  "refundable_balance_cents": 2000
}

Given this, a well-prompted agent can recover gracefully — offer the customer the correct refundable amount, or explain why a full refund isn't possible — instead of retrying the same failing call or hallucinating an apology with no factual basis. Structured, descriptive errors are one of the highest-leverage, lowest-effort improvements you can make to an existing tool suite, and they rarely require touching the input schema at all.

Scale changes the rules: fewer, better tools beat many mediocre ones

Everything above holds for a five-tool agent. Once you're past twenty or thirty tools, a second-order problem appears: tool selection itself becomes the bottleneck, independent of how well each individual schema is written. The model has to hold every tool name, description, and parameter list in context on every turn, and past a certain count, similar-sounding tools start colliding in the model's attention — it picks update_order when it meant update_order_status, because both were plausible given a rushed read of a long list.

A few practices help as toolsets grow:

  1. Group related tools with a shared naming prefix (orders_get, orders_update_status, orders_cancel) so the model can pattern-match on domain before it has to disambiguate on action.
  2. Actively prune. If two tools are called for near-identical scenarios, merge them or delete the redundant one — don't keep both because "someone might need it."
  3. Consider tool namespacing or dynamic tool loading, where only the subset of tools relevant to the current task is exposed to the model, rather than the entire catalog on every call. Fewer live options in context is a direct, measurable reliability win.
  4. Write a short "when to use which" note in your system prompt for tool clusters that are genuinely similar, rather than trying to cram the disambiguation logic into each individual tool description.

The instinct to expose "everything, just in case" is understandable, but it works against the model, not for it. A focused set of ten well-specified tools will consistently outperform forty loosely-specified ones, because the marginal tool you add doesn't just cost the model nothing when unused — it costs a small amount of confusion on every single call, even the ones that don't touch it.

Test your schemas the way you'd test your code

Tool schemas deserve the same rigor as any other interface you ship. Before trusting a tool in production, run it through deliberately messy inputs: ambiguous phrasing, missing context, a user who gives a partial order number instead of the full one. Watch what the model actually sends. If it guesses a field value instead of asking for clarification or calling a lookup tool first, that's a schema and description problem, not a model problem — fix the description, add a lookup tool, or tighten the required fields, and rerun the same test.

It's also worth logging every real tool call your agent makes in production, including the ones that fail validation. That log is the single best source of truth for what your schema is actually missing, because it shows you the gap between what you assumed the model would infer and what it actually inferred. Patterns emerge fast — you'll typically find that two or three fields account for the majority of malformed calls, and those are exactly the fields that need an enum, a unit suffix, or a sharper description.

Closing thoughts

Good tool design isn't a one-time schema-writing exercise — it's an ongoing discipline of treating your function signatures as part of the prompt, because that's exactly what they are to the model calling them. Name tools for what they do and when to use them. Split polymorphic action-and-data tools into single-purpose ones. Encode constraints structurally with enums and types instead of describing them in prose. Design return values that tell the model what happened and what to do next. And as your toolset grows, actively resist the urge to keep every tool "just in case" — a smaller, sharper set of tools beats a sprawling one every time.

If you want to go deeper on this — building multi-tool agents from scratch, debugging real tool-call failures, and designing agent architectures that hold up past the demo stage — that's exactly what we cover hands-on in 30 Days of Hermes Agent, our agent-engineering course at teachyou.ai. It's built around the same problems this article walks through: real schemas, real failure modes, and real fixes, not toy examples.