Writing Your Own Toolset
Build toolsets with the @toolset / @toolify decorators and register them with add_toolset.
#Writing your own toolset
A toolset is just a plain Python class whose methods become agent tools. If you already have a class that talks to a service — a database client, an internal API wrapper, a reporting helper — you can expose its methods to an Agent by adding two decorators, no boilerplate in between. That is exactly how every provider connector in maivn-tools is built, and the same pattern works in your own code.
The @toolset and @toolify decorators in maivn are the recommended way to expose a configured class as a collection of Agent tools. Every provider connector in maivn-tools follows this pattern; the same decorators work in your own code.
from maivn import Agent, toolify, toolset
from maivn_tools import PermissionFlag, PermissionSet
@toolset(prefix="reports")
class WeeklyReportsToolSet:
"""Pull weekly KPI reports from an internal SQLite warehouse."""
def __init__(self, db_path: str):
self._db_path = db_path
@toolify(permissions=PermissionSet(PermissionFlag.READ))
def weekly_signups(self, week_of: str) -> list[dict]:
"""Signup counts by source for the week starting ``week_of`` (YYYY-MM-DD)."""
...
@toolify(permissions=PermissionSet(PermissionFlag.READ))
def revenue_per_signup(self, week_of: str) -> float:
"""Compute revenue per signup for ``week_of``."""
...
agent = Agent(name="weekly-reports", model="auto")
agent.add_toolset(WeeklyReportsToolSet("/srv/warehouse.db"))After add_toolset returns, the agent has two new tools — REPORTS_weekly_signups and REPORTS_revenue_per_signup — with parameters and descriptions inferred from the methods. The toolset prefix is uppercased and joined to the method name with an underscore so the generated names match the OpenAI/Anthropic tool-name regex (^[a-zA-Z0-9_-]{1,64}$).
#Agent-ready toolset checklist
The success criterion for a toolset is simple: a developer should be able to plug it into an agent and get useful behavior from an ordinary user request:
agent.add_toolset(MyToolSet(...))If the toolset needs a custom DAG, a long system prompt, or demo-specific wrappers to work, improve the toolset interface first.
Use this checklist when creating or reviewing a toolset:
- Permissions are accurate. Every method has the right
permissions=PermissionSet(...); irreversible tools also usedestructive=True. - Filtering works. Read-only agents can use
include_tags=["read"]; non-destructive agents can useexclude_tags=["destructive"]. - Docstrings are operational. Each tool explains when to use it, what arguments mean, what it returns, and which returned values are intended for follow-up calls.
- List/search defaults are human-readable. Broad tools return compact summaries by default instead of full provider payloads.
- IDs are not noise. Raw provider IDs are omitted by default unless a follow-up tool needs them. Prefer an opt-in flag such as
include_ids=False. - Broad calls are bounded. Defaults use small useful limits, usually 10 or 25, and expensive metadata expansion has its own cap.
- Write tools accept natural read results when safe. If a read tool returns a record with
record_id, a write tool can accept either the raw ID or that record shape when the mapping is obvious and non-dangerous. - Output schemas are explicit. Use
@tool_output(...)for a stable result schema. Ordinary Pydantic return annotations are not inferred by the current SDK. - Provider code stays generic. Register application wrappers for different defaults, result handling, or dependencies. Current
add_toolandadd_toolsetdo not acceptToolOverridearguments.
For example, a search tool should favor compact summaries:
@toolify(permissions=PermissionSet(PermissionFlag.READ))
def search_records(
query: str,
*,
limit: int = 10,
include_ids: bool = False,
) -> dict:
"""Search records and return compact summaries.
Returns `record_ref`, title, owner, status, and updated time. Raw provider
IDs are omitted unless `include_ids=True`; use IDs only for follow-up tool
calls, not final answers.
"""
...And a related write tool can accept the natural output shape when safe:
@toolify(permissions=PermissionSet(PermissionFlag.WRITE))
def update_record(
record: str | dict,
*,
status: str,
) -> dict:
"""Update one record by provider ID or by a record returned from search."""
...#Naming convention
Toolset classes use the XToolSet suffix:
The builder classes keep the Connector suffix because their tool surface is dynamic (e.g. Zapier exposes one tool per configured webhook URL) and they don't fit the @toolset class-walk model. register_connector dispatches to the right path automatically.
#Decorator semantics
#@toolset(...)
Marks a class as a toolset. Two forms:
@toolset
class Plain: ...
@toolset(prefix="github", tags=["developer"], require_marker=True)
class Configured: ...#@toolify(...)
Marks an individual method as a tool. Two forms:
class Example:
@toolify
def simple(self) -> None:
"""Bare form. No permission tags, no destructive marker."""
@toolify(
name="renamed", # override exposed tool name
description="...", # override the docstring
permissions=PermissionSet(PermissionFlag.WRITE),
destructive=False,
always_execute=False,
final_tool=False,
tags=["payments"],
metadata={"audit_zone": "high"},
before_execute=...,
after_execute=...,
)
def write_payment(self, ...): ...@toolify coexists with the existing dependency decorators (@depends_on_tool, @depends_on_agent, @compose_argument_policy, etc.). Order doesn't matter — each decorator attaches its own attribute and the dependency collector reads them all at registration time.
#@tool_output(...)
Use @tool_output(...) when a method returns a generic container such as dict[str, object] but the compact provider response has a stable shape that agents should be able to plan against. Keep output contracts separate from @toolify(...) metadata; metadata remains for operational tags and provider notes, not result schemas.
from maivn import tool_output, toolify, toolset
_SEARCH_OUTPUT_SCHEMA = {
"type": "object",
"properties": {
"records": {
"type": "array",
"items": {
"type": "object",
"properties": {
"record_ref": {"type": "string"},
"title": {"type": "string"},
},
"required": ["record_ref", "title"],
},
}
},
"required": ["records"],
}
@toolset(prefix="records")
class RecordsToolSet:
@toolify(permissions=PermissionSet(PermissionFlag.READ))
@tool_output(_SEARCH_OUTPUT_SCHEMA)
def search_records(self, query: str) -> dict[str, object]:
"""Search records and return compact summaries."""
...Use an explicit output decorator even for ordinary Pydantic return annotations. For a different application contract, decorate a wrapper function with @tool_output(...). ToolOverride registration is not supported by the current SDK.
#Registration
agent.add_toolset(instance) # register every @toolify method
agent.add_toolset(other_instance) # multiple toolsets accumulateThe SDK walks the class, finds every @toolify-marked method, and creates one ToolMetadata entry per method bound to the instance. Tools are appended to agent.tools so anything that inspects the agent's tool list sees them the same way it sees free-callable tools.
#Filtering at registration
add_toolset accepts four optional filter keywords. Each toolset class ships its complete tool surface; filters let callers narrow that surface to just what a given agent needs.
agent.add_toolset(instance, *,
include=None, # list[str]: only these method names register
exclude=None, # list[str]: skip these method names
include_tags=None, # list[str]: only methods with any matching tag
exclude_tags=None, # list[str]: skip methods with any matching tag
)Filters compose: a method must pass every active filter.
#Examples
Whitelist one method:
# An agent that should only be able to send Gmail, not read it.
agent.add_toolset(
GmailToolSet(token=cache),
include=["send_email"],
)Blacklist destructive methods:
# Keep everything except the destructive operations.
agent.add_toolset(
GoogleDriveToolSet(token=cache),
exclude_tags=["destructive"],
)Only read tools:
# Read-only Google Drive access.
agent.add_toolset(
GoogleDriveToolSet(token=cache),
include_tags=["read"],
)Combine include + exclude:
# All Slack tools EXCEPT post_message and search_messages.
agent.add_toolset(
SlackToolSet(token=bot_token),
exclude=["post_message", "search_messages"],
)By user-defined tags:
@toolset(prefix="dispatch")
class DispatchToolSet:
@toolify(tags=["email"])
def send_alert(self, ...): ...
@toolify(tags=["sms"])
def send_sms(self, ...): ...
@toolify(tags=["push"])
def send_push(self, ...): ...
# Only email + SMS, no push.
agent.add_toolset(DispatchToolSet(), include_tags=["email", "sms"])#Auto-derived tags
When evaluating include_tags / exclude_tags, the SDK augments the method's user-supplied tags with values derived from permissions and destructive:
That's why include_tags=["read"] and exclude_tags=["destructive"] work without you having to add those strings to every method's tags= list manually. The registered ToolMetadata.tags includes those derived tags as well as class and method tags.
#Names use the post-resolution method name
include / exclude match against:
- The method's Python attribute name (default), or
- The
name=override on@toolify(name=...).
Match is on the unprefixed name — no need to know the toolset prefix when narrowing.
#Filter-to-zero behavior
If filtering removes every method and the toolset has require_marker=True (the default), add_toolset raises ValueError with a clear hint that filters dropped everything. Set require_marker=False if you want filter-to-zero to silently pass.
#Tool inspection
Each registered ToolMetadata entry exposes its name, description, input/output schemas, tags, metadata, and callable target. The target is the bound method; its instance binding is preserved. Permission metadata is a list of lower-case permission names, not the original PermissionSet object.
for tool in agent.list_tools():
print(tool.name, tool.tags, tool.metadata)#What does NOT become a tool
add_toolset skips, in order:
- Names starting with
_(private convention). @property,@classmethod,@staticmethoddeclarations.- Nested classes.
- Non-callable attributes.
- Public methods without
@toolify— whenrequire_marker=True(default).
#Errors at registration
add_toolset can raise during registration. It appends methods as it goes, so a later error can leave earlier methods registered:
#Builder-style connectors
For backwards compatibility and for connectors whose tool surface is data-driven rather than method-driven, the Connector suffix and tools() -> list[Callable] pattern still works. register_connector detects the absence of @toolset and falls through to that path.
Builder connectors in maivn-tools:
GenericHttpConnector— tools come from a runtime list ofHttpEndpoint.OpenAPIConnector— tools come from a parsed OpenAPI document.GraphQLConnector— tools come from a runtime list ofGraphQLOperation.WebhookListener,MCPBridge— single-purpose adapters with no classical method-tool surface.ZapierConnector— dynamic per-webhook tool names.
These do not accept include/exclude / tag filters through add_toolset. If you need to narrow a builder, filter the input list before construction (e.g., pass a subset of HttpEndpoints).