Creating Tools
SkillDev toolsScaffold and build new tool integrations in tools/. Use when asked to create a new tool, add an API integration, or build a new client for an external service.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Creating Tools skill
What this skill tells your AI
The instructions your AI receives, as published by paradigmxyz/centaur in .agents/skills/creating-tools/SKILL.md and read by ahel’s review.
Scaffold and implement new tool integrations following the established conventions.
File Structure
Every tool lives at tools/<name>/ with exactly these files:
tools/<name>/
├── __init__.py # Empty file
├── .env.example # Document required secrets (one per line: KEY=description)
├── client.py # API client class + _client() factory function
├── cli.py # Typer CLI for standalone use
└── pyproject.toml # Package metadata + [tool.ai-v2] section
Step-by-Step
1. Create pyproject.toml
[project]
name = "<name>"
description = "<One-line description of what the tool does>"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"httpx>=0.27.0",
"typer>=0.12.0",
"rich>=13.0.0",
"python-dotenv>=1.0.0",
]
[project.scripts]
<name> = "<name>.cli:app"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.ai-v2]
module = "client.py"
The [tool.ai-v2] module = "client.py" line is required — the tool manager uses it to discover and register the tool.
Add extra dependencies only if needed (e.g., websockets, pydantic). The base set (httpx, typer, rich, python-dotenv) covers most tools.
2. Create client.py
Rules:
- NO
load_dotenv()— secrets come fromsecret()helper or env vars at runtime - Import
secretfromshared.tool_sdk— never useos.getenv()for API keys - Class-based — one main client class with public methods
_client()factory function at module bottom — this is how the tool manager instantiates the client- Methods starting with
_are excluded from tool registration (use for internal helpers) - Lifecycle methods (
close,__enter__,__exit__) are also excluded - All imports at file top — never inside functions
- Type hints on all public methods — the tool manager uses them to generate schemas
"""<Name> API client."""
import httpx
from shared.tool_sdk import secret
class <Name>Client:
"""Client for <Name> API."""
def __init__(self, api_key: str | None = None, timeout: float = 30.0):
self._api_key = api_key
self.base_url = "https://api.example.com"
self.timeout = timeout
self._client: httpx.Client | None = None
@property
def client(self) -> httpx.Client:
if self._client is None:
self._client = httpx.Client(timeout=self.timeout)
return self._client
def _get_api_key(self) -> str | None:
if self._api_key:
return self._api_key
return secret("<NAME>_API_KEY", "")
def _request(self, endpoint: str, params: dict | None = None) -> dict | list:
api_key = self._get_api_key()
if not api_key:
raise RuntimeError("<NAME>_API_KEY not set.")
url = f"{self.base_url}{endpoint}"
headers = {"Authorization": f"Bearer {api_key}"}
try:
response = self.client.get(url, params=params, headers=headers)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
raise RuntimeError(f"API error: {e.response.status_code} - {e.response.text}")
except httpx.RequestError as e:
raise RuntimeError(f"Request failed: {e}")
def search(self, query: str, limit: int = 10) -> dict:
"""Search for items."""
return self._request("/search", params={"q": query, "limit": limit})
def close(self):
if self._client:
self._client.close()
self._client = None
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
def _client() -> <Name>Client:
api_key = secret("<NAME>_API_KEY", "")
if not api_key:
raise RuntimeError("<NAME>_API_KEY not set.")
return <Name>Client(api_key=api_key)
3. Create cli.py
Rules:
- YES
load_dotenv()at the very top — CLIs run standalone and need to load.env - Thin wrapper around the client — each CLI command calls one client method
- Use
typerfor the CLI framework - Use
richorshared.cli_tablesfor formatted output - Support
--jsonand--markdownoutput flags on every command
"""CLI for <Name> API."""
from dotenv import load_dotenv
load_dotenv()
import json
import typer
from rich.console import Console
from shared.cli_tables import Table
app = typer.Typer(name="<name>", help="<Description>")
console = Console()
def get_client():
from .client import <Name>Client
return <Name>Client()
@app.command()
def search(
query: str = typer.Argument(..., help="Search query"),
limit: int = typer.Option(10, "--limit", "-n", help="Max results"),
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
):
"""Search for items."""
client = get_client()
data = client.search(query, limit=limit)
if json_output:
print(json.dumps(data, indent=2))
return
# ... rich table output ...
if __name__ == "__main__":
app()
4. Create __init__.py
Empty file:
5. Create .env.example
NAME_API_KEY=your-api-key-here
6. Add to 1Password (if needed)
If this is a credentialed tool, add the secret to 1Password:
- Vault: use the vault configured for your deployment
- Account: use the 1Password account configured for your deployment
- Item title: use the exact
ENV_VARname (e.g.,COINGECKO_API_KEY)
7. Update tools/README.md
Add a row to the "Available Plugins" table with the tool name, description, and required secrets.
Secrets Resolution Order
- Tool
.envfile (tools/<name>/.env) — per-tool overrides for local dev - Root
.envfile (repo root) — central file for all secrets - Environment variables — Docker, CI, 1Password secret manager
- Secret manager sidecar (
http://secrets:8100) — production (accessed viasecret())
Always use secret("KEY") in client.py — it handles all resolution layers. Never use os.getenv() or os.environ for API keys.
Common Patterns
No-auth tools (public APIs)
Skip _get_api_key() and auth headers. The _client() factory can be simpler:
def _client() -> DefillLlamaClient:
return DefiLlamaClient()
Multi-secret tools
Some tools need multiple credentials:
def _client() -> CoinbaseClient:
return CoinbaseClient(
api_key=secret("COINBASE_API_KEY"),
api_secret=secret("COINBASE_API_SECRET"),
passphrase=secret("COINBASE_API_PASSPHRASE"),
)
Secret cleaning
1Password sometimes returns multi-line blobs. If your API is sensitive to whitespace:
def _clean_secret(value: str) -> str:
return value.strip().split("\n")[0].strip()
POST/mutation methods
Name methods clearly (create_, delete_, update_). The tool-qa skill skips these during automated testing, but they're still registered for agent use.
Testing
After creating the tool:
- Verify registration: restart the API (or hit
POST /admin/reload-tools) and checkGET /toolsincludes your tool - Run tool-qa: use the
tool-qaskill to systematically test all methods - Test via curl:
source .env
curl -s "http://localhost:8000/tools/<name>" \
-H "Authorization: Bearer $API_SECRET_KEY" | jq
curl -s -X POST "http://localhost:8000/tools/<name>/search" \
-H "Authorization: Bearer $API_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "test", "limit": 3}' | jq
Deployment
Tools are hot-reloaded — no container restart needed. On merge to main:
- CI runs
git pullon the server - The API's file watcher detects changes in
tools/ - Tool is auto-reloaded within seconds
- Fallback:
POST /admin/reload-tools
Signals
- GitHub stars
- 1k
- Forks
- 241
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
creating-tools- Source
- github.com/paradigmxyz/centaur