Parts

Import an OpenAPI document

Turn an API's OpenAPI 3.0 or 3.1 document into a plugin with one tool function per operation, marked read-only or destructive from the method, with the API's key injected on the node.

For: plugin authors.

Run oz0 plugin import openapi to generate a plugin from an API's OpenAPI document. The result has one tool function per supported operation. Each function's HTTP method determines its initial annotations, so the tenant's tool policy applies from the first install.

The node inserts the API credential into outbound requests. The generated plugin does not hold that credential.

oz0 plugin import openapi petstore.yaml --name petstore --out ./petstore
cd petstore && uv lock && oz0 plugin lint
printf %s "$PETSTORE_TOKEN" | oz0 secret set PETSTORE_API_KEY
git init -q && git add . && git commit -qm "Import the petstore API" && oz0 plugin install "file://$PWD"
Imported Petstore 1.0.0 (OpenAPI 3.0.3, sha256 9d105a411fae) as the plugin petstore in ./petstore:
  6 functions of the tool petstore: 3 read, 2 write, 1 destructive
  calls https://pets.example.test/v1 (egress pets.example.test:443)
  credential: the tenant secret PETSTORE_API_KEY, as a bearer token in Authorization, injected by the node's egress
  proxy: the plugin's process holds only a placeholder; from the document's security scheme bearerAuth
Left out (1):
  POST /pets/{petId}/photo (uploadPhoto): its request body (multipart/form-data) is not one this importer can send
Imported with changes (2):
  list_pets: the header parameter Accept is ignored, as the specification says
  schema extensions (x-...) are not kept
Tool definitions: about 445 tokens in every model request of an agent that lists them all.

The document is a file or an https:// URL (YAML or JSON, at most 16 MiB). A URL is fetched once, from your machine, with TLS verified and no credentials; it is the only connection the command makes (Outbound connections). The document's text in the output (its title, version, references and names) is printed with control characters written out (\u{001B}), so a document cannot set your terminal's clipboard or title, or erase lines of the report.

What you get

The folder is a plugin like any other: lint it, test it, keep it in Git, install it. Nothing in the platform knows it came from OpenAPI.

