
Tooluniverse Custom Tool
- 242 installs
- 1.6k repo stars
- Updated August 4, 2026
- mims-harvard/tooluniverse
Design, implement, and register custom ToolUniverse tools with schemas, handlers, auth, and limits so agents can call new domain-specific capabilities safely.
About
ToolUniverse custom tool from mims-harvard/tooluniverse walks through designing, implementing, and registering bespoke tools—schemas, handlers, authentication, and execution limits—so agents can reliably invoke new domain-specific capabilities inside ToolUniverse.
- Defines schemas and execution handlers
- Registers tools into ToolUniverse catalog
- Sets auth, limits, and error boundaries
- Extends agents with domain actions
Tooluniverse Custom Tool by the numbers
- 242 all-time installs (skills.sh)
- +7 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #2,592 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/mims-harvard/tooluniverse --skill tooluniverse-custom-toolAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 242 |
|---|---|
| repo stars | ★ 1.6k |
| Last updated | August 4, 2026 |
| Repository | mims-harvard/tooluniverse ↗ |
What it does
Design, implement, and register custom ToolUniverse tools with schemas, handlers, auth, and limits so agents can call new domain-specific capabilities safely.
Files
Adding Custom Tools to ToolUniverse
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 |
---
Option A — Workspace tools (local use)
Tools in .tooluniverse/tools/ are auto-discovered at startup. No installation needed.
mkdir -p .tooluniverse/toolsJSON config
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.
Python class
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.
Test workspace tools
# 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:
- Result is not None or empty
return_schemavalidation — validatesresult["data"]against the JSON Schema defined inreturn_schema(if present)expect_statusandexpect_keys— only if set in the config file
Gotchas: (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).
Use with MCP server
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.
---
Option B — Plugin package (shareable, pip-installable)
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.
Package layout
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.
Python class in a plugin package
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:
- Inherit from
BaseTool(fromtooluniverse.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__receivestool_configdict; callsuper().__init__(tool_config)first
JSON config in a plugin package
Place 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__.py
Keep 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.
Install and verify
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.
---
Offline / pure-computation tools
Calculator tools (no HTTP) follow the plugin-package pattern but skip the HTTP layer. Key design patterns:
- Preset lookup tables: Define
Dict[str, float]at module level. Resolution priority: explicit value → preset name → default. Include presets inmetadatafor discoverability. - Bidirectional equations: Expose as separate
operationvalues in a single tool. Use"fields": {"operation": "default_op"}in JSON config. - Physical constants: Define at module level (
_MU0 = 4*pi*1e-7, etc.). Material-specific values as named dicts. - Multi-output: Return all related results in
data(e.g., temperature + headroom + pass/fail) rather than forcing multiple calls.
For complete patterns, see references/python-tool.md.
JSON Tool Config Reference
Minimal example
[
{
"name": "MyAPI_search",
"description": "...",
"type": "BaseRESTTool",
"fields": { "endpoint": "https://api.example.com/search" },
"parameter": {
"type": "object",
"properties": {
"q": { "type": "string", "description": "Search query" }
},
"required": ["q"]
}
}
]All fields
| Field | Required | Description |
|---|---|---|
name | Yes | Unique tool identifier. Convention: ProviderName_action (e.g., MyAPI_search) |
description | Yes | What the tool does and returns. Be specific — the AI uses this to decide when to call the tool. |
type | Yes | "BaseRESTTool" for workspace REST tools; or the class name string passed to @register_tool("ClassName") for plugin package tools |
fields.endpoint | Yes (BaseRESTTool only) | The API endpoint URL. Only required when "type" is "BaseRESTTool". |
parameter | Yes | JSON Schema object for the tool's input parameters |
fields.method | No | HTTP method: "GET" (default) or "POST" |
fields.headers | No | Static headers as key-value pairs |
fields | No (plugin) | Any key-value pairs passed to __init__ as tool_config["fields"]. Commonly "operation" is used to route multiple tools from one class. |
test_examples | No | List of example argument dicts — used automatically by tu test <tool_name> |
return_schema | No | JSON Schema for the response data — validated automatically by tu test |
tags | No | List of category tags (e.g., ["genomics", "search"]) |
Parameter types
"properties": {
"required_string": { "type": "string", "description": "..." },
"optional_int": { "type": ["integer", "null"], "description": "..." },
"optional_string": { "type": ["string", "null"], "description": "..." },
"optional_boolean": { "type": ["boolean", "null"], "description": "..." }
}Mark optional params with ["type", "null"] — omit them from "required".
If a tool has no required parameters, use "required": [] (not omitting the key):
"parameter": {
"type": "object",
"properties": {
"query": { "type": ["string", "null"], "description": "Optional search term" },
"limit": { "type": ["integer", "null"], "description": "Max results" }
},
"required": []
}POST example
{
"name": "MyAPI_submit",
"description": "Submit a job to the processing queue. Returns job_id.",
"type": "BaseRESTTool",
"fields": {
"endpoint": "https://api.example.com/jobs",
"method": "POST",
"headers": { "Content-Type": "application/json" }
},
"parameter": {
"type": "object",
"properties": {
"input": { "type": "string", "description": "Input data to process" },
"priority": { "type": ["integer", "null"], "description": "Job priority 1-10" }
},
"required": ["input"]
},
"test_examples": [
{ "input": "sample data", "priority": 5 }
]
}Authentication
For APIs requiring API keys, reference the env var in a header. Store the key value in .tooluniverse/.env (or ~/.tooluniverse/.env), never in the JSON:
"fields": {
"endpoint": "https://api.example.com/search",
"headers": { "Authorization": "Bearer ${MY_API_KEY}" }
}Then declare the key so ToolUniverse knows the tool needs it, and describe it with an api_key_info block so it appears in the setup UI (/tooluniverse:setup-keys) and the generated .env.template:
{
"name": "MyAPI_search",
"type": "MyAPITool",
"required_api_keys": ["MY_API_KEY"],
"api_key_info": {
"MY_API_KEY": {
"domain": "Drugs & Chemistry",
"type": "secret",
"register_url": "https://example.com/get-a-key",
"purpose": "What this key unlocks (one sentence).",
"without": "What happens without it: blocked / demo mode / lower limits."
}
}
}- Use
required_api_keysif the tool cannot work without the key, or
optional_api_keys if it only raises rate limits / unlocks extras.
api_key_infolives in the same config — define it once per key even if
several tools share it. type is secret (an API key) or endpoint (a self-hosted server URL); domain groups the key in the setup UI.
- After editing, run
python scripts/gen_api_key_catalog.pyto refresh
src/tooluniverse/data/api_keys_catalog.json and .env.template. CI also auto-syncs on push and fails PRs whose catalog is out of date.
.tooluniverse/.env:
MY_API_KEY=your-actual-key-herereturn_schema
Describes the structure of result["data"]. tu test validates every result against this schema automatically — no extra config needed. Use JSON Schema format.
Critical: The schema must match the type of what your run() puts under the "data" key — not the full response dict. Most search tools return a list, so the top-level type is "array":
{
"name": "MyAPI_search",
...
"test_examples": [{"q": "test"}],
"return_schema": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "string" },
"title": { "type": "string" },
"score": { "type": "number" }
},
"required": ["id", "title"]
}
}
}If data is a single object (e.g. get / lookup operations):
"return_schema": {
"type": "object",
"properties": {
"id": { "type": "string" },
"title": { "type": "string" }
},
"required": ["id"]
}If the API can return multiple shapes (success vs error), use oneOf:
"return_schema": {
"oneOf": [
{
"type": "array",
"items": { "type": "object" }
},
{
"type": "object",
"properties": { "error": { "type": "string" } },
"required": ["error"]
}
]
}Note: return_schema validation only runs when result["status"] == "success", so error responses (which don't have a "data" key) are skipped automatically.
Gotcha: An empty array [] satisfies "type": "array" and passes schema validation. Make sure test_examples use arguments that actually return non-empty results, otherwise a broken tool will pass all tests silently.
Gotcha (tools with multiple output shapes): If your tool returns different fields depending on the inputs (e.g., filter_type: RC returns cutoff_frequency_Hz but filter_type: LC returns resonant_frequency_Hz), only require fields that are present in ALL execution paths. Adding a field to required that only appears in some paths will cause schema validation to fail for the other paths. Use a loose schema (no required list, or a minimal one) for dispatch-style tools.
Verify test_examples with Python before writing them. Use urllib rather than curl — it matches what the tool will actually do and handles edge cases like redirects more visibly:
import urllib.request, json
with urllib.request.urlopen("https://api.example.com/search?q=test") as r:
print(json.dumps(json.loads(r.read()), indent=2))Some search APIs use intitle-style matching where all words must appear literally in a title or name field — overly specific queries like "I2C pull-up resistor value" can return 0 results even when the tool is working. Use 2-4 key words that reliably appear in real content (e.g., "pull-up resistor" instead).
Verify the URL is a real JSON endpoint. Some URLs that look like REST APIs (e.g., https://certification.example.org/api/projects) may redirect to a static HTML page. A urllib fetch will show you the Content-Type and body immediately, before you write any code.
Multiple tools in one file
[
{ "name": "MyAPI_search", ... },
{ "name": "MyAPI_get_record", ... },
{ "name": "MyAPI_list_collections", ... }
]Offline tools with an operation parameter
For pure-computation tools that handle multiple operations in a single Python class, you have two design choices:
Choice A — Single tool, user passes `operation` as a parameter: The JSON config exposes operation as a parameter property. One tool, one JSON entry, user chooses the mode at call time.
{
"name": "Circuit_wire_gauge",
"type": "WireGaugeTool",
"parameter": {
"type": "object",
"properties": {
"operation": {
"type": ["string", "null"],
"description": "Operation: 'from_current' (default) or 'from_awg'."
},
"current_A": { "type": ["number", "null"], "description": "..." },
"awg": { "type": ["number", "null"], "description": "..." }
},
"required": []
}
}Use this when the operations share most parameters and the distinction is a simple mode switch.
Choice B — Multiple tools, each backed by the same class via `fields.operation`: Each JSON entry is a separate tool with its own name, description, and parameters. The Python class reads self.operation = tool_config["fields"]["operation"] in __init__.
[
{ "name": "Circuit_wire_from_current", "type": "WireGaugeTool",
"fields": {"operation": "from_current"}, "parameter": { ... only current_A, ambient_C ... } },
{ "name": "Circuit_wire_from_awg", "type": "WireGaugeTool",
"fields": {"operation": "from_awg"}, "parameter": { ... only awg, temp_C ... } }
]Use this when the operations have very different parameters or descriptions — it gives the AI cleaner, more targeted tool choices.
---
Array-of-arrays parameters
When a tool accepts a list of structured items (e.g., RC network segments, waypoints, coefficient lists), use "type": ["array", "null"] with an "items" schema:
"segments": {
"type": ["array", "null"],
"description": "List of [R_i, C_i] pairs from driver to load. Each R_i in ohms, C_i in farads. Example: [[100, 50e-15], [200, 50e-15], [0, 100e-15]].",
"items": {
"type": "array",
"items": { "type": "number" },
"minItems": 2,
"maxItems": 2
}
}Always include a concrete example in the description (e.g., [[100, 50e-15], ...]) — the AI needs to see the expected format. Use test_examples that exercise a non-trivial list:
"test_examples": [
{"mode": "chain", "segments": [[100, 50e-15], [200, 50e-15], [0, 100e-15]]}
]The final segment often has R=0 (pure load capacitance). In the Python class, validate with r < 0 (reject negative R) rather than r <= 0 to allow load-only segments.
Python Tool Reference
There are two Python class patterns depending on where the tool lives.
---
Pattern 1 — Workspace tool (.tooluniverse/tools/)
Use this for local/private tools dropped into a workspace directory.
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()}Key points:
@register_toolwith no argument — the class name is used as the tool namerun(self, **named_params)— ToolUniverse unpacks the validated arguments as keyword argsname,description,input_schemaare class attributes (not__init__)
---
Pattern 2 — Plugin package tool
Use this for tools in a pip-installable package (e.g. tooluniverse-circuit).
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 (used as docstring, not as the LLM-facing description)."""
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 Pattern 1:
@register_tool("ClassName")takes the class name as a string argument- Inherits from
BaseTool(from tooluniverse.base_tool import BaseTool) __init__receivestool_configdict — callsuper().__init__(tool_config)firstrun(self, arguments: Dict)receives all arguments in one dict — use.get()to extract- The LLM-facing name and description come from the JSON config file, not the class
fieldsin the config can pass routing info to__init__(commonly"operation")
Multiple tools from one class (fields.operation pattern)
One Python class can back multiple JSON-configured tools. Use "fields": {"operation": "..."} in the JSON config and self.operation in __init__ to dispatch:
@register_tool("MyAPITool")
class MyAPITool(BaseTool):
def __init__(self, tool_config: Dict[str, Any]):
super().__init__(tool_config)
self.operation = tool_config.get("fields", {}).get("operation", "search")
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
if self.operation == "search":
return self._search(arguments)
elif self.operation == "list":
return self._list(arguments)
return {"error": f"Unknown operation: {self.operation}"}JSON configs for the two tools both set "type": "MyAPITool" but different "fields":
[
{ "name": "MyAPI_search", "type": "MyAPITool", "fields": {"operation": "search"}, ... },
{ "name": "MyAPI_list", "type": "MyAPITool", "fields": {"operation": "list"}, ... }
]Each JSON entry becomes a separate tool in ToolUniverse with its own name, description, and parameters.
---
Required elements (both patterns)
| Element | Pattern 1 (workspace) | Pattern 2 (plugin) |
|---|---|---|
| Decorator | @register_tool | @register_tool("ClassName") |
| Base class | plain class | BaseTool |
| Name/description | class attributes | JSON config file |
run signature | run(self, param1, param2=...) | run(self, arguments: Dict) |
---
Return format
Always return a dict. Conventions:
# Success — data can be a list or a dict
return {"status": "success", "data": result}
# Success with extra context (source URL, total count, applied filters, etc.)
return {
"status": "success",
"data": result,
"metadata": {
"source": "My API (api.example.com)",
"query": query,
"total_results": len(result),
},
}
# Error — return, don't raise
return {"status": "error", "message": "Record not found"}
# Plugin packages sometimes use "error" key directly (no "status"):
return {"error": "Record not found"}tu test validates result["data"] against return_schema (only when status == "success"). The metadata key is ignored by schema validation — it is purely informational for the caller. Make sure the return_schema top-level type matches what you put in data:
- List of results →
"type": "array" - Single record lookup →
"type": "object"
---
input_schema patterns (Pattern 1 / workspace)
input_schema = {
"type": "object",
"properties": {
# Required string
"gene_id": {"type": "string", "description": "Ensembl gene ID (e.g. ENSG00000139618)"},
# Optional string (include "null" in type, omit from required)
"species": {"type": ["string", "null"], "description": "Species name (default: human)"},
# Optional integer
"limit": {"type": ["integer", "null"], "description": "Max results (default 10)"},
# Enum
"format": {
"type": "string",
"enum": ["json", "csv", "tsv"],
"description": "Output format"
},
},
"required": ["gene_id"]
}If a tool has NO required parameters (all params are optional), use "required": [] — do not omit the key entirely:
input_schema = {
"type": "object",
"properties": {
"query": {"type": ["string", "null"], "description": "Optional search filter"},
"limit": {"type": ["integer", "null"], "description": "Max results (default 50)"},
},
"required": []
}For plugin packages, the schema is in the JSON config file under "parameter", not in the class.
---
In-memory caching (for large one-time fetches)
If your tool fetches a large index that rarely changes (e.g. a full catalog or reference dataset), cache it at module level so subsequent calls within the same process are instant:
from typing import Optional, List, Dict, Any
_cache: Optional[List[Dict[str, Any]]] = None
def _fetch_data(timeout: int = 30) -> List[Dict[str, Any]]:
global _cache
if _cache is None:
import requests
resp = requests.get("https://api.example.com/full-index", timeout=timeout)
resp.raise_for_status()
_cache = resp.json().get("items", [])
return _cache
@register_tool("MyCachingTool")
class MyCachingTool(BaseTool):
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
items = _fetch_data(self.timeout)
# filter/search locally ...
return {"status": "success", "data": results}This avoids re-downloading on every tu test run and makes local search very fast.
---
Using environment variables
import os
def run(self, q: str) -> dict:
api_key = os.environ.get("MY_API_KEY")
if not api_key:
return {"status": "error", "message": "MY_API_KEY not set in .tooluniverse/.env"}
# ...Set the value in .tooluniverse/.env (workspace) or export it in the shell (plugin packages).
---
Multiple tools in one file
from tooluniverse.tool_registry import register_tool
@register_tool
class MyAPI_search:
name = "MyAPI_search"
description = "..."
input_schema = { ... }
def run(self, q: str) -> dict: ...
@register_tool
class MyAPI_get_record:
name = "MyAPI_get_record"
description = "..."
input_schema = { ... }
def run(self, record_id: str) -> dict: ...---
Tagging for category search (Pattern 1)
Add a category class attribute to make the tool discoverable by tu list --category:
@register_tool
class MyAPI_search:
name = "MyAPI_search"
category = ["my_domain", "search"]
description = "..."
...---
Error handling pattern
def run(self, q: str) -> dict:
import requests
try:
resp = requests.get("https://api.example.com/search", params={"q": q}, timeout=30)
resp.raise_for_status()
return {"status": "success", "data": resp.json()}
except requests.exceptions.Timeout:
return {"status": "error", "message": "Request timed out"}
except requests.exceptions.HTTPError as e:
return {"status": "error", "message": f"HTTP {e.response.status_code}: {e.response.text}"}
except Exception as e:
return {"status": "error", "message": str(e)}---
Return schema for tools with multiple output shapes
When a single tool returns different fields depending on inputs (e.g., an RC filter returns cutoff_frequency_Hz but an LC filter returns resonant_frequency_Hz), use a loose schema that only requires the fields common to all paths. Do not require fields that are absent in some cases — schema validation will fail for those code paths even when the tool is correct:
# In JSON config: only require fields that are always present
"return_schema": {
"type": "object",
"properties": {
"topology": {"type": "string"},
"formula": {"type": "string"}
}
# No "required" list — or only fields that always appear
}This is better than oneOf for tools that are one class with multiple dispatch paths, because oneOf requires exactly one schema to match which can be fragile.
---
Pure-computation tools (no HTTP)
Tools that perform local calculations (unit converters, color codes, checksum calculators, etc.) follow the same pattern but skip the HTTP layer entirely. Move the logic into standalone helper functions and call them from run():
from tooluniverse.base_tool import BaseTool
from tooluniverse.tool_registry import register_tool
from typing import Dict, Any
def _compute(value: float) -> Dict[str, Any]:
"""Pure computation — no imports needed at module level."""
result = value * 2 # replace with real logic
return {"input": value, "output": result}
@register_tool("MyCalculatorTool")
class MyCalculatorTool(BaseTool):
def __init__(self, tool_config: Dict[str, Any]):
super().__init__(tool_config)
self.operation = tool_config.get("fields", {}).get("operation", "compute")
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
try:
value = float(arguments.get("value") or 0)
result = _compute(value)
return {
"status": "success",
"data": result,
"metadata": {"note": "Runs offline — no network request."},
}
except Exception as e:
return {"error": str(e)}Key points for pure-computation tools:
- No
requestsimport needed — the tool is always available even without internet - Put logic in module-level helper functions; keep
run()thin - State the offline nature in the description and
metadata.note - No
timeoutparameter needed in__init__
SI prefix formatting helper
A reusable pattern for formatting numbers with SI prefixes (Ω, V, A, F, etc.). Always return the prefix without a trailing space so callers can append the unit directly ("4.7k" + "Ω" → "4.7kΩ", not "4.7k Ω"):
def _fmt_si(value: float) -> str:
"""Return value with SI prefix, no unit. Append unit in caller."""
abs_v = abs(value)
if abs_v == 0:
return "0"
if abs_v >= 1e9:
return f"{value / 1e9:.4g}G"
if abs_v >= 1e6:
return f"{value / 1e6:.4g}M"
if abs_v >= 1e3:
return f"{value / 1e3:.4g}k"
if abs_v >= 1:
return f"{value:.4g}"
if abs_v >= 1e-3:
return f"{value * 1e3:.4g}m"
if abs_v >= 1e-6:
return f"{value * 1e6:.4g}µ"
if abs_v >= 1e-9:
return f"{value * 1e9:.4g}n"
return f"{value * 1e12:.4g}p"
# Usage:
formatted = _fmt_si(4700) + "Ω" # "4.7kΩ"
formatted = _fmt_si(0.02) + "A" # "20mA"
formatted = _fmt_si(0.000047) + "F" # "47µF"The :.4g format gives 4 significant figures and drops trailing zeros automatically (4.700 → 4.7, 100.0 → 100).
Gotcha — never pass a pre-scaled unit to a two-argument `_fmt_si(value, unit)` variant. Some existing circuit tools use a variant that takes the unit as a second argument and appends it after the SI prefix. This only works correctly when the input value is in the base SI unit (meters, ohms, volts, amps, farads, hertz). If you pass an already-scaled value (e.g. width in mm) or a compound unit (e.g. "mΩ/m"), you will get double-prefixed output:
# WRONG — value is in mm, not metres; unit already contains "m"
_fmt_si(0.7814, "mm") # → "781.4 mmm" (triple-m!)
# CORRECT — value is in base SI unit (metres)
_fmt_si(0.0007814, "m") # → "781.4 µm"
# CORRECT for non-SI-scalable compound units — just format directly
f"{resistance_mohm_per_m:.4g} mΩ/m" # → "667.5 mΩ/m"The unit-free variant above (returning only the prefixed number) is safer because the caller always provides the unit explicitly at the call site, making the scale clear.
---
Tools with no required parameters
When all parameters are optional (e.g., a "get current position" tool that takes no input), set "required": [] in the JSON config and add {} as a test example:
{
"parameter": {
"type": "object",
"properties": {},
"required": []
},
"test_examples": [{}]
}tu test will call the tool with an empty dict {} and validate the result normally.
Gotcha — `"required": []` does not mean "no runtime requirements". Many calculator tools set "required": [] in the JSON schema (to avoid schema validation errors when callers omit optional parameters) but still need certain params at runtime depending on the operation. Check for missing required parameters in run() and return a clear error:
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
alpha = arguments.get("switching_activity")
cap_F = arguments.get("capacitance_F")
missing = [n for n, v in [("switching_activity", alpha), ("capacitance_F", cap_F)]
if v is None]
if missing:
return {"status": "error",
"message": f"Missing required parameters: {missing}"}---
Handling exponential computations safely
For tools like metastability calculators that use e^x with very large exponents (e.g., x = T_res / τ can be 10–1000), clamp the exponent before calling math.exp() to avoid OverflowError (which would produce an unhandled exception instead of a result):
import math
log_mtbf = exponent - math.log(denominator) # work in log space
mtbf = math.exp(min(log_mtbf, 700)) # cap at e^700 ≈ 10^304 before overflow
# For results requiring display, prefer log-domain representation
# when the value may exceed float max (~1.8e308):
if log_mtbf > 700:
result["MTBF_note"] = f"MTBF > e^700 seconds (effectively infinite)"Always work in log space first (log_result = log_a - log_b + exponent) and only convert to linear (math.exp(log_result)) at the very end. This is standard practice for any formula of the form e^x / denom where x can be large.
---
Allowing R=0 in multi-segment RC networks
When a tool accepts a list of [R, C] segments representing an RC network, the last segment often has R=0 (a pure load capacitance with no series resistance). Validate using r < 0 (reject negative) rather than r <= 0 (reject zero):
for i, seg in enumerate(segments):
r, c = float(seg[0]), float(seg[1])
if r < 0: # NOT r <= 0: zero resistance is valid for load-only nodes
raise ValueError(f"Segment {i}: R must be non-negative, got {r}")
if c <= 0: # C=0 is never physically meaningful
raise ValueError(f"Segment {i}: C must be positive, got {c}")---
Dual-mode operation: fields.operation vs runtime argument
When a tool supports multiple operations, you have two choices for how the caller selects the mode:
Config-time dispatch (one tool per operation): Set the mode via "fields": {"operation": "..."} in the JSON config. Each JSON entry becomes a separate tool with its own name, description, and parameter list. The class reads self.operation from __init__. This is the cleanest approach when operations have different parameters.
Runtime dispatch (single tool, caller passes operation): Expose "operation" as an optional parameter in the JSON schema. Read it in run() with a fallback to self.operation:
def __init__(self, tool_config):
super().__init__(tool_config)
self.operation = tool_config.get("fields", {}).get("operation", "solve_power")
def run(self, arguments):
# Runtime arg overrides config-time default
op = arguments.get("operation") or self.operation
if op == "solve_power":
...Use runtime dispatch when a single JSON tool entry covers all modes and the description clearly explains all modes. Use config-time dispatch when different modes warrant different parameter descriptions (e.g., Circuit_dynamic_power vs Circuit_max_frequency).
---
Physical constants and preset lookup tables
Define physical constants and named preset tables at module level (not inside the class), so they are available to helper functions and are easy to read and update:
import math
# Physical constants
_MU0 = 4.0 * math.pi * 1e-7 # H/m — permeability of free space
_KB_EV = 8.617333e-5 # eV/K — Boltzmann constant
# Named preset table with physical values
_PACKAGE_THETA_JA: Dict[str, float] = {
"sot-23": 200.0, # °C/W
"sot-223": 60.0,
"to-220": 50.0,
"tqfp-100": 35.0,
"bga-256": 20.0,
}In run(), resolve the parameter from: (1) explicit user value, (2) preset lookup, (3) default. Always give a clear error if the preset name is unrecognised:
def _resolve_theta(theta_ja, package):
if theta_ja is not None:
return float(theta_ja)
if package is not None:
key = package.lower().strip()
if key in _PACKAGE_THETA_JA:
return _PACKAGE_THETA_JA[key]
raise ValueError(
f"Unknown package '{package}'. Known: " + ", ".join(_PACKAGE_THETA_JA.keys())
)
raise ValueError("Provide theta_ja or a package name.")Include the preset table in the metadata key of the return value so callers can discover valid preset names without reading the source:
return {
"status": "success",
"data": {...},
"metadata": {
"note": "Runs offline — no network request.",
"package_presets": _PACKAGE_THETA_JA,
},
}---
Required-parameter extraction helper
When a tool has several required parameters, a small helper avoids repetitive None checks and gives a consistent error message:
def _req_float(arguments: Dict[str, Any], key: str) -> float:
v = arguments.get(key)
if v is None:
raise ValueError(f"Required parameter '{key}' is missing.")
return float(v)
def _req_int(arguments: Dict[str, Any], key: str) -> int:
v = arguments.get(key)
if v is None:
raise ValueError(f"Required parameter '{key}' is missing.")
return int(float(v)) # accept "1000" or 1000.0 as well as 1000Call these from run() inside the try block — they raise ValueError on missing params, which is caught and returned as {"status": "error", "message": ...}:
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
try:
current_A = _req_float(arguments, "current_A")
width_um = _req_float(arguments, "width_um")
num_stages = _req_int(arguments, "num_stages")
...
except ValueError as e:
return {"status": "error", "message": str(e)}---
Significant-figure rounding for wide-range outputs
round(x, N) works well when the magnitude of x is known, but fails silently when values can span many orders of magnitude — e.g., MTTF can range from milliseconds to decades, and round(0.00289, 1) returns 0.0 (the value is lost entirely):
>>> round(0.00289, 1)
0.0 # WRONG — looks like zeroFor outputs that can span orders of magnitude, use a 4-significant-figure helper instead of round(x, N):
import math
def _sig4(v: float) -> float:
"""Round to 4 significant figures; handles any magnitude."""
if v == 0:
return 0.0
mag = math.floor(math.log10(abs(v)))
return round(v, -int(mag) + 3)>>> _sig4(0.00289)
0.002887 # correct — 4 sig figs preserved
>>> _sig4(1234567)
1235000.0 # correct — 4 sig figs
>>> _sig4(26.088)
26.09 # correctUse _sig4() for values like MTTF, inductance, safe current limits, and any other quantity that could legitimately be anywhere from sub-nano to mega. Use round(x, N) only when you know the output will always stay near a fixed scale.