Add custom local tools to ToolUniverse alongside the 1000+ built-in tools. Covers JSON-config tools (simplest, no code), Python class tools (REST/SOAP/GraphQL APIs, computational logic), and best-practices for return schemas. Use for wrapping new APIs, adding domain-specific computations, or contributing tools to the registry.
80
100%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
When to create a custom tool: Create one if you need to access an API that ToolUniverse doesn't cover, or if you need a specialized data transformation that no existing tool provides. Start with the JSON config approach (simplest — no Python needed); escalate to a Python class only if you need custom response parsing or stateful logic.
Three ways to add tools — pick the one that fits your needs:
| Approach | When to use |
|---|---|
| JSON config | REST API with standard request/response — no coding needed |
| Python class (workspace) | Custom logic for local/private use only |
| Plugin package | Reusable tools you want to share or install via pip |
Tools in .tooluniverse/tools/ are auto-discovered at startup. No installation needed.
mkdir -p .tooluniverse/toolsCreate .tooluniverse/tools/my_tools.json:
[
{
"name": "MyAPI_search",
"description": "Search my internal database. Returns matching records with id, title, and score.",
"type": "BaseRESTTool",
"fields": {
"endpoint": "https://my-api.example.com/search"
},
"parameter": {
"type": "object",
"properties": {
"q": {
"type": "string",
"description": "Search query"
},
"limit": {
"type": ["integer", "null"],
"description": "Max results to return (default 10)"
}
},
"required": ["q"]
}
}
]One JSON file can define multiple tools — just add more objects to the array.
For the full JSON field reference, see references/json-tool.md.
Create .tooluniverse/tools/my_tool.py:
from tooluniverse.tool_registry import register_tool
@register_tool
class MyAPI_search:
name = "MyAPI_search"
description = "Search my internal database. Returns matching records with id, title, and score."
input_schema = {
"type": "object",
"properties": {
"q": {"type": "string", "description": "Search query"},
"limit": {"type": "integer", "description": "Max results (default 10)"}
},
"required": ["q"]
}
def run(self, q: str, limit: int = 10) -> dict:
import requests
resp = requests.get(
"https://my-api.example.com/search",
params={"q": q, "limit": limit},
timeout=30,
)
resp.raise_for_status()
return {"status": "success", "data": resp.json()}Note: workspace Python tools use run(self, **named_params) — arguments are unpacked as keyword
arguments matching the input_schema properties.
For the full Python class reference, see references/python-tool.md.
# Uses test_examples from the tool's JSON config — zero config needed
tu test MyAPI_search
# Single ad-hoc call
tu test MyAPI_search '{"q": "test"}'
# Full config with assertions
tu test --config my_tool_tests.jsontu test automatically runs these checks on every call:
return_schema validation — validates result["data"] against the JSON Schema defined in return_schema (if present)expect_status and expect_keys — only if set in the config fileGotchas: (1) tu test does NOT verify non-empty results — [] passes schema validation. Use test_examples args that return real data. (2) Verify test_examples manually first with urllib (not curl) to confirm the API returns JSON, not HTML. Use 2-4 broad keywords.
Add test_examples and return_schema to JSON config for best coverage. tu test validates result["data"] against return_schema (match "type": "array" or "type": "object" to your data shape).
Optional my_tool_tests.json for extra assertions (expect_status, expect_keys).
Tools in .tooluniverse/tools/ are auto-available via tu serve. Workspace priority: --workspace flag → TOOLUNIVERSE_HOME env → ./.tooluniverse/ → ~/.tooluniverse/.
To use a different tools directory, add sources: [./my-custom-tools/] in .tooluniverse/profile.yaml and start with tooluniverse --load .tooluniverse/profile.yaml.
Use this when you want to distribute tools as a reusable Python package that other users can
install with pip install. The plugin package has the same directory layout as a workspace, plus a
pyproject.toml that declares the entry point.
my_project_root/ # directory containing pyproject.toml
pyproject.toml
my_tools_package/ # importable Python package (matches entry-point value)
__init__.py # minimal — one-line docstring, no registration code
my_api_tool.py # tool class(es) with @register_tool
data/
my_api_tools.json # JSON tool configs (type must match registered class name)
profile.yaml # optional: name, description, required_envJSON config files are discovered from both data/ and the package root directory. The convention is data/.
pyproject.toml entry point[project.entry-points."tooluniverse.plugins"]
my-tools = "my_tools_package"The value (my_tools_package) must be the importable Python package name.
Plugin package tools use BaseTool and receive all arguments as a single Dict:
import requests
from typing import Dict, Any
from tooluniverse.base_tool import BaseTool
from tooluniverse.tool_registry import register_tool
@register_tool("MyAPITool")
class MyAPITool(BaseTool):
"""Tool description here."""
def __init__(self, tool_config: Dict[str, Any]):
super().__init__(tool_config)
self.timeout = tool_config.get("timeout", 30)
fields = tool_config.get("fields", {})
self.operation = fields.get("operation", "search")
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
query = arguments.get("query", "")
if not query:
return {"error": "query parameter is required"}
try:
resp = requests.get(
"https://my-api.example.com/search",
params={"q": query},
timeout=self.timeout,
)
resp.raise_for_status()
return {"status": "success", "data": resp.json()}
except requests.exceptions.RequestException as e:
return {"error": str(e)}Key differences from the workspace pattern:
BaseTool (from tooluniverse.base_tool)@register_tool("ClassName") takes the class name as a string argumentrun(self, arguments: Dict) receives all arguments in a single dict — extract them with .get()__init__ receives tool_config dict; call super().__init__(tool_config) firstPlace configs in data/my_api_tools.json. The "type" field must match the string passed to
@register_tool(...):
[
{
"name": "MyAPI_search",
"description": "Search my API. Returns matching records.",
"type": "MyAPITool",
"fields": { "operation": "search" },
"parameter": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "Search query" },
"limit": { "type": ["integer", "null"], "description": "Max results" }
},
"required": ["query"]
}
}
]__init__.pyKeep minimal — just a docstring. The plugin system auto-imports all .py files via _discover_entry_point_plugins(), so @register_tool decorators fire automatically. Optional: add from . import my_api_tool for IDE support (idempotent). Do NOT add registration logic or JSON loading here.
pip install -e /path/to/my_project_root
cd /path/to/my_project_root # MUST run from plugin repo directory
tu test MyAPI_search '{"query": "test"}'Must pip install -e first. Run tu test from plugin repo dir (workspace auto-detection needs .tooluniverse/). Add test_examples to JSON config for zero-config testing. Use tu info MyAPI_search to confirm the tool loaded.
Calculator tools (no HTTP) follow the plugin-package pattern but skip the HTTP layer. Key design patterns:
Dict[str, float] at module level. Resolution priority: explicit value → preset name → default. Include presets in metadata for discoverability.operation values in a single tool. Use "fields": {"operation": "default_op"} in JSON config._MU0 = 4*pi*1e-7, etc.). Material-specific values as named dicts.data (e.g., temperature + headroom + pass/fail) rather than forcing multiple calls.For complete patterns, see references/python-tool.md.
089eb8e
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.