---
title: "Your first plugin"
description: "Write a tool that reports on the machine it runs on, give it to an agent, and publish it."
canonical_url: "https://docs.oz0.ai/build/plugins/first-plugin"
---
# Your first plugin

> Write a tool that reports on the machine it runs on, give it to an agent, and publish it.

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](https://docs.oz0.ai/getting-started/quickstart)'s first step, and an Anthropic key for the agent.

<tip>

Start from one file. An agent that only calls tools other plugins provide needs no folder: `oz0 agent new ops-helper` writes `ops-helper.md`, which installs, runs and reloads as it is ([An agent in one file](https://docs.oz0.ai/build/parts/agents#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:`.

</tip>

<steps level="2">

## Scaffold it

```bash [Terminal 1]
oz0 plugin new ops-tools --template python-agent \
  --description "Answers questions about the machine it runs on"
cd ops-tools
```

```text
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](https://docs.oz0.ai/fleet/offline-install)'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](https://docs.oz0.ai/build/parts/typescript-tools)) `byo-agent` ([an agent you already have](https://docs.oz0.ai/build/parts/bring-your-own-agent)), and `quarantine-map-reduce` and `quarantine-dual-model`, agents that read untrusted documents only through [quarantine children](https://docs.oz0.ai/govern/taint-and-labels/quarantine#the-quarantine-child). `procedure` is an agent that follows a written procedure an expert approves ([Develop and test](https://docs.oz0.ai/build/plugins/develop-and-test/tests-and-drills#start-from-a-written-procedure)), and `flow-tool` a fixed procedure offered to an agent as one [flow tool](https://docs.oz0.ai/build/parts/flows/flow-tools#flows-as-tools). Building with a coding agent? See [Build with Claude Code](https://docs.oz0.ai/build/plugins/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](https://docs.oz0.ai/govern/tool-policy) lets them run without asking anyone.

```python [src/ops_tools/server.py]
"""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:

```python [tests/test_server.py]
"""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](https://docs.oz0.ai/build/plugins/structure#four-plugin-kinds)):

```yaml [oz0-plugin.yaml]
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](https://docs.oz0.ai/spend/models-and-keys/aliases#the-standard-aliases)). `input` and `output` are its [contract](https://docs.oz0.ai/build/parts/contracts-and-limits): this one takes and gives text.

```yaml [agents/ops-tools.yaml]
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
```

```md [prompts/ops-tools.md]
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

```bash [Terminal 1]
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](https://docs.oz0.ai/build/plugins/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:

```bash [Terminal 1]
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:

```bash [Terminal 2]
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](https://docs.oz0.ai/build/plugins/publish#document-and-license-it)).

A plugin installs from Git. Commit it, tag a release and push it to a repository the edge can reach:

```bash [Terminal 1]
git init -b main && git add . && git commit -m "ops-tools 0.1.0"
git tag v0.1.0
```

```bash [Terminal 1]
git remote add origin https://github.com/your-org/ops-tools.git
git push -u origin main --tags
```

Then, against your cluster:

```bash [Terminal]
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](https://docs.oz0.ai/concepts/routing)).

</steps>

## Next

- [How to structure plugins](https://docs.oz0.ai/build/plugins/structure): connectors, capabilities and policies, and the house rules
`plugin lint` checks. `ops-tools` mixes a tool and an agent with a job, which is fine for a first plugin; a plugin
others build on splits them.
- [Tools](https://docs.oz0.ai/build/parts/tools): affinity, environment and what the model sees.
- [Agents](https://docs.oz0.ai/build/parts/agents): every field of an agent definition.
- [Develop and test](https://docs.oz0.ai/build/plugins/develop-and-test): the dev loop and calling a tool directly.
- [Publish and version](https://docs.oz0.ai/build/plugins/publish): refs, tags and pinning.


## Sitemap

See the full [sitemap](https://docs.oz0.ai/sitemap.md) for all pages.
