Your agent doesn't need every tool in its context
DynoTable's AI agent can reach dozens of tools, and it rarely sees all of them at once. Most of the catalog is visible from the start. A minority stays hidden until the model searches for it.
That split is the reverse of where we started. At launch, a small core was visible and the rest waited behind search. Then we watched cheap models use it, and the visible core grew substantially.
The search mechanism survived. Our theory about which tools could wait behind it did not.
Every tool you expose makes the model's choice harder
A model sees each tool as its name, its description and its full input schema. All of that goes into the request before the user types anything.
Those definitions cost tokens. They also cost selection accuracy: the more options a model scans, the more often it picks the wrong one.
Anthropic's tool search documentation puts a number on it for Claude: tool selection degrades past 30 to 50 available tools.
Our catalog contains similar options on purpose. Several tools come in pairs,
such as openTable and proposeOpenTable. Both open a table. The first does it
at once.
The second puts a chip in the chat, and nothing opens until the user clicks it. That difference matters for user control, and in a flat list of names it is nearly invisible.
Discovery is a search tool plus a narrowed tool list
The agent works in steps. In each step, the model reads the conversation, calls tools and gets their results back. One user message, which we call a turn, can take many steps.
Before each step, the agent loop decides which tools the model may call in that step. We call that list the active set.
Each tool is either inline or discoverable. Inline tools are in the active set from the first step. Discoverable tools join it only after the model searches for them.
The search tool scores the whole catalog against a query by name, keywords and description. It returns the top five matches by default, and each discoverable match joins the active set for the next step.
The search tool updates the active set inside its own execution, so the change is in place before the loop prepares the next step.
The search tool's description lists every withheld tool by name. Only their descriptions and input schemas stay out of the request until a search returns them.
If the model calls a withheld tool anyway, the AI SDK we build on rejects the call before any of our code runs. The model gets the SDK's error back as the tool result, with the tool name and the active set filled in:
Model tried to call unavailable tool '<name>'. Available tools: <active set>.The search tool is inline, so that list always names it. We once wrote our own, more explicit rejection message for this case.
Then we traced the SDK and found that it rejects the call earlier. Our message had never reached a model, so we deleted it.
Anthropic also offers tool search as a server-side feature. It supports only Claude models, and on Amazon Bedrock it works only through the InvokeModel API. An agent that must also run Amazon Nova needs its own.
Cheap models don't reliably search
We don't tune this agent against a frontier model. It runs on your own Bedrock credentials, and many people pick a cheap model.
So our evals run against two cheap floor models: Amazon Nova 2 Lite and Claude Haiku 4.5. The first sign of trouble came from a live chat about an attached file.
The model was Nova Lite, the first-generation model that Nova 2 Lite later replaced as our floor. It looked for the file in the list of open tabs instead of searching for the file tool.
The same model failed twice more that day for the same reason. It missed the tool that lists tables and the tools that propose a joined view of two tables.
We promoted all of those tools to inline. The model no longer has to search before it can use them.
The clearest case came later, with bulkEdit. It stages one change on many
items in a single call, into the staging area where agent
writes wait for the user to review and commit them.
It shipped discoverable, and neither floor model found it. Haiku 4.5 never called the search tool in six tries.
Nova 2 Lite searched only after stageItem, the single-row tool, refused to
keep staging row by row, and it often hit the step limit. With bulkEdit
visible from the first step, it staged a 35-row change in one call.
After we made bulkEdit inline, both models passed both bulk scenarios on all
three repetitions. A later run with five bulk scenarios scored Haiku 100% on
all five.
Nova 2 Lite scored 100% on four. On a bulk delete by filter it scored 57%, because it offered to look the items over first instead of staging the deletes.
The promotion had a cost elsewhere. In a scenario where Haiku had to find the tool that opens the staging panel, it went from three passes out of three to none.
With bulkEdit in view, Haiku answered that it had no tool for that, instead
of searching. We kept bulkEdit inline, and it ships that way in v1.14.0.
Those failures rule out search as the only route to a tool that requests need without naming. Each such request would depend on the model deciding to look.
Visibility follows the user's phrasing
So we split the catalog with one question about each tool. Does the way a user asks for it name the tool?
"Export this to CSV" makes a model search for export, so the export tools stay discoverable. Index statistics, vector search and saved specs work the same way.
So do the tools that show pending writes in the staging area.
People ask for those by name or not at all. Other tools are needed by requests that never name them.
"Show me last week's orders" never mentions a clock. The model still needs
today's date to answer it, so getCurrentTime is inline.
Attached files work the same way. People ask about the contents and never name the tool that reads them, so the file tools are inline too.
Anthropic's guidance for its own tool search is to keep the three to five most-used tools loaded. Cheap models pushed our inline core well past that.
Three searches, then stop
The search tool has a budget of three calls per turn. The fourth call returns this instead of running:
{
"error": "search-budget-exhausted",
"message": "search budget exhausted, call ask() to clarify with the user or use a previously-discovered tool",
"budgetCap": 3
}The cap exists because of a specific loop. The model searches and doesn't find what it imagined. It searches again with a synonym, then another, and spends its steps without touching the database.
The cap forces a decision at the point where more searching stops paying. The model either commits to a tool it has already found or asks the user.
The message names both ways out. We apply the same rule to our validators. A rejection that names the recovery gives the model a next step instead of a dead end.
Each tool is declared once, in one row
The catalog is a flat list with one row per tool. The row holds the tool's catalog identity: name, the description search returns, search keywords, inline or discoverable, tier and MCP exposure.
The tier says whether a call needs permission. Silent tools run without asking anyone. Gated tools pass the permission check first.
The search tool belongs to neither tier. It is part of the agent loop and never touches your data.
MCP exposure says what an external client connected to the MCP server can call. The value is the lowest access level that allows the tool: Read only, Read & stage or Full access.
Only a couple of tools are excluded from MCP, and the search tool is one.
Every other list of tools in the app is derived from these rows. That includes the silent set, the tools each MCP access level allows and the set of tools that count as writes. None of them is maintained by hand.
Hand-kept lists drift. If the silent list and the MCP list were kept apart, they could disagree, and a tool gated in chat could reach an external client without the same check.
The list has one constraint we didn't see coming. It must have zero runtime imports, because both the desktop UI and the backend load it.
One import would pull in a tool's implementation, and through it a Node-only crypto module. In the UI's browser bundle, the app would fail at module load.
The type checker and the unit tests miss this, because both resolve the import without complaint. A test that reads the file as text catches it.
That test fails on any statement that loads a module at run time. A type-only import is the one form it allows, because the compiler erases it.
What transfers if you're building one
- Measure the split. Run your evals with a tool inline and with it discoverable, and let the scores decide. Rerun the rest of the suite too: promoting one tool can change how the model treats another.
- Test discovery against your weakest model. A frontier model may search when it should, but that tells you nothing about the model your users pick.
- Make a tool inline when requests need it without naming it. Leave it discoverable when people ask for it by name.
- Cap the tools that manage the agent instead of the data, such as search. A tool that never touches real state can be called again and again, and every call spends a step.
- Make every rejection name the recovery action. Check what your SDK already says before you write your own message.
- Declare each tool's catalog entry once and derive every other list from it.
- If a module has a constraint your compiler can't express, enforce it with a test that reads the source as text.
Where this runs
All of this ships in DynoTable's tool catalog, with the agent running on your own Bedrock credentials. The same rows drive the MCP server that external agents connect to.
On that server, each row's exposure value decides which access level a client needs to call the tool. How we made that safe, with OAuth, consent and credential isolation, is a separate story.
The validators that make each of these tools usable by a cheap model are their own post.