From mims-harvard-tooluniverse
Guides adding custom tools to ToolUniverse via JSON config (no Python needed), Python classes, or pip-installable plugins for private APIs, local workspaces, and internal services.
npx claudepluginhub joshuarweaver/cascade-data-analytics --plugin mims-harvard-tooluniverseThis skill uses the workspace's default tool permissions.
**When to create a custom tool:** Create one if you need to access an API that ToolUniverse
Conducts multi-round deep research on GitHub repos via API and web searches, generating markdown reports with executive summaries, timelines, metrics, and Mermaid diagrams.
Dynamically discovers and combines enabled skills into cohesive, unexpected delightful experiences like interactive HTML or themed artifacts. Activates on 'surprise me', inspiration, or boredom cues.
Generates images from structured JSON prompts via Python script execution. Supports reference images and aspect ratios for characters, scenes, products, visuals.
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/tools
Create .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.json
tu 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_env
JSON 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.