Skip to content

Tool Interfaces: Designing Effective Tool APIs

Overview

A good tool interface is like a well-designed API—clear, predictable, and impossible to misuse.

LLMs need tools with explicit contracts they can understand and follow reliably.


The Problem

Without Good Design

# ❌ BAD: Vague, unpredictable
def tool_1(x):
    """Do something with x"""
    return result

# LLM doesn't know:
# - What type of x?
# - What does "something" mean?
# - What type is result?
# - When does it fail?
# - What are side effects?

With Good Design

# ✅ GOOD: Clear contract
@agent_tool
def search_database(
    query: str,  # What we're searching for (1-500 chars)
    table: str,  # Which table ("users", "products", "orders")
    limit: int = 10  # Max results (1-100, default 10)
) -> dict:
    """
    Search database table for matching records.

    Returns:
    {
        "success": bool,
        "results": List[dict],  # Matching records
        "count": int,  # Number of results
        "query_time_ms": float
    }
    """

5 Principles of Good Tool Design

1. Explicit Contracts

# Specify everything
@tool(
    name="calculate",
    description="Perform arithmetic on two numbers",
    required_params=["a", "b", "operation"],
    optional_params=["precision"]
)
def calculate(
    a: float,  # First number
    b: float,  # Second number
    operation: Literal["add", "subtract", "multiply", "divide"],  # Operation type
    precision: int = 2  # Decimal places (0-10)
) -> dict:
    """Calculate result with specified precision"""
    ...

2. Clear Error Handling

def tool_with_errors():
    """
    Raises:
    - ValueError: If arguments are invalid
    - TimeoutError: If operation takes > 30 seconds
    - PermissionError: If user lacks access

    Returns: {...} on success
    """
    ...

3. Meaningful Defaults

# ✅ GOOD: Defaults make sense
def search(
    query: str,
    max_results: int = 10,  # Reasonable default
    timeout_sec: int = 30
):
    ...

# ❌ BAD: Confusing defaults
def search(query: str, max_results: int = 999999):
    ...

4. Consistent Responses

# ✅ GOOD: Predictable structure
{
    "success": bool,
    "data": Any,  # Actual result
    "error": Optional[str],  # Error message if failed
    "metadata": {
        "execution_time_ms": float,
        "timestamp": str
    }
}

# ❌ BAD: Variable structure
# Sometimes returns string, sometimes dict, sometimes list

5. Comprehensive Examples

@tool
def send_email(
    to: str,  # Email address
    subject: str,  # Email subject
    body: str,  # Email body (markdown)
    cc: Optional[List[str]] = None  # CC recipients
) -> dict:
    """
    Send an email message.

    Examples:

    Example 1: Simple email
    send_email(
        to="user@example.com",
        subject="Hello",
        body="Hi there!"
    )

    Example 2: With CC
    send_email(
        to="manager@example.com",
        subject="Report",
        body="Q3 Results:\n- Revenue: $50K\n- Growth: 15%",
        cc=["cto@example.com"]
    )
    """

Tool Interface Patterns

Pattern 1: Dictionary-Based Tools

tools_registry = {
    "calculate": {
        "description": "Perform arithmetic",
        "function": calculate_func,
        "params": {
            "a": {"type": "float", "required": True},
            "b": {"type": "float", "required": True},
            "op": {"type": "string", "enum": ["add", "subtract", "multiply"]}
        },
        "returns": {"type": "dict", "properties": {...}}
    },
    "search": {
        "description": "Search documents",
        "function": search_func,
        "params": {...}
    }
}

Pattern 2: Class-Based Tools

class Tool(ABC):
    @property
    def name(self) -> str:
        """Tool name"""

    @property
    def description(self) -> str:
        """What it does"""

    @property
    def parameters(self) -> dict:
        """Parameter schema"""

    @abstractmethod
    def call(self, **kwargs) -> dict:
        """Execute the tool"""

class CalculatorTool(Tool):
    @property
    def name(self):
        return "calculator"

    @property
    def description(self):
        return "Perform arithmetic operations"

    @property
    def parameters(self):
        return {
            "a": {"type": "number"},
            "b": {"type": "number"},
            "operation": {"type": "string", "enum": ["add", "subtract"]}
        }

    def call(self, a: float, b: float, operation: str) -> dict:
        if operation == "add":
            result = a + b
        elif operation == "subtract":
            result = a - b
        else:
            return {"error": f"Unknown operation: {operation}"}

        return {"success": True, "result": result}

