Every agent tutorial I have seen defines all the tools in one prompt. It works, because tutorials have five tools. The first version of the agent I work on at my current company did the same thing, and it was fine at eight tools. Around fifteen it started picking the wrong tool once or twice a day. The agent now runs more than 60 actions across 13 integrations — not just search, real writes: it creates records, updates schedules, sends messages, changes assignments. Past thirty tools I would not let the old design near production data.

The incident that made me rebuild it was small. A user asked the agent to "close out" an item. We had two actions with near-identical descriptions: one marked the item complete, the other archived it and hid it from the active view. The model picked archive. The user meant complete. Both are reasonable readings of "close out". Nothing was lost — the action had a revert path, and we undid it in one click — but the pattern was clear. We gave the model two almost-identical tools and asked it to guess. It guessed. The problem was the catalog, not the model.

Flat tool lists fail in several ways at once, and the ways stack. The obvious one is tokens. A typical tool schema with descriptions is 200–400 tokens. Sixty of them is 15–20k tokens on every turn, before the user has said a word. You pay that in money and in latency, and you pay it again on every turn of the conversation.

The less obvious one is attention. Models choose well between ten clearly distinct tools. They choose badly between sixty where many overlap — and real product catalogs always overlap: update versus reschedule, archive versus delete, three different kinds of "send". And the failure is silent. The model does not say "I am not sure which tool". It picks one, fills the arguments with full confidence, and moves on.

The router is a classifier, not an agent

The design that fixed it has two stages. Stage one is a router: a small, fast, cheap model that sees the user message and a compact index of the catalog — action id, one-line description, category. No schemas, no parameters. It returns a tiny JSON object: up to three candidate action ids with confidence scores. This call costs about a thousand tokens and comes back in a few hundred milliseconds.

Stage two is the actual agent. It gets the full schemas for only those candidates — usually one, sometimes two or three — plus the per-action instructions: required fields, validation rules, and the confirmation policy for that action. The system prompt went from about 20k tokens of tool definitions to under 2k. Wrong-tool picks did not just get rarer. When they happen now, they are visible, because the router's decision is logged separately from the agent's execution. The first debugging question is always "did the router pick the wrong action, or did the agent fill the wrong fields", and one look at the trace answers it.

The important detail is what happens when the router is not sure. If the top two candidates are close, the agent does not receive one action. It receives both, plus a stored clarifying question for that specific pair. For complete-versus-archive the question is literally written in the catalog: "Do you want to mark this as done, or archive it out of the active list?" The agent asks it, more or less verbatim, before doing anything.

Writing disambiguation questions by hand felt primitive. I expected we would need something smarter. We did not. Ambiguity in a real catalog is finite and observable: out of 60+ actions we have about fifteen pairs that actually collide in practice, and we found all of them in logs, not by imagining them upfront. A hand-written question per pair covers them. There is also a product effect I did not predict. Users like the clarifying question. To them, a wrong guess looks like a bug, even when the guess was defensible. A short question just looks like the agent being careful.

Every action is a contract, not a function

The second half of the design is that an action is not a function the model calls. It is a typed contract, defined in code, reviewed like an API endpoint. Ours look roughly like this:

defineAction({
  id: "task.complete",
  risk: "low",
  confirmation: "confirm", // execute | confirm | review
  input: z.object({
    taskId: z.string(),
    note: z.string().optional(),
  }),
  collidesWith: ["task.archive"],
  revert: (ctx) => reopenTask(ctx.taskId),
});

The confirmation field is the gate. Low-risk, read-adjacent actions just execute. Most writes are confirm: the agent shows exactly what it is about to do, with the filled arguments, and the user approves. High-risk actions are review: they land in a queue where a human signs off before anything runs. Everything goes into an audit trail with one-click revert. The point is that the gate lives in the contract, not in the prompt. A prompt instruction like "always ask before deleting" is a suggestion the model can talk itself out of. A confirmation mode in code is not something it can skip.

The revert function deserves a separate mention. We made it required for any action above low risk, and it turned out to be the best forcing function we have. If you cannot describe how to undo an action, you have not understood the action well enough to let an agent run it. A few proposed actions died in review because nobody could write their revert. I think that was the right outcome. They would have failed in production instead, at a worse time.

Nobody planned this, but the catalog became the product surface. When someone asks what the agent can do, the answer is not a prompt file or a demo. It is the catalog: 60+ entries, each with an id, a risk level, a confirmation mode, a revert path, and its known collisions. Adding an action is a pull request, and the review argues about the right things: is the risk level honest, is the revert real, which existing actions does this collide with, what is the disambiguation question. We never had arguments that concrete back when the tools lived in a prompt file.

I know how unfashionable this design is. The router is a text classifier — a technique that would not have impressed anyone in 2018 — sitting in front of a frontier model and deciding which three tools it is allowed to see. There is no planner and no dynamic tool discovery. We even tried the fashionable version: embedding search over tool descriptions to select tools per request. It performed worse than the dumb classifier, and the reason is almost funny. Descriptions of similar actions embed close together, and similar actions are exactly the ones you need to separate.

Boring is the point here. A classifier has an accuracy number you can measure per action. A schema either validates or it does not, and a confirmation gate either fired or it did not. Every piece of the system answers a yes/no question in the logs. Free-form tool calling with sixty tools in one prompt feels more like "an agent", and I understand the appeal. But it turns every mistake into an argument about model behavior, and nobody wins those arguments. With the catalog, a mistake points at something specific: a bad description, a missing collision pair, a wrong risk level.

If you are at a dozen tools and it still works, stay there. The pattern is not worth its cost at that size. But the day you write a second action whose description starts with the same six words as an existing one, build the router. Being boring here is cheap, and it is the part of the system I have had to apologize for the least.