Guides and How-toHow-to
Write a tool
Define a tool, register it, and gate it with a permission policy.
On this page
A tool is a function the LLM can call. Keeping capabilities in tools keeps the loop thin and lets the permission gates and hooks apply to your tool too. Adding one tool is four steps: define, implement, register, classify.
1. Register the definition
The schema the LLM sees lives in core/tools/definitions.json. An entry is one object in the list with name (snake_case), description, input_schema (JSON Schema), and the classification metadata category and cost_tier.
{
"name": "weather_lookup",
"description": "Look up the current weather for a city.",
"category": "external",
"cost_tier": "free",
"input_schema": {
"type": "object",
"properties": {
"city": { "type": "string", "description": "City name" }
},
"required": ["city"]
}
}The allowed values for category and cost_tier (free / cheap / expensive) are defined in core/tools/base.py as VALID_CATEGORIES and VALID_COST_TIERS.
2. Implement the handler
A tool satisfies the Tool protocol in core/tools/base.py. Four members make it valid: the name, description, and parameters properties plus the aexecute() coroutine. The protocol is duck-typed, so you do not subclass anything. Return a structured dict via tool_error() so the LLM can classify and recover. Model it on WebFetchTool in core/tools/web_tools.py.
# core/tools/weather_tools.py
from typing import Any
class WeatherLookupTool:
@property
def name(self) -> str:
return "weather_lookup"
@property
def description(self) -> str:
return "Look up the current weather for a city."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"},
},
"required": ["city"],
}
async def aexecute(self, **kwargs: Any) -> dict[str, Any]:
city = kwargs["city"]
if not city:
from core.tools.base import tool_error
return tool_error("city is required", error_type="validation")
# ... fetch and shape ...
return {"result": {"city": city, "summary": "..."}}3. Register it in the handler map
A handler that exists is not yet callable. Execution happens when the neutral composition in core/tools/handlers/ folds per-group builders into one collision-checked immutable plan. Instantiate the class, wrap aexecute in a closure, return it as UniqueEntries, and register the builder once in neutral_handler_groups() (the pattern in core/tools/handlers/single_tool.py).
# the core/tools/handlers/single_tool.py pattern
from core.tools.handlers.registration import UniqueEntries
def _build_weather_handlers() -> UniqueEntries[str, Any]:
from core.tools.weather_tools import WeatherLookupTool
tool = WeatherLookupTool()
async def handle_weather_lookup(**kwargs: Any) -> dict[str, Any]:
return await tool.aexecute(**kwargs)
return UniqueEntries((("weather_lookup", handle_weather_lookup),))
# core/tools/handlers/__init__.py — neutral_handler_groups()
("weather", _build_weather_handlers()),The dict key must match the name in definitions.json exactly. A missing schema or handler now fails explicitly in compose_tool_plan() before the session starts.
4. Set the permission tier
The tier is decided by the frozensets in core/agent/safety.py. If the tool is read-only, add it to SAFE_TOOLS and it runs without an approval prompt. If it mutates persistent state (memory, files, credentials), add it to WRITE_TOOLS so it requires user confirmation; for system access, use DANGEROUS_TOOLS. For costly calls, add an estimate to the EXPENSIVE_TOOLS dict to flip the cost gate on. ApprovalWorkflow in core/agent/approval.py reads these sets and raises the HITL prompt just before execution.
# core/agent/safety.py
SAFE_TOOLS = frozenset({
...,
"weather_lookup", # read-only — no approval prompt
})For per-mode or per-node filtering on top of the tier, use the PolicyChain in core/tools/policy.py to set denied_tools / allowed_tools across the 6-layer policy chain.
Verify
Confirm both ends, the schema and the handler, are wired.
uv run python -c "
from core.tools.composition import compose_tool_plan
bound, _transient = compose_tool_plan()
print('weather_lookup' in bound.schema_map)
print('weather_lookup' in bound.handlers)
"When both pass, the LLM sees the schema and calls execute. Finally smoke it inside an interactive session; a shell one-shot is not supported.
geode
> what's the weather in SeoulSee: Tool protocol, MCP tools.
Moved from the GEODE repository’s documentation site. Source commit de077a4 · View the source on GitHub