Local Tools Tutorial

Learn how to create local Python tools with ToolUniverse. This tutorial teaches you step by step, from the simplest example to advanced features.

Quick Checklist

How to add a local tool:

  1. Create Python file with your tool class

  2. Add decorator @register_tool('ToolName', config={...})

  3. Inherit from BaseTool and implement run() method

  4. Define parameters in config (if needed)

  5. Import the file to register the tool

  6. Use with ToolUniverse via run()

Note

💡 Self-Use: This tutorial covers using tools in your own projects.

🚀 Contributing: If you want to contribute tools to the ToolUniverse repository, see the contributing guide for additional steps.

Step 1: Your First Tool (Simplest Example)

Let’s start with the absolute minimum. This tool just says hello:

# hello_tool.py - Save this file anywhere
from tooluniverse.tool_registry import register_tool
from tooluniverse.base_tool import BaseTool

@register_tool('HelloTool', config={
    "name": "hello_tool",
    "description": "Say hello"
})
class HelloTool(BaseTool):
    def run(self, arguments=None, **kwargs):
        return {"message": "Hello!", "success": True}

# Usage
from tooluniverse import ToolUniverse
from hello_tool import HelloTool  # This registers the tool

tu = ToolUniverse()
tu.load_tools()

result = tu.run({
    "name": "hello_tool",
    "arguments": {}
})
print(result)  # {"message": "Hello!", "success": True}

What just happened? - @register_tool tells ToolUniverse about your tool - config defines the tool’s name and description - run() method does the actual work - ToolUniverse automatically discovers and loads your tool

Try it: Save as hello_tool.py, run it, and see “Hello!” printed.

Step 2: Adding Parameters

Now let’s add input parameters. This tool greets a specific person:

@register_tool('GreetTool', config={
    "name": "greet_tool",
    "description": "Greet a person by name",
    "parameter": {  # ← This is new!
        "type": "object",
        "properties": {
            "name": {"type": "string", "description": "Person's name"}
        },
        "required": ["name"]
    }
})
class GreetTool(BaseTool):
    def run(self, arguments=None, **kwargs):
        # Handle both direct calls and ToolUniverse calls
        if arguments is None:
            arguments = kwargs

        name = arguments.get('name') if isinstance(arguments, dict) else arguments
        return {"message": f"Hello, {name}!", "success": True}

# Usage
result = tu.run({
    "name": "greet_tool",
    "arguments": {"name": "Alice"}
})
print(result)  # {"message": "Hello, Alice!", "success": True}

Understanding the config: - "parameter": Defines what inputs your tool accepts - "type": "object": Tool accepts multiple parameters (not just one value) - "properties": Defines each parameter (name, type, description) - "required": Lists which parameters are mandatory

Parameter types you can use: - "string": Text input - "number": Numeric input - "boolean": True/false input - "array": List of values

Step 3: Adding Input Validation

Let’s add validation to catch bad inputs:

@register_tool('ValidatedGreetTool', config={
    "name": "validated_greet_tool",
    "description": "Greet a person with validation",
    "parameter": {
        "type": "object",
        "properties": {
            "name": {"type": "string", "description": "Person's name"}
        },
        "required": ["name"]
    }
})
class ValidatedGreetTool(BaseTool):
    def run(self, arguments=None, **kwargs):
        if arguments is None:
            arguments = kwargs

        name = arguments.get('name') if isinstance(arguments, dict) else arguments

        # Validate input
        self.validate_input(name=name)

        return {"message": f"Hello, {name}!", "success": True}

    def validate_input(self, **kwargs):
        """Validate input parameters."""
        name = kwargs.get('name')

        if not name:
            raise ValueError("Name is required")

        if not isinstance(name, str):
            raise ValueError("Name must be a string")

        if len(name.strip()) == 0:
            raise ValueError("Name cannot be empty")

Why validation matters: - Prevents crashes from bad input - Gives clear error messages to users - Makes your tool more reliable

Alternative: Using validate_parameters()

For more complex validation, you can override the validate_parameters() method instead:

def validate_parameters(self, arguments):
    """Validate input parameters using BaseTool's validation system."""
    # First, run base validation
    base_error = super().validate_parameters(arguments)
    if base_error:
        return base_error

    # Add your custom validation
    name = arguments.get('name', '')
    if len(name) < 2:
        from tooluniverse.exceptions import ToolValidationError
        return ToolValidationError(
            "Name must be at least 2 characters",
            details={"field": "name", "min_length": 2}
        )

    return None  # Validation passed

When to use which: - Use validate_input() for simple validation in your run() method - Use validate_parameters() for complex validation that integrates with ToolUniverse’s error system

Step 4: Complete Real Example

Now let’s build something useful - a protein molecular weight calculator:

@register_tool('ProteinCalculator', config={
    "name": "protein_calculator",
    "description": "Calculate molecular weight of protein sequences",
    "parameter": {
        "type": "object",
        "properties": {
            "sequence": {"type": "string", "description": "Protein sequence (single letter amino acid codes)"}
        },
        "required": ["sequence"]
    }
})
class ProteinCalculator(BaseTool):
    def __init__(self, tool_config=None):
        super().__init__(tool_config)
        # Amino acid molecular weights (in Daltons)
        self.aa_weights = {
            'A': 89.09, 'R': 174.20, 'N': 132.12, 'D': 133.10,
            'C': 121.16, 'Q': 146.15, 'E': 147.13, 'G': 75.07,
            'H': 155.16, 'I': 131.17, 'L': 131.17, 'K': 146.19,
            'M': 149.21, 'F': 165.19, 'P': 115.13, 'S': 105.09,
            'T': 119.12, 'W': 204.23, 'Y': 181.19, 'V': 117.15
        }

    def run(self, arguments=None, **kwargs):
        if arguments is None:
            arguments = kwargs

        sequence = arguments.get('sequence') if isinstance(arguments, dict) else arguments
        self.validate_input(sequence=sequence)

        # Clean sequence (remove whitespace, convert to uppercase)
        clean_sequence = sequence.strip().upper()

        # Calculate molecular weight
        total_weight = sum(self.aa_weights.get(aa, 0) for aa in clean_sequence)
        # Subtract water molecules for peptide bonds
        water_weight = (len(clean_sequence) - 1) * 18.015
        molecular_weight = total_weight - water_weight

        return {
            "molecular_weight": round(molecular_weight, 2),
            "sequence_length": len(clean_sequence),
            "sequence": clean_sequence,
            "success": True
        }

    def validate_input(self, **kwargs):
        sequence = kwargs.get('sequence')

        if not sequence:
            raise ValueError("Sequence is required")

        if not isinstance(sequence, str):
            raise ValueError("Sequence must be a string")

        if len(sequence.strip()) == 0:
            raise ValueError("Sequence cannot be empty")

        # Check for valid amino acid codes
        valid_aa = set(self.aa_weights.keys())
        invalid_chars = set(sequence.upper()) - valid_aa
        if invalid_chars:
            raise ValueError(f"Invalid amino acid codes: {', '.join(invalid_chars)}")

# Usage
result = tu.run({
    "name": "protein_calculator",
    "arguments": {"sequence": "GIVEQCCTSICSLYQLENYCN"}
})
print(result)  # {"molecular_weight": 2401.45, "sequence_length": 20, "success": True}

This example shows: - Complex business logic - Data initialization in __init__ - Input validation with custom rules - Meaningful return values - Error handling

Common Scenarios

I want to call an external API

import requests

@register_tool('APITool', config={
    "name": "api_tool",
    "description": "Make API call to specified URL",
    "parameter": {
        "type": "object",
        "properties": {
            "url": {"type": "string", "description": "API URL"},
            "method": {"type": "string", "description": "HTTP method", "default": "GET"}
        },
        "required": ["url"]
    }
})
class APITool(BaseTool):
    def run(self, arguments=None, **kwargs):
        if arguments is None:
            arguments = kwargs

        url = arguments.get('url') if isinstance(arguments, dict) else arguments
        method = arguments.get('method', 'GET') if isinstance(arguments, dict) else 'GET'

        self.validate_input(url=url, method=method)

        try:
            if method == "GET":
                response = requests.get(url)
            else:
                response = requests.post(url)

            response.raise_for_status()
            return {"data": response.json(), "success": True}
        except Exception as e:
            return {"error": str(e), "success": False}

I want to process files

@register_tool('FileProcessor', config={
    "name": "file_processor",
    "description": "Process file based on specified operation",
    "parameter": {
        "type": "object",
        "properties": {
            "file_path": {"type": "string", "description": "Path to file"},
            "operation": {"type": "string", "description": "Operation to perform", "default": "read"}
        },
        "required": ["file_path"]
    }
})
class FileProcessor(BaseTool):
    def run(self, arguments=None, **kwargs):
        if arguments is None:
            arguments = kwargs

        file_path = arguments.get('file_path') if isinstance(arguments, dict) else arguments
        operation = arguments.get('operation', 'read') if isinstance(arguments, dict) else 'read'

        self.validate_input(file_path=file_path, operation=operation)

        try:
            with open(file_path, 'r') as f:
                content = f.read()

            if operation == "analyze":
                result = {"lines": len(content.split('\n')), "chars": len(content)}
            else:
                result = {"content": content}

            return {"result": result, "success": True}
        except Exception as e:
            return {"error": str(e), "success": False}

I want to use API keys (environment variables)

Add to your config:

@register_tool('MyAPITool', config={
    "name": "my_api_tool",
    "description": "Tool that uses API keys",
    "parameter": {
        "type": "object",
        "properties": {
            "query": {"type": "string", "description": "Search query"}
        },
        "required": ["query"]
    },
    "settings": {  # ← Add this section
        "api_key": "env:MY_API_KEY",  # ← Reference environment variable
        "base_url": "https://api.example.com"
    }
})

Then in your run method:

def __init__(self, tool_config: Dict[str, Any] = None):
    super().__init__(tool_config)
    self.api_key = self.config.get("settings", {}).get("api_key")
    self.base_url = self.config.get("settings", {}).get("base_url")

I want better error handling

For more sophisticated error handling, override the handle_error() method:

from tooluniverse.exceptions import ToolValidationError, ToolAuthError

class APITool(BaseTool):
    def handle_error(self, exception):
        """Provide tool-specific error classification."""
        error_str = str(exception).lower()

        # API-specific error patterns
        if "api_key" in error_str or "unauthorized" in error_str:
            return ToolAuthError(
                "API authentication failed",
                next_steps=[
                    "Check API key configuration",
                    "Verify API key permissions",
                    "Contact API provider if issues persist"
                ]
            )

        if "rate limit" in error_str:
            return ToolValidationError(
                "API rate limit exceeded",
                next_steps=[
                    "Wait before retrying",
                    "Check your API usage limits",
                    "Consider upgrading your plan"
                ]
            )

        # Fall back to base error handling
        return super().handle_error(exception)

Benefits of custom error handling: - Users get specific, actionable error messages - Different error types can be handled differently - Better debugging and troubleshooting experience

Troubleshooting

Naming Conflicts (Failed to initialize tool for validation)

Problem: Getting “Failed to initialize tool for validation” error

Cause: Wrapper function name matches the class name

Solution: Use different names .. code-block:: python

# Wrapper function must use snake_case def my_tool(…): # ✅ Good def MyTool(…): # ❌ Bad if class is also called MyTool

Convention: Class names use PascalCase, wrapper functions use snake_case.

Tool not found

  • Is the tool file imported? (need to import or run directly)

  • Is the @register_tool decorator used correctly?

  • Is ToolUniverse instantiated after tool import?

Parameter errors

  • Do "parameter" definitions in config match run() method parameters?

  • Are required parameters listed in "required" array?

  • Are parameter types (string/number/object) correct?

Execution failures

  • Does the class inherit from BaseTool?

  • Does __init__ call super().__init__(tool_config)?

  • Does run() return a dict with "success" field?

  • Is validate_input() implemented for parameter validation?

Next Steps

Now that you can create local tools:

  • 🔗 Remote Tools: Learn about remote tool integration

  • 🚀 Contributing: Submit your tools to ToolUniverse

  • 🤖 AI Integration: Connect your tools with AI assistants

  • 🔬 Scientific Workflows: Build research pipelines

Tip

Development tip: Start simple, test thoroughly, and gradually add complexity. The ToolUniverse community is here to help if you get stuck!