Your first plugin
For: plugin authors.
Build ops-tools, a plugin with a tool that reports disk space and system facts, and an ops-helper agent that answers questions about them. The tool runs on the job's home node, so its answers describe that node.
You need the binaries on your PATH and the runtime bundle from the Quickstart's first step, and an Anthropic key for the agent.
oz0 agent new ops-helper writes ops-helper.md, which installs, runs and reloads as it is (An agent in one file).This page builds the other case, a plugin with a tool of its own, which needs the full folder.When an agent in one file grows that far, oz0 plugin eject ./ops-helper.md --out ops-helper writes the folder (the manifest, the agent's YAML, its prompt and an evals skeleton), keeping its name and version, and you add the tool to it as below, with runtime: python and the tool under tools:.Scaffold it
oz0 plugin new ops-tools --template python-agent \
--description "Answers questions about the machine it runs on"
cd ops-tools
ops-tools/
├── oz0-plugin.yaml the manifest
├── README.md its documentation: what it does, how to set it up, its limits
├── LICENSE a placeholder until you choose a licence
├── AGENTS.md the rules and commands, for you and coding agents
├── .claude/skills/oz0-plugin/SKILL.md the same as a Claude Code skill
├── pyproject.toml the Python project (mcp==2.2.0, pytest for tests; uv takes releases a week old or older)
├── agents/ops-tools.yaml the agent
├── prompts/ops-tools.md its instructions
├── skills/ops-tools/SKILL.md
├── src/ops_tools/server.py the MCP server
└── tests/test_server.py the server's tests (uv run pytest)
Every Python template's pyproject.toml sets exclude-newer = "7 days" in [tool.uv], so uv lock takes only releases at least a week old, and the TypeScript template's .npmrc sets min-release-age=7 for the same with npm (11.10 or newer reads it): a malicious release is usually found and pulled within that time. You can change it in your plugin; keeping it is safer. uv reads a release's age from the upload time its package index reports, and leaves out a file whose index reports none.
An offline node resolves through the edge's toolchain, which reports each wheel's upload time as the package index gave it when oz0-node toolchain fetch fetched the wheel, so the rule holds there too (a toolchain fetched by an earlier release has no upload times until it is fetched again).
An offline folder's own packages carry no upload time, so a project without a uv.lock cannot resolve them under the rule; a plugin with a lock installs the files its lock names, and so does a tool's pypi: package, which the edge resolves and pins by hash when the plugin installs: a node installs the pinned files without reading the plugin's [tool.uv] settings again.
The other templates are python-tool (a tool alone), ts-tool (a tool in TypeScript) byo-agent (an agent you already have), and quarantine-map-reduce and quarantine-dual-model, agents that read untrusted documents only through quarantine children. procedure is an agent that follows a written procedure an expert approves (Develop and test), and flow-tool a fixed procedure offered to an agent as one flow tool. Building with a coding agent? See Build with Claude Code.
Write the tool
Replace src/ops_tools/server.py. Every @server.tool() function becomes a function agents can call as ops_tools.<function>; its docstring and type hints tell the model what it does. Its annotations tell the platform what it does to the world: these two only read, so they may run again when a call fails, and the tenant's tool policy lets them run without asking anyone.
"""MCP server for ops-tools: facts about the machine it runs on."""
import os
import platform
import shutil
from mcp.server import MCPServer
from mcp.types import ToolAnnotations
server = MCPServer("ops-tools")
READ_ONLY = ToolAnnotations(read_only_hint=True, open_world_hint=False)
@server.tool(annotations=READ_ONLY)
def disk_usage(path: str = "/") -> dict:
"""Total, used and free space of the disk that holds path, in gigabytes."""
total, used, free = shutil.disk_usage(path)
gb = 1024**3
return {
"path": path,
"total_gb": round(total / gb, 1),
"used_gb": round(used / gb, 1),
"free_gb": round(free / gb, 1),
}
@server.tool(annotations=READ_ONLY)
def system_info() -> dict:
"""Host name, operating system, architecture and number of CPUs of this machine."""
return {
"host": platform.node(),
"os": platform.system(),
"release": platform.release(),
"arch": platform.machine(),
"cpus": os.cpu_count(),
}
def main() -> None:
server.run("stdio")
if __name__ == "__main__":
main()
The template's test greets with hello, which is gone now. Test the new functions instead:
"""Tests for the MCP server of ops-tools."""
from ops_tools.server import disk_usage, system_info
def test_disk_usage() -> None:
usage = disk_usage("/")
assert usage["total_gb"] >= usage["free_gb"] >= 0
def test_system_info() -> None:
assert system_info()["cpus"] >= 1
Keep the tool on the home node
The template sets affinity: any, which lets the tool run on any node. A tool about this machine must run where the job runs, so remove that line and the default, node, applies. The template also says which kind of plugin this is: kind: capability, one job's tool, skill and agent, reaching no outside system (the kinds):
apiVersion: oz0/v1
name: ops-tools
version: 0.1.0
description: Answers questions about the machine it runs on
runtime: python
kind: capability
tools:
- id: ops_tools
mcp: ["python", "-m", "ops_tools.server"]
agents: [agents/]
skills: [skills/]
Write the agent
Rename the agent and give it the two functions. It keeps the template's model: the tenant's default alias, for a model that can call tools, so it runs on whichever provider the tenant has (the standard aliases). input and output are its contract: this one takes and gives text.
name: ops-helper
description: Answers questions about the machine it runs on
runtime: pydantic-ai
model:
use: default
needs: [tools]
instructions: prompts/ops-tools.md
input: text
output: text
tools: [ops_tools.disk_usage, ops_tools.system_info]
limits:
max_steps: 10
max_tokens: 50000
timeout: 2m
You are ops-helper. You answer questions about the machine you run on.
Use ops_tools.system_info and ops_tools.disk_usage to get facts; never guess them.
Answer in two or three sentences and name the host.
Update skills/ops-tools/SKILL.md to say when to use the two functions.
Lint and run it
uv lock
oz0 plugin lint
uv run pytest -q
plugin lint runs the same checks the edge runs before an install: unknown fields are errors, so a typo does not pass. It also reads the README and the licence the way the hub's page does, and warns that the plugin has no licence file while LICENSE holds the placeholder (Plugin limits). uv run pytest runs the plugin's own tests; AGENTS.md lists these commands for you and for coding agents. Then start a dev server with the plugin:
oz0 dev
dev prints the export OZ0_CONTEXT=... line and ops-tools ... ready in ...: ops_tools.disk_usage, ops_tools.system_info. In a second terminal:
export OZ0_CONTEXT=... # the line dev printed
printf %s "$ANTHROPIC_API_KEY" | oz0 secret set ANTHROPIC_API_KEY
oz0 run ops-helper "How much free disk does this machine have?"
The agent calls ops_tools.system_info and ops_tools.disk_usage on the node that picked up the job, and answers with that machine's numbers.
Publish it
Before others use it, say in README.md what ops-tools does, how to set it up and its limits (the template's sections ask for them), and choose a licence: replace LICENSE with the licence's text and declare it in oz0-plugin.yaml, such as license: MIT. A plugin installs from Git without one, but the hub lists none without it (Publish and version).
A plugin installs from Git. Commit it, tag a release and push it to a repository the edge can reach:
git init -b main && git add . && git commit -m "ops-tools 0.1.0"
git tag v0.1.0
git remote add origin https://github.com/your-org/ops-tools.git
git push -u origin main --tags
Then, against your cluster:
oz0 plugin install https://github.com/your-org/ops-tools
oz0 catalog
Every node of the tenant builds it and starts the tool, and ops-helper can run anywhere in the fleet. Ask it from different nodes' points of view by giving them labels and requiring one in the agent (see Routing).
Next
- How to structure plugins: connectors, capabilities and policies, and the house rules
plugin lintchecks.ops-toolsmixes a tool and an agent with a job, which is fine for a first plugin; a plugin others build on splits them. - Tools: affinity, environment and what the model sees.
- Agents: every field of an agent definition.
- Develop and test: the dev loop and calling a tool directly.
- Publish and version: refs, tags and pinning.