Agent Tool Plugins¶
In a nutshell
These plugins give an AI assistant real abilities, like fetching information or performing an action, instead of only talking. Each "tool" is described in a standard way. The AI can read this description to learn what the tool does and what information it needs. See Agentic Loops for how an assistant decides to use them, and the Glossary for unfamiliar terms.
The OPM ToolBox framework provides a standardized mechanism for exposing discoverable, schema-validated functions to OVOS agents (persona solvers, agentic loops, MCP/UTCP clients). For full authoring documentation see the Plugin Manager reference.
OPM ToolBox Interface¶
Every ToolBox plugin:
- Declares its tools via
discover_tools(), returning a list ofAgentToolinstances. - Each
AgentToolcarries Pydanticargument_schemaandoutput_schemamodels — these are converted to JSON Schema for LLM tool-use / function-calling. - Registers messagebus handlers automatically when a bus is injected.
Plugin entry point¶
Authoring a ToolBox plugin¶
A minimal ToolBox implementing a single add tool:
from typing import List
from ovos_plugin_manager.templates.agent_tools import ToolBox, AgentTool, ToolArguments, ToolOutput
class AddArgs(ToolArguments):
a: float
b: float
class AddResult(ToolOutput):
sum: float
def add(args: AddArgs) -> AddResult:
return AddResult(sum=args.a + args.b)
class MathToolBox(ToolBox):
def __init__(self, config=None, bus=None):
# the plugin declares its own id, matching the entry-point name
super().__init__(toolbox_id="my-toolbox", config=config, bus=bus)
def discover_tools(self) -> List[AgentTool]:
return [
AgentTool(
name="add",
description="Add two numbers together.",
argument_schema=AddArgs,
output_schema=AddResult,
tool_call=add,
)
]
The base class signature is ToolBox.__init__(self, toolbox_id, config=None, bus=None) —
toolbox_id is required and positional. A plugin normally hides it, taking
__init__(self, config=None, bus=None) and passing its own identity up, as above, so that
callers do not have to know the name.
Loaders and shipped toolboxes agree on (config, bus)
All three loaders in the org (ovos-persona-server, ovos-PHAL-plugin-tools,
ovos-agentic-loop) construct a toolbox as cls(config=..., bus=bus), and every
shipped toolbox accepts __init__(self, config=None, bus=None). Plugins own their
own toolbox_id; no loader passes one. Write new toolboxes with that signature.
ToolBox.__init__ calls discover_tools() immediately to populate self.tools, and bind(bus)
registers the messagebus handlers described below. The full authoring guide with more
AgentTool, ToolArguments, and ToolOutput examples is embedded in the
Plugin Manager reference.
Walkthrough: your own tool, wired in¶
This mirrors the Personas 3-step pattern, applied to a tool instead of a persona. A skeleton toolbox with one tool, a script call, and a persona entry are enough.
1. Define the ToolBox. One tool, check_disk_space, shells out to a home script:
# my_ops_tools/toolbox.py
import subprocess
from typing import List
from ovos_plugin_manager.templates.agent_tools import ToolBox, AgentTool, ToolArguments, ToolOutput
class DiskSpaceArgs(ToolArguments):
path: str = "/"
class DiskSpaceResult(ToolOutput):
output: str
def check_disk_space(args: DiskSpaceArgs) -> DiskSpaceResult:
result = subprocess.run(
["/home/user/scripts/disk_report.sh", args.path],
capture_output=True, text=True, timeout=10
)
return DiskSpaceResult(output=result.stdout.strip())
class OpsToolBox(ToolBox):
def __init__(self, config=None, bus=None):
super().__init__(toolbox_id="my-ops-tools", config=config, bus=bus)
def discover_tools(self) -> List[AgentTool]:
return [
AgentTool(
name="check_disk_space",
description="Report free disk space for a given path by running a local script.",
argument_schema=DiskSpaceArgs,
output_schema=DiskSpaceResult,
tool_call=check_disk_space,
)
]
2. Register the entry point:
# pyproject.toml
[project.entry-points."opm.agents.toolbox"]
my-ops-tools = "my_ops_tools.toolbox:OpsToolBox"
3. Wire it into a persona alongside an LLM handler that can call it:
{
"name": "OpsAssistant",
"handlers": ["ovos-react-loop"],
"ovos-react-loop": {
"brain": "ovos-chat-openai-plugin",
"ovos-chat-openai-plugin": {
"api_url": "http://localhost:11434/v1"
},
"toolboxes": ["my-ops-tools"]
}
}
The persona names the loop as its handler, the loop's brain does the reasoning, and
toolboxes lists the entry-point name from step 2 so the loop loads OpsToolBox and
offers check_disk_space to the brain.
Static vs instance members¶
| Member | Kind | Why |
|---|---|---|
tool_json_list |
@property |
Reads self.tools, so it needs the instance's discovered tools. |
openai_tools |
@property |
Calls self.tools_to_openai_spec(self.tool_json_list) on this instance's own tools. |
tools_to_openai_spec |
@staticmethod |
Converts a plain tool_json_list-shaped list to the OpenAI tools spec. It takes no self, so a caller can hand it a list merged from several toolboxes at once, not just one instance's tools. |
normalize_tools |
@staticmethod |
Coerces a ToolBox, an OpenAI tool dict, or a list mixing either, into one flat OpenAI tools list. It has to work before an instance is chosen, since one of its inputs is a whole list of toolboxes. |
validate_input / validate_output |
@staticmethod |
Validate a given AgentTool's schema against raw arguments/results; the tool being validated is passed in, so no instance state is needed. |
ovos-agentic-loop's NativeToolCallEngine is the concrete case that needs tools_to_openai_spec
to be static: it merges tool_json_list output from several toolboxes into one list before
converting the merged list to the OpenAI spec in a single call.
PHAL Bus Provider¶
OpenVoiceOS/ovos-PHAL-plugin-tools is a PHAL plugin that loads all installed ToolBox plugins and registers them on the messagebus. Any component that can emit bus messages can then use them.
Entry point group: ovos.plugin.phal, the legacy name that OPM still aliases to opm.phal; plugin name ovos-phal-plugin-tools.
messagebus event table¶
| Message type | Direction | Payload |
|---|---|---|
ovos.tools.list |
→ plugin | (none) |
ovos.tools.list.response |
plugin → | {tools: [{name, description, argument_schema, output_schema, toolbox_id}]} |
ovos.tools.get |
→ plugin | {name: str} |
ovos.tools.get.response |
plugin → | Full schema dict or {error: str} |
ovos.tools.invoke |
→ plugin | {name: str, args: dict} |
ovos.tools.invoke.response |
plugin → | {name, result: dict} or {name, error: str} |
ovos.tools.reload |
→ plugin | (none) |
ovos.tools.reload.response |
plugin → | {loaded: [str, ...], total_tools: int} |
Every request gets an answer. Unknown tools, bad arguments, and tool exceptions
come back as an error field. The plugin never stays silent.
Third-party usage¶
from ovos_bus_client import MessageBusClient
from ovos_bus_client.message import Message
bus = MessageBusClient()
bus.run_in_thread()
tools = bus.wait_for_response(Message("ovos.tools.list"))
schema = bus.wait_for_response(Message("ovos.tools.get", {"name": "add"}))
result = bus.wait_for_response(Message("ovos.tools.invoke",
{"name": "add", "args": {"a": 1, "b": 2}}))
wait_for_response hangs forever if the bus was never reachable
Its default timeout=3.0 only starts counting once the client has connected. If the
messagebus is unreachable when run_in_thread() starts, emit()'s internal send waits on
an unbounded connected-event, and every call above blocks forever instead of respecting the
timeout. Make sure the messagebus service is already running before using this recipe.
ovos-agentic-loop Toolboxes¶
The built-in toolboxes are listed on Agentic Loops.
Wire them into a persona:
{
"name": "researcher",
"handlers": ["ovos-react-loop"],
"ovos-react-loop": {
"brain": "ovos-chat-openai-plugin",
"toolboxes": ["ovos-math-tools", "ovos-web-search-tools"]
}
}
Exposing Tools over MCP / UTCP¶
The Persona Server can bridge any installed ToolBox plugin to MCP and UTCP clients. See agent-interop.md#persona-server-tool-plugins-via-mcp-utcp.
Function calling: client-side vs server-side tools¶
The Persona Server can offer a model two
different kinds of tools in the same request, and only one of them is a ToolBox:
- Client-side tools are whatever the API caller put in the request's
toolsfield. The caller executes them itself; the server only relays the model'stool_calls. - Server-side tools are the persona's own
ToolBoxplugins (documented on this page). The server executes these in a bounded agentic loop and the client never sees the call.
If a client tool shares a name with one of the persona's ToolBox tools, the persona's tool
wins — the duplicate is dropped before the model ever sees it. Give tools specific names
(search_local_docs, not search) to avoid the collision.
Consuming an external MCP or UTCP server as a ToolBox¶
ovos-tool-adapters ships two opm.agents.toolbox plugins that bridge an external tool server
into the ToolBox interface, so a persona can call it like any native toolbox:
| Plugin ID | Package | Bridges |
|---|---|---|
ovos-mcp-toolbox |
ovos-tool-adapters |
An MCP server, over the stdio, sse, or http transport |
ovos-utcp-toolbox |
ovos-tool-adapters |
A UTCP-manual-advertising HTTP server |
pip install ovos-tool-adapters[mcp] # for ovos-mcp-toolbox
pip install ovos-tool-adapters[utcp] # for ovos-utcp-toolbox
Each takes its own config section, keyed by plugin name in the persona JSON exactly like a
handler plugin. For ovos-mcp-toolbox, transport selects the connection kind: stdio needs
command (and args), sse and http need a url:
{
"name": "researcher",
"handlers": ["ovos-react-loop"],
"ovos-react-loop": {
"brain": "ovos-chat-openai-plugin",
"toolboxes": ["ovos-mcp-toolbox"]
},
"ovos-mcp-toolbox": {
"transport": "stdio",
"command": "uvx",
"args": ["mcp-server-fetch"]
}
}
A ToolBox that fails to connect serves zero tools, silently
If the bridged server cannot be reached, or its config is wrong, the failure surfaces during
plugin discovery as a logged warning, not an error the caller sees. The persona keeps
running and answers normally — it just offers no tools at all. If a persona that should have
tools behaves as if it has none, check the server log for a ToolBox warning before assuming
the model is refusing to call anything.
Available ToolBoxes¶
These opm.agents.toolbox plugins each wrap one external service as a set of callable
AgentTool functions. Call them directly with ToolBox.call_tool(name, kwargs), or over the
bus via ovos.persona.tools.{toolbox_id}.call.
| Plugin ID | Tools | Package | API key |
|---|---|---|---|
ovos-wikipedia-tools |
search_wikipedia |
ovos-wikipedia-plugin |
None, public Wikipedia REST API |
ovos-ddg-tools |
search_duckduckgo, duckduckgo_infobox, duckduckgo_image |
ovos-ddg-plugin |
None, DuckDuckGo Instant Answer API |
ovos-wolfram-alpha-tools |
search_wolfram_alpha |
ovos-wolfram-alpha-plugin |
Optional, free key at developer.wolframalpha.com; a demo key ships in the plugin |
ovos-wordnet-tools |
define_word, word_relations |
ovos-wordnet-plugin |
None, local wn (Open English WordNet) corpus, auto-downloaded |
Skills are a natural place for more of these — weather, date and time, the ISS tracker — but
none of those skills registers an opm.agents.toolbox entry point yet. Only the four above
exist in the OVOS org.
The agentic loop bundles its own toolboxes (ovos-filesystem-tools,
ovos-shell-tools, ovos-web-search-tools, ovos-clock-tools, ovos-math-tools,
ovos-skill-md-toolbox). See that page for those.
Available Chat Engines¶
The OpenVoiceOS-org opm.agents.chat registrants are ovos-openai-plugin and
ovos-gguf-plugin (see the Agent Plugins catalog),
ovos-messagebus-chat-plugin, the ovos-agentic-loop reasoning loops, and
ovos-a2a-agent-plugin (ovos-a2a-solver, documented on
Agent Interoperability). This manual only documents plugins backed by
an OpenVoiceOS-org repo.
Testing Tool Calling Without a GPU¶
Reviewing tool-calling code needs a model that reliably emits tool calls, not necessarily a smart one. A small CPU-only model under llama.cpp's own OpenAI-compatible server is enough to exercise the whole path — server discovers tools, model requests one, caller or server executes it — without a GPU:
Qwen3-0.6B at Q4_K_M quantization is a roughly 380 MB download. Recent llama.cpp builds
enable the chat template by default, and --jinja above asks for it explicitly, which keeps
the command working on an older build. Where the template is off, the server produces no
tool_calls at all, and every tool-calling test degrades into an ordinary plain-text
conversation instead of failing loudly.
Point a persona at the running server with the OpenAI-compatible chat engine:
{
"name": "tool-test",
"handlers": ["ovos-chat-openai-plugin"],
"ovos-chat-openai-plugin": {
"api_url": "http://localhost:8080/v1"
}
}
Port 8080 here is llama-server's default, which is also the documented default port of
ovos-stt-server — if both run on the same host, start one of them on a
different port (llama-server --port 8081 and adjust api_url to match).
Entry-point name vs. package name
The entry point is ovos-chat-openai-plugin, not the package name
(ovos-openai-plugin, pip install ovos-openai-plugin). Writing the package name into
handlers instead of the entry-point name raises ImportError: 'ovos-openai-plugin' not
installed — which reads like the package genuinely isn't installed, when the actual problem
is the wrong key.
Read next: Interoperability (MCP/UTCP/A2A) Related: Agentic Loop Architectures · Agent Engine Types