Agent tool catalogues grow faster than anyone plans for. You start with a dozen functions. Then come broker reads, a canvas editor, a browser, a code sandbox, alert builders, a couple of MCP servers with thirty operations each, a client-specific integration, and some calculators someone wrote last week. Suddenly the model has hundreds of options, and every request carries tens of kilobytes of schemas before the user’s question appears.
There are two obvious responses: send everything, or send a few and let the model ask for more. Neither is right on its own. I find it clearer to treat tool exposure as three separate problems — what exists for this user, what’s attached to this request, and what’s allowed when a call actually happens — and to keep them in separate code.
Permission decides what exists
Before any routing or relevance logic, filter the catalogue by the user’s role. A tool the user can’t use should be removed before the model sees it and before any discovery mechanism can find it. If it’s searchable but not callable, the model will try to call it, fail, and either apologise or improvise a workaround.
A simple and readable scheme is to derive required permissions from a tool’s family:
FAMILY_PERMISSIONS = {
"web": {"research.web"},
"sandbox": {"analysis.code"},
"broker": {"market.view", "account.read"},
"alerts": {"alerts.write"},
"delegate": {"research.delegate"},
}
def required(tool_name: str) -> set[str]:
family = tool_name.split("_", 1)[0]
return {"chat"} | FAMILY_PERMISSIONS.get(family, set())
It’s easy to audit, because the whole policy fits on one screen. Its weakness is just as obvious: a new tool has to be named into the right family, or it silently gets the minimum permission. That’s a review rule, and it’s worth adding a test that fails when a tool’s family isn’t in the table.
It also helps to tell the model, briefly, what this user can’t do, with an instruction not to promise, simulate, or work around those capabilities. Otherwise a model without a publishing tool will cheerfully describe having published something.
A route picks a stable profile
Most requests fall into a small number of shapes: a simple question, a market read, building an alert, inspecting something saved, browsing, building an artefact, editing a workspace, deep research. Classify each request into one of those routes and attach a fixed set of tools for it, plus a small stable core every parent run gets: asking the user a question, presenting a structured block, listing and loading procedures, searching history, and planning.
Routes should add, not replace. On top of the route’s own tools, attach the tools a loaded procedure suggests, lexically related tools above a similarity floor, and anything the user referred to by name. If you include an MCP server’s tools, I’d include its whole catalogue rather than a hand-picked subset. Partial catalogues from a server confuse models more than they help, because the operations reference each other.
Routes go wrong in boring ways, and the fixes are boring too. “Looks good, turn it on” after building an alert sounds like small talk, and a classifier will happily route it to a tool-less simple chat. The agent then spends the next turn rediscovering the tools it just had. Keep an active task’s route sticky across short follow-ups. Similarly, “include their earnings as well” after an answer-only question needs market tools. If you have to pick a default for ambiguous follow-ups, pick the one that can do work.
Record what was attached and why on every run. When an answer is wrong, “the model never had the right tool” is a common cause and an easy one to check, but only if you logged it.
Loading on demand is an escape hatch
When a route doesn’t have what’s needed, the agent should be able to search the user’s permitted inventory and load a few tools. Anthropic describes a version of this as a tool search tool in its advanced tool use write-up. The design details that matter:
- Search returns a few results with short summaries, not full schemas.
- Load takes a small number of exact names and is all-or-nothing.
- The inventory is fixed for the run and versioned, so a load against a stale version fails clearly.
- Loaded tools attach on the next model step and are appended to the list, never re-sorted (see the prompt caching post for why order matters).
- Loaded names are saved with the run, so a resumed run gets the same tools back.
def load_tools(names: list[str]) -> dict:
if len(names) > 8 or any(n not in run_inventory for n in names):
return {"error": "unavailable", "retry": False,
"note": "Not in this run's permitted inventory."}
attach_next_step(names)
return {"note": "Available on the next step. Nothing was executed."}
Say “nothing was executed” explicitly. Models sometimes believe that loading a tool did the thing.
A guard on every call
None of the above grants anything. Visibility is a convenience. Every tool should be wrapped in a guard that re-reads the user’s permissions fresh — no long-lived permission cache — before running. If the user’s access changed since the tools were attached, the call is refused. The guard checks resource ownership and any information-barrier rules on what’s being read, and whether the arguments mention something the user is restricted from.
Then it checks the output. A broad search can return a restricted company without anyone having named it. If a desk is walled off from a security, the result should be refused before the model sees it.
async def guarded(call):
if (refusal := policy.check_args(call)):
return refusal
result = await call.run()
return policy.check_output(call, result) or result
Procedures and tool loading only decide what’s convenient to reach. Neither should ever widen access.
Procedures are versioned text, so review them
Reusable procedures — “skills”, in some frameworks — are markdown with a description of when to use them and when not to, a version, suggested tools, and trigger phrases. Keep the catalogue of names and descriptions small in the prompt, cap each loaded body, and limit how many load automatically. Append loaded bodies at the end of the model input, after the conversation, so they don’t disturb the cached prefix.
Treat changes to them like code. A model asked to draft an improvement should produce an inactive proposal, never an active change. If you allow some revisions to apply automatically, restrict that to methodology text — never tool suggestions, triggers, or delegation settings — and bind the approval to a fingerprint of the exact revision and its test outputs, so editing it afterwards voids the approval.
When loading less costs more
The natural next step is to make deferred loading the default: attach a small core and let the agent load the rest. It’s worth measuring before you do.
Deferred loading definitely shrinks the schema bytes per request. But each discovery and load is another model round trip, and each round trip resends the whole prefix. In a paired comparison on identical frozen inputs, you can easily see the deferred strategy use fewer schema bytes per call and more total input per task, plus more latency, because it needed extra calls to find what a full route profile would have attached up front. If tool loading also disturbs the cached prefix, it gets worse again.
My conclusion: attach a full, stable profile for the route, and keep on-demand loading as the escape hatch, not the main path. Embedding-based tool retrieval has a similar catch. If your workers are fresh processes, loading an embedding model on the first tool call of each one can cost more memory and startup time than the retrieval saves. Lexical matching over good tool descriptions gets you most of the way.
The number to optimise is input per completed task, not bytes per request. They often move in opposite directions.