FileWhat it is
oz0-plugin.yamlOne tool (the plugin's name with _ for -), the API's host as the plugin's only egress, the credential's secret and its binding to the host
src/<module>/operations.jsonThe operations, as data: name, method, path, parameters, body, input and output schemas, annotations, description
src/<module>/server.py, httpcall.pyThe MCP server and its HTTP client: fixed code from Orchestrator Zero's template, the same for every import
README.mdWhich document (its source and SHA-256), what was imported, what was left out and what was changed
tests/, AGENTS.md, skills/The server's own tests, the rules for coding agents, a skill on using the tool

The document never becomes code. Its names become identifiers by a fixed rule, and its descriptions, summaries and schemas are data in operations.json, so a document cannot make the importer write a program. Invisible characters in its text are written out (\u{202E}), as in every tool listing.

Functions

Each operation becomes one function of the tool, named after its operationId in snake case (getPetById is get_pet_by_id), or after its method and path when it has none (GET /pets/{petId} is get_pets_pet_id). The name a model sees, <tool>__<function>, is at most 64 characters: a longer one is cut and ends in six hex digits of a hash. Two operations that come to the same name are refused, naming both; leave one out with --operations.

  • Arguments are the path, query and header parameters by name, and the request body as body. A parameter whose name a model provider would refuse (filter[label]) or that two locations share gets another argument name; the README lists each one.

The input schema

The importer writes JSON Schema 2020-12: $ref resolved inside the document, oneOf, anyOf, allOf and formats kept, 3.0's nullable, boolean exclusiveMinimum and example rewritten, readOnly properties left out of request bodies. A schema that refers to itself is cut where it comes back, with a description that says so. Common mistakes are fixed or dropped with a note: required: true on a property moves into its object's required list, an examples map becomes a list, and a keyword whose value JSON Schema does not allow (type: file, minLength: "3") is dropped.

Every input schema is then checked against JSON Schema 2020-12, since a model provider refuses a request with an invalid tool schema; an operation whose schema still fails is left out.

The output

The function returns the HTTP status and body: the output schema is {status, body}. body is open, because an MCP client fails a call whose answer does not match the output schema, and APIs often answer with a null or a field their document does not list; the first 2xx JSON response's schema is kept under body as x-oz0-documented-schema, for reading, and is not enforced. A status of 400 or more comes back as an error result with the API's answer, which usually says what to change.

  • Request bodies go as JSON (application/json and +json types), application/x-www-form-urlencoded, multipart/form-data without file parts, or text/plain.

Sizes are bounded

The importer bounds expansion so a small document cannot create an arbitrarily large plugin: one enum, required list or examples list keeps at most 1,000 entries, and one enum, const, default, pattern or examples value at most 16 KiB as JSON. A larger value is dropped (examples are cut to what fits) with a note; a dropped enum no longer limits the argument. One function's input or output schema stops at 4,000 schema objects or 1 MiB (the rest is left open, with a note), and an operation takes at most 256 parameters with names of at most 256 bytes.

All functions together stop at 400,000 schema objects or 32 MiB, about the size of operations.json: past that the command stops and asks you to select fewer operations.

What the importer cannot carry over is never dropped silently: cookie parameters, the matrix and label path styles, file uploads, references to other files, discriminator, xml, x- extensions and a form body's encoding are named in the output and the README. An operation that could not be called correctly without them is left out, with the reason. So is an operation or path whose own servers name another place than the base URL: its requests would go to the wrong host. Import it on its own with --base-url and --operations.

What each function does to the world

The function's annotations come from its method:

MethodAnnotationsWhat the import writes
GET, HEAD, OPTIONSreadOnlyHint: trueRuns
POST, PATCHreadOnlyHint: false, destructiveHint: falseWaits for a person's approval
PUTas POST, and idempotentHint: trueWaits for a person's approval
DELETEdestructiveHint: true, idempotentHint: trueWaits for a person's approval

Every write is in the tool's requires_approval, with the deciders a plugin may ship: approval: {deciders: {role: operator}, not_starter: true}, an operator who did not start the job. A tenant names its own teams and people in its tool policy. --writes-without-approval leaves the writes to the tenant's policy instead, where only deletes wait by default.

Every function is openWorldHint: true: it calls an outside API. TRACE operations are not imported. The method is the document's word: a GET that changes something is the document's mistake, so read the README's list before you install, and change a function's annotations in operations.json if you must.

The calls

The server calls one origin: the document's first server (its variables at their defaults, a relative URL resolved against the document's URL), or --base-url. It must be https://; --allow-http takes http:// for a local test API, and never with a credential. The host is the plugin's whole egress, so in sandbox tier 1 the plugin can reach nothing else.

  • Every path parameter is percent-encoded into its own segment, and an empty value, . or .. is refused: ../admin, @other.host or a full URL stay inside their segment. No argument can change the scheme, host or port, and the server checks the URL it built against the base URL before it connects.
  • Queries are encoded by each parameter's style (form, spaceDelimited, pipeDelimited, deepObject); header values may not hold a line break; headers the connection owns (Host, Content-Length, Cookie and the like) cannot be parameters.
  • A redirect is followed only to the same scheme, host and port, for GET and HEAD, at most five times. Any other comes back as its status and the Location it named.
  • An answer is read up to 1 MiB and 30 seconds; the 30 seconds hold for the whole call, however slowly the API sends. OZ0_OPENAPI_MAX_BYTES and OZ0_OPENAPI_TIMEOUT in the tool's env change that, up to 16 MiB and 300 seconds.
  • The server goes through the node's egress proxy (HTTPS_PROXY) whenever there is one, and trusts the certificates the node gives it (SSL_CERT_FILE).

The API's credential

--auth says how the API takes its credential; without it, the importer reads the document's security schemes and says which one it chose.

--authThe request carries
bearerAuthorization: Bearer <secret> (also for OAuth 2.0 and OpenID Connect schemes, with a token an admin sets)
header:NAMENAME: <secret> (not a header the connection owns, such as Host, Content-Length or Proxy-*; Authorization is fine)
basic:USERHTTP Basic authentication as USER with the secret as the password
query:NAME?NAME=<secret> (servers and proxies log URLs: prefer a header)
nonenothing

The secret is a tenant secret (--secret, by default <NAME>_API_KEY). By default the plugin binds it to the API's host with credential injection: the node's egress proxy puts the value on each request to that host, and the plugin's process holds only a placeholder, so code in it cannot send the key anywhere. --auth-secret gives the process the value instead, on a descriptor (secrets_from: fd), for nodes that cannot inject (Windows); prefer the default. An operation whose document says security: [] is called without the credential.

An API on each tenant's own instance

Some APIs are a product each customer runs on a host of its own: Jira or Confluence Data Center, a GitLab, an internal service. --connection <name> imports one with no URL or secret fixed at import:

Terminal
oz0 plugin import openapi jira.json --name jira-dc --out ./jira-dc --connection jira-dc \
  --operations 'GET /api/2/search' --operations 'POST /api/2/issue/{issueIdOrKey}/comment' \
  --rename search_1=search_issues --ai-label add_comment=/body/body
  • Each call's token and instance come from the plugin's connection, in the call's _meta as the runtime hands them over for that call: the token goes in Authorization: Bearer, and the call goes to the connection's base_url and nowhere else. No requires.secrets, no credential binding, no egress of the plugin's own; a job an outside event starts reaches the instance in the sandbox once its host is on the tenant's egress allowlist.
  • The instance is checked before each call. The server resolves the instance's host on the node, checks every address against the tenant's egress allowlist and connects only to the addresses it checked, keeping the name for TLS and the Host header. A public address needs no entry. A private one (the company's own network, where most such instances are) needs the allowlist to name the instance (oz0 tenant egress). An address of the node itself, its link or a cloud's metadata service needs an entry that names that address.
  • The manifest gets the connection, with a guided step where an admin pastes the instance's URL and an access token, kept together, so a token always goes to the instance it was pasted for. Where the API has its own sign-in, replace the step with it, an authorization code with PKCE and a refresh, saving the instance's URL with the token (save.values: {base_url: "https://{params.host}"}): Connections. ai.oz0/atlassian-dc does this for Jira's and Confluence's application links.
  • --base-path is the path under the instance's URL that the operations are under: the document's first server's path by default (Jira's /rest), / for none.

Two more flags help any import:

  • --rename <operationId or "METHOD /path">=<name> names a function whose operationId is not a name a model reads well, such as search_1 or update_2 (repeatable).
  • --ai-label <function>=<JSON pointer> names where a function that writes what people read puts the tenant's AI label: add_comment=/body/body is the comment's text in its request body. The server puts the label at the start of that text on each call, after the approval, from the call's _meta; with the label turned off it sends the text as written. The pointer must reach a string of the function's input schema, or an object the schema leaves open.

When the operation takes an Idempotency-Key header and the model gives none, the server sends the call's own key, so a call the platform makes again is one change.

Large APIs

Every function's definition goes into each model request of an agent that lists it. The command prints what the definitions cost together, estimated as the measured listing does, and past an agent's tool-size budget it names the biggest functions and suggests:

  • importing fewer operations: --operations takes globs over the function's name, its operationId or METHOD /path (--operations 'GET /invoices' --operations 'GET /invoices/*'; * stops at /, so GET /invoices/* matches GET /invoices/{id} but neither GET /invoices nor GET /invoices/{id}/lines), and --tag takes the document's tags; both repeat;
  • listing only the functions an agent needs in its tools;
  • letting the agent load them when it needs them (tool_loading: lazy).

Past a plugin's default budget, the manifest records the measured size as tool_tokens.

Import a newer document

Run the same command with --force. It rewrites the files an import writes and keeps the others, such as uv.lock and tests of your own; it refuses a folder that no import wrote. git diff then shows what changed in the API, operation by operation, in operations.json.

Flags

FlagMeaning
--nameThe plugin's name (required)
--outThe folder to write, empty or new (required)
--operations GLOB, --tag TAGImport only the matching operations (repeatable)
--base-url URLThe API's base URL instead of the document's servers
--auth, --secret, --auth-secretThe credential, above
--connection NAME, --base-path PATHAn API on each tenant's own instance, above
--rename FROM=NAMEA function's name of its own (repeatable)
--ai-label FUNCTION=POINTERWhere the AI label goes in what a function writes (repeatable)
--writes-without-approvalLeave the writes to the tenant's tool policy
--allow-httpAn http:// base URL, for a local test API without a credential
--forceRewrite a folder an earlier import wrote
--jsonPrint what was written as JSON
Copyright © 2026