How AI Agent Tools Work: Adding Capabilities to Your AI Agent
I tried to add a new tool to my AI agent. I wrote the function, tested it in isolation, and it worked perfectly. But when I ran the agent, the model called my tool and… nothing happened.
User: Read the file config.json and tell me what's in it.Agent: I'll read the file for you. [Tool call: read_file]Result: Unknown tool: read_fileUnknown tool? I defined the function right there! It took me a while to realize that defining a function and registering it as a tool are two completely different things.
Here’s what I learned about how AI agent tools actually work.
The Three Missing Pieces
When the model calls a tool, three things must happen:
- The model needs to know the tool exists (a definition)
- Something needs to execute the tool (a handler)
- The agent loop needs to find the handler (a registration)
I was missing piece #3. Let me show you the complete pattern.
What is a Tool?
A tool is how an AI agent interacts with the world. Without tools, an agent can only talk. With tools, it can:
- Read and write files
- Execute shell commands
- Make HTTP requests
- Query databases
- Send messages
The learn-claude-code project demonstrates this progression beautifully:
| Session | Tools Added | Total |
|---|---|---|
| s01 | bash | 1 |
| s02 | read_file, write_file, edit_file | 4 |
| s03 | todo_write | 5 |
| s07 | task CRUD operations | 8 |
| s09 | spawn_teammate, send_message, read_inbox, broadcast | 12+ |
But how does each tool actually get connected to the agent?
Part 1: The Tool Definition
The tool definition tells the model what the tool does and what inputs it accepts. This is sent to the LLM API as part of the messages call.
TOOLS = [{ "name": "bash", "description": "Run a shell command. Executes the given command in a bash shell.", "input_schema": { "type": "object", "properties": { "command": { "type": "string", "description": "The command to execute" } }, "required": ["command"], },}]The input_schema is a JSON Schema. It defines what parameters the tool accepts. The model uses this to validate its tool calls before sending them.
Here’s a more complex example with multiple parameters:
{ "name": "read_file", "description": "Read a file from the filesystem.", "input_schema": { "type": "object", "properties": { "path": { "type": "string", "description": "The absolute path to the file to read" }, "limit": { "type": "integer", "description": "Number of lines to read (optional)" }, "offset": { "type": "integer", "description": "Line number to start reading from (optional)" } }, "required": ["path"], },}The schema lets you specify:
- Required vs optional parameters
- Parameter types (string, integer, boolean, array, object)
- Descriptions that help the model understand when to use each parameter
Part 2: The Handler Function
The handler is the actual Python function that executes when the tool is called. This is where the real work happens.
import subprocess
def run_bash(command: str) -> str: """Execute a bash command and return the output.""" try: result = subprocess.run( command, shell=True, capture_output=True, text=True, timeout=120 # 2 minute timeout ) if result.returncode != 0: return f"Error: {result.stderr}" return result.stdout except subprocess.TimeoutExpired: return "Error: Command timed out after 120 seconds" except Exception as e: return f"Error: {str(e)}"The handler is just a regular Python function. It takes parameters (matching the schema), does something, and returns a string result.
Here’s a handler for file reading:
def run_read(path: str, limit: int = None, offset: int = None) -> str: """Read a file and return its contents.""" try: with open(path, 'r') as f: lines = f.readlines()
# Apply offset and limit start = offset or 0 end = start + limit if limit else len(lines)
# Format with line numbers result_lines = [] for i, line in enumerate(lines[start:end], start=start + 1): result_lines.append(f"{i:6}\t{line}")
return ''.join(result_lines) except FileNotFoundError: return f"Error: File not found: {path}" except Exception as e: return f"Error: {str(e)}"Key points about handlers:
- Always return a string (the model receives this as text)
- Handle errors gracefully and return descriptive error messages
- Include timeouts for potentially slow operations
- Use try/except to catch unexpected errors
Part 3: The Dispatch Map (Registration)
This is the piece I was missing. The dispatch map connects tool names to handler functions.
TOOL_HANDLERS = { "bash": lambda **kw: run_bash(kw["command"]), "read_file": lambda **kw: run_read(kw["path"], kw.get("limit"), kw.get("offset")), "write_file": lambda **kw: run_write(kw["path"], kw["content"]), "edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),}The dispatch map is a dictionary where:
- Keys are tool names (matching the definition’s
namefield) - Values are functions that call the handler with the right arguments
The lambda pattern lambda **kw: ... lets us pass a dictionary of arguments and extract the ones we need.
How the Loop Uses Tools
Now let’s see how all three pieces come together in the agent loop:
# ... inside the main agent loop ...
for block in response.content: if block.type == "tool_use": # Look up the handler in the dispatch map handler = TOOL_HANDLERS.get(block.name)
if handler: # Execute the handler with the tool's input output = handler(**block.input) else: # Unknown tool - this was my error! output = f"Unknown tool: {block.name}"
# Build the tool result to send back to the model results.append({ "type": "tool_result", "tool_use_id": block.id, "content": str(output) })The flow looks like this:
+-------------+ +-------------+ +-------------+| Model | --> | Dispatch | --> | Handler || calls | | Map | | Function || "bash" | | lookup | | executes |+-------------+ +-------------+ +-------------+ | | v v +-------------+ +-------------+ | Tool | <-- | Return | | Result | | output | +-------------+ +-------------+Adding a New Tool
Let me add a new list_files tool to demonstrate the complete process.
Step 1: Define the tool
TOOLS = [ # ... existing tools ... { "name": "list_files", "description": "List files in a directory.", "input_schema": { "type": "object", "properties": { "path": { "type": "string", "description": "Directory path to list" }, "pattern": { "type": "string", "description": "Glob pattern to filter files (optional)" } }, "required": ["path"], }, }]Step 2: Write the handler
import osimport glob as glob_module
def run_list_files(path: str, pattern: str = "*") -> str: """List files in a directory matching a pattern.""" try: full_path = os.path.join(path, pattern) files = glob_module.glob(full_path)
if not files: return f"No files found matching {full_path}"
return "\n".join(sorted(files)) except Exception as e: return f"Error: {str(e)}"Step 3: Register in the dispatch map
TOOL_HANDLERS = { # ... existing handlers ... "list_files": lambda **kw: run_list_files(kw["path"], kw.get("pattern", "*")),}That’s it. The loop stays unchanged. New tools just add one definition and one entry to the map.
The Motto: Adding a Tool Means Adding One Handler
The learn-claude-code project has a motto that captures this perfectly:
Adding a tool means adding one handler.
The loop stays the same. The architecture stays the same. You just:
- Add a definition to TOOLS
- Add a handler function
- Add an entry to TOOL_HANDLERS
This is why the project can grow from 1 tool (s01) to 16+ tools (s12) without changing the core loop.
Error Handling in Tools
Good error handling makes tools robust. Here’s a pattern I found useful:
def safe_tool_handler(func): """Decorator to safely handle tool errors.""" def wrapper(**kwargs): try: result = func(**kwargs) return result except KeyError as e: return f"Error: Missing required parameter: {e}" except TypeError as e: return f"Error: Invalid parameter type: {e}" except ValueError as e: return f"Error: Invalid value: {e}" except PermissionError: return "Error: Permission denied" except FileNotFoundError: return "Error: File or directory not found" except Exception as e: return f"Error: {type(e).__name__}: {str(e)}" return wrapper
@safe_tool_handlerdef run_edit(path: str, old_text: str, new_text: str) -> str: """Edit a file by replacing old_text with new_text.""" with open(path, 'r') as f: content = f.read()
if old_text not in content: return f"Error: Text not found in file: {old_text[:50]}..."
new_content = content.replace(old_text, new_text, 1)
with open(path, 'w') as f: f.write(new_content)
return f"Successfully edited {path}"Scaling to Many Tools
As your agent grows, you might have 10, 20, or more tools. Organization becomes important.
# tools/file_tools.pyFILE_TOOLS = [ {"name": "read_file", ...}, {"name": "write_file", ...}, {"name": "edit_file", ...},]
FILE_HANDLERS = { "read_file": ..., "write_file": ..., "edit_file": ...,}
# tools/shell_tools.pySHELL_TOOLS = [ {"name": "bash", ...},]
SHELL_HANDLERS = { "bash": ...,}
# tools/message_tools.pyMESSAGE_TOOLS = [ {"name": "send_message", ...}, {"name": "read_inbox", ...},]
MESSAGE_HANDLERS = { "send_message": ..., "read_inbox": ...,}
# agent.py - combine all toolsTOOLS = FILE_TOOLS + SHELL_TOOLS + MESSAGE_TOOLSTOOL_HANDLERS = {**FILE_HANDLERS, **SHELL_HANDLERS, **MESSAGE_HANDLERS}This pattern keeps your code organized and makes it easy to add or remove tool categories.
Common Mistakes I Made
Mistake 1: Forgetting to register the handler
# I defined the function...def run_read(path: str) -> str: with open(path, 'r') as f: return f.read()
# I added the definition...TOOLS = [{"name": "read_file", ...}]
# But I forgot to register it!TOOL_HANDLERS = { "bash": lambda **kw: run_bash(kw["command"]), # Missing: "read_file": lambda **kw: run_read(kw["path"]),}Result: “Unknown tool: read_file”
Mistake 2: Mismatched names
# Definition says "read_file"TOOLS = [{"name": "read_file", ...}]
# But handler is registered as "read"TOOL_HANDLERS = {"read": lambda **kw: run_read(kw["path"])}The names must match exactly.
Mistake 3: Wrong parameter names
# Schema says "command"{"properties": {"command": {"type": "string"}}}
# Handler expects "cmd"def run_bash(cmd: str) -> str: ...The schema property names must match what you extract from block.input.
Why This Pattern Works
The dispatch map pattern is powerful because:
- The loop doesn’t change: New tools don’t require modifying the core agent loop
- Tools are isolated: Each tool is self-contained with its own handler
- Easy to test: You can test each handler independently
- Clear interface: The definition, handler, and registration are all explicit
This is the same pattern used by production AI agents like Claude Code. The learn-claude-code project teaches it from session s01 (the first tool) through s12 (16+ tools).
Summary
Adding a tool to an AI agent requires three parts:
- Definition: Tell the model what the tool does and what inputs it accepts
- Handler: Write the Python function that executes the tool
- Registration: Add the tool to the dispatch map so the loop can find it
The dispatch map pattern keeps your agent loop clean while scaling from 1 to 16+ tools. Each new tool is just one definition, one handler, and one line in the map.
The motto from learn-claude-code says it best: “Adding a tool means adding one handler.”
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments