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!