Skip to content

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.

The confusing output
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_file

Unknown 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:

  1. The model needs to know the tool exists (a definition)
  2. Something needs to execute the tool (a handler)
  3. 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:

SessionTools AddedTotal
s01bash1
s02read_file, write_file, edit_file4
s03todo_write5
s07task CRUD operations8
s09spawn_teammate, send_message, read_inbox, broadcast12+

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.

Tool definition structure
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:

Tool definition 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.

Handler function for bash tool
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:

Handler function for read_file tool
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.

The dispatch map
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 name field)
  • 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:

Tool execution 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

Adding tool definition
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

Handler for list_files
import os
import 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

Registering the new tool
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:

  1. Add a definition to TOOLS
  2. Add a handler function
  3. 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:

Error handling pattern
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_handler
def 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.

Organizing tools by category
# tools/file_tools.py
FILE_TOOLS = [
{"name": "read_file", ...},
{"name": "write_file", ...},
{"name": "edit_file", ...},
]
FILE_HANDLERS = {
"read_file": ...,
"write_file": ...,
"edit_file": ...,
}
# tools/shell_tools.py
SHELL_TOOLS = [
{"name": "bash", ...},
]
SHELL_HANDLERS = {
"bash": ...,
}
# tools/message_tools.py
MESSAGE_TOOLS = [
{"name": "send_message", ...},
{"name": "read_inbox", ...},
]
MESSAGE_HANDLERS = {
"send_message": ...,
"read_inbox": ...,
}
# agent.py - combine all tools
TOOLS = FILE_TOOLS + SHELL_TOOLS + MESSAGE_TOOLS
TOOL_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

The mistake I made
# 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

Name mismatch error
# 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

Parameter name mismatch
# 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:

  1. The loop doesn’t change: New tools don’t require modifying the core agent loop
  2. Tools are isolated: Each tool is self-contained with its own handler
  3. Easy to test: You can test each handler independently
  4. 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:

  1. Definition: Tell the model what the tool does and what inputs it accepts
  2. Handler: Write the Python function that executes the tool
  3. 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