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 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,
)
4. Register as a plugin¶
To make your tool discoverable by the TUI and other Axio applications, add an
entry point to your pyproject.toml:
[project.entry-points."axio.tools"]
word_count = "my_tools.word_count:word_count"
After installing or syncing, discover_tools() will find it automatically.
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 and
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()
Dynamic tool providers¶
If your package needs to provide a variable number of tools based on
configuration (like MCP servers or Docker containers), implement the
ToolsPlugin protocol instead. ToolsPlugin is defined in the
axio_tui package:
from typing import Any
from axio_tui.plugin import ToolsPlugin
from axio import Tool
class MyPlugin:
"""A dynamic tool provider."""
@property
def label(self) -> str:
return "My Plugin"
async def init(self, config: Any = None, global_config: Any = None) -> None:
# Load configuration, connect to external services, etc.
pass
@property
def all_tools(self) -> list[Tool]:
# Build and return tools based on current configuration
return [...]
def settings_screen(self) -> Any:
# Return a Textual Screen for configuring this plugin in the TUI
return None
async def close(self) -> None:
# Clean up connections and resources
pass
Register under axio.tools.settings:
[project.entry-points."axio.tools.settings"]
my_plugin = "my_package.plugin:MyPlugin"
See Plugin System for the full ToolsPlugin protocol
and lifecycle documentation.