---
title: "Develop a plugin"
description: "Reload local edits and exercise tools during development."
canonical_url: "https://docs.oz0.ai/build/plugins/develop-and-test"
---
# Develop a plugin

> Reload local edits and exercise tools during development.

For: plugin authors.

Use the local development loop to reload a plugin while you edit it. Before releasing, test the candidate on a cluster, inspect the proposed install and run its checks. The companion pages cover those separate stages and failure drills.

## The dev loop

```bash [Terminal]
oz0 dev            # in the plugin's folder, or: oz0 dev ./my-plugin
```

`dev` lints the plugin, starts a dev server on free ports and a node, installs the plugin from its working tree (no commit needed) and waits until the node reports its functions. Then it watches the folder and reinstalls the plugin within seconds of every change.

```text
Dev server ready. In another terminal:
  export OZ0_CONTEXT=/tmp/.../operator/context.json
Web UI: https://127.0.0.1:... (user admin, password in /tmp/.../admin-password)
Temporal UI: http://127.0.0.1:...
Node started (log: /tmp/.../node.log)
my-plugin dev-... ready in 14.0s: my_plugin.hello
Call a tool:
  oz0 tool call my_plugin.hello --args '{}'
Watching for changes; Ctrl+C stops.
my-plugin dev-... reloaded in 8.5s: my_plugin.hello, my_plugin.goodbye
```

When the tenant's [tool policy](https://docs.oz0.ai/govern/tool-policy) would not let one of the plugin's functions run freely, `dev` says so after it installs the plugin, with the fix:

```text
The tenant's tool policy does not let these functions run freely:
  my_plugin.save waits for a person's approval at each call by the tool policy: it is undeclared (no destructiveHint)
    fix: add destructiveHint: false (or true) to my_plugin.save, or readOnlyHint: true if it only reads; or let it run: oz0 policy allow my_plugin.save
```

Ctrl+C or SIGTERM stops the dev server and its node within about ten seconds, and a second one at once. On Linux the node also stops when `dev` is killed.

`ANTHROPIC_API_KEY` and `OPENAI_API_KEY` from your environment become the dev tenant's keys. Without one, `dev` says how to store a key or serve a local model. In a folder without a plugin, `dev` makes a [first run](https://docs.oz0.ai/getting-started/quickstart) with the hello agent on recorded answers instead.

What it needs:

- `orchestrator-zero-node` next to the server binary, on `PATH` or in an installed server's `dist/` folder, or `--node-binary`.
- A runtime bundle: `--runtime-bundle`, or `$OZ0_RUNTIME_BUNDLE`, or the newest in `./dist/runtime`, or an installed server's `dist/` folder.

The node's uv, Python and Node.js are cached between runs (`--cache-dir`), so only the first start is slow. The server starts empty every time.

The dev server's gateway sends model calls to the providers' public APIs. `--llm-anthropic-url` and `--llm-openai-url` (or `OZ0_LLM_ANTHROPIC_URL` and `OZ0_LLM_OPENAI_URL`) send them somewhere else instead, such as a company proxy or a stand-in for tests; the docs' own test runs the quickstart that way.

Secrets in the plugin's `requires.secrets` come from your environment: `dev` and `plugin test` copy each one that is set into the dev server's tenant before installing, so `export JEV_API_KEY=...` before `oz0 dev` is all a tool with a key needs. A declared secret that is not set keeps the node from starting the plugin, and `dev` says which.

On a machine with sandbox tier 1, the dev node runs your plugin's servers as an installed plugin's run: in a sandbox that reaches only the hosts of your manifest's `egress:` and the tenant's list, so a host you forgot to declare is refused here first, with what to add. A tool that says `sandbox: host` or `sandbox: own` runs, since you are the operator of that node. A stand-in for your API on `localhost` cannot be reached from that sandbox: start `oz0 dev` or `oz0 plugin test` with `--plugin-sandbox auto`, which keeps the host's network.

The sandbox's only way out is the egress proxy, which its `HTTP_PROXY` and `HTTPS_PROXY` point at, and Node.js is told to use them with `NODE_USE_ENV_PROXY=1`. A client that ignores them, such as a raw socket, fails with a name lookup error: no refusal, no denial on record, and nothing for `oz0 egress learn` to propose.

## Plugins it uses

A plugin that calls another plugin's tools or agents declares them under `uses` in its manifest. `dev` installs those plugins in the dev tenant before the working copy, and grants the working copy what its `uses` asks for there, as an admin's install with `--accept-uses` would:

```bash [Terminal]
oz0 dev ./pr-reviewer --with ../github        # repeatable: --with ../a --with ../b
```

```text
Dependency github dev-9c41... ready in 12.3s (from /home/you/github)
Granted in the dev tenant: pr-reviewer wants github.get_pull and delegation to github, from plugin github
pr-reviewer dev-4be0... ready in 8.1s: pr_reviewer.review
Watching for changes; Ctrl+C stops.
Dependency github dev-0d2a... reloaded in 7.9s (from /home/you/github)
```

