The Problem
Your agent can "read files" and "run commands." Sounds powerful. Then it tries to read a 500MB log file and crashes. It runs rm -rf / because you forgot to restrict dangerous commands. It calls your API 10,000 times in a loop and racks up a $2000 bill.
The tools you give your agent are the only things standing between it and disaster.
Most developers treat tools as an afterthought - "just wrap the function and let the agent call it." But tool design determines whether your agent is useful or dangerous.
The Core Insight
Tools are contracts between agent intent and system safety. Design them defensively.
Think of tools like giving someone physical access to your computer. You wouldn't hand over root access without guardrails. Same with agents: every tool is a capability, and capabilities need constraints.
Good tool design makes the right thing easy and the dangerous thing impossible.
The Walkthrough
Tool Design Principles
1. Make Tools Atomic
One tool, one action. Don't create super-tools that do multiple things.
# Bad: Swiss army knife tool
def file_operations(action, path, content=None):
if action == "read":
return open(path).read()
elif action == "write":
open(path, 'w').write(content)
elif action == "delete":
os.remove(path)
# Agent must guess action parameter
# Good: Atomic tools
def read_file(path: str) -> str:
"""Read and return file contents."""
return safe_read(path)
def write_file(path: str, content: str) -> bool:
"""Write content to file. Returns success status."""
return safe_write(path, content)
# Agent picks the right tool naturally
2. Add Guard Rails
Every tool should validate inputs and limit scope:
def read_file(path: str, max_size_mb: int = 10) -> str:
"""
Read file contents.
Args:
path: File path (must be within project directory)
max_size_mb: Max file size to read (default 10MB)
Raises:
SecurityError: If path is outside allowed directory
FileTooLargeError: If file exceeds size limit
"""
# Security: Only allow project files
if not is_safe_path(path):
raise SecurityError(f"Access denied: {path}")
# Prevent reading huge files
size_mb = os.path.getsize(path) / 1024 / 1024
if size_mb > max_size_mb:
raise FileTooLargeError(
f"File too large ({size_mb:.1f}MB). Use read_file_chunk instead."
)
with open(path, 'r') as f:
return f.read()
3. Return Structured Data
Help agents parse results reliably:
# Bad: Unstructured string return
def run_command(cmd):
result = subprocess.run(cmd, capture_output=True)
return result.stdout + result.stderr # Agent must parse
# Good: Structured return
def run_command(cmd: str) -> CommandResult:
result = subprocess.run(cmd, capture_output=True, text=True)
return {
"success": result.returncode == 0,
"stdout": result.stdout,
"stderr": result.stderr,
"exit_code": result.returncode
}
# Agent can check result["success"] reliably
The Tool Safety Levels
Categorize tools by risk:
| Level | Risk | Examples | Safeguards |
|---|---|---|---|
| Read-Only | Low | read_file, list_directory, search_code | Size limits, path restrictions |
| Write | Medium | write_file, create_directory | Path whitelist, backup before write |
| Execute | High | run_command, call_api | Command whitelist, rate limits, sandboxing |
| Destructive | Critical | delete_file, drop_database | Confirmation required, audit log, undo capability |
The Confirmation Pattern for Destructive Actions
def delete_file(path: str, confirm: bool = False) -> dict:
"""Delete a file. Requires explicit confirmation."""
if not confirm:
return {
"action": "confirmation_required",
"message": f"About to delete {path}. Call with confirm=True to proceed.",
"tool": "delete_file",
"params": {"path": path, "confirm": True}
}
# Actually delete
os.remove(path)
return {"success": True, "deleted": path}
Forces agent to make two deliberate calls for dangerous actions.
Error Handling in Tools
Agents can't handle exceptions well. Return errors as data:
# Bad: Throws exception agent can't catch
def api_call(endpoint):
response = requests.get(endpoint)
response.raise_for_status() # Agent sees generic error
return response.json()
# Good: Returns error as structured data
def api_call(endpoint: str) -> dict:
"""Call API endpoint and return structured result."""
try:
response = requests.get(endpoint, timeout=10)
response.raise_for_status()
return {
"success": True,
"data": response.json(),
"status_code": response.status_code
}
except requests.Timeout:
return {
"success": False,
"error": "timeout",
"message": "API call timed out after 10 seconds",
"retry_suggested": True
}
except requests.HTTPError as e:
return {
"success": False,
"error": "http_error",
"status_code": e.response.status_code,
"message": str(e),
"retry_suggested": e.response.status_code >= 500
}
# Agent can check result["success"] and act accordingly
The Tool Documentation Pattern
Agents rely on tool descriptions. Make them clear:
def search_codebase(
pattern: str,
file_extension: str = None,
case_sensitive: bool = False
) -> list[dict]:
"""
Search for pattern across codebase files.
Use this when you need to find where code exists or how
something is implemented. Returns file paths and line numbers.
Args:
pattern: Search string or regex pattern
file_extension: Optional filter (e.g., "py", "js")
case_sensitive: Whether to match case (default: False)
Returns:
List of matches with:
- file: File path
- line: Line number
- content: The matching line
- context: 2 lines before/after
Examples:
search_codebase("def handle_payment")
search_codebase("TODO", file_extension="py")
"""
# Implementation...
Failure Patterns
1. The Unrestricted Tool
Symptom: Agent deletes production database because you gave it run_sql() with no limits.
Fix: Whitelist safe operations. Read-only by default. Destructive actions require confirmation.
2. The Black-Box Tool
Symptom: Tool returns "Error" and agent has no idea what went wrong.
Fix: Return structured errors with actionable messages: what failed, why, what to try next.
3. The Expensive Loop
Symptom: Agent calls api_fetch() 10,000 times in a loop, maxing out rate limits.
Fix: Add rate limiting and batch operations. Provide api_fetch_batch() for bulk operations.
4. The Tool Zoo
Symptom: 50 tools, agent has no idea which to use when.
Fix: 5-10 tools max. Each with clear, distinct purpose. Good docs. Examples.
The Turing Tarpit
Giving an agent a run_python() or exec_code() tool seems powerful. It's also dangerous and unpredictable. Better: give specific tools for specific tasks. Execution should be last resort, heavily sandboxed.
Example: File System Tool Suite
Minimal Safe Interface
class FileSystemTools:
"""Safe file operations for agents."""
def __init__(self, allowed_dir: str):
self.allowed_dir = Path(allowed_dir).resolve()
def read_file(self, path: str) -> dict:
"""Read file contents (max 10MB)."""
full_path = self._validate_path(path)
if full_path.stat().st_size > 10 * 1024 * 1024:
return {"error": "file_too_large", "max_size": "10MB"}
return {
"success": True,
"content": full_path.read_text(),
"size_bytes": full_path.stat().st_size
}
def write_file(self, path: str, content: str, create_backup: bool = True) -> dict:
"""Write file with automatic backup."""
full_path = self._validate_path(path)
if create_backup and full_path.exists():
backup_path = full_path.with_suffix(full_path.suffix + '.backup')
shutil.copy(full_path, backup_path)
full_path.write_text(content)
return {"success": True, "path": str(full_path)}
def list_files(self, pattern: str = "*") -> dict:
"""List files matching pattern."""
files = list(self.allowed_dir.rglob(pattern))
return {
"success": True,
"files": [str(f.relative_to(self.allowed_dir)) for f in files],
"count": len(files)
}
def _validate_path(self, path: str) -> Path:
"""Ensure path is within allowed directory."""
full_path = (self.allowed_dir / path).resolve()
if not str(full_path).startswith(str(self.allowed_dir)):
raise SecurityError(f"Path outside allowed directory: {path}")
return full_path
Quick Reference
Tool Design Checklist:
- ✅ One tool, one clear purpose
- ✅ Input validation and size limits
- ✅ Path/command whitelisting for security
- ✅ Structured return values (not strings)
- ✅ Errors as data (not exceptions)
- ✅ Clear docstrings with examples
- ✅ Confirmation for destructive actions
Safety Levels:
- Read: Size limits, path restrictions
- Write: Whitelist locations, auto-backup
- Execute: Command whitelist, sandboxing
- Destroy: Confirmation + audit log
Rule of Thumb:
If you wouldn't trust a junior developer with unrestricted access, don't give it to your agent. Tools are trust boundaries.