Designing modular tool integrations for coding agents
Your agent had 3 tools and the code was clean. It has 15 now.
You started with read_file, edit_file, run_bash. The dispatch block was a 10-line if/elif. Then you added glob, grep, git_diff, run_tests, search_docs, fetch_url, and 8 more. The dispatcher is now 200 lines of copy-pasted conditionals. Every new tool touches 4 places: the JSON schema list, the handler function, the dispatcher, and the registration call in startup. Adding tool 16 takes longer than writing tool 1 did.
This is the tool-integration debt every coding agent accumulates. The fix is not to add a framework. The fix is a small registry pattern that lets you define a tool in one place, register it with a decorator, and have the dispatcher pick it up automatically. 80 lines total. Scales from 3 tools to 30 without changing shape.
This post is the registry pattern, the schema-first tool definition, and the 4 anti-patterns to avoid if you want your tool integrations to stay sane.
Why does naive tool dispatch break at 15 tools?
Because every tool touches 4 places in the codebase, and every touch is an opportunity to forget a step. The naive dispatcher looks like this:
graph TD
Call[LLM returns tool_use] --> D{Naive dispatcher}
D -->|read_file| R[read handler]
D -->|edit_file| E[edit handler]
D -->|run_bash| B[bash handler]
D -->|unknown| X[Error: tool not found]
X --> Retry[Model retries blindly]
Call --> Reg[Registry dispatcher]
Reg -->|lookup by name| Table[(REGISTRY dict)]
Table --> Any[Any registered tool]
style X fill:#fee2e2,stroke:#b91c1c
style Reg fill:#dcfce7,stroke:#15803d
# filename: naive_dispatch.py
# description: The dispatcher you should not ship. Works for 3 tools,
# grows linearly with pain, breaks silently when you add a tool halfway.
TOOLS = [
{'name': 'read_file', 'description': '...', 'input_schema': {...}},
{'name': 'edit_file', 'description': '...', 'input_schema': {...}},
{'name': 'run_bash', 'description': '...', 'input_schema': {...}},
]
def dispatch(name: str, args: dict) -> str:
if name == 'read_file':
return read_file(**args)
elif name == 'edit_file':
return edit_file(**args)
elif name == 'run_bash':
return run_bash(**args)
else:
return f'unknown tool: {name}'
3 places to update for every new tool: the TOOLS list, the dispatch function, and the handler itself. If any one of the 3 gets out of sync, you get a "tool not found" error or a schema mismatch the model cannot recover from.
3 concrete failure modes I have shipped:
- Added a tool to
TOOLSbut forgot to add it todispatch. The model calls it and gets "unknown tool." It retries 3 times, each adding a new message to the history, then gives up. - Added a tool to
dispatchbut forgot to add it toTOOLS. The model never tries to call it because it is not in the schema. You wonder why the new tool is never used. - Renamed a tool and forgot to update the dispatch branch. Silent failure that only shows up when the model happens to call that specific tool.
The registry pattern collapses the 3 places into 1. One decorator registers a handler AND appends to the schema list AND wires it into the dispatcher.
What does a tool registry look like?
A dict that maps tool names to callable handlers, plus metadata for the schema. A decorator registers a function under its name and attaches the input schema. The dispatcher looks up by name and calls.
# filename: registry.py
# description: Tool registry with decorator-based registration.
# One place to define a tool: the handler function.
from dataclasses import dataclass
from typing import Callable, Any
@dataclass
class ToolSpec:
name: str
description: str
input_schema: dict
handler: Callable[..., Any]
REGISTRY: dict[str, ToolSpec] = {}
def tool(name: str, description: str, input_schema: dict):
def wrap(fn: Callable[..., Any]) -> Callable[..., Any]:
REGISTRY[name] = ToolSpec(
name=name,
description=description,
input_schema=input_schema,
handler=fn,
)
return fn
return wrap
def as_api_tools() -> list[dict]:
return [
{
'name': spec.name,
'description': spec.description,
'input_schema': spec.input_schema,
}
for spec in REGISTRY.values()
]
def dispatch(name: str, args: dict) -> Any:
spec = REGISTRY.get(name)
if not spec:
return {'error': f'unknown tool: {name}'}
try:
return spec.handler(**args)
except Exception as exc:
return {'error': str(exc)}
60 lines and it does everything the 200-line dispatcher was doing, plus it is impossible to forget a step because there is only one step.
How do you define a tool with the registry?
One function, one decorator, one place. The handler and the schema live next to each other. The registry picks it up automatically when the module is imported.
# filename: file_tools.py
# description: 2 file tools defined with the registry decorator.
# The schema and the handler are in the same file, same function.
from pathlib import Path
from registry import tool
@tool(
name='read_file',
description='Read a text file and return its contents.',
input_schema={
'type': 'object',
'properties': {'path': {'type': 'string'}},
'required': ['path'],
},
)
def read_file(path: str) -> str:
return Path(path).read_text()
@tool(
name='edit_file',
description='Replace a substring in a file. Targeted single edit.',
input_schema={
'type': 'object',
'properties': {
'path': {'type': 'string'},
'old': {'type': 'string'},
'new': {'type': 'string'},
},
'required': ['path', 'old', 'new'],
},
)
def edit_file(path: str, old: str, new: str) -> str:
p = Path(path)
text = p.read_text()
if old not in text:
return f'ERROR: substring not found in {path}'
p.write_text(text.replace(old, new, 1))
return f'Edited {path}'
Notice what is NOT here: no update to a central TOOLS list. No elif in a dispatcher. No registration call in an init. Just 2 functions with decorators. Add a new tool the same way and it is immediately visible to the agent.
How does the agent loop use the registry?
Pass as_api_tools() to the LLM call and route tool use blocks through dispatch(). The loop itself never changes when you add tools.
# filename: agent.py
# description: The agent loop reads tools and dispatches all tool calls
# through the registry. Adding a new tool does not touch this file.
from anthropic import Anthropic
from registry import as_api_tools, dispatch
import file_tools # import registers the tools
import bash_tools
import search_tools
client = Anthropic()
def run(user_message: str) -> str:
messages = [{'role': 'user', 'content': user_message}]
tools = as_api_tools()
for _ in range(30):
reply = client.messages.create(
model='claude-sonnet-4-6',
max_tokens=2048,
tools=tools,
messages=messages,
)
if reply.stop_reason == 'end_turn':
return next(b.text for b in reply.content if b.type == 'text')
messages.append({'role': 'assistant', 'content': reply.content})
results = []
for block in reply.content:
if block.type == 'tool_use':
output = dispatch(block.name, block.input)
results.append({
'type': 'tool_result',
'tool_use_id': block.id,
'content': str(output),
})
messages.append({'role': 'user', 'content': results})
return 'max steps reached'
Read the imports. import file_tools is the side-effect import that runs the decorators and populates the registry. Adding a new tool module adds one import line to this file and nothing else. The loop is agnostic to the tool count.
For the broader context of how the event loop works, see the Event Loop Inside a Coding Agent post. For the step-by-step build of an agent that uses this registry, see Build a Coding Agent with Claude: A Step-by-Step Guide.
How do you group related tools into modules?
By domain, not by tool count. file_tools.py holds read_file, edit_file, and write_file. bash_tools.py holds run_bash and run_tests. search_tools.py holds glob, grep, and fetch_docs. Each module is importable on its own and registers its tools at import time.
3 benefits of module grouping:
- You can enable or disable a whole domain by not importing it. A code-review-only agent imports
file_toolsandsearch_toolsbut notbash_tools. - Related tools share helper functions that stay private to the module. Path sanitization lives in
file_tools.pyand is used by all 3 file tools without leaking to the rest of the codebase. - Tests can exercise one module at a time. A failing test in
bash_toolsdoes not block changes tofile_tools.
The rule I use: if 2 tools share more than 5 lines of helper code, they belong in the same module. If they do not, split them.
What are the 4 anti-patterns to avoid?
-
Tool names that are verbs without objects.
run,get,do. Too vague; the model picks the wrong tool because 5 tools match the intent. Always name likerun_bash,get_file,do_migration. -
Tool descriptions that are one-word labels. "Reads a file." The model has no idea when to use it vs
read_source_filevsread_config. Descriptions should be 1 to 2 sentences and should mention when NOT to use the tool. -
Arguments that are dicts of dicts. Deeply nested input schemas are hard for the model to generate correctly. Flatten to top-level strings and ints wherever possible. A tool with 8 flat string args is easier for the model than a tool with 2 args where one is a nested object.
-
Tools that combine read and write. A single tool that both reads and modifies files is ambiguous to the planner. The model does not know whether calling it is safe. Split into
read_fileandwrite_fileand let the plan step decide.
For the bash tool specifically, the full safe-execution pattern is covered in Bash Tools for Coding Agents: Safe Shell Execution. For the full walkthrough of how this registry plays with a production agent, the Build Your Own Coding Agent course covers it module by module. The free AI Agents Fundamentals primer is the right starting point if the loop concept is still new.
How do you handle async tools and sync tools in one registry?
Mark each tool as async or sync in the registry and have the dispatcher handle both. A simple inspect.iscoroutinefunction(handler) check at dispatch time is enough.
# filename: async_dispatch.py
# description: Dispatcher that handles both sync and async tool handlers
# so the registry can mix them without forcing the agent loop to know.
import inspect
from registry import REGISTRY
async def dispatch_async(name: str, args: dict):
spec = REGISTRY.get(name)
if not spec:
return {'error': f'unknown tool: {name}'}
try:
if inspect.iscoroutinefunction(spec.handler):
return await spec.handler(**args)
return spec.handler(**args)
except Exception as exc:
return {'error': str(exc)}
This lets you mix a sync read_file with an async fetch_url and have both work the same way from the agent loop. The loop calls await dispatch_async(...) for every tool call and the dispatcher figures out which path to take.
What to do Monday morning
- Open your current tool dispatcher. Count the
elifbranches. If there are more than 5, you will benefit from the registry pattern. - Create
registry.pywith thetooldecorator from this post. Do not skip thedispatchandas_api_toolshelpers; they are 20 lines and they pay for themselves immediately. - Convert your 3 most-used tools to use the decorator. Delete the old dispatch branches. Run your agent to confirm nothing broke.
- Group the rest of your tools by domain.
file_tools.py,bash_tools.py,search_tools.pyis a good starting split for most coding agents. - Rename any tool whose name is a bare verb or a one-word label. The few minutes of renaming pay for themselves in fewer misrouted tool calls.
The headline: a tool registry is 60 lines of Python that collapse 3 maintenance points into 1. Adding tool 16 should be exactly as easy as adding tool 1. If it is not, you have the wrong pattern.
Frequently asked questions
What is the registry pattern for tool integrations?
A registry pattern stores tool specifications (name, description, input schema, handler) in a shared dict, populated by a decorator applied to each handler function. The agent loop reads the registry to get the list of tools to pass to the LLM and to dispatch tool calls to the right handler. It replaces the scattered elif dispatchers that most agent codebases grow into.
How many tools should a coding agent have?
Between 5 and 20 for most workloads. Below 5, you can get by without a registry. Above 20, the model starts confusing similar tools and you should consider splitting into sub-agents that each see a smaller tool set. The sweet spot for a single-agent codebase is around 10 to 12 well-named tools with clear, non-overlapping descriptions.
Should I use MCP instead of a custom registry?
If you are sharing tools across multiple agents or products, yes. Model Context Protocol standardizes the tool interface so a tool written once runs in Claude Desktop, a custom agent, and any other MCP-compatible client. If you are building a single in-house agent, a custom registry is simpler and equally effective. MCP adds value when portability matters.
How do I group related tools in a coding agent?
By domain, not by count. Put file operations in one module, shell execution in another, code search in a third. Each module is importable on its own, which lets you enable or disable whole categories of tools by adjusting imports. Shared helpers stay private to each module, which keeps the public tool surface clean.
What tool names work best with LLMs?
Verb-object names that are specific and non-overlapping: read_file, run_bash, grep_codebase, fetch_url. Avoid bare verbs like run or get because they match too many intents. Avoid one-word nouns like files because they do not say what the tool does. A 2-word name with a verb and an object hits the sweet spot for model accuracy.
Key takeaways
- Naive
if/elifdispatchers break at 15 tools because every new tool touches 3 places in the codebase and each touch is an opportunity to forget a step. - A tool registry collapses those 3 places into 1. A decorator registers the handler, the schema, and the dispatch target simultaneously.
- Group tools by domain into modules. Import modules in your agent entry point; the decorators populate the registry on import.
- Name tools as verb-object. Short, specific names beat bare verbs and one-word nouns for model accuracy.
- Split read and write tools. A single tool that both reads and modifies is ambiguous to the planner and leads to wrong routing.
- To see this registry wired into a full coding agent with the event loop, file edits, and bash execution, walk through the Build Your Own Coding Agent course, or start with the AI Agents Fundamentals primer.
For the Anthropic documentation on tool use schemas and dispatch best practices, see the Anthropic tool use guide. The schema rules in this post map directly onto the examples there.
Continue Reading
Ready to go deeper?
Go beyond articles. Build production AI systems with hands-on workshops and our intensive AI Bootcamp.