- A folder given with `--with` is installed as it is on disk and reloads on every change, like the working copy, so you can change both at once.
- A `uses` entry that no `--with` folder gives is installed from its `source` (such as `source: hub:ai.oz0/github`), from the dev server's trusted [plugin indexes](https://docs.oz0.ai/build/plugins/indexes).
- An entry with neither is reported: its tools and agents are not in the dev tenant until you give its folder.
- When the installed version is outside the range the entry asks for, `dev` says so; the install's own check decides.
- The grant is the dev tenant's only, and its audit log records it (`uses.grant`). On a real cluster a tenant admin grants it at install.

`plugin test --with ../github` installs them the same way before it runs the evals.

## Call a tool by hand

Agents call tools for you. To call one yourself:

```bash [Terminal]
oz0 tool call my_plugin.hello --args '{"name": "you"}'
```

```text
job tool-1c9e... calls my_plugin.hello on node node-91378eb09343
tool:my_plugin.hello → my_plugin.hello {"name":"you"}
tool:my_plugin.hello ← my_plugin.hello Hello, you!
Hello, you!
trace: oz0 job trace tool-1c9e..., https://127.0.0.1:40121/jobs/tool-1c9e...
```

The call is a job of its own, `tool-<id>`, named `tool:<function>` in `job list`, on an online node of the tenant whose plugin has the function ready (`--node <id>` picks one, `--tenant` the tenant). It goes through the same checks as an agent's call: the tenant's [tool policy](https://docs.oz0.ai/govern/tool-policy), the plugins' `pre_tool_call` and `post_tool_call` [hooks](https://docs.oz0.ai/build/parts/hooks), and [approvals](https://docs.oz0.ai/agents/approvals), so a call that needs one waits until someone decides it (`oz0 approval list`). A refused call fails with the rule that refused it and how to fix it.

It starts like any job, too: kill switches of the tenant and of the tool stop it, and it counts against your [quotas](https://docs.oz0.ai/spend/budgets-and-quotas). `--id` names the job; an ID a running job has is refused, as are the IDs of routines, events and chat channels. A node that runs a [private candidate](https://docs.oz0.ai/build/plugins/develop-and-test/candidate-checks#try-it-on-a-real-cluster) refuses calls of other jobs, this one included. `--json` prints the job.

## Run the MCP server by hand

Your tool is a normal MCP server, so you can also run it outside Orchestrator Zero:

```bash [Terminal]
uv run python -m my_plugin.server        # speaks MCP over stdio
```

Any MCP client or inspector can talk to it.

## Related tasks

- [Check a plugin candidate](https://docs.oz0.ai/build/plugins/develop-and-test/candidate-checks): Test a candidate against a tenant before installing it.
- [Lint and test a plugin](https://docs.oz0.ai/build/plugins/develop-and-test/lint-and-tests): Check plugin definitions and run their declared tests.
- [Run failure drills](https://docs.oz0.ai/build/plugins/develop-and-test/tests-and-drills): Exercise failures and turn written procedures into tested plugin work.

<legacy-sections>

- <span id="a-changesets-candidate-check">

Section

</span>

: [a changesets candidate check](https://docs.oz0.ai/build/plugins/develop-and-test/candidate-checks#a-changesets-candidate-check)
- <span id="a-failure-drill">

Section

</span>

: [a failure drill](https://docs.oz0.ai/build/plugins/develop-and-test/tests-and-drills#a-failure-drill)
- <span id="lint">

Section

</span>

: [lint](https://docs.oz0.ai/build/plugins/develop-and-test/lint-and-tests#lint)
- <span id="names-of-your-own">

Section

</span>

: [names of your own](https://docs.oz0.ai/build/plugins/develop-and-test/lint-and-tests#names-of-your-own)
- <span id="preview-an-install-against-a-real-tenant">

Section

</span>

: [preview an install against a real tenant](https://docs.oz0.ai/build/plugins/develop-and-test/candidate-checks#preview-an-install-against-a-real-tenant)
- <span id="start-from-a-written-procedure">

Section

</span>

: [start from a written procedure](https://docs.oz0.ai/build/plugins/develop-and-test/tests-and-drills#start-from-a-written-procedure)
- <span id="test-it">

Section

</span>

: [test it](https://docs.oz0.ai/build/plugins/develop-and-test/lint-and-tests#test-it)
- <span id="the-fake-and-testsdrillyaml">

Section

</span>

: [the fake and testsdrillyaml](https://docs.oz0.ai/build/plugins/develop-and-test/tests-and-drills#the-fake-and-testsdrillyaml)
- <span id="try-it-on-a-real-cluster">

Section

</span>

: [try it on a real cluster](https://docs.oz0.ai/build/plugins/develop-and-test/candidate-checks#try-it-on-a-real-cluster)
- <span id="what-your-tools-answers-cost">

Section

</span>

: [what your tools answers cost](https://docs.oz0.ai/build/plugins/develop-and-test/lint-and-tests#what-your-tools-answers-cost)

</legacy-sections>


## Sitemap

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