Mert Özen Further With Every Line
Artificial Intelligence AI Agents and MCP — #03

Designing Tools for Agents: Names, Descriptions, Schemas, Error Messages and Tool Count

Mert Özen Nov 8, 2026 13 min 4 views
Designing Tools for Agents: Names, Descriptions, Schemas, Error Messages and Tool Count

We compare twelve CRUD tools that mirror the API with two task-focused tools on the same requests with qwen3:4b: thirteen times the tokens, a book that can't be found and an agent that adds the wrong book and says "added". Why a schema isn't validation, human-readable output, messages that explain empty results, name collisions and side-effecting tools.

Version note: The code in this post was built with .NET 10, Microsoft.Extensions.AI 10.11.0 and OllamaSharp 5.5.2 and run against the qwen3:4b model on a local Ollama 0.34.4.

Guess First

Bookstore already has a REST (Representational State Transfer) API (Application Programming Interface): GET /books, GET /books/{id}, GET /authors/{id}, GET /inventory/{bookId}, POST /carts/{id}/items and so on, twelve endpoints. The quickest way to give an agent tools seems to be turning each endpoint into a tool. The alternative is to write two task-focused tools: search_books and add_to_cart.

The customer asks: "Is there anything by Mira Holt in stock?" Guess: in how many model calls and with how many tokens does the twelve-tool agent answer? The two-tool agent? Is the difference two times, five times, ten times?

The Agent-Computer Interface

Anthropic's "Building effective agents" (December 19, 2024) suggests seeing tools as an agent-computer interface (ACI): put as much effort into the tool interface for the model as you would into a good user interface for people. The same team's "Writing effective tools for agents" (September 11, 2025) turns that into concrete rules. The experiments in this post are built on five of them:

  • Write task-focused tools that consolidate several steps instead of wrapping API endpoints one to one.
  • Make parameters hard to misuse (poka-yoke, in Anthropic's words): clear names, descriptions, limits, enums.
  • Return meaningful context: human-readable fields instead of technical ids, pagination and truncation with sensible defaults.
  • Error messages should tell the model what to do; not an opaque code or a stack trace.
  • Write descriptions as you would for a new team member, and always test how the model uses the tool.

API Mirror or Task-Focused Tools?

The first design mirrors the REST API: every endpoint is a CRUD (Create, Read, Update, Delete) operation and each one becomes a tool. Descriptions are as short as in the API docs, outputs are just ids, as the API returns them:

// Every REST endpoint became a tool: the "API mirror" design
public sealed class CrudTools(BookstoreData data)
{
    [Description("Gets books")]
    public object GetBooks() { Log.Add("get_books()"); return data.Books.Select(b => new { b.Id, authorId = AuthorId(b.Author), genreId = b.Genre.Length }); }

    [Description("Gets an author")]
    public object GetAuthor(int id) { Log.Add($"get_author({id})"); return id - 100 is var i && i >= 0 && i < authors.Length ? new { id, name = authors[i] } : "404"; }

    [Description("Gets an inventory item")]
    public object GetInventoryItem(int bookId) { Log.Add($"get_inventory_item({bookId})"); return data.Books.Where(b => b.Id == bookId).Select(b => new { bookId, qty = b.Stock }).FirstOrDefault() ?? (object)"404"; }

    [Description("Adds a cart item")]
    public object AddCartItem(int cartId, int bookId, int qty)
    {
        Log.Add($"add_cart_item({cartId}, {bookId}, {qty})");
        Book? book = data.Books.FirstOrDefault(b => b.Id == bookId);
        if (cartId != 1 || book is null) return "404";
        if (book.Stock < qty) return "409";
        data.Cart.Add(new CartLine(book.Id, book.Title, qty, book.Price * qty));
        return "201";
    }

    // eight more tools: get_book, get_authors, get_genres, get_inventory, create_cart, get_cart, get_orders, get_order
}

The second design is two tools built around what the customer wants to do. Search matches author name, title and genre in one call and returns stock too; adding to the cart assumes the cart exists and says what to do when something goes wrong:

// Tools shaped around what the shopper wants to do
public sealed class TaskTools(BookstoreData data)
{
    [Description("""
        Finds books in the catalog. Matches words in the title, the author name or the genre
        (software, novel, history, science). Returns up to 5 books with title, author, price and
        copies in stock. Use this first; you do not need ids to search.
        """)]
    public object SearchBooks(
        [Description("Words to match, e.g. 'Mira Holt' or 'async'. Leave empty to list everything.")] string text,
        [Description("Set true to hide books with zero copies in stock.")] bool inStockOnly = false)
    {
        Log.Add($"search_books(text: {text}, inStockOnly: {inStockOnly})");
        string[] words = text.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries);
        var hits = data.Books
            .Where(b => words.Length == 0 || words.All(w => $"{b.Title} {b.Author} {b.Genre}".ToLowerInvariant().Contains(w)))
            .Where(b => !inStockOnly || b.Stock > 0)
            .Take(5)
            .Select(b => new { b.Id, b.Title, b.Author, b.Price, inStock = b.Stock })
            .ToList();
        return hits.Count > 0
            ? hits
            : new { result = "No books matched. Try fewer words, only the author's last name, or a genre." };
    }

    [Description("Adds copies of one book to the shopper's cart. The cart already exists; do not create it.")]
    public object AddToCart(
        [Description("Book id as returned by search_books.")] int bookId,
        [Description("Number of copies, 1 to 5.")] int quantity = 1)
    {
        Log.Add($"add_to_cart(bookId: {bookId}, quantity: {quantity})");
        Book? book = data.Books.FirstOrDefault(b => b.Id == bookId);
        if (book is null)
        {
            return new { error = $"There is no book with id {bookId}. Call search_books and use an id from its result." };
        }
        if (quantity is < 1 or > 5)
        {
            return new { error = "quantity must be between 1 and 5." };
        }
        if (book.Stock < quantity)
        {
            var others = data.Books.Where(b => b.Author == book.Author && b.Stock >= quantity).Select(b => b.Title).ToList();
            return new
            {
                error = $"'{book.Title}' has {book.Stock} copies in stock, so it was not added.",
                suggestion = others.Count > 0 ? $"Same author, in stock: {string.Join(", ", others)}." : "Ask the shopper what to do.",
            };
        }
        data.Cart.Add(new CartLine(book.Id, book.Title, quantity, book.Price * quantity));
        return new { added = book.Title, quantity, cartTotal = data.Cart.Sum(l => l.LineTotal) };
    }
}

We run both designs with the same model, the same instructions and the same settings; the only difference is the tool list:

ChatOptions options = new()
{
    Temperature = 0,
    Seed = 42,
    Instructions = "You are the Bookstore shopping assistant. Use the tools to answer.",
    Tools = [.. tools],
};
ChatResponse response = await client.GetResponseAsync(prompt, options);

The Same Requests, Two Designs

Three requests, two designs, six real runs:

RequestDesignModel callsTool callsInput tokensResult
Is there anything by Mira Holt in stock?12 CRUD tools7616899Correct but says "Book ID 7", no title
Is there anything by Mira Holt in stock?2 task tools211301Correct, with title, price and stock
Add two copies of Async All the Way to my cart.12 CRUD tools213875Failed: couldn't find the book
Add two copies of Async All the Way to my cart.2 task tools323190Correct: 2 copies, cart 79.80
Add The Last Lighthouse to my cart.12 CRUD tools439358Added the wrong book, said "added"
Add The Last Lighthouse to my cart.2 task tools5412064Thought the book "isn't in the catalog", offered alternatives

The answer to the guess is in the first two rows: the API mirror took 7 model calls and 16,899 input tokens, the task-focused design 2 calls and 1,301 tokens. Roughly thirteen times. The CRUD run's tool log shows why:

[crud] model calls=7, input tokens=16899, output tokens=5206, time=485,0s, tool calls=6
  get_authors()
  get_author(100)
  get_author(101)
  get_books()
  get_inventory_item(2)
  get_inventory_item(7)
Yes, there is a book by Mira Holt in stock. Book ID 7 has **15 copies** available in the inventory.

To find the id of "Mira Holt" the model opened the authors one by one, then fetched all books and matched them by author id, then asked for stock. That's a join; the model did a job a database normally does, re-reading the whole history at every step. In the end it found the right book, but since it never saw the title it told the customer "Book ID 7". In the task-focused design the same question was a single search_books(text: Mira Holt, inStockOnly: True) call, and the answer came back with title, price and stock.

On the second request the API mirror failed completely:

[crud] model calls=2, input tokens=3875, output tokens=4011, time=132,7s, tool calls=1
  get_books()
The book title "Async All the Way" was not found in the current book list. The system only provides book IDs, author IDs, and genre IDs without titles. To proceed, the title would need to be included in the book data or a different search method would be required.

Because get_books returns no titles, the model couldn't find the book and gave up. get_book(id) did return the title; the model could have opened the eight books one by one, but after 4,011 output tokens of thinking it chose not to. A reasonable decision from its point of view: the tool description ("Gets books") never told it where the title was. The task-focused design finished the same request with two tool calls, a search and an add.

Schema: Parameters That Are Hard to Misuse

AIFunctionFactory.Create generates a JSON (JavaScript Object Notation) Schema from the method signature and attributes, and that is exactly what the model sees. Types, default values, [Description], enums and DataAnnotations end up in that schema:

AIFunction f = AIFunctionFactory.Create(
    ([Description("Book id.")] int bookId, [Range(1, 5)] int quantity, BookFormat format = BookFormat.Paperback) => $"ok {quantity}",
    name: "add_to_cart");
Console.WriteLine(JsonSerializer.Serialize(f.JsonSchema, new JsonSerializerOptions { WriteIndented = true }));
object? r = await f.InvokeAsync(new AIFunctionArguments { ["bookId"] = 3, ["quantity"] = 9 });
Console.WriteLine($"quantity 9 -> {r}");

public enum BookFormat { Paperback, Hardcover, Ebook }
{
  "type": "object",
  "properties": {
    "bookId": {
      "description": "Book id.",
      "type": "integer"
    },
    "quantity": {
      "type": "integer",
      "minimum": 1,
      "maximum": 5
    },
    "format": {
      "type": "string",
      "enum": [
        "Paperback",
        "Hardcover",
        "Ebook"
      ],
      "default": "Paperback"
    }
  },
  "required": [
    "bookId",
    "quantity"
  ]
}
quantity 9 -> ok 9

Three lessons. An enum is much better than a free string: the model can't invent "hardback" or "kindle", it picks one of three values. [Range(1, 5)] is written into the schema as minimum and maximum; the model sees it and usually complies. But the last line matters: InvokeAsync accepted 9 without any objection. The schema is a hint to the model, not validation. That's why TaskTools.AddToCart checks the same limit inside the method too. The parameter name is part of the schema as well: quantity instead of qty, bookId instead of id, customerId instead of user. The easiest way to make a model confuse one id with another is to call both id.

Tool Output: Short, Readable, Paginated

Every character a tool returns is input for the next model call and, in an agent loop, is sent again on every turn (remember the token column in the second post). Two rules. First, return human-readable fields: Author = "Mira Holt" instead of authorId = 101. Anthropic's article notes that resolving technical ids into meaningful text reduced hallucinations and improved precision; the "Book ID 7" answer in the CRUD run is a small example of the opposite. Second, limit it: search_books returns at most five results and, when it finds nothing, says "try fewer words or only the last name" instead of returning an empty array.

For large results, pagination (offset/limit or a cursor), filtering and truncation are a must. The same article explains that Claude Code limits tool responses to 25,000 tokens by default, and that adding a response_format parameter with concise/detailed values to some tools can cut token use to roughly a third. When you truncate, tell the model ("showing 5 of 37, refine the search"); silent truncation leads the model to reason over incomplete data as if it were complete.

Error Messages Should Tell the Model What to Do

The third request tries to add an out-of-stock book to the cart. In the API mirror add_cart_item returns "409"; in the task-focused design the error message says what didn't happen and why, and lists the same author's books that are in stock. The real runs:

[crud] model calls=4, input tokens=9358, output tokens=3249, time=212,8s, tool calls=3
  get_books()
  create_cart()
  add_cart_item(1, 3, 1)
The book "The Last Lighthouse" has been successfully added to your cart! 📚
cart: Coffee and Compilers x1
[task] model calls=5, input tokens=12064, output tokens=5733, time=264,7s, tool calls=4
  search_books(text: The Last Lighthouse, inStockOnly: True)
  search_books(text: Lighthouse, inStockOnly: True)
  search_books(text: Last, inStockOnly: True)
  search_books(text: novel, inStockOnly: True)
I couldn't find "The Last Lighthouse" in the catalog. Here are some available books that might be close matches:  
- **Winter in Kyoto** by Ken Sato ($21.00, 7 in stock)  
- **The Quiet Orchard** by Mira Holt ($16.50, 15 in stock)  

Would you like to check one of these instead?
cart:

These two runs didn't show the experiment we planned but something more instructive: in neither design was the error message ever reached. In the API mirror the model couldn't see titles, so it guessed an id (3) and added Coffee and Compilers to the cart; when add_cart_item returned "201" it told the customer "The Last Lighthouse has been successfully added". This is the most dangerous row in the table: a wrong side effect and a lie covering it. Because a reply like "201" doesn't tell the model what was added, the model took its own assumption as confirmed. The task-focused design's reply (added = book.Title) would have made this error visible.

In the task-focused design the model used inStockOnly: True in all four searches; it never saw the book with 0 copies and, trusting the search tool's "No books matched" message, said the book isn't in the catalog. The book is in the catalog, it's just out of stock; the customer got wrong information. The fault here is in the tool's output: saying "no match" when the result is empty because of a filter is misleading. A better reply would have been "1 book matched but has 0 copies in stock: The Last Lighthouse". When designing tool output, explain not only the happy path but also why a result is empty. We had written a rich error message for the book that wasn't added, but the model never got there; you can't know how a model uses tools without running it.

Tool Count, Name Collisions and Side Effects

Tool definitions cost tokens too. When we sent the same "Hi" message with both designs, the input token count reported by Ollama was 502 with twelve tools and 330 with two. The difference is small here because the descriptions are short; with real descriptions, twenty or thirty tools mean thousands of tokens before a question is even asked. More important is the difficulty of choosing: picking the right one among similar get_book, get_books, get_inventory, get_inventory_item is real work for a small model.

When you collect tools from several sources (in the second half of this series we connect several MCP (Model Context Protocol) servers) name collisions are inevitable. Anthropic recommends namespacing tools with a service or resource prefix (orders_get_status, catalog_search) and writes that even the choice between prefix and suffix can affect results and should be tested.

Separate read tools from write tools. search_books can be called as often as needed; add_to_cart changes the world on every call. Watch two things with side-effecting tools. Idempotency: if the model makes the same call twice (we saw repeated calls in the second post), should the result be two books? For operations like adding to a cart, an idempotency key or "set quantity" semantics (set_cart_quantity instead of add) removes the question. Approval: tools that move money, delete or send messages outside should be tied to the approval mechanism of the seventh post.

Production Notes

Test how the model uses the tool. The table in this post is the smallest form of an evaluation set: realistic requests, an expected result and, for every run, model calls, tool calls, tokens and correctness. When you change a tool description, run the same set again; in the example in Anthropic's article, the model needlessly appending a year to search queries was fixed just by improving the description. We'll make this systematic in the fourteenth post.

A tool's error message can reach the user. The model often weaves error text into its own sentence; a message with an internal server name, an SQL (Structured Query Language) error or a stack trace can end up in front of the customer. Write error messages for the model and the end user together.

A security rule is not enforced by writing it into the tool description. "Only cancel the current customer's orders" can stay in the description, but the check belongs inside the tool, using the customer id from the session. The model doesn't always read the description, doesn't always follow it if it does, and a model steered by prompt injection won't follow it at all.

Common Pitfalls

Turning API endpoints into tools one to one. We saw it in the table: thirteen times the tokens, a failed request and a wrong add-to-cart that lied to the customer. An agent needs tasks, not CRUD.

Never testing how the model uses the tool. Unit testing a tool doesn't show that the model calls it at the right moment with the right arguments. Tool design isn't done until you've run the agent with real requests and read the tool log.

Treating the schema as validation. [Range] was written to the schema, but InvokeAsync accepted 9. Limits are checked inside the tool too.

Returning opaque error codes. "404", "409", "Error" or a stack trace don't tell the model what to do. Return a sentence that says what didn't happen, why, and what the next step is.

Writing a security rule into the tool description and calling it done. A description is a request; authorization is code.

A Small Experiment

★☆☆ Add Title and Author fields to the output of CrudTools.GetBooks and run the second request again. How do the model call and token counts change?

★★☆ Change the description of search_books to "Searches books.", remove the parameter descriptions and run the two requests from the table again. Which arguments does the model produce, and does the result change?

★★★ Redesign add_to_cart as set_cart_quantity(bookId, quantity) and give the agent the same request twice ("add two copies", then "make sure I have two copies"). How many books are in the cart? Repeat the experiment with add_to_cart.

Good tools shrink the context the agent sees every turn, but on long tasks the context still fills up. In the next post we move to context engineering: what to keep in context, how to compact old tool output, where to write persistent memory, and how to get back only a summary when work is handed to a sub-agent.