Creating Custom Tooling
Best practices for building robust, reusable tools for your agents
Tools are the hands of your agent—they allow it to interact with the outside world, from searching the web to querying databases. While Peargent comes with built-in tools, the real power lies in creating custom tools tailored to your specific needs.
The Anatomy of a Tool
Every tool in Peargent has four essential components:
from peargent import create_tool
def search_database(query: str) -> str:
# Your implementation here
return "Results found"
tool = create_tool(
name="search_database", # - Tool identifier
description="""Searches the product database for matching items.
Use this tool when users ask about products, inventory, or availability.""", # - What LLM sees
input_parameters={"query": str}, # - Expected arguments
call_function=search_database # - Function to execute
)The Four Components:
- name: Unique identifier the LLM uses to call the tool
- description: Tells the LLM what the tool does and when to use it
- input_parameters: Dict mapping parameter names to their types
- call_function: The Python function that implements the tool logic
The Golden Rules of Tool Building
1. Descriptive Descriptions are Mandatory
The LLM uses the tool's description parameter to understand what the tool does and when to use it. Be verbose and precise.
Bad: Vague Description
tool = create_tool(
name="fetch_user",
description="Gets user info.", # ❌ Too vague!
input_parameters={"user_id": str},
call_function=fetch_user
)Good: Clear and Specific
tool = create_tool(
name="fetch_user_data",
description="""Retrieves detailed profile information for a specific user from the database.
Use this tool when you need to look up:
- User's email address
- Phone number
- Account status (active/suspended)
- Registration date
Do NOT use this for:
- Searching users by name (use search_users instead)
- Updating user data (use update_user instead)
Returns: Dict with keys: user_id, email, phone, status, registered_at""", # ✅ Clear when to use it!
input_parameters={"user_id": str},
call_function=fetch_user_data
)Why This Matters: The LLM decides which tool to call based solely on the description. A clear description = correct tool selection.
2. Type Hinting is Critical
Peargent uses type hints to generate the JSON schema for the LLM. Always type your arguments and return values.
Bad: No Type Hints
def calculate_tax(amount, region): # ❌ LLM can't infer types!
return amount * 0.1Good: Full Type Hints
def calculate_tax(amount: float, region: str) -> float: # ✅ Clear types!
"""Calculates tax based on amount and region."""
tax_rates = {"CA": 0.0725, "NY": 0.08875, "TX": 0.0625}
return amount * tax_rates.get(region, 0.05)Supported Types:
- Primitives:
str,int,float,bool - Collections:
list,dict,list[str],dict[str, int] - Pydantic Models: For complex structured inputs (see below)
3. Handle Errors Gracefully
Tools should not crash the agent. Use the on_error parameter to control failure behavior.
from peargent import create_tool
def search_database(query: str) -> str:
"""Searches the database for results."""
try:
results = db.execute(query)
return str(results)
except Exception as e:
return f"Error executing query: {str(e)}. Please check your syntax."
# Three error handling strategies:
# Strategy 1: RAISE (Default) - Strict mode
critical_tool = create_tool(
name="critical_operation",
description="Must succeed - handles payment",
input_parameters={"amount": float},
call_function=process_payment,
on_error="raise" # - Crash if fails (default)
)
# Strategy 2: RETURN_ERROR - Graceful mode
optional_tool = create_tool(
name="get_recommendations",
description="Optional product recommendations",
input_parameters={"user_id": str},
call_function=get_recommendations,
on_error="return_error" # - Return error message as string
)
# Strategy 3: RETURN_NONE - Silent mode
analytics_tool = create_tool(
name="log_event",
description="Logs analytics events",
input_parameters={"event": str},
call_function=log_analytics,
on_error="return_none" # - Return None silently
)When to Use Each Strategy:
| Strategy | Use Case | Example |
|---|---|---|
"raise" | Critical operations that must succeed | Authentication, payments, database writes |
"return_error" | Optional features that shouldn't break flow | Recommendations, third-party APIs, cache lookups |
"return_none" | Nice-to-have features | Analytics, logging, notifications |
4. Keep It Simple (Idempotency)
Ideally, tools should be idempotent—calling them multiple times with the same arguments should produce the same result. Avoid tools that rely heavily on hidden state.
Bad: Stateful Tool
counter = 0 # ❌ Hidden state!
def increment() -> int:
"""Increments counter."""
global counter
counter += 1
return counterGood: Stateless Tool
def get_user_count() -> int:
"""Gets current user count from database."""
return db.query("SELECT COUNT(*) FROM users").scalar() # ✅ Same input = same outputAdvanced Features
Complex Input with Pydantic
For tools with many parameters or nested data, use Pydantic models for input.
from pydantic import BaseModel, Field
from peargent import create_tool
class TicketInput(BaseModel):
title: str = Field(..., description="Brief summary of the issue")
priority: str = Field(..., description="Ticket priority")
description: str = Field(..., description="Detailed description")
category: str = Field(default="general", description="Ticket category")
# Validation
def __init__(self, **data):
# Validate priority
if data.get("priority") not in ["LOW", "MEDIUM", "HIGH"]:
raise ValueError("Priority must be LOW, MEDIUM, or HIGH")
super().__init__(**data)
def create_support_ticket(data: TicketInput) -> str:
"""
Creates a new support ticket in the system.
Required fields:
- title: Brief summary (e.g., "Cannot login")
- priority: LOW, MEDIUM, or HIGH
- description: Detailed explanation
Optional fields:
- category: Ticket category (default: "general")
"""
# Access validated fields
ticket_id = db.create_ticket(
title=data.title,
priority=data.priority,
description=data.description,
category=data.category
)
return f"Ticket #{ticket_id} created with priority {data.priority}"
ticket_tool = create_tool(
name="create_ticket",
description="""Creates a new support ticket in the system.
Required fields:
- title: Brief summary (e.g., "Cannot login")
- priority: LOW, MEDIUM, or HIGH
- description: Detailed explanation
Optional fields:
- category: Ticket category (default: "general")""",
input_parameters={"data": TicketInput}, # - Use Pydantic model
call_function=create_support_ticket
)Benefits:
- Clear parameter structure
- Built-in validation
- Default values
- Nested objects supported
Real-World Examples
Example 1: Database Query Tool
from peargent import create_tool
import sqlite3
def query_products(
category: str,
min_price: float = 0.0,
max_price: float = 999999.99
) -> str:
"""
Searches the product database by category and price range.
Use this tool when users ask about:
- Products in a specific category
- Products within a price range
- Available inventory
Examples:
- "Show me electronics under $500" → category="electronics", max_price=500
- "What furniture do you have?" → category="furniture"
Returns: Formatted list of matching products with prices
"""
try:
conn = sqlite3.connect("products.db")
cursor = conn.cursor()
query = """
SELECT name, price, stock
FROM products
WHERE category = ? AND price BETWEEN ? AND ?
ORDER BY price
"""
results = cursor.fetchall()
conn.close()
if not results:
return f"No products found in '{category}' between ${min_price} and ${max_price}"
# Format results
output = f"Found {len(results)} products in '{category}':\n\n"
for name, price, stock in results:
output += f"- {name}: ${price:.2f} ({stock} in stock)\n"
return output
except Exception as e:
return f"Database error: {str(e)}"
product_tool = create_tool(
name="query_products",
description="""Searches the product database by category and price range.
Use this tool when users ask about:
- Products in a specific category
- Products within a price range
- Available inventory
Examples:
- "Show me electronics under $500" → category="electronics", max_price=500
- "What furniture do you have?" → category="furniture"
Returns: Formatted list of matching products with prices""",
input_parameters={
"category": str,
"min_price": float,
"max_price": float
},
call_function=query_products,
on_error="return_error" # Don't crash on DB errors
)Example 2: External API
from peargent import create_tool
import requests
def fetch_weather(city: str) -> str:
"""
Fetches current weather data from OpenWeatherMap API.
Use this tool when users ask about:
- Current weather conditions
- Temperature
- Weather forecasts
Returns: Human-readable weather description
"""
api_key = os.getenv("OPENWEATHER_API_KEY")
url = f"https://api.openweathermap.org/data/2.5/weather?q={city}&appid={api_key}"
response = requests.get(url, timeout=5)
if response.status_code == 404:
return f"City '{city}' not found. Please check spelling."
if response.status_code != 200:
raise Exception(f"API returned status {response.status_code}")
data = response.json()
temp_f = (data["main"]["temp"] - 273.15) * 9/5 + 32
condition = data["weather"][0]["description"]
return f"Weather in {city}: {condition.capitalize()}, {temp_f:.1f}°F"
weather_tool = create_tool(
name="get_weather",
description="""Fetches current weather data from OpenWeatherMap API.
Use this tool when users ask about:
- Current weather conditions
- Temperature
- Weather forecasts
Returns: Human-readable weather description""",
input_parameters={"city": str},
call_function=fetch_weather,
on_error="return_error" # Return error message, don't crash
)Example 3: File Processing
from peargent import create_tool
import os
def analyze_file(filepath: str) -> dict:
"""
Analyzes a text file and returns metadata and preview.
Use this tool when users want to:
- Check file size
- See file contents preview
- Count lines in a file
Returns: Dict with file metadata
"""
if not os.path.exists(filepath):
raise FileNotFoundError(f"File '{filepath}' does not exist")
size = os.path.getsize(filepath)
with open(filepath, 'r', encoding='utf-8') as f:
lines = f.readlines()
preview = ''.join(lines[:5])
return {
"filename": os.path.basename(filepath),
"size_bytes": size,
"line_count": len(lines),
"preview": preview
}
file_tool = create_tool(
name="analyze_file",
description="""Analyzes a text file and returns metadata and preview.
Use this tool when users want to:
- Check file size
- See file contents preview
- Count lines in a file
Returns: Dict with file metadata""",
input_parameters={"filepath": str},
call_function=analyze_file,
on_error="return_error"
)
# Usage
result = file_tool.run({"filepath": "/path/to/file.txt"})
print(f"File: {result['filename']}")
print(f"Size: {result['size_bytes']} bytes")
print(f"Lines: {result['line_count']}")Tool Building Checklist
Use this checklist when creating production tools:
Design Phase
- Tool has a single, clear responsibility
- Input parameters are minimal and well-typed
- Docstring explains when to use the tool, not just what it does
- Examples included in docstring for clarity
Implementation Phase
- All parameters have type hints
- Function handles errors gracefully (try-except)
- Tool is idempotent (same input = same output)
- No hidden global state
Configuration Phase
-
on_errorstrategy chosen based on criticality
Testing Phase
- Tool works with valid inputs
- Tool handles invalid inputs gracefully
Common Patterns
Pattern 1: Multi-Step Tool
from pydantic import BaseModel, Field
from peargent import create_tool
class OrderResult(BaseModel):
order_id: str
status: str
total: float
items: list[str]
def process_order(customer_id: str, items: list[str]) -> OrderResult:
"""
Processes a customer order through multiple steps.
Steps:
1. Validate customer exists
2. Check inventory for all items
3. Calculate total with taxes
4. Create order record
5. Update inventory
Returns: OrderResult with order details
"""
# Step 1: Validate customer
customer = db.get_customer(customer_id)
if not customer:
raise ValueError(f"Customer {customer_id} not found")
# Step 2: Check inventory
for item in items:
if not inventory.check_availability(item):
raise ValueError(f"Item {item} out of stock")
# Step 3: Calculate total
total = sum(catalog.get_price(item) for item in items)
tax = total * 0.0725
final_total = total + tax
# Step 4: Create order
order_id = db.create_order(customer_id, items, final_total)
# Step 5: Update inventory
for item in items:
inventory.decrement(item)
return OrderResult(
order_id=order_id,
status="completed",
total=final_total,
items=items
)
order_tool = create_tool(
name="process_order",
description="""Processes a customer order through multiple steps.
Steps:
1. Validate customer exists
2. Check inventory for all items
3. Calculate total with taxes
4. Create order record
5. Update inventory
Returns: OrderResult with order details""",
input_parameters={
"customer_id": str,
"items": list
},
call_function=process_order,
on_error="raise" # Critical operation - must succeed
)Pattern 2: Tool Composition
Break complex operations into smaller tools:
# Small, focused tools
search_tool = create_tool(
name="search_products",
description="Searches products by keyword",
input_parameters={"query": str},
call_function=search_products
)
filter_tool = create_tool(
name="filter_by_price",
description="Filters products by price range",
input_parameters={"products": list, "min_price": float, "max_price": float},
call_function=filter_by_price
)
sort_tool = create_tool(
name="sort_products",
description="Sorts products by field",
input_parameters={"products": list, "sort_by": str},
call_function=sort_products
)
# Agent uses tools in sequence
agent = create_agent(
name="ProductAgent",
persona="You help users find products. Use multiple tools in sequence.",
tools=[search_tool, filter_tool, sort_tool]
)
# User: "Show me laptops under $1000, sorted by price"
# Agent will:
# 1. Call search_tool(query="laptops")
# 2. Call filter_tool(products=results, max_price=1000)
# 3. Call sort_tool(products=filtered, sort_by="price")When to Create Custom Tools
✅ Good Use Cases:
- Domain-Specific Logic: Calculating pricing based on your company's unique rules
- Internal APIs: Fetching data from your private microservices
- Database Operations: Querying your application's database
- Complex Workflows: Triggering multi-step processes (e.g., "onboard_new_employee")
- External Integrations: Calling third-party APIs (Stripe, Twilio, etc.)
- File Operations: Reading, writing, or processing files
- Business Logic: Tax calculations, shipping estimates, etc.
❌ Poor Use Cases:
- Generic Operations: Use existing tools (web search, calculations)
- One-Off Tasks: Write regular Python code instead
- State Management: Don't use tools to track conversation state (use history instead)
- Pure Computation: Simple math doesn't need a tool (LLM can do it)
Summary
Building Great Tools:
- Clear Docstrings - Explain when to use the tool, not just what it does
- Type Everything - Full type hints for parameters and returns
- Handle Errors - Choose appropriate
on_errorstrategy - Keep It Simple - One responsibility per tool, idempotent when possible
Essential Parameters:
name,description,input_parameters,call_function- Always requiredon_error-"raise","return_error", or"return_none"
Start with simple tools and add advanced features as needed!