- Book: AI That Acts
- The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
When an agent picks the wrong tool, the reflex is to rewrite the
system prompt. Add a paragraph explaining when to use which. Watch it
help a little, then drift back.
The prompt is usually not where the problem is. The model chooses
from the tool definitions — names, descriptions, and schemas — and
those are the surface it actually reads. Six recurring design errors,
each with the version that fixes it.
1. Names that overlap
{ name: "get_user" }
{ name: "get_user_details" }
{ name: "fetch_user_info" }
Three names for what a model has to guess are three different things.
It picks one, gets a partial answer, calls another, and you pay for
three round trips to assemble what one call should have returned.
The fix is usually consolidation rather than better names:
{
name: "get_user",
description: ""
"Fetch a user. `include` controls what comes back: " +
"'profile' (name, email), 'billing' (plan, invoices), " +
"'activity' (last 30 days). Omit for profile only.",
schema: z.object({
userId: z.string(),
include: z.array(z.enum(["profile", "billing", "activity"]))
.default(["profile"]),
}),
}
One tool, one obvious choice, with the variation moved into a typed
parameter. Note this is not the god-tool from mistake three — the
difference is that include selects scope of the same operation
rather than switching to a different operation.
2. Descriptions written for humans
description: "Retrieves order information from the orders service."
Accurate and useless. It says what the tool is and nothing about
when to reach for it — which is the only decision the model is
making.
description: ""
"Fetch one order by its id. Use when the user refers to a " +
"specific order ('my last order', 'order ord_abc123'). " +
"Requires an exact id — call search_orders first if you only " +
"have a date, product name, or description. " +
"Returns items, status, totals, and shipping address.",
Four things in that: what it does, when to use it, what it needs
first, and what comes back. The third line does the most work — it
tells the model the dependency between two tools, which no amount of
system-prompt prose conveys as reliably.
Write descriptions as if for a new engineer who can see only the tool
list and never the codebase. That is exactly the model's situation.
3. The god-tool with a mode flag
{
name: "manage_subscription",
schema: z.object({
action: z.enum(["create", "cancel", "pause", "resume", "upgrade"]),
subscriptionId: z.string().optional(),
plan: z.string().optional(),
resumeAt: z.string().optional(),
}),
}
Every field is optional because each is required for some actions and
meaningless for others. The schema cannot express that, so the model
has to infer the correlation — and it will send plan with
action: "cancel", or omit subscriptionId on a cancel because the
schema said optional.
Split them. Each tool then has an honest schema:
{ name: "cancel_subscription",
schema: z.object({ subscriptionId: SubId, reason: z.string() }) }
{ name: "upgrade_subscription",
schema: z.object({ subscriptionId: SubId, toPlan: PlanId }) }
{ name: "pause_subscription",
schema: z.object({ subscriptionId: SubId, resumeAt: z.string().date() }) }
Now resumeAt is required exactly where it is required, and the
model cannot construct a nonsensical call. As a bonus, per-tool
permissions become expressible — cancel and upgrade are different
risk levels, and a single manage_subscription cannot distinguish
them.
If splitting genuinely is not an option, a discriminated union at
least encodes the correlation:
const Args = z.discriminatedUnion("action", [
z.object({ action: z.literal("cancel"), subscriptionId: SubId,
reason: z.string() }),
z.object({ action: z.literal("upgrade"), subscriptionId: SubId,
toPlan: PlanId }),
]);
Support for unions in provider schema conversion varies, so check
that yours survives zodToJsonSchema before relying on it.
4. Optional parameters with unstated defaults
schema: z.object({
query: z.string(),
limit: z.number().optional(),
includeArchived: z.boolean().optional(),
})
What happens when limit is omitted? The model does not know, so it
guesses — often by supplying a value to be safe, and often a large
one. limit: 1000 arrives and your context fills with rows.
State the default in the schema and in the description: ""
schema: z.object({
query: z.string().min(3),
limit: z.number().int().min(1).max(50).default(20)
.describe("Max results. Default 20, maximum 50."),
includeArchived: z.boolean().default(false)
.describe("Include archived orders. Default false."),
})
.default() makes the behaviour real rather than implied, and
.max(50) makes the runaway value unexpressible. The description
repeats it because the model reads prose more reliably than it reads
schema keywords.
5. Tools that return unbounded blobs
handler: async ({ query }) => db.orders.search(query),
That returns whatever the database returns. Two hundred orders, each
with nested line items, serialised into the context window — where it
is resent on every subsequent turn for the rest of the run.
Shape the return value as deliberately as the arguments:
const Summary = z.object({
id: z.string(),
date: z.string().date(),
status: OrderStatus,
total: z.number(),
itemCount: z.number(),
});
handler: async ({ query, limit }) => {
const rows = await db.orders.search(query, { limit: limit + 1 });
const page = rows.slice(0, limit);
return {
results: page.map((r) => Summary.parse(r)),
hasMore: rows.length > limit,
hint: rows.length > limit
? "More results exist. Narrow the query or use get_order."
: undefined,
};
};
Summaries in the list, full detail behind a second call. hasMore
plus a hint tells the model what to do rather than leaving it to
infer that the list was cut. Fetching limit + 1 is how you know
there is more without a second count query.
6. No example in the description
Format constraints stated abstractly get approximated.
description: "Fetch analytics for a date range."
schema: z.object({ from: z.string(), to: z.string() })
You will receive "last week", "2026-08", "08/06/2026", and
occasionally "2026-08-06T00:00:00.000Z".
description: ""
"Fetch analytics for a date range. Dates are ISO 8601 " +
"calendar dates. Example: { from: '2026-07-01', " +
"to: '2026-07-31' } for July 2026. Maximum range 90 days.",
schema: z.object({
from: z.string().date(),
to: z.string().date(),
}).refine((r) => days(r.from, r.to) <= 90, {
message: "Range cannot exceed 90 days",
path: ["to"],
}),
One concrete example does more than three sentences of specification.
z.string().date() then rejects the ones that slip through, and the
.refine catches the range rule that no format check would.
Testing tool selection
Selection is testable without asserting on generated prose. Ask for a
task, assert on which tool got called.
const CASES = [
{ ask: "What's in order ord_a1b2c3d4e5?", expect: "get_order" },
{ ask: "Find my orders from July", expect: "search_orders" },
{ ask: "Cancel my subscription", expect: "cancel_subscription" },
];
it.each(CASES)("picks $expect for $ask", async ({ ask, expect: want }) => {
const res = await client.messages.create({
model: "claude-opus-5", max_tokens: 512,
tools: toolDefs, messages: [{ role: "user", content: ask }],
});
const called = res.content.find((b) => b.type === "tool_use");
expect(called?.name).toBe(want);
});
Cheap, fast, and it turns "the model keeps picking the wrong tool"
into a red test you can iterate against. When you add a seventh tool
and two older cases go red, you have found the overlap before your
users do.
The framing
Your tool definitions are an API whose only consumer reads
documentation and never the source. Every ambiguity you leave is
resolved by guessing.
Design them the way you would design a public API for developers who
cannot ask you questions — because that is precisely the constraint.
If this was useful
AI That Acts covers tool
design as its own discipline — naming, granularity, schemas that
prevent bad calls, return shapes that respect the context window, and
testing selection.
The full series is at
xgabriel.com/ai-in-typescript.



Top comments (0)