Writing Tools¶
This guide walks through creating a custom tool from scratch and registering it as a plugin.
1. Create the handler¶
A tool handler is a plain async def function. Parameters become the tool’s
input parameters; the docstring becomes the description.
# my_tools/word_count.py
from axio import Tool
async def word_count(text: str) -> str:
"""Count the number of words in the given text."""
count = len(text.split())
return f"The text contains {count} words."
Key points:
The docstring becomes the tool description sent to the LLM.
Parameters support all standard Python type annotations. Use
Annotated+Fieldfromaxio.fieldfor descriptions, defaults, or numeric bounds.The function must be
async. It can return astr, adict, or any JSON-serialisable value. The agent coerces non-string return values to JSON when building theToolResultBlock.
Annotating parameters¶
Use Annotated together with Field from axio.field to attach metadata
to individual parameters. This controls what the LLM sees in the generated
JSON schema: descriptions, optional defaults, and numeric constraints.
Descriptions and optional parameters¶
Parameter descriptions are included in the JSON schema sent to the LLM with every tool call. Clear descriptions help the model understand what each parameter expects and produce correct values - especially for parameters whose purpose isn’t obvious from the name alone.
from typing import Annotated
from axio import Field, Tool
async def search(
query: Annotated[str, Field(description="Search query string")],
limit: Annotated[int, Field(description="Maximum results to return", default=10)],
) -> str:
"""Search for items matching the query."""
return f"Found results for '{query}' (limit={limit})"
tool = Tool(name="search", handler=search)
schema = tool.input_schema
assert schema["properties"]["query"]["description"] == "Search query string"
assert schema["properties"]["limit"]["description"] == "Maximum results to return"
# 'query' is required; 'limit' has a default so it is optional
assert "query" in schema["required"]
assert "limit" not in schema.get("required", [])
Parameters with a default value are omitted from required in the schema.
When the LLM omits an optional parameter, the default is applied
automatically before the handler is called. No None check is needed.
Numeric constraints¶
Use ge (≥) and le (≤) to add bounds that are included in the JSON schema
and enforced at call time:
from typing import Annotated
from axio import Field, Tool
async def resize(
width: Annotated[int, Field(description="Width in pixels", ge=1, le=4096)],
height: Annotated[int, Field(description="Height in pixels", ge=1, le=4096)],
) -> str:
"""Resize an image."""
return f"Resized to {width}x{height}"
tool = Tool(name="resize", handler=resize)
schema = tool.input_schema
assert schema["properties"]["width"]["minimum"] == 1
assert schema["properties"]["width"]["maximum"] == 4096
Strict string parameters¶
StrictStr rejects values that are not already a str (no silent coercion
from int or other types). Import it from axio.field:
from axio import StrictStr, Tool
async def echo(message: StrictStr) -> str:
"""Echo the message back."""
return message
tool = Tool(name="echo", handler=echo)
schema = tool.input_schema
assert schema["properties"]["message"]["type"] == "string"
StrictStr is equivalent to Annotated[str, FieldInfo(strict=True)]. The LLM
always sends strings, so StrictStr is mainly useful when you call a tool from
Python code and want to catch accidental non-string inputs early.
2. Wrap it in a Tool¶
from axio import Tool
word_count_tool = Tool(
name="word_count",
handler=word_count,
)
Tool reads the description from handler.__doc__ automatically.
Pass an explicit description= string to override it.
3. Use it with an agent¶
agent = Agent(
system="You are a helpful assistant.",
tools=[word_count_tool],
transport=my_transport,
)
Adding guards¶
Attach guards to control when the tool can run:
from axio.permission import AllowAllGuard
tool = Tool(
name="word_count",
handler=word_count,
guards=(AllowAllGuard(),),
)
See Guards for more on the guard system.
Concurrency control¶
Limit how many instances of your tool can run simultaneously:
async def web_fetch(url: str) -> str:
"""Fetch a URL."""
...
tool = Tool(
name="web_fetch",
handler=web_fetch,
concurrency=3, # at most 3 concurrent fetches
)
Error handling¶
If your handler raises an exception, Axio wraps it in HandlerError. It
sends the error message back to the model as a ToolResultBlock with
is_error=True. The model sees the error and can adjust its approach.
For expected failures, raise HandlerError directly with a clear message:
from axio import HandlerError, Tool
async def read_file(path: str) -> str:
"""Read a file."""
p = Path(path)
if not p.exists():
raise HandlerError(f"File not found: {path}")
return p.read_text()