Adding Tools to ToolUniverse - Complete Tutorial¶
This tutorial covers everything you need to know about adding custom tools to ToolUniverse using the decorator-based auto-registration system.
Table of Contents¶
:ref:
overview
:ref:
development-environment-setup
:ref:
quick-start
:ref:
method-1-decorator-registration-recommended
:ref:
method-2-manual-registration
:ref:
tool-configuration
:ref:
parameter-validation-and-error-handling
:ref:
real-world-examples
:ref:
best-practices
:ref:
troubleshooting
.. _overview:
Overview¶
ToolUniverse supports automatic tool discovery through decorators. When you add a new tool, it’s automatically registered and available without needing to manually edit core files.
What You Can Add¶
Custom API wrappers
Data processing tools
File manipulation utilities
External service integrations
Analysis and computation tools
What Changed¶
❌ Before: Manual imports and mappings in
execute_function.py
✅ Now: Simple decorator registration with auto-discovery
.. _development-environment-setup:
Development Environment Setup¶
Before adding tools, ensure you have the proper development environment set up:
1. Clone and Install ToolUniverse¶
# Clone the repository
git clone https://github.com/mims-harvard/ToolUniverse.git
cd ToolUniverse
# Install in development mode
pip install -e ".[dev]"
2. Set Up Pre-commit Hooks (Recommended)¶
Automatic Setup:
# Auto-install pre-commit hooks
./setup_precommit.sh
Manual Setup:
# Install pre-commit if not already installed
pip install pre-commit
# Install hooks
pre-commit install
# Update to latest versions
pre-commit autoupdate
Pre-commit Benefits:
✅ Automatic code formatting with Black
✅ Code linting with flake8 and ruff
✅ Import cleanup with autoflake
✅ File validation (YAML, TOML, AST checks)
✅ Runs on every commit to ensure code quality
3. Verify Installation¶
from tooluniverse import ToolUniverse
tu = ToolUniverse()
tu.load_tools()
print(f"✅ Loaded {len(tu.all_tools)} tools!")
.. _quick-start:
Quick Start¶
Here’s the fastest way to add a new tool:
# my_custom_tool.py
from tooluniverse.tool_registry import register_tool
@register_tool('MyAwesomeTool', config={
"name": "my_awesome_tool",
"type": "MyAwesomeTool",
"description": "A simple example tool",
"parameter": {
"type": "object",
"properties": {
"message": {"type": "string", "description": "Message to process"}
},
"required": ["message"]
}
})
class MyAwesomeTool:
def __init__(self, tool_config=None):
self.tool_config = tool_config
def run(self, arguments):
message = arguments.get('message', '')
return {
'processed_message': f"Hello! You said: '{message}'",
'original': message,
'tool_name': 'MyAwesomeTool'
}
# Usage
from tooluniverse import ToolUniverse
tu = ToolUniverse()
tu.load_tools() # Auto-discovers and loads your tool
result = tu.run_one_function({
"name": "my_awesome_tool",
"arguments": {"message": "This is a test"}
})
print(result)
That’s it! Your tool is automatically discovered and ready to use.
.. _method-1-decorator-registration-recommended:
Method 1: Decorator Registration (Recommended)¶
The decorator approach is the simplest and most powerful way to add tools.
Basic Structure¶
from tooluniverse.tool_registry import register_tool
@register_tool('YourToolType', config={...})
class YourTool:
def __init__(self, tool_config=None):
# Initialize your tool
pass
def run(self, arguments):
# Process arguments and return results
return {...}
Step-by-Step Tutorial¶
1. Create Your Tool File¶
Create a new Python file ending with _tool.py
(e.g., weather_tool.py
):
# weather_tool.py
import requests
from tooluniverse.tool_registry import register_tool
@register_tool('WeatherTool', config={
"name": "get_weather",
"type": "WeatherTool",
"description": "Get current weather for a city",
"parameter": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name to get weather for"
},
"units": {
"type": "string",
"description": "Temperature units (metric/imperial)",
"default": "metric"
}
},
"required": ["city"]
}
})
class WeatherTool:
def __init__(self, tool_config=None):
self.tool_config = tool_config
self.api_key = "your_api_key_here" # In practice, use env vars
def run(self, arguments):
city = arguments.get('city')
units = arguments.get('units', 'metric')
# Mock API call (replace with real API)
url = f"http://api.openweathermap.org/data/2.5/weather"
params = {
"q": city,
"appid": self.api_key,
"units": units
}
try:
response = requests.get(url, params=params)
data = response.json()
return {
"city": city,
"temperature": data["main"]["temp"],
"description": data["weather"][0]["description"],
"units": units,
"success": True
}
except Exception as e:
return {
"error": f"Failed to get weather: {str(e)}",
"success": False
}
2. Place in ToolUniverse Package¶
Move your file to the ToolUniverse source directory:
cp weather_tool.py /path/to/ToolUniverse/src/tooluniverse/
3. Use Your Tool¶
Your tool is automatically discovered:
from tooluniverse import ToolUniverse
tu = ToolUniverse()
tu.load_tools()
# Your tool is now available!
result = tu.run_one_function({
"name": "get_weather",
"arguments": {"city": "San Francisco", "units": "metric"}
})
print(result)
Configuration Options¶
The config
parameter in the decorator supports all standard ToolUniverse configuration:
@register_tool('AdvancedTool', config={
"name": "advanced_tool",
"type": "AdvancedTool",
"description": "An advanced tool with complex parameters",
"parameter": {
"type": "object",
"properties": {
"input_data": {
"type": "object",
"description": "Complex input data",
"properties": {
"values": {"type": "array", "items": {"type": "number"}},
"operation": {"type": "string", "enum": ["sum", "avg", "max"]}
}
},
"options": {
"type": "object",
"description": "Optional settings",
"properties": {
"precision": {"type": "integer", "default": 2},
"format": {"type": "string", "default": "json"}
}
}
},
"required": ["input_data"]
},
"settings": {
"timeout": 30,
"max_retries": 3,
"custom_field": "value"
}
})
.. _method-2-manual-registration:
Method 2: Manual Registration¶
For dynamic tools or special cases, you can register tools at runtime:
from tooluniverse import ToolUniverse
class DynamicTool:
def __init__(self, tool_config=None):
self.tool_config = tool_config
self.dynamic_data = self._load_dynamic_data()
def run(self, arguments):
return {"result": f"Processed {arguments}"}
def _load_dynamic_data(self):
# Load configuration from database, API, etc.
return {}
# Create ToolUniverse instance
tu = ToolUniverse()
tu.load_tools()
# Manual registration
tool_config = {
"name": "dynamic_tool",
"type": "DynamicTool",
"description": "A dynamically configured tool",
"parameter": {
"type": "object",
"properties": {
"input": {"type": "string", "description": "Input data"}
},
"required": ["input"]
}
}
tu.register_custom_tool(DynamicTool, 'DynamicTool', tool_config)
# Use the manually registered tool
result = tu.run_one_function({
"name": "dynamic_tool",
"arguments": {"input": "test"}
})
.. _tool-configuration:
Tool Configuration¶
Required Fields¶
name
: Unique identifier for your tooltype
: Class name (must match decorator parameter)description
: Human-readable descriptionparameter
: JSON Schema defining input parameters
Optional Fields¶
settings
: Tool-specific configurationversion
: Tool versiontags
: Categorization tagsexamples
: Usage examples
Parameter Schema Examples¶
Simple Parameters¶
{
"type": "object",
"properties": {
"text": {"type": "string", "description": "Input text"},
"count": {"type": "integer", "minimum": 1, "maximum": 100}
},
"required": ["text"]
}
Complex Parameters¶
{
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": "string"},
"value": {"type": "number"}
}
}
},
"options": {
"type": "object",
"properties": {
"format": {"type": "string", "enum": ["json", "csv", "xml"]},
"include_metadata": {"type": "boolean", "default": false}
}
}
},
"required": ["data"]
}
.. _parameter-validation-and-error-handling:
Parameter Validation and Error Handling¶
ToolUniverse provides built-in support for parameter validation and structured error handling to help you create robust tools.
Parameter Validation¶
You can implement custom parameter validation by overriding the validate_parameters()
method:
from tooluniverse.exceptions import ToolValidationError
class MyTool:
def validate_parameters(self, arguments):
# First run base validation
base_error = super().validate_parameters(arguments)
if base_error:
return base_error
# Add custom validation
if "email" in arguments:
email = arguments["email"]
if "@" not in email:
return ToolValidationError(
"Invalid email format",
next_steps=["Provide a valid email address"],
details={"field": "email", "value": email}
)
return None # Validation passed
Error Handling¶
Override handle_error()
to provide tool-specific error classification:
from tooluniverse.exceptions import ToolAuthError, ToolRateLimitError
class APITool:
def handle_error(self, exception):
error_str = str(exception).lower()
if "api key" in error_str:
return ToolAuthError(
"API authentication failed",
next_steps=["Check API key", "Verify environment variables"]
)
elif "rate limit" in error_str:
return ToolRateLimitError(
"API rate limit exceeded",
next_steps=["Wait and retry", "Check quota limits"]
)
# Fall back to base error handling
return super().handle_error(exception)
Exception System Migration¶
If you’re using older exception classes, migrate to the new structured system:
# Old way (deprecated)
from tooluniverse.base_tool import ValidationError
raise ValidationError("Invalid input")
# New way (recommended)
from tooluniverse.exceptions import ToolValidationError
raise ToolValidationError(
"Invalid input",
next_steps=["Check parameter format"],
details={"field": "email"}
)
For more information about the exception system, see the API documentation.
.. _real-world-examples:
Real-World Examples¶
Example 1: Database Query Tool¶
# database_tool.py
import sqlite3
from typing import Dict, Any, List
from tooluniverse.tool_registry import register_tool
@register_tool('DatabaseQueryTool', config={
"name": "query_database",
"type": "DatabaseQueryTool",
"description": "Execute SQL queries on a database",
"parameter": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "SQL query to execute"},
"database": {"type": "string", "description": "Database name"},
"limit": {"type": "integer", "default": 100, "maximum": 1000}
},
"required": ["query", "database"]
},
"settings": {
"db_path": "/path/to/databases/",
"readonly": true
}
})
class DatabaseQueryTool:
def __init__(self, tool_config: Dict[str, Any]):
self.tool_config = tool_config
self.db_path = tool_config.get("settings", {}).get("db_path", ".")
self.readonly = tool_config.get("settings", {}).get("readonly", True)
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
query = arguments.get('query')
database = arguments.get('database')
limit = arguments.get('limit', 100)
try:
db_file = f"{self.db_path}/{database}.db"
conn = sqlite3.connect(db_file)
conn.row_factory = sqlite3.Row # Enable column access by name
cursor = conn.cursor()
# Add LIMIT if not present in query
if 'LIMIT' not in query.upper():
query += f" LIMIT {limit}"
cursor.execute(query)
rows = cursor.fetchall()
# Convert to list of dictionaries
results = [dict(row) for row in rows]
conn.close()
return {
"results": results,
"count": len(results),
"query": query,
"database": database,
"success": True
}
except Exception as e:
return {
"error": str(e),
"query": query,
"database": database,
"success": False
}
Example 2: File Processing Tool¶
# file_processor_tool.py
import os
import json
import csv
from pathlib import Path
from tooluniverse.tool_registry import register_tool
@register_tool('FileProcessorTool', config={
"name": "process_file",
"type": "FileProcessorTool",
"description": "Process and transform files between different formats",
"parameter": {
"type": "object",
"properties": {
"input_file": {"type": "string", "description": "Path to input file"},
"output_format": {"type": "string", "enum": ["json", "csv", "txt"]},
"operation": {"type": "string", "enum": ["convert", "analyze", "validate"]}
},
"required": ["input_file", "operation"]
}
})
class FileProcessorTool:
def __init__(self, tool_config=None):
self.tool_config = tool_config
self.supported_formats = ['.json', '.csv', '.txt', '.xml']
def run(self, arguments):
input_file = arguments.get('input_file')
operation = arguments.get('operation')
output_format = arguments.get('output_format')
if not os.path.exists(input_file):
return {"error": f"File not found: {input_file}", "success": False}
try:
if operation == "analyze":
return self._analyze_file(input_file)
elif operation == "convert":
return self._convert_file(input_file, output_format)
elif operation == "validate":
return self._validate_file(input_file)
else:
return {"error": f"Unknown operation: {operation}", "success": False}
except Exception as e:
return {"error": str(e), "success": False}
def _analyze_file(self, file_path):
file_stats = os.stat(file_path)
file_ext = Path(file_path).suffix.lower()
analysis = {
"file_path": file_path,
"size_bytes": file_stats.st_size,
"extension": file_ext,
"readable": os.access(file_path, os.R_OK),
"success": True
}
# Format-specific analysis
if file_ext == '.json':
try:
with open(file_path, 'r') as f:
data = json.load(f)
analysis["json_valid"] = True
analysis["json_type"] = type(data).__name__
if isinstance(data, list):
analysis["items_count"] = len(data)
elif isinstance(data, dict):
analysis["keys_count"] = len(data.keys())
except:
analysis["json_valid"] = False
return analysis
def _convert_file(self, input_file, output_format):
# Implementation for file conversion
return {
"message": f"Would convert {input_file} to {output_format}",
"input_file": input_file,
"output_format": output_format,
"success": True
}
def _validate_file(self, file_path):
# Implementation for file validation
return {
"file_path": file_path,
"valid": True,
"issues": [],
"success": True
}
Example 3: API Integration Tool¶
# api_client_tool.py
import requests
from typing import Dict, Any, Optional
from tooluniverse.tool_registry import register_tool
@register_tool('APIClientTool', config={
"name": "call_api",
"type": "APIClientTool",
"description": "Make HTTP API calls with built-in error handling",
"parameter": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "API endpoint URL"},
"method": {"type": "string", "enum": ["GET", "POST", "PUT", "DELETE"], "default": "GET"},
"headers": {"type": "object", "description": "HTTP headers"},
"data": {"type": "object", "description": "Request body data"},
"timeout": {"type": "integer", "default": 30, "minimum": 1, "maximum": 300}
},
"required": ["url"]
},
"settings": {
"max_retries": 3,
"default_headers": {
"User-Agent": "ToolUniverse-APIClient/1.0"
}
}
})
class APIClientTool:
def __init__(self, tool_config: Dict[str, Any]):
self.tool_config = tool_config
self.max_retries = tool_config.get("settings", {}).get("max_retries", 3)
self.default_headers = tool_config.get("settings", {}).get("default_headers", {})
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
url = arguments.get('url')
method = arguments.get('method', 'GET').upper()
headers = {**self.default_headers, **arguments.get('headers', {})}
data = arguments.get('data')
timeout = arguments.get('timeout', 30)
for attempt in range(self.max_retries + 1):
try:
response = requests.request(
method=method,
url=url,
headers=headers,
json=data if data else None,
timeout=timeout
)
result = {
"status_code": response.status_code,
"url": url,
"method": method,
"success": response.status_code < 400,
"attempt": attempt + 1
}
# Try to parse JSON response
try:
result["data"] = response.json()
except:
result["data"] = response.text
if response.status_code < 400:
return result
else:
result["error"] = f"HTTP {response.status_code}"
if attempt == self.max_retries:
return result
except Exception as e:
if attempt == self.max_retries:
return {
"error": str(e),
"url": url,
"method": method,
"success": False,
"attempt": attempt + 1
}
return {"error": "Max retries exceeded", "success": False}
.. _best-practices:
Best Practices¶
1. File Naming¶
Name your tool files with
_tool.py
suffixUse descriptive names:
weather_tool.py
,database_tool.py
2. Class Design¶
class MyTool:
def __init__(self, tool_config=None):
"""Always accept tool_config parameter"""
self.tool_config = tool_config or {}
# Initialize from config
self.setting_value = tool_config.get("settings", {}).get("key", "default")
def run(self, arguments):
"""Main entry point - always called 'run'"""
# Validate inputs
required_param = arguments.get('required_param')
if not required_param:
return {"error": "required_param is missing", "success": False}
# Process and return results
return {"result": "success", "success": True}
3. Error Handling¶
def run(self, arguments):
try:
# Your logic here
result = self.process_data(arguments)
return {"result": result, "success": True}
except ValueError as e:
return {"error": f"Invalid input: {str(e)}", "success": False}
except Exception as e:
return {"error": f"Unexpected error: {str(e)}", "success": False}
4. Configuration Management¶
@register_tool('ConfigurableTool', config={
"name": "my_tool",
"type": "ConfigurableTool",
"description": "A tool that uses configuration",
"parameter": {...},
"settings": {
"api_endpoint": "https://api.example.com",
"timeout": 30,
"retries": 3
}
})
class ConfigurableTool:
def __init__(self, tool_config=None):
settings = tool_config.get("settings", {})
self.api_endpoint = settings.get("api_endpoint")
self.timeout = settings.get("timeout", 30)
self.retries = settings.get("retries", 3)
5. Return Format Consistency¶
Always return dictionaries with consistent structure:
# Success
return {
"result": actual_result,
"success": True,
"metadata": {...} # optional
}
# Error
return {
"error": "Description of what went wrong",
"success": False,
"details": {...} # optional error details
}
.. _troubleshooting:
Troubleshooting¶
Tool Not Found¶
Problem: Your tool isn’t being discovered Solutions:
Ensure file ends with
_tool.py
Check file is in the correct directory
Verify the import doesn’t fail:
python -c "from your_tool_module import YourTool"
Import Errors¶
Problem: Module import fails during auto-discovery Solutions:
Check all dependencies are installed
Ensure relative imports use proper syntax
Test import manually:
import sys sys.path.append('/path/to/tooluniverse/src') from tooluniverse.your_tool import YourTool
Configuration Issues¶
Problem: Tool config not loading properly Solutions:
Validate JSON syntax in config
Check required fields are present
Test config separately:
import json config = {...} # your config print(json.dumps(config, indent=2)) # Should not error
Runtime Errors¶
Problem: Tool fails during execution Solutions:
Add comprehensive error handling
Validate all inputs in
run()
methodReturn error details:
except Exception as e: return { "error": str(e), "traceback": traceback.format_exc(), # For debugging "success": False }
Testing Your Tool¶
Create a simple test script:
# test_my_tool.py
from tooluniverse import ToolUniverse
def test_my_tool():
tu = ToolUniverse()
tu.load_tools()
# Test your tool
result = tu.run_one_function({
"name": "your_tool_name",
"arguments": {"test_param": "test_value"}
})
print("Result:", result)
assert result.get("success") is True, f"Tool failed: {result}"
print("✅ Tool test passed!")
if __name__ == "__main__":
test_my_tool()
Next Steps¶
Start Simple: Begin with a basic tool using the quick start example
Add Complexity: Gradually add more features and configuration
Test Thoroughly: Create test cases for different scenarios
Document: Add clear descriptions and examples
Share: Consider contributing useful tools back to the community
Need Help?¶
Check existing tools in the ToolUniverse codebase for examples
Review the configuration schemas of similar tools
Test incrementally - start with basic functionality
Use the troubleshooting section for common issues
The decorator-based system makes adding tools straightforward while maintaining all the power and flexibility of ToolUniverse. Happy tool building! 🛠️