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:
Create Python file with your tool class
Add decorator
@register_tool('ToolName', config={...})Inherit from BaseTool and implement
run()methodDefine parameters in config (if needed)
Import the file to register the tool
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
importor run directly)Is the
@register_tooldecorator used correctly?Is ToolUniverse instantiated after tool import?
Parameter errors#
Do
"parameter"definitions in config matchrun()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__callsuper().__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!