Structure a plugin
For: plugin authors.
Choose plugin boundaries around ownership, releases and permissions. A plugin has one version, rollout, grant and set of evals; splitting it changes what an owner approves and what a release can affect.
Use this page to choose the boundary and kind. The companion lint reference lists the house rules enforced by oz0 plugin lint, with their rule IDs.
Four plugin kinds
Every plugin says which kind it is, with kind: in oz0-plugin.yaml:
| Kind | One per | It holds | Example |
|---|---|---|---|
connector | outside system | the system's tools, its connection, its event sources, output schemas and contract tests; at most one generic delegate agent | ai.oz0/github, ai.oz0/slack |
capability | task or use case, owned by one team | agents, skills, flows, hooks and evals; it reaches connectors only through uses: grants that name exact functions | ai.oz0/pr-reviewer, ai.oz0/support-triage |
policy | organisation | hooks and guardrail packs that apply to every agent of the tenant | ai.oz0/guardrails |
set | combination evaluated together | no parts of its own: other plugins, each pinned to an exact version and source, and evals of the combination | ai.oz0/essentials |
apiVersion: oz0/v1
name: jira
version: 1.0.0
kind: connector
A connector has no business logic. Its one agent, if it has one, is a generic delegate: other plugins hand it
their work on the system ("comment on this issue", "find the open incidents"), and it calls only its own plugin's
functions. Mark it with tags: [delegate]. A connector may carry a flow of one step that runs one of its own
functions, so that a routine or an event can run it, and nothing longer.
A capability supports one task or use case. Its agents, flows and skills belong to the team that owns that work, and its evals guard it. It holds no credentials: it reaches the systems through their connectors, under a grant a tenant admin approves.
A plugin that only checks or changes what other plugins do, for the whole organisation, is a policy: redaction, injection checks, a check of each call that may only ask a person. Its hooks do the checking, and its guardrail packs are data the hooks read, so a policy has hooks. An agent a hook asks, such as a judge, may come with it.
The kind is the author's word for what the plugin is. Installs take a plugin without it; plugin lint says it is
missing (OZ-S001) and cannot run the rules that depend on it.
Harness and remote-agent plugins are connectors
A plugin that adapts an outside agent runtime to OZ is a connector: it connects to that runtime as other connectors
connect to an outside system. That covers a harness plugin, which runs one session of a
harness such as the Claude Agent SDK or an ACP agent per job, and a remote-agent plugin,
which runs an agent's loop at a provider such as Claude Managed Agents, or reaches another organisation's A2A agent.
The first-party ones say so: harness-claude, harness-acp, remote-claude-managed, a2a-remote, a2a-foundry
and a2a-agentcore are all kind: connector.
Such a connector holds the harness or the remote agent and its key or connection, and no job of its own. A
capability's agent runs on it by naming it, as in runtime: harness:claude-agent-sdk. A capability whose own agent
is written on a framework and run as a harness of the plugin (the harness-adk, harness-strands and
harness-agent-framework templates) stays a capability: that harness is the job's code, not an outside runtime.
Sets: the fourth kind
A set ships plugins that were evaluated together, as one install. It has no tools, agents, skills, flows, hooks,
connections or secrets of its own (lint refuses each, with a fix that moves it into a member): it lists members:,
each pinned to an exact version and a pinned source, with settings and model mappings it may suggest, and evals that
run the members' flows and agents by address.
apiVersion: oz0/v1
name: starter
version: 1.0.0
kind: set
description: Our team's starter, evaluated together
members:
- plugin: github
version: 1.4.0 # exact: a range is refused
source: hub:ai.oz0/github # or https://<repository>@<commit>, or oci://<image>@sha256:<digest>
settings: {GITHUB_ORG: acme} # suggested, never a secret
- plugin: pr-reviewer
version: 1.0.0
source: hub:ai.oz0/pr-reviewer
evals: [evals/]
A set is never how a capability gets its connectors: a capability names them under uses: with ranges, so a
connector's security fix reaches it without a new release of every capability. Build each member on its own first,
then a set when a combination is worth shipping as one decision, such as a team's starter. A Git member in a folder of
its repository is written https://<repository>//<folder>@<commit>. Sets says what installing,
updating and removing one does.
When to split
Split a plugin when its parts change at different speeds or belong to different people:
- A system and a job in one plugin. Move the job's agents, flows and skills into a capability, and keep the tools, the connection and the event sources in the connector. Teams that never run the job still use the connector, and the job's rollout no longer moves the system's tools.
- Two jobs in one capability. Give each its own plugin, owner and evals. One app can still use both.
- Two systems in one connector. One connector per system, even when one vendor sells both.
A split keeps the plugin's published name on one half and gives the other a new name: names are never reassigned to something else. Say in the upgrade note which plugin a tenant installs to keep what it had.
Splitting an agent inside a plugin is a different question, with its own reasons and boundaries: see When to split an agent.
Naming
- A connector's
nameis its system:jira,servicenow,github. - A capability's
nameis its job:it-triage,pr-reviewer,incident-responder. - The manifest's name carries no publisher. The hub listing adds it:
<publisher>/<name>, such asio.github.alice/jira. One publisher'sjiraand another's never clash on the hub, and a tenant picks the one it trusts. - No
-plugin,-connector,-mcpor-integrationin the name: the kind says that. - A name already published stays as it is.
Inside the plugin, tool IDs and agent names start with the plugin's name, as plugin lint's naming checks say.
Least privilege with uses:
A capability names the exact functions it calls of each connector, never a whole tool:
uses:
github:
version: ">=1.0, <2"
tools:
- oz0_github.get_pull_request
- oz0_github.list_checks
- oz0_github.comment
A tenant admin approves exactly that list at install, and the capability's agents and flows reach nothing else of
the connector. A grant of oz0_github.* would let a new version of the connector add a function the capability
reaches without anyone approving it (OZ-S010).
Test both ends of a range
version: is the range of the connector's versions the capability works with. Give it two ends: the lowest version
you tested, and an upper bound below the next major version (">=1.1, <2", or "^1.1"), never one exact version.
Once the capability is installed, an install or update of the connector outside that range is refused, and so is a version without a function, agent or skill the capability's grant names (updates and the plugins that use them).
An exact pin would hold back the connector's security fixes until you release the capability again; exact combinations of versions are what sets carry.
Test both ends in the capability's own tests:
- the newest end: every function, agent and skill
uses:names exists in the connector as it is now (its measuredtools.listing.json, or what its manifest names); - the lowest end: every one exists in a fixture of the connector at the lowest version, such as
tests/fixtures/github-1.1.0.json, written from the connector's source at that version. Itshowfield says where the lists came from, so the next person can write the fixture of a new lowest version the same way.
When the connector's next version adds a function you start to call, raise the lowest end to that version and write
its fixture. The verified capabilities do this; plugin lint --verified fails a capability whose range has no upper
bound, no range, or one exact version (OZ-S021).
A job that every company does on a different system, such as support triage on Jira Service Management or
ServiceNow, is still one capability: it marks each system's entry optional: true, and an install asks only for the
systems the tenant has (optional entries;
ai.oz0/support-triage is built that way).
An application is never a plugin
An application, such as a support portal, a chat app or an internal tool, lives outside OZ and calls agents:
- over the runtime API, AG-UI, MCP or a chat channel;
- one application uses many capabilities, and one capability serves many applications;
- an application that serves several customers uses one tenant per customer, so each customer's data, budgets and audit stay apart.
An application has its own users, release cycle and hosting. Inside a plugin it would be tied to a tenant's rollout, and its users to the tenant's accounts.
A suggested policy
A plugin may suggest what the tenant's tool policy does with its own functions, in the
manifest's policy: (its keys are in the manifest reference). A suggestion never
overrides the tenant's own rules, and only tightens what a function's type would give it: it asks a person (ask),
refuses (refuse) or puts a question to a judge (judge, which currently asks a person), for every call
or, with when, for the calls a condition holds for. Allow is never a suggestion.
policy:
- match: oz0_github.create_issue
action: ask
reason: the plugin asks before it writes
- match: oz0_github.delete_branch
action: judge
question: Is this branch merged and no longer needed?
reason: the plugin asks before it deletes
- At install the suggestion becomes ordinary rules of the tenant's tool policy, after the tenant's own: a rule the tenant already has for a function decides first, then the plugin's suggestion, then what the function's annotations say. Each rule is marked as the plugin's, with its reason. An installer needs no governance permission for it, since it only tightens; where the tenant's policy is stricter already (it refuses destructive calls, say), the stricter word stays.
- The installer may switch a suggested Ask to Allow, for one function or a group, with a reason (and a second admin where four eyes are on). The switched rule is then the tenant's own: an update keeps it.
- An update writes the new version's suggestions. A suggestion the new version drops or loosens stays as it was, and the install says which rule to remove if the tenant agrees.
- Uninstalling the plugin removes the rules it suggested, and leaves the ones the tenant switched.
- A judge is written as Ask until judges ship, and the install screen says so: the question waits in the rule's mark.
plugin lint checks a suggestion against the measured listing: one that would let a call run that the function's
annotations or requires_approval ask a person about (a judge on a destructive function, for instance) is an error,
and install refuses it too. A suggestion that names no listed function is a warning.
requires_approval is different: it is the one setting of a plugin that no rule of the tenant lowers. A person
decides every call unless the tenant refuses it, and the install screen shows the function as "Ask, set by the
plugin", with no switch. Two things stand in for that person: an agent's changes policy, for the call that applies its
changeset, and a person's allowance of the same call in one job. Keep it for calls a person
must decide every time (OZ-S018 says which).
Functions that declare nothing
A function whose listing says nothing about what it does asks a person at every call. At setup, an admin can say what such a function does instead, which writes one rule per function with the admin's reason:
oz0 plugin declare acme-crm acme_crm.lookup=read acme_crm.update=write --reason "lookup only reads; checked the source"
Saying a function only reads, or writes nothing destructive, lets its calls run, which loosens the policy: it takes a
reason, and a second admin where four eyes are on. The rule names the function; rules by kind (kinds:) and
conditions on tool.class still read the function as one that declares nothing, since its listing still says
nothing. Ask the plugin's author for the annotations.
Related tasks
- Plugin structure rules: Look up the verified plugin house rules enforced by lint.
Previous section links
- Section: oz s001
- Section: oz s002
- Section: oz s003
- Section: oz s004
- Section: oz s005
- Section: oz s006
- Section: oz s007
- Section: oz s008
- Section: oz s009
- Section: oz s010
- Section: oz s011
- Section: oz s012
- Section: oz s013
- Section: oz s014
- Section: oz s015
- Section: oz s016
- Section: oz s017
- Section: oz s018
- Section: oz s019
- Section: oz s020
- Section: oz s021
- Section: oz s022
- Section: the house rules
- Section: the rules