Adding Tools¶
This guide explains how to add new toolsets to Bond using the protocol-driven architecture.
Overview¶
Bond toolsets follow a consistent pattern:
- Protocol - Defines the interface (what the backend must implement)
- Models - Pydantic models for request/response validation
- Adapter - Concrete implementation of the protocol
- Tools - Agent-facing functions that use dependency injection
This separation enables:
- Backend flexibility (swap implementations without changing tools)
- Type safety (all inputs/outputs validated)
- Testability (mock the protocol in tests)
File Structure¶
Create a new directory under src/bond/tools/:
src/bond/tools/my_tool/
├── __init__.py # Public API exports
├── _protocols.py # Protocol definition
├── _models.py # Request/response models
├── _adapter.py # Protocol implementation
├── _types.py # (optional) Domain types
├── _exceptions.py # (optional) Custom exceptions
└── tools.py # Tool functions
Step-by-Step Guide¶
1. Define the Protocol¶
The protocol defines what operations your backend must support:
# _protocols.py
"""Protocol definition for MyTool."""
from typing import Protocol, runtime_checkable
from ._types import MyResult
@runtime_checkable
class MyToolProtocol(Protocol):
"""Protocol for MyTool operations.
Provides methods to:
- Do something useful
- Do something else useful
"""
async def do_something(
self,
input_value: str,
options: dict[str, str] | None = None,
) -> MyResult:
"""Perform the main operation.
Args:
input_value: The input to process.
options: Optional configuration.
Returns:
MyResult with the operation outcome.
Raises:
MyToolError: If operation fails.
"""
...
Key points:
- Use
@runtime_checkablefor isinstance checks - Document all methods with Google-style docstrings
- Use
...(ellipsis) for method bodies in protocols
2. Create Request/Response Models¶
Define Pydantic models for tool inputs:
# _models.py
"""Request and error models for MyTool."""
from typing import Annotated
from pydantic import BaseModel, Field
class DoSomethingRequest(BaseModel):
"""Request to perform the main operation.
Agent Usage: Use this when you need to process an input
and get a structured result.
"""
input_value: Annotated[
str,
Field(description="The input value to process"),
]
options: Annotated[
dict[str, str] | None,
Field(default=None, description="Optional configuration"),
]
class Error(BaseModel):
"""Error response from MyTool operations.
Used as union return type: `MyResult | Error`.
"""
description: Annotated[
str,
Field(description="Error message explaining what went wrong"),
]
Best practices:
- Include "Agent Usage" in model docstrings to help the LLM understand when to use the tool
- Use
AnnotatedwithField(description=...)for all fields - Add validation constraints where appropriate (
ge=,le=,min_length=, etc.)
3. Implement the Adapter¶
Create a concrete implementation of your protocol:
# _adapter.py
"""Adapter implementation for MyTool."""
from ._exceptions import MyToolError
from ._protocols import MyToolProtocol
from ._types import MyResult
class MyToolAdapter:
"""Default implementation of MyToolProtocol.
Uses external service/library to perform operations.
"""
def __init__(self, api_key: str | None = None) -> None:
"""Initialize the adapter.
Args:
api_key: Optional API key for authentication.
"""
self._api_key = api_key
async def do_something(
self,
input_value: str,
options: dict[str, str] | None = None,
) -> MyResult:
"""Perform the main operation.
Args:
input_value: The input to process.
options: Optional configuration.
Returns:
MyResult with the operation outcome.
Raises:
MyToolError: If operation fails.
"""
try:
# Implementation here
result = await self._call_external_service(input_value, options)
return MyResult(value=result)
except Exception as e:
raise MyToolError(str(e)) from e
4. Create Tool Functions¶
Tool functions use RunContext for dependency injection:
# tools.py
"""MyTool tools for PydanticAI agents."""
from pydantic_ai import RunContext
from pydantic_ai.tools import Tool
from ._exceptions import MyToolError
from ._models import DoSomethingRequest, Error
from ._protocols import MyToolProtocol
from ._types import MyResult
async def do_something(
ctx: RunContext[MyToolProtocol],
request: DoSomethingRequest,
) -> MyResult | Error:
"""Perform the main operation.
Agent Usage:
Call this tool when you need to process an input value
and get a structured result:
- "Process this data" → do_something with the input
- "Transform this value" → check the result
Example:
```python
do_something({
"input_value": "hello world",
"options": {"format": "uppercase"}
})
```
Returns:
MyResult with the processed value,
or Error if the operation failed.
"""
try:
return await ctx.deps.do_something(
input_value=request.input_value,
options=request.options,
)
except MyToolError as e:
return Error(description=str(e))
# Export as toolset for BondAgent
my_tool_toolset: list[Tool[MyToolProtocol]] = [
Tool(do_something),
]
Key patterns:
- Return
Error, don't raise: Tools return union types likeMyResult | Error - Agent Usage docstrings: Describe when and how the agent should use the tool
- Example blocks: Show the exact JSON the agent should pass
- Type the toolset: Use
list[Tool[MyToolProtocol]]for type safety
5. Export Public API¶
Define what's public in __init__.py:
# __init__.py
"""MyTool: Description of what this toolset does.
Provides tools for:
- Operation one
- Operation two
"""
from ._adapter import MyToolAdapter
from ._exceptions import MyToolError
from ._models import DoSomethingRequest, Error
from ._protocols import MyToolProtocol
from ._types import MyResult
from .tools import my_tool_toolset
__all__ = [
# Adapter
"MyToolAdapter",
# Types
"MyResult",
# Protocol
"MyToolProtocol",
# Toolset
"my_tool_toolset",
# Request Models
"DoSomethingRequest",
"Error",
# Exceptions
"MyToolError",
]
6. Register with BondAgent¶
Users can now use your toolset:
from bond import BondAgent
from bond.tools.my_tool import my_tool_toolset, MyToolAdapter
# Create the adapter
adapter = MyToolAdapter(api_key="...")
# Create agent with tools
agent = BondAgent(
name="my-agent",
instructions="Use MyTool to process inputs.",
model="anthropic:claude-sonnet-4-20250514",
toolsets=[my_tool_toolset],
deps=adapter,
)
# Run the agent
result = await agent.ask("Process this: hello world")
Best Practices¶
Error Handling¶
Always return Error models instead of raising exceptions in tool functions:
# Good - returns Error
async def my_tool(ctx: RunContext[Protocol], request: Request) -> Result | Error:
try:
return await ctx.deps.operation(request.value)
except MyError as e:
return Error(description=str(e))
# Bad - raises exception
async def my_tool(ctx: RunContext[Protocol], request: Request) -> Result:
return await ctx.deps.operation(request.value) # May raise!
Docstrings¶
Include "Agent Usage" sections that explain:
- When to use the tool
- What inputs it expects
- What outputs to expect
async def analyze_data(
ctx: RunContext[AnalysisProtocol],
request: AnalyzeRequest,
) -> AnalysisResult | Error:
"""Analyze data and return insights.
Agent Usage:
Call this tool when you need to analyze structured data:
- "What patterns are in this data?" → analyze_data
- "Summarize these metrics" → analyze_data with summary=True
Example:
```python
analyze_data({
"data": [1, 2, 3, 4, 5],
"summary": true
})
```
Returns:
AnalysisResult with insights and statistics,
or Error if analysis failed.
"""
Type Safety¶
Use Annotated fields with descriptive metadata:
class MyRequest(BaseModel):
# Good - descriptive, validated
count: Annotated[
int,
Field(ge=1, le=100, description="Number of items to process (1-100)"),
]
# Bad - no description, no validation
count: int
See Also¶
- Testing - How to test your new toolset
- GitHunter Guide - Example toolset documentation
- API Reference: Tools - Full API documentation