Agents Honestly
Part VIII · Tool Engineering

Tools Are APIs Designed for Models

Never expose your internal API to an agent. Build an interface for the consumer you actually have.

Exercise

Part II showed how a tool call works: a contract, a request, a round trip. This part asks a different question: which tools should exist at all. It is the one that decides whether an agent is debuggable.

The answer starts with an observation that sounds obvious and is routinely ignored in practice.

Meridian already has an API. That API is not a tool surface, and wrapping it is the most common way to build an agent nobody can fix.

Every API was designed for a consumer

Nobody is surprised that a company's public REST API, its internal gRPC service, its CLI, and its client SDK look different. They wrap the same system and expose it differently, because they have different consumers with different constraints. That is ordinary engineering.

A tool surface is another one of those. What makes it feel unfamiliar is that the consumer has a profile no previous client had:

A developer integrating your APIA model calling your tools
Reads the docsOnce, in another window, at leisureNever. The description is the documentation, and it is in the prompt
Learns from a mistakeOver a careerWithin one conversation, then forgets entirely
Discovers what existsBrowses, greps, asks a colleagueSees only what you declared this turn
Chains three callsWrites a client function oncePays a full round trip and context growth per hop
Holds intermediate stateIn a variable, for freeIn the context window, at token cost, forever
On an ambiguous parameterReads the sourceGuesses, plausibly
Cost of a wrong callAn exception in devA confident answer to a customer

None of those rows say the model is worse. They say it is a different client, and the differences are all in the same direction: it cannot go look things up, everything it knows is in the prompt, and it does not fail loudly.

Design for that client and the tool surface stops resembling your endpoints almost immediately.

What auto-generation actually produces

Meridian's warehouse has an OpenAPI spec. Generating tools from it is one command, and it yields about sixty tools. Every one of the following is wrong for the consumer above, and none of them are wrong for a programmer:

The names are addresses. listOrderLineItemsV2 describes a position in a URL space. It was never meant to be the routing signal for anything, and here it is the routing signal.

The decomposition assumes a caller who will chain. Answering "why is the Acme account at risk" means GET /accounts/{id}, then /accounts/{id}/contracts, then /contracts/{id}/terms. For a programmer that is three lines in a client function. For an agent it is three round trips of latency, three model calls billed, and three full JSON payloads that sit in the window for the rest of the conversation.

The responses are complete. REST returns every field because bandwidth is cheap and the caller destructures what it wants. Here, every unused field is context spent, and the highest-leverage change in tool design is returning only the fields the agent needs.

Pagination is a protocol. ?limit=&offset= presumes a caller with a loop and a cursor. A model given an offset parameter will either ignore it or invent values for it.

Errors are for operators. 500 and a stack trace tell you where to look. The model needs a sentence saying what to do instead. Errors are prompt text.

Auth is a header somebody else sets. In a tool schema there is no "somebody else" unless you build one, and if identity becomes a parameter the model fills in, you have built a tenancy bug with a friendly name.

Sixty tools also blows the selection budget by a factor of three, for reasons Part II measured and this chapter will not re-argue.

Generate to look, not to ship

This is not an argument against the generators. Pointing one at your spec is a genuinely good way to see your own API through an agent's eyes for the first time, and the exercise is uncomfortable in a useful way.

The consensus that has settled across the ecosystem is narrow and worth stating exactly: treat the existing API as a source of truth to be translated, not a finished product to be wrapped. Generate it, run your tickets through it, watch what the model does, then curate. Shipping the generated surface is the step to skip.

Five properties, and the rest of Part VIII

Everything the remaining chapters develop comes out of the consumer table above. Named once, here:

1 · A tool is a job, not an endpoint. The unit is a task someone wants done. If a question always requires three calls in a fixed order, that chain is one tool. Consolidating frequently-chained multi-step work into a single call is the whole point. → Schema and Granularity

2 · The description is the interface. Not a docstring, not a comment. It is shipped, it is the routing logic, it is re-sent on every request, and it is the only thing standing between two tools that could both plausibly answer a question. → this chapter, and every chapter after it

3 · The result is context, not a response. What comes back is not consumed and discarded; it is appended and re-sent. Shape it, cap it, and say that you capped it. → Tool Result Design

4 · The failure mode is a confident wrong answer. Not a stack trace. A tool that returns something misleading produces an agent that is articulate and incorrect, which is worse than one that crashed. → Errors as Instructions, Read Tools and Write Tools

5 · Arguments come from the model. Authority does not. This one is load-bearing enough to get its own section.

The rule that has no exceptions

The model chooses every value in input. That is what a tool call is. So the schema must contain only things it is allowed to choose.

   MODEL CHOOSES                 YOUR CODE SUPPLIES
   ─────────────                 ──────────────────
   account_id: "4471"            requester = ticket.customer_id
   period:     "12mo"            (from the session, never the schema)
          │                              │
          └──────────┬───────────────────┘

              run_tool(name, input, requester)


          ┌──────────────────────────┐
          │ is `requester` entitled  │  ← enforced here, in code
          │ to `account_id`?         │     not requested in a prompt
          └──────────────────────────┘
The model proposes the subject. Your code supplies the requester. They arrive by different paths and are checked against each other.

Two different things travel to your tool. One is the subject: which account, which period, which order. That is a legitimate model decision, and it is the capability you bought. The requester is not a decision at all. It comes from the session, is never in input_schema, and is what the tool filters on.

This is the invariant-versus-request distinction landing in a function signature, and it matters more here than anywhere because of a property established in Part II: tool results enter the transcript as user content. A model that has just read a policy document mentioning another account can, entirely without malice, ask for that account next. If requester were a parameter, it would fill that in too. See Prompt Injection for the version where the document was written by someone who wanted exactly that, and Least Privilege for how far the principle extends.

Rewriting Meridian's four endpoints as one tool

Concretely, the risk question. The REST surface:

GET /v2/accounts/{id}                  → tier, owner, renewal_date, +31 fields
GET /v2/accounts/{id}/contracts        → [ {id, start, end, terms_ref}, … ]
GET /v2/contracts/{id}/terms           → the actual entitlements
GET /v2/accounts/{id}/tickets?status=&limit=&offset=

Four endpoints, three of them chained, all of them complete. The tool:

ts/src/tools/account.ts
export const accountRiskProfile = {
  name: 'crm_account_risk_profile',
  description:
    'Everything needed to judge whether one account is at renewal risk: ' +
    'tier, renewal date, contract entitlements, open ticket count with the ' +
    'oldest age, and escalation count over the last 90 days. Resolves the ' +
    'account, its current contract, and its terms in one call — do not look ' +
    'those up separately. Use this when a ticket asks about account health, ' +
    'churn risk, or renewal. For a single order use `get_order`; for ' +
    'shipment totals use `wms_order_volume`.',
  input_schema: {
    type: 'object',
    properties: {
      account_id: {
        type: 'string',
        description: 'Meridian account ID, e.g. "4471". Digits only.',
      },
    },
    required: ['account_id'],
    additionalProperties: false,
  },
  strict: true,
} as const;

Four things changed, and each maps to a row in the consumer table.

Three hops became one. In step 2 of the Atlas trace, the model decides, unprompted, to fetch tickets and volume after seeing a renewal date. That stays exactly as adaptive. What disappears is the mechanical chaining that was never a decision.

The description names its neighbours. It calls out two wms_ tools by name, in prose, with the conditions that select them. Boundaries stated in one tool's description are half a boundary; the sibling tools say the reverse.

The names are namespaced by service and resource. crm_, wms_, erp_. This is a small thing with a measurable effect on selection. Prefixes give the model a coarse filter before it reaches the fine distinctions, and they make an ambiguous call legible in a trace without cross-referencing anything.

Pagination is gone, and its absence is stated. The tool returns a count and the oldest few, because "how many open tickets and how old is the worst" is the actual question. A limit/offset pair would have been a protocol the consumer cannot drive.

And account_id is still a parameter. The model picks the subject, while run_tool checks entitlement to that account against the ticket's requester.

The honest tension

Consolidation is not free, and the argument against it is real.

A task-shaped tool encodes a guess about the task. When the guess is right it saves three round trips; when it is wrong the model has no way to compose around it, because you removed the primitives it would have composed with. Endpoint-shaped tools are the opposite: worse selection accuracy, more hops, more context, and a model that can answer a question you never anticipated.

That is the trade, stated plainly: composability against selection accuracy. It has no universal answer, which is why the next chapter is entirely about where to sit on it. That chapter opens on the extreme position, "just give it execute_sql and let it compose". One tool, perfect composability, no selection problem at all, and a blast radius nobody can bound.

Thin wrapping is also genuinely right in two places worth naming now: when the server is an MCP server you do not own, where the surface is someone else's decision, and when the job really is CRUD-shaped and a get/create/update triple is the honest description of the work.

You cannot design this from a spec

The last property is a method rather than a rule, and it is the one that makes the other five actionable: the tool surface is derived from the ticket set, not from the system diagram.

Take the twenty tickets. For each, write the questions that must be answered before a reply can be sent. Group the questions that always occur together. Each group is a candidate tool, and the grouping, not the endpoint list, is what sets the granularity. Then run it, watch which calls the model makes, and consolidate the chains it keeps repeating.

That loop is evaluation-driven by construction, which is the same discipline as everywhere else in this book: build a prototype, run it against realistic cases, read the failures, refine. It also means your tool surface is a thing with versions and a regression suite, not a thing you specified once. Part XIV is where that becomes formal; the sample you need to start already exists in data/tickets.jsonl.

Takeaways

  • Your existing API was designed for a consumer who reads docs, chains calls cheaply, and fails loudly. A model does none of those. A tool surface is a separate interface, the way a CLI and an SDK are separate interfaces.
  • Auto-generated tools from an OpenAPI spec are a good way to see your API through an agent's eyes and a bad thing to ship. Translate the source of truth; don't wrap the finished product.
  • A tool is a job, not an endpoint. A chain that always runs in a fixed order is one tool.
  • The description is the interface: shipped, re-sent every request, and the entire routing logic. It should name its sibling tools and the conditions that select them.
  • Namespace by service and resource (crm_, wms_, erp_). It gives the model a coarse filter and makes traces readable without a lookup.
  • The result is context, not a response: returning only the fields the agent needs is the single highest-leverage change available.
  • The model chooses the subject; your code supplies the requester. Identity is never in input_schema, and entitlement is enforced in the tool rather than requested in the prompt.
  • Consolidation trades composability for selection accuracy. Task-shaped tools are faster and cheaper when your guess about the task is right, and a dead end when it isn't.
  • Derive the surface from the ticket set, then measure which chains the model keeps repeating and collapse those. The tool catalogue is a versioned artifact with a regression suite.

A catalogue derived from the ticket set settles which tools exist. It settles nothing about how wide each one should be. Next: Schema and Granularity, why execute_sql(query) is a bad tool and list_unpaid_invoices(customer_id) is a good one.

On this page