peargent.

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:

  1. name: Unique identifier the LLM uses to call the tool
  2. description: Tells the LLM what the tool does and when to use it
  3. input_parameters: Dict mapping parameter names to their types
  4. 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.1

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

StrategyUse CaseExample
"raise"Critical operations that must succeedAuthentication, payments, database writes
"return_error"Optional features that shouldn't break flowRecommendations, third-party APIs, cache lookups
"return_none"Nice-to-have featuresAnalytics, 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 counter

Good: Stateless Tool

def get_user_count() -> int:
    """Gets current user count from database."""
    return db.query("SELECT COUNT(*) FROM users").scalar()  # ✅ Same input = same output

Advanced 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_error strategy 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:

  1. Clear Docstrings - Explain when to use the tool, not just what it does
  2. Type Everything - Full type hints for parameters and returns
  3. Handle Errors - Choose appropriate on_error strategy
  4. Keep It Simple - One responsibility per tool, idempotent when possible

Essential Parameters:

  • name, description, input_parameters, call_function - Always required
  • on_error - "raise", "return_error", or "return_none"

Start with simple tools and add advanced features as needed!