Structure a plugin

Plugin structure rules

Look up the verified plugin house rules enforced by lint.

For: plugin authors.

Look up the verified plugin house rules enforced by lint.

The house rules

Verified plugins keep these rules, and the build kit teaches them to coding agents:

  • every function says whether it writes, and the plugin's suggested policy asks a person about each write by default, a default the installer may switch; requires_approval only for calls a person must decide every time;
  • a connection, never a pasted secret;
  • exact egress hosts, with the vendor's EU regional host where it has one, and a host that serves files anyone can publish only when the plugin must download from it (OZ-S022);
  • writes that are safe to repeat (an idempotency key);
  • the AI label on everything sent to people;
  • output schemas, so results become cards;
  • evals for agents that replay offline, and contract tests with fakes for connectors;
  • about 20 to 30 functions per agent before lazy tools;
  • agents ask for the tenant's aliases (default, fast, smart), never a vendor's model without a reason;
  • the naming above, and the kind.

Every plugin, verified or not, also keeps its README and its licence within the checks on Plugin limits: its README.md is its documentation on the hub's page, within the limits it shows, and a licence file holds the licence license: declares. Those are the loader's checks, with codes of their own rather than rule IDs, and plugin lint, plugin pack and hub submit fail on them.

plugin lint checks the rules it can see, each with its ID. They are warnings for every plugin, except OZ-S012, which is an error. plugin lint --verified lints at the level verified plugins are held to: every warning is an error there, and the rules only verified plugins must pass run too. The project's tests lint every first-party plugin this way, with a freshly measured listing. Installs never refuse a plugin over a house rule.

Some rules read the plugin's measured tool listing (tools.listing.json, which oz0 plugin test or oz0 plugin pack --list-tools writes): the functions' annotations and output schemas are in the listing, not in the manifest. Without one, lint says once that those rules could not run: a note, and an error with --verified, which also fails a listing that leaves a tool out or was measured from other files (OZ-S014). A remote (url:) tool has no measured listing, since no test node holds its connection's token: its committed snapshot, snapshots/<tool>.json, stands for it, with the manifest's overrides merged (OZ-S015).

Lint cannot see whether a message carries the AI label. A verified plugin proves it in a contract test of each function that sends to people: the test calls the function against a fake of the system and checks that the label is on what arrives.

"Verified" means the project tested, pinned and secured the plugin. It never means that a system's vendor endorses it. See what verified means.

oz0 plugin test plugins/jira            # measures tools.listing.json and replays the evals
oz0 plugin lint --verified plugins/jira # every house rule as an error
oz0 plugin lint --json plugins/jira | jq '.problems[] | select(.code | startswith("OZ-S"))'

In --json, each finding has its rule ID as code, warning, and strict: true when --verified makes it an error.

The rules

OZ-S001

The manifest says no kind, or one that is not connector, capability or policy, or policy for a plugin that is not one. A warning. A policy is not one when it has no hooks, when it holds a connection, an event source or a channel, which are an outside system's, or when its agents or flows reach hosts with secrets of the plugin's own, which is a capability's work. A policy's hooks may keep the credentials of a service they call, since hooks call only the plugin's own functions.

Why: the kind says where agents, credentials and business logic belong, and three rules (OZ-S002, OZ-S003, OZ-S011) depend on it. Without a kind they cannot run, and lint says which. None of them runs on a policy, so a connector or a capability that only said policy would pass them all. The connector's rules (OZ-S002, OZ-S011) also run on any plugin with a connection, an event source or a channel, whatever its kind says, and their findings say so.

Fix: add kind: connector, kind: capability or kind: policy to oz0-plugin.yaml, the one the plugin is. Lint's fix says which the plugin looks like. A policy's connection, event source or channel moves into the system's connector.

OZ-S002

A connector carries business logic. A warning. It finds a connector with more than one agent, an agent that is not a generic delegate (not tagged delegate, or calling another plugin's functions), or a flow of more than one step or one that calls something other than one of the connector's own functions.

Why: business logic in a connector ties a job's changes to the system's tools. Every tenant that only needs the system gets the job too, and its rollout moves both.

Fix: move the agents and flows that do a job into a capability that reaches the connector through uses:. Keep one delegate, tagged delegate, that calls only the connector's functions.

OZ-S003

A capability holds credentials of its own. A warning. It finds a capability with a connection, a secret bound to hosts (requires.inject), or, when the capability runs a server or a harness of its own, or has an agent with egress of its own (a harness session of the agent holds the plugin's secrets and may reach those hosts), a secret in requires.secrets or requires.optional_secrets.

Leaving out egress: does not exempt a secret: a process that receives it must still be trusted with it. Strict plugin sandboxing constrains network access on tier-1 nodes, but tier 0, explicit host-network modes and operator exemptions have different boundaries. See plugin sandboxes.

A capability made only of flows over other plugins' functions hands no process a secret.

Why: the system's credentials belong to its connector, where one connection, one set of approvals and one audit trail cover every capability that uses it.

Fix: reach the system through its connector, with its exact functions under uses:. A system without a connector gets one: a plugin of kind connector with the tools and the connection. A secret that is data, not a credential (such as names to look for that must not be readable as a setting), says so on a line of the manifest of its own, with the reason, ten to 300 characters:

oz0-plugin.yaml
requires:
  # OZ-S003 allow PUBLIC_ACCESS_NAMES: names to look for in an export, matched in the plugin's own process; it reaches no host
  optional_secrets: [PUBLIC_ACCESS_NAMES]

Lint then reports nothing at the plain level, and at the verified level prints the reason as a warning it does not fail on, so whoever reviews a verified plugin reads it and checks that the plugin's servers send the secret nowhere.

OZ-S004

A write function is neither marked nor behind approval. A warning. It reads the measured listing: a function that says neither readOnlyHint nor destructiveHint, and is not under requires_approval or the tool's approval:.

Why: MCP reads a function without annotations as a destructive write. The tenant's tool policy decides calls by these annotations, and cannot tell such a function from a read.

Fix: give every function its annotations. A read says readOnlyHint: true. A write says readOnlyHint: false, and destructiveHint: true when it deletes or overwrites, which the default tool policy sends to a person. A write a person should be asked about by default gets a rule in the plugin's suggested policy that asks (a suggestion that asks counts as behind approval here). Only a call a person must decide every time goes under requires_approval.

OZ-S005

Egress names a wildcard instead of exact hosts. A warning. It finds any * in the manifest's egress or an agent's.

Why: a wildcard such as *.example.com lets a tool reach every host under the domain, including ones its vendor lets customers create.

Fix: name each host the plugin reaches, with the vendor's EU regional host where it has one. A host that differs per customer, such as a region or an account's own host, goes on the tenant's egress allowlist (oz0 tenant egress), and the plugin's docs say which host to add.

OZ-S006

The plugin's name is off the format. A warning. It finds a manifest name that carries a publisher (oz0-, or a label of the hub listing's publisher), one that ends in -plugin, -connector, -mcp, -mcp-server or -integration, and a hub listing (hub/listing.yaml) whose name is not <publisher>/<manifest name>.

Why: the hub adds the publisher, so a name that carries one says it twice, and a name that says what a plugin is repeats the kind.

Fix: name a connector for its system and a capability for its job, before you publish. A name already published stays as it is, since names are never reassigned.

OZ-S007

A pasted secret for a system the plugin also has a connection for. A warning. It finds a secret in requires.secrets or requires.optional_secrets that one of the plugin's connections names, or that carries a connection's prefix.

Why: a pasted secret sits in the plugin's processes for as long as they run, while a connection gives each call only its own token, for its own function, and the edge can refresh or revoke it.

Fix: take the secret out of requires and let the connection give each call its token: name the functions under the connection's scopes, and read the token from the call's _meta under oz0/connections (connections).

OZ-S008

A function has no output schema. Verified plugins only, from the measured listing; an error with --verified.

Why: OZ draws a function's results as cards from its output schema, and checks results against it.

Fix: return structured content with an output schema. In the Python MCP SDK, a typed return value (a TypedDict or a model) becomes the function's outputSchema.

OZ-S009

An agent lists more than 30 functions without lazy tools. A warning.

Why: every function an agent lists is in every model request, which costs tokens and makes the model pick worse.

Fix: set tool_loading: lazy on the agent (plugin lint --fix does), so the model finds the functions it needs, or split the agent's work.

OZ-S010

uses: grants a whole tool instead of exact functions. A warning. It finds <tool>.* under uses.<plugin>.tools.

Why: a whole-tool grant reaches every function a later version of the other plugin adds, without anyone approving it.

Fix: name each function the plugin's agents and flows call.

OZ-S011

A verified connector has no evals that replay offline, or no contract tests of its own. Verified connectors only; an error with --verified. A connector with functions needs tests in tests/. A connector with a flow, or an agent that calls a model and is not its generic delegate, needs an eval whose every case replays from a recording (evals/cassettes/<eval>/<case>.jsonl, or a flow's evals/steps/<eval>/<case>.json).

The delegate (tagged delegate, calling only the connector's own functions) needs none: it does what the agent handing it work asks, with the functions the tests cover, so the eval of that work belongs to the capability that delegates. An install's quality gate does not replay recorded model answers, so an eval of the delegate would make every install and update of the connector call your models.

Why: a connector is tested against a fake of its system, shaped from the vendor's documentation, so CI can run it without the system, and its evals run without a model.

Fix: add the tests, and record the evals' answers (evals). Include a test, for each function that sends to people, that the AI label is on what it sends.

OZ-S012

The plugin depends on LiteLLM. An error for every plugin and template, verified or not. It reads every folder the plugin carries but a checkout's .git:

  • dependency files: pyproject.toml (requirement lists and the tables of poetry and pdm), Pipfile, setup.cfg, setup.py's requirement strings, requirements and constraints files (requirements*.txt and .in, and any .txt or .in in a requirements or constraints folder), conda's environment.yml, package.json, and the lockfiles uv.lock, poetry.lock, pdm.lock, Pipfile.lock, package-lock.json, npm-shrinkwrap.json, yarn.lock, pnpm-lock.yaml and bun.lock (a lockfile names transitive dependencies too).
    A TOML, JSON or YAML file among them is decoded as its installer decodes it, and every name in it is read after its quoting and escapes; one that does not decode is an error.
    A requirements file is read as pip reads it: lines continued with a backslash, requirements given as a URL (a wheel, an archive, a repository, #egg=), and the files it reads with -r or -c whatever their names (one outside the plugin, or by URL, is an error);
  • Python sources (.py, .pyi, .pyw) and notebooks' code cells, read as Python's tokenizer reads them, with every line ending (LF, CRLF or a lone CR), comments and strings apart: an import or from statement of any form, after a colon (try: import litellm), across a backslash or brackets, a module named in a string to importlib.import_module or __import__, an import in a string given to exec, eval or compile, and a notebook's !pip install or %pip install line;
  • JavaScript and TypeScript sources: require, import ... from, export ... from, a bare import and import() of a string;
  • a package of LiteLLM's installed or vendored in any folder, .venv and node_modules included, by its folder's name, and a wheel or source archive of it the plugin carries;
  • a package tool's pinned closure (the lock the edge makes when it pins package:), by its packages' names: a package that depends on LiteLLM through others is refused when the edge pins it, in dev mode too.

A file larger than 16 MiB, a file or folder the rule cannot read, or more files than it reads, is an error too: the rule says the plugin holds no LiteLLM only when it read all of it. What it cannot see is a module name made at run time from parts or escapes, or code a package fetches when it runs: the lockfiles and the pinned closures, which the rule decodes, name every package a node installs.

Why: LiteLLM's supply chain was compromised in March 2026, and OZ's gateway already does what it is used for. Agents and tools call models through the gateway, where budgets, data rules and metering apply.

Fix: take LiteLLM out, and the package that brings it in. Call the model with the provider's own SDK, or an OpenAI-compatible client pointed at the gateway's base URL, and lock again.

OZ-S013

The plugin is for an area of the AI Act's Annex III. A warning. It reads the purposes the plugin's agents and flows declare and the descriptions of the plugin, its agents and its flows, and finds words that point at an Annex III area: biometrics, critical infrastructure, education, employment and workers' management, access to essential services (credit scoring, insurance pricing, public benefits, emergency calls), law enforcement, migration and border control, and the administration of justice and democratic processes.

Why: an AI system used in these areas may be high-risk under the AI Act, with duties the Act sets out. None of the project's published plugins is for these areas. Lint sees only what the plugin says about itself; what a tenant records in its inventory (a risk class, a purpose) is the tenant's, and lint never reads it. The rule is a reminder, not a classification.

Fix: if the plugin is meant for such an area, have its use reviewed before anyone runs it, and record its agents' risk class in the tenant's inventory. If the words mean something else here, rephrase them.

OZ-S014

The measured tool listing is missing, leaves a tool out or is stale, so the rules that read it could not run. At the verified level, an error; without --verified, lint prints a note instead, so a new plugin does not warn until it is measured. It finds three things:

  • no tools.listing.json at all, said once (unless every tool is a remote one read from its snapshot);
  • a tool with a server (mcp:, package: or url:) whose functions the listing does not hold, one finding per tool (a remote tool with a committed snapshot is read from the snapshot instead);
  • a listing measured from other files than the plugin's now: its source_digest is not the digest of the files oz0 plugin pack packs.

Why: the functions' annotations and output schemas are in what the plugin's servers list, which only running them shows. OZ-S004 and OZ-S008 read them, so a listing that leaves a tool out, or one from before a function changed, would let them pass on functions they never saw.

Fix: run oz0 plugin test or oz0 plugin pack --list-tools, which start the plugin's servers on a test node and write tools.listing.json, after your last change. Commit it with the files it was measured from, or measure it in CI before you lint. A remote tool is measured by its snapshot instead (OZ-S015).

OZ-S015

A tool is not pinned. Verified plugins only; an error with --verified. It finds a package: that does not name one exact version (pypi:<name>==<version>, npm:<name>@<version>, oci:<image>@sha256:<digest>), and a remote (url:) tool without a committed snapshot of the functions it expects, snapshots/<tool>.json, or with one whose hashes are not its listings' or that lacks a function the manifest names (an override, requires_approval).

It also finds a function's ai_label that points at an argument the snapshot's input schema does not give as a string (a nullable string, a local $ref and a branch of anyOf count; a reference to another document does not). A pointer into an open object counts as pinned: an object that names no properties and takes any key ({"type": "object"}, or additionalProperties: true), as an issue tracker's fields often is, so ai_label: /fields/description passes.

The snapshot pins that the server takes such a member, and the node labels it when a call gives it as text and refuses the call when it gives something else. An object whose additionalProperties is a schema gives its members that schema, and one that names its properties or says additionalProperties: false takes only what it names, so a pointer past them is still a finding.

Why: a verified plugin is what was tested. A package that floats to a newer version, or a remote server that lists other functions than the ones the plugin was written and tested for, is something else. The snapshot also lets lint read a remote tool's functions: OZ-S004 and OZ-S008 check them with the manifest's overrides merged, as a node merges them.

Fix: pin the version you tested. For a remote tool, commit its snapshot: the functions you expect the server to list (names, descriptions, input and output schemas, annotations), each with the hash a node's tool snapshot gives its listing (SHA-256 of its canonical JSON), and a contract test that a fake of the server lists exactly that.

oz0 plugin new --template mcp-connector writes both. The first node's snapshot stays what decides which functions nodes call; compare it with yours in oz0 catalog --json. A snapshot may also name the server's url as the manifest writes it (placeholders and all); when it does, it must be the manifest's.

snapshots/tracker.json
{
  "format": "oz0.tool-snapshot.v1",
  "tool": "tracker",
  "listed_from": "the vendor's MCP server documentation, read 2026-10-14",
  "functions": [
    {"name": "tracker.list_issues", "hash": "sha256:...",
     "listing": {"name": "list_issues", "description": "List the open issues.",
                 "inputSchema": {"type": "object", "properties": {}}}}
  ]
}

OZ-S016

A verified agent names a vendor's model without model.vendor_reason. Verified level only, where it is an error.

Why: an agent that names anthropic/... or openai/... runs only for tenants who pay that vendor. An agent that asks for an alias runs on whatever models the tenant maps it to, and every tenant has default, fast and smart once a provider is set up (the standard aliases). Some agents do need one vendor: an agent on the OpenAI Agents SDK, or one that uses a vendor's own server-side feature.

Fix: ask for an alias with what the agent needs of it:

model: {use: default, needs: [tools]}

or, when the agent needs the vendor's own feature, keep the model and say which in one sentence:

model:
  primary: openai/gpt-5.5
  vendor_reason: Runs on the OpenAI Agents SDK, whose hand-offs need an OpenAI model.

OZ-S017

A verified plugin asks for a model provider's key. Verified level only, where it is an error.

Why: a provider's key in requires.secrets or requires.optional_secrets (ANTHROPIC_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY or any other provider preset's key) reaches the plugin's own processes. With it, a tool calls the provider around the gateway: unmetered, outside every budget and quota, and outside the tenant's data rules.

Fix: take the key out and ask the platform for models (tools that need a model):

requires:
  models:
    sampling: default            # MCP sampling, answered as the job's model call
    gateway: [openai-embeddings] # a per-call token for what sampling cannot express

OZ-S018

A write the plugin's suggested policy does not ask about. Verified level only, where it is an error. It reads the measured listing: a function that does not say readOnlyHint: true, is not under its tool's requires_approval, and is not covered by a rule of the plugin's policy: without a condition that asks or refuses.

Why: a verified plugin's writes ask a person by default. The install screen shows that default, "Ask", with the plugin's reason, and the installer may switch a write, or the whole group of writes, to Allow with a reason. A judge does not count as an explicit requirement for human approval. A suggested judge is currently written as Ask, so the separate check of what an agent combines, which counts anything but Allow, counts it as closing.

Fix: add a rule for the function to the manifest's policy::

policy:
  - match: oz0_jira.create_issue
    action: ask
    reason: the plugin asks before it writes

<tool>.* covers every function of a tool, reads included, which then ask too: name the writes. Put a function under requires_approval instead only when a person must decide every call, whatever the tenant chooses: an effect that cannot be undone and reaches outside the tenant (sending mail, merging into a protected branch), or a call the system expects a named person to authorise.

OZ-S019

References to another plugin's parts name it. A warning; an error at the verified level.

Why: a bare name means a part of the same plugin. When an agent, a flow, a hook or an eval names another plugin's function or agent by its bare name, a reader of the file cannot tell that it reaches across plugins, or which plugin it needs under uses:. The address <plugin>/<part> says both where it is written.

Fix: write the address, which plugin lint --fix does for YAML files:

# before
tools:
  - oz0_github.get_pull_request
# after
tools:
  - github/oz0_github.get_pull_request

The bundle still holds the bare name, so the change asks nothing of running jobs or older nodes.

OZ-S020

An agent that combines private data, outside input and sending out asks before it sends. A warning; at the verified level an error for a capability, and a note for the other kinds. It reads the measured listing, the manifest and the plugin's own flows.

Why: an agent that reads private data, is steered by text from outside and may send without anyone asked could be talked into sending that data away in one job. Lint reads the plugin alone, so it finds:

  • private data: a function whose tool has a connection, or declares a label source (labels.source);
  • outside input: a function that does not say openWorldHint: false (or a remote server's), a tool with labels.untrusted: true, oz.wait_for_event, and a flow of the plugin whose webhook or event trigger runs the agent in a step;
  • sending out: a function of type Write, Sends out, Destructive or Unknown.

It flags the agent when it holds all three and a sending function runs without asking: its default, before any tenant rule, is Allow (the plugin's suggestion first, then what the function declares; requires_approval always asks). Another plugin's function under uses: is Unknown to lint, so it counts as sending out and asks as Unknown does; the install screen reads its real type and the tenant's rules. See What an agent combines.

Fix: ask about each sending function the finding names, in the plugin's policy::

policy:
  - match: oz0_notes.save
    action: ask
    reason: a person decides what an agent that reads private data and outside input sends

Or take an ingredient away: say openWorldHint: false on a function that only reads the tenant's own data, or give the outside input to another agent that cannot send.

OZ-S021

A verified capability's uses: give each connector a range with an upper bound. Verified level only, where it is an error.

Why: an update of a plugin is checked against every installed plugin whose uses: names it (updates and the plugins that use them): a version outside a dependent's range, or one without a function, agent or skill it names, is refused. A capability with no range takes any version, including a next major one that drops what it calls. A range with no upper bound does the same. One exact version is the other mistake: it would hold back the connector's security fix until the capability is released again.

Fix: give each entry the versions the capability was tested with, from the lowest one its tests read to below the next major version, and test both ends (test both ends of a range):

uses:
  github:
    version: ">=1.1, <2"
    tools: [oz0_github.get_pull_request, oz0_github.comment]

Exact combinations of versions are what sets carry.

OZ-S022

Egress lists a host that serves files anyone can publish. A warning; an error with --verified. It finds an entry of the manifest's egress or an agent's that reaches a content host: a code host's raw files, archives or gists (raw.githubusercontent.com, codeload.github.com, github.com), a package registry (registry.npmjs.org, files.pythonhosted.org), a container registry (registry-1.docker.io, ghcr.io), a public package CDN, a model hub (huggingface.co), an object store (*.s3.amazonaws.com, storage.googleapis.com), a paste site (pastebin.com), a temporary file host (transfer.sh, 0x0.st), a tunnel (*.ngrok.io, *.trycloudflare.com) or a file share (cdn.discordapp.com).

A pattern that overlaps one counts too: *.githubusercontent.com reaches raw.githubusercontent.com, and *.amazonaws.com reaches the S3 hosts. The list is one list the server's change reviews, oz0 egress learn, the install screen and the console read alike.

Why: whatever anyone publishes there, a session that may reach the host may download and run. A model with a backdoor in its weights needs only such a host to fetch its payload, and an injected instruction needs only one to fetch a script. The finding says what the host is; it is a reason to look, not a verdict. Installs never refuse a plugin for it: the install screen shows the same note, and a tenant admin decides.

A connector's own download host is shown at every level and never fails lint. The list names, for such a host, the exact hosts of the one system that downloads from it (its companions), and the exception holds only when the connector's manifest lists one of them too: ai.oz0/github lists codeload.github.com beside api.github.com, for the repository archives the API redirects to. A host on the same domain is not enough: gist.github.com beside api.github.com, or *.s3.amazonaws.com beside sts.amazonaws.com, still fails with --verified, so adding a harmless host never silences the rule. An agent's egress has no such exception.

Fix: keep the host only if the plugin must download from it, and say in the plugin's docs what it fetches there. A host only some customers need, such as their package mirror or bucket, goes on the tenant's egress allowlist instead (oz0 tenant egress), where a widening is a reviewed change that shows the same note.

Copyright © 2026