What is Tool Router?
Tool Router is an AI design pattern for LLM apps and agents, in the Tools and action group. Pick a small subset of tools before calling the main model.
Fábio runs the back office assistant at a distributor. The agent reconciles payments, issues invoices, checks stock, opens tickets with carriers, and answers questions about commissions.
After merging the duplicate tools last quarter, thirty-four of them remained. All legitimate, all different. And someone uses every one of them.
But the agent still makes mistakes. On a reconciliation request, it calls check stock, instead of find payment. Both take an order code, and their descriptions start the same way. The trace shows the model hesitating between tools that shouldn't even be on the table.
And there's the bill. Every call carries all thirty-four definitions. In a six turn loop, they go to the model six times.
Fábio can't remove tools. What he can change is how many the model sees at a time. That's this lesson.
Organize the tools into groups by topic. Reconciliation, invoices, stock, and logistics, each in its own group. Before the agent loop starts, a short step decides which groups this task needs. And the main agent runs with only those groups.
The key move is taking one choice away from the big model, and putting it in a small, cheap, testable step. The main agent stops answering which of these thirty-four, and starts answering which of these eight. And there's always a way back. If the agent discovers it needs another group, it can ask for it.
The task arrives, as a person's text or a system event.
A small model reads the task, and the list of groups, one description line each.
The catalog stores the groups and each group's tools, versioned.
The router returns the chosen groups, and its confidence.
The main agent runs with those groups' tools, plus one extra tool, to ask for another group.
Tools execute as usual.
And the answer goes out, with the trace recording which groups were used.
A task comes in. Reconcile the payment for order eighty-eight two thirty-one with yesterday's statement, and if it matches, settle the bill. The router gets the task, and four lines, one per group.
Billing: use to reconcile payments against statements, and settle bills.
Invoices, stock and logistics, each with its own use.
It returns: billing, with ninety-two percent confidence. The main agent runs with the seven billing tools, plus the one to request a group. The stock tool that caused the confusion never shows up. And a tool that isn't on the list can't be picked by mistake.
Now the math, with illustrative numbers. Say each tool definition takes about two hundred fifty tokens. The thirty-four add up to roughly eight thousand five hundred tokens per call.
Over a six turn loop, that's about fifty-one thousand tokens of definitions alone.
With eight tools, the same task carries about twelve thousand. The router costs one short call on a small model. It pays once. The savings happen on every turn.
Here's one for you. A week after launching the router, the dashboard shows that in forty percent of collections tasks, the agent asks for the invoices group mid-run. What's the most direct fix?
Include invoices in the initial selection for collections tasks, by adjusting the router's description or its examples. A group requested often, for the same task type, means the initial selection is too narrow. Expansion is an escape hatch, not the normal route.
Removing the request tool, because it makes the agent slow, would leave those tasks without the right tool. And merging all groups back into one undoes the pattern. Collections and invoices show up together, so they go in together.
If the agent has few tools, consolidate first. That was the last lesson.
If tasks cross every topic all the time, the router would always pick everything.
If your provider offers tool search on demand, and your only problem is token cost, that mechanism solves it, without an extra step of yours.
And if the input channel already tells you the topic, like a queue or an event type, use a fixed rule in code. No model, zero cost, and zero classification errors.
Now the common traps. Grouping by system, not by task. Everything that talks to the management system becomes a twenty-five tool group. Group by what the person wants to do. Like reconcile, issue, or ship an order.
Describing contents, not use. Billing tools doesn't help the router. Use to reconcile payments against statements does, because it matches how tasks arrive.
A default set with side effects. Low confidence means the router is unsure. The safe default is read only. Not everything, just in case.
And forgetting the escape hatch. Without a way to request a group, a router mistake becomes an agent that answers without the right tool. Or makes up the data.
The most expensive failure here is the silent one. The right group was left out, and the agent didn't ask. It answers I couldn't find the payment, with total calm. That's why coverage matters most. In how many tasks do the chosen groups include every tool the correct solution uses?
But coverage and size pull in opposite directions. A router that always picks everything has perfect coverage, and is useless. Track both together.
And watch the expansion rate per group. A group requested often should join the initial selection for that task type.
You can build the router's dataset from traces you already have. For every task solved successfully, the expected groups are the groups of the tools the agent actually called. Include tasks that cross topics on purpose, like issue the invoice and book the pickup. And vague ones, like see what happened with this order. And when you add a new tool, rerun the router's eval. A changed group description can divert tasks that used to go to the right place.
And one security point. The router shrinks the surface. Side effect tools only appear on routes that need them. A question about delivery dates has no way to trigger a refund. But don't treat it as access control. Malicious text inside the task can talk the router into a different group, and the agent can ask for groups by itself. Real permission lives in the tool and in the backend. Who the user is, what they can do, and the amount limit.
So here's the idea to take with you. When every tool is legitimate but no task needs them all, choose the toolbox before the work starts. A small step picks the groups, the agent works with a short list and a way to ask for more, and you measure coverage and size together.
In the next lesson, we move from the tools to the conversation itself, and what happens as it gets long: context rot. See you there.
Roteador de Ferramentas
Tool Router is an AI design pattern for LLM apps and agents, in the Tools and action group. Pick a small subset of tools before calling the main model.
Pick a small subset of tools before calling the main model. It is for agents that cover many topics and need many tools in total, but few in each task.
A mechanic who brings the toolbox for today’s repair to the car, not the whole workshop.
Fábio runs the back-office assistant at a distributor. The agent reconciles payments, issues invoices, checks stock, opens tickets with carriers, and answers commission questions. After merging duplicate tools, 34 remained, all legitimate and all different.
The agent still makes mistakes. On a reconciliation request, it calls check_stock instead of find_payment, because both take an order code and their descriptions start the same way. The trace shows the model hesitating between tools that should not even be on the table.
Then there is the bill. Every call carries all 34 definitions, so every task’s context starts with thousands of tokens of tools that have nothing to do with it. In a six-turn loop, those definitions go to the model six times.
Fábio cannot remove tools: someone uses every one of them. What he can change is how many the model sees at a time.
The question this pattern answers: how do you keep many tools in the system without making the model choose among all of them on every call?
Organize tools into groups by topic (reconciliation, invoices, stock, logistics). Before the agent loop, a short step decides which groups the task needs. The main agent runs with only those groups.
The router can be a small model with structured output, a similarity search between the task and the group descriptions, or plain rules when the channel already tells you the topic. There is always a way back: if the agent finds it needs another group, it can ask for it.
The key move is taking a choice away from the big model and putting it in a small, cheap, testable step. The main agent stops answering “which of these 34?” and starts answering “which of these 8?”.
A task comes in: “Reconcile the payment for order 88231 with yesterday’s statement and, if it matches, settle the bill”. The router gets the task and four description lines, one per group:
billing: Use to reconcile payments against statements and settle bills.
invoices: Use to issue, cancel, or look up invoices (NF-e).
stock: Use for stock levels, reservations, and movements.
logistics: Use for freight, pickups, and carrier tickets.
It returns a short structured output:
{ "groups": ["billing"], "confidence": 0.92 }
The main agent runs with the 7 billing tools plus request_group. The stock tool that caused the confusion never shows up. (NF-e is Brazil’s mandatory electronic invoice; the distributor issues one for every sale.)
Now the math, with illustrative numbers. Suppose each tool definition takes about 250 tokens. The 34 add up to roughly 8,500 tokens per call; over a six-turn loop, that is about 51,000 tokens of definitions alone. With 8 tools, the same task carries about 12,000. The router costs one short call on a small model, with the task text and four group lines.
The exact numbers depend on your catalog and your provider. The shape of the math does not: the router pays once, and the savings happen on every loop turn.
flowchart LR
IN(["1. Task"]):::io --> R(["2. Tool router"]):::llm
C[("3. Group catalog")]:::store --> R
R --> S["4. Chosen subset"]:::tool
S --> A(["5. Main agent"]):::llm
A --> T["6. Group tools"]:::tool
T --> A
A -->|"needs another group"| R
A --> OUT(["7. Answer"]):::io
schema Selection { groups: list["billing" | "invoices" | "stock" | "logistics"], confidence: float }
GROUPS = catalog.load("tool-groups@4")
function handle(task):
# 2. pick groups with a small model
sel = llm(ROUTER_PROMPT + GROUPS.summaries(), task) -> Selection
trace.span("tool_route", sel)
if sel.confidence < 0.6:
sel.groups = GROUPS.default_set() # 4. safe default set
tools = GROUPS.tools_for(sel.groups) + [request_group]
# 5 and 6. main agent with the subset only
while budget.check():
reply = llm_with_tools(AGENT_PROMPT, tools, state)
if reply.is_final:
return reply.text # 7
for call in reply.tool_calls:
if call.name == "request_group":
tools += GROUPS.tools_for([call.args.group])
trace.span("tool_route_expand", call.args)
state.add(execute(call))
request_group tool to ask for a group that was left out.request_group, a router mistake becomes an agent that answers without the right tool, or makes up the data.| Variation | Extra cost | Handles ambiguity | When to pick it |
|---|---|---|---|
| Rule | none | no | the channel already gives the topic |
| Embedding | one search | a little | many groups, short and clear tasks |
| Small model | one call | well | free-text tasks that mix topics |
| Provider search | no step of yours | depends on the provider | the main problem is tokens |
| Aspect | What happens | How it shows up in the trace |
|---|---|---|
| Calls | One extra per task, on the small model | tool_route span before the loop |
| Latency | Router time adds to the start | tool_route duration |
| Cost | Fewer tool tokens on every loop turn offsets the router | smaller input tokens on each step |
| Failure: missing group | The agent lacks the tool and answers without it | frequent request_group for the same group |
| Failure: too many groups | The router picks everything to be safe | average groups per task close to the total |
The most expensive failure is the silent one: the right group was left out and the agent did not ask. It answers “I could not find the payment” with total calm. That is why the coverage eval below matters more than the size one.
{ "task": "Reconcile the payment for order 88231 with yesterday's statement", "expected_groups": ["billing"] }
Build the dataset from the traces you already have. For each task solved successfully, the expected groups are the groups of the tools the agent actually called. Include cross-topic tasks on purpose (“issue the invoice and book the pickup”) and vague ones (“see what happened with order 88231”).
Coverage and size pull in opposite directions. A router that always picks everything has perfect coverage and is useless. Track both together.
tool_route span with groups, confidence, and catalog version.request_group frequency per group. A group requested often should join the initial selection for that task type.The router shrinks the surface: side-effect tools only appear on routes that need them. Do not treat this as access control. Malicious text inside the task can talk the router into a different group, and the agent can also ask for groups through request_group.
Real permission lives in the tool and the backend: who the user is, what they can do, what the amount limit is. See Narrow Tool Permissions.
| Pros | Cons |
|---|---|
| Less confusion when choosing tools | One extra call at the start of each task |
| Fewer tokens on every loop turn | A wrong group leaves the agent without the right tool |
| Side-effect tools stay out of tasks that do not need them | The group catalog is one more thing to maintain |
| New groups plug in without touching the others | Cross-topic tasks get slower |
The tabs pick groups with a small model and run the agent with only the chosen groups. Python and TypeScript build the list and call the model. LangGraph splits the two pieces: one node picks the groups, the next node creates the agent with only those groups’ tools.
Python. Use this when the agent runs in Python and you build the tool list per task.
from typing import Literal
import anthropic
from pydantic import BaseModel
client = anthropic.Anthropic()
GROUPS = { # 3. catalog: group -> (description, tools)
"billing": ("Use to reconcile payments against statements and settle bills.", BILLING_TOOLS),
"invoices": ("Use to issue, cancel, or look up invoices.", INVOICE_TOOLS),
"stock": ("Use for stock levels, reservations, and movements.", STOCK_TOOLS),
"logistics": ("Use for freight, pickups, and carrier tickets.", LOGISTICS_TOOLS),
}
Group = Literal["billing", "invoices", "stock", "logistics"]
class Selection(BaseModel):
groups: list[Group]
confidence: float
def select_tools(task: str) -> list[dict]:
summary = "\n".join(f"{name}: {desc}" for name, (desc, _) in GROUPS.items())
sel = client.messages.parse( # 2. router on a small model
model="claude-haiku-4-5",
max_tokens=200,
system=f"Pick the tool groups this task needs.\n{summary}",
messages=[{"role": "user", "content": task}],
output_format=Selection,
).parsed_output
groups = sel.groups if sel.confidence >= 0.6 else ["billing"] # 4. safe default
return [tool for g in groups for tool in GROUPS[g][1]]
def handle(task: str) -> str:
tools = select_tools(task)
return run_agent(task, tools) # 5. the same loop as before, with fewer toolsWhat you gained: each task’s tool list is explicit and testable. What you lost: expansion via request_group has to be handled in the loop.
TypeScript. Use this when the agent runs on Node and you want the groups typed.
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { z } from "zod";
const client = new Anthropic();
const GROUPS = {
billing: { desc: "Use to reconcile payments against statements and settle bills.", tools: BILLING_TOOLS },
invoices: { desc: "Use to issue, cancel, or look up invoices.", tools: INVOICE_TOOLS },
stock: { desc: "Use for stock levels, reservations, and movements.", tools: STOCK_TOOLS },
logistics: { desc: "Use for freight, pickups, and carrier tickets.", tools: LOGISTICS_TOOLS },
} as const;
type Group = keyof typeof GROUPS;
const Selection = z.object({
groups: z.array(z.enum(["billing", "invoices", "stock", "logistics"])),
confidence: z.number(),
});
export async function selectTools(task: string): Promise<Anthropic.Tool[]> {
const summary = Object.entries(GROUPS).map(([k, g]) => `${k}: ${g.desc}`).join("\n");
const { parsed_output: sel } = await client.messages.parse({
model: "claude-haiku-4-5",
max_tokens: 200,
system: `Pick the tool groups this task needs.\n${summary}`,
messages: [{ role: "user", content: task }],
output_config: { format: zodOutputFormat(Selection) },
});
const groups: Group[] = sel && sel.confidence >= 0.6 ? sel.groups : ["billing"];
return groups.flatMap((g) => [...GROUPS[g].tools]);
}What you gained: the compiler guarantees only known groups exist. What you lost: same as Python; the agent loop is still on you.
Python + LangGraph. Use this when picking groups is a node and the agent is created in the next node, with only those tools.
from typing import Literal
from langchain.agents import create_agent
from langchain.chat_models import init_chat_model
from langgraph.graph import END, START, StateGraph
from pydantic import BaseModel
from typing_extensions import TypedDict
Group = Literal["billing", "invoices", "stock", "logistics"]
class Selection(BaseModel):
groups: list[Group]
confidence: float
class State(TypedDict):
task: str
groups: list[str]
answer: str
router = init_chat_model("anthropic:claude-haiku-4-5", temperature=0)
def select(state: State) -> dict:
# 2. the same GROUPS as the Python tab: name -> (description, tools)
summary = "\n".join(f"{name}: {desc}" for name, (desc, _) in GROUPS.items())
sel = router.with_structured_output(Selection).invoke(
[("system", f"Pick the tool groups this task needs.\n{summary}"),
("human", state["task"])]
)
groups = sel.groups if sel.confidence >= 0.6 else ["billing"] # 4
return {"groups": list(groups)}
def run(state: State) -> dict:
tools = [tool for g in state["groups"] for tool in GROUPS[g][1]] # 5
agent = create_agent("anthropic:claude-opus-5-5", tools=tools,
system_prompt="You are the back-office assistant.")
result = agent.invoke({"messages": [("human", state["task"])]}, {"recursion_limit": 16})
return {"answer": result["messages"][-1].text}
graph = StateGraph(State)
graph.add_node("select", select)
graph.add_node("run", run)
graph.add_edge(START, "select")
graph.add_edge("select", "run")
graph.add_edge("run", END)
app = graph.compile()The other ways to write the create_agent loop are on ReAct Loop. request_group stays a tool of yours, inside tools.
What you gained: the agent is created after the choice, so the stock tool does not exist on the second node. What you lost: recursion_limit counts graph steps, not money.
Answer the 5 questions to complete this lesson. Watching alone does not complete it: the check is what counts toward the trail and the certificate.
Tool Router is an AI design pattern for LLM apps and agents, in the Tools and action group. Pick a small subset of tools before calling the main model.
Tool Sprawl, Routing System Prompt, Mega Agent, and Unused Tools.
In the real-world scenarios Back-office automation with durable execution and Support bot with human escalation.