Pattern 3: Decorator-Based Tools

@register_tool(
    description="Send email notifications",
    examples=[
        {"input": {"to": "user@example.com", "message": "Hello"},
         "output": {"success": True}}
    ]
)
def send_notification(
    to: str,  # Recipient email
    message: str,  # Message to send (max 1000 chars)
    priority: str = "normal"  # "low", "normal", "high"
) -> dict:
    """Send email notification to user"""
    ...

Tool Metadata Specification

TOOL_SPEC = {
    # Identification
    "name": "tool_name",  # Unique identifier
    "version": "1.0.0",  # Semantic versioning

    # Description
    "description": "What this tool does",  # Brief (1 sentence)
    "long_description": "...",  # Detailed (2-3 sentences)
    "category": "communication",  # categorization

    # Parameters
    "parameters": {
        "type": "object",
        "properties": {
            "param_name": {
                "type": "string",  # string, number, boolean, array, object
                "description": "What this parameter means",
                "examples": ["example1", "example2"],
                "constraints": {
                    "minLength": 1,
                    "maxLength": 500,
                    "pattern": "^[a-z]+$"
                }
            }
        },
        "required": ["required_param"],
        "optional": ["optional_param"]
    },

    # Return value
    "returns": {
        "type": "object",
        "description": "What the tool returns on success",
        "properties": {
            "success": {"type": "boolean"},
            "data": {"type": "object"},
            "error": {"type": "string"}
        }
    },

    # Error handling
    "errors": [
        {"code": "INVALID_INPUT", "description": "Input validation failed"},
        {"code": "TIMEOUT", "description": "Operation took too long"},
        {"code": "PERMISSION_DENIED", "description": "User lacks permission"}
    ],

    # Usage
    "examples": [
        {
            "description": "Simple example",
            "input": {"param1": "value1"},
            "output": {"success": True, "data": {...}}
        }
    ],

    # Constraints
    "constraints": {
        "rate_limit": "100 calls/minute",
        "timeout_sec": 30,
        "requires_auth": True,
        "costs_tokens": True
    },

    # Integration
    "dependencies": ["database", "auth"],
    "side_effects": ["Sends email", "Updates database"],
    "idempotent": False
}

Tool Abstraction Layer

class ToolInterface:
    """Unified interface for all tools"""

    def validate_inputs(self, inputs: dict) -> tuple[bool, str]:
        """Validate inputs match schema"""
        try:
            jsonschema.validate(inputs, self.schema["parameters"])
            return True, ""
        except jsonschema.ValidationError as e:
            return False, f"Invalid input: {e.message}"

    def call(self, **inputs) -> dict:
        """Execute tool with validation"""
        valid, error = self.validate_inputs(inputs)
        if not valid:
            return {"success": False, "error": error}

        try:
            result = self._execute(**inputs)
            return {"success": True, "data": result}
        except Exception as e:
            return {"success": False, "error": str(e)}

    def to_openai_schema(self) -> dict:
        """Convert to OpenAI function calling format"""
        return {
            "type": "function",
            "function": {
                "name": self.name,
                "description": self.description,
                "parameters": self.schema["parameters"]
            }
        }

    def to_claude_schema(self) -> dict:
        """Convert to Claude tool_use format"""
        return {
            "name": self.name,
            "description": self.description,
            "input_schema": self.schema["parameters"]
        }

Tool Registry

class ToolRegistry:
    """Manage collection of tools"""

    def __init__(self):
        self.tools = {}
        self.categories = {}

    def register(self, tool: Tool) -> None:
        """Register a new tool"""
        if tool.name in self.tools:
            raise ValueError(f"Tool {tool.name} already registered")

        self.tools[tool.name] = tool

        # Index by category
        category = tool.metadata.get("category")
        if category not in self.categories:
            self.categories[category] = []
        self.categories[category].append(tool.name)

    def get_tool(self, name: str) -> Tool:
        """Get tool by name"""
        if name not in self.tools:
            raise ValueError(f"Tool {name} not found")
        return self.tools[name]

    def get_tools_by_category(self, category: str) -> List[Tool]:
        """Get all tools in category"""
        names = self.categories.get(category, [])
        return [self.tools[name] for name in names]

    def list_tools(self) -> dict:
        """List all available tools with descriptions"""
        return {
            name: tool.description 
            for name, tool in self.tools.items()
        }

3 Tool Interface Warnings ⚠️

Warning 1: Ambiguous Descriptions

# ❌ WRONG: Confuses LLM
@tool(description="Process data")
def process_data(x):
    ...

# LLM doesn't know:
# - What type of data?
# - How is it processed?
# - What's returned?

# ✅ RIGHT: Clear and specific
@tool(
    description="Clean and validate user input data (names, emails, etc) "
                "by removing whitespace, checking format, and returning "
                "standardized version"
)
def validate_user_data(
    data: str,  # User input (name or email)
    data_type: Literal["name", "email"]  # What kind of data
) -> dict:
    """Returns {'valid': bool, 'cleaned': str, 'error': str}"""
    ...

Key Lesson: Be specific. LLMs work better with detailed descriptions.

Warning 2: Missing Examples

# ❌ WRONG: No examples
@tool(description="Send message")
def send_message(to, message):
    ...

# LLM doesn't know:
# - Email or phone number?
# - How long can message be?
# - What format for recipient?

# ✅ RIGHT: With examples
@tool(
    description="Send message via email or SMS",
    examples=[
        {
            "description": "Send email",
            "input": {"to": "user@example.com", "message": "Hi!"},
            "output": {"success": True, "message_id": "msg_123"}
        },
        {
            "description": "Send SMS",
            "input": {"to": "+1234567890", "message": "Hello"},
            "output": {"success": True, "message_id": "msg_124"}
        }
    ]
)
def send_message(
    to: str,  # Email address or phone number
    message: str  # Message content (max 500 chars)
) -> dict:
    ...

Key Lesson: Examples prevent misuse and improve accuracy.

Warning 3: Leaky Abstractions

# ❌ WRONG: Exposes internal details
@tool
def query_database(
    sql: str,  # Raw SQL query (DANGER!)
    connection_pool_size: int = 10
) -> List[dict]:
    ...

# Problems:
# - LLM can write malicious SQL
# - Expose internal connection details
# - Breaks if schema changes

# ✅ RIGHT: Abstract the interface
@tool
def search_users(
    search_term: str,  # Name or email to search
    limit: int = 10  # Max results
) -> dict:
    """
    Search for users by name or email.

    Returns: {'users': [{'id': str, 'name': str, 'email': str}]}
    """
    # Internal: construct safe SQL
    # SELECT * FROM users WHERE name LIKE ? OR email LIKE ? LIMIT ?
    ...

Key Lesson: Hide implementation details. Only expose what's necessary.


Best Practices

1. Version Tools

@tool(version="1.2.0")
def my_tool():
    """Track breaking changes"""

# Deprecation path:
# v1.0: Original implementation
# v1.1: Bug fix (backward compatible)
# v1.2: Performance improvement (backward compatible)
# v2.0: Breaking API change

2. Document Rate Limits

@tool(
    constraints={
        "rate_limit": "100 calls per minute",
        "quota_per_day": "10,000 calls"
    }
)
def expensive_tool():
    """Tool with usage constraints"""
    ...

3. Enable Tool Discovery

def list_available_tools() -> dict:
    """Return tools agent can use"""

    return {
        tool.name: {
            "description": tool.description,
            "parameters": tool.parameters,
            "category": tool.category,
            "rate_limit": tool.rate_limit
        }
        for tool in registry.list_all()
    }

Key Takeaways

  1. Good interfaces are explicit - LLMs need clear contracts
  2. Examples matter - Show, don't just tell
  3. Hide complexity - Expose only what's necessary
  4. Validate inputs - Catch errors early
  5. Consistent responses - Predictable format
  6. Document thoroughly - Every parameter, return value, error
  7. Version your tools - Track changes over time

Next Steps


Last Updated: August 9, 2026