Skip to content

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:

  1. Declares its tools via discover_tools(), returning a list of AgentTool instances.
  2. Each AgentTool carries Pydantic argument_schema and output_schema models — these are converted to JSON Schema for LLM tool-use / function-calling.
  3. Registers messagebus handlers automatically when a bus is injected.

Plugin entry point

# pyproject.toml
[project.entry-points."opm.agents.toolbox"]
my-toolbox = "my_package:MyToolBox"

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.

pip install --pre ovos-PHAL-plugin-tools

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 tools field. The caller executes them itself; the server only relays the model's tool_calls.
  • Server-side tools are the persona's own ToolBox plugins (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:

llama-server -m Qwen3-0.6B-Q4_K_M.gguf --jinja -c 8192

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