---
title: "Import an OpenAPI document"
description: "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."
canonical_url: "https://docs.oz0.ai/build/parts/openapi-import"
---
# 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](https://docs.oz0.ai/govern/tool-policy) applies from the first install.

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

```bash
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"
```

```text
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](https://docs.oz0.ai/concepts/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.

<table>
<thead>
  <tr>
    <th>
      File
    </th>
    
    <th>
      What it is
    </th>
  </tr>
</thead>

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

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](https://docs.oz0.ai/build/parts/tools/definitions#what-the-model-sees).

## 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](https://docs.oz0.ai/build/parts/tools/definitions#say-what-a-tool-does) come from its method:

<table>
<thead>
  <tr>
    <th>
      Method
    </th>
    
    <th>
      Annotations
    </th>
    
    <th>
      What the import writes
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      GET, HEAD, OPTIONS
    </td>
    
    <td>
      <code>
        readOnlyHint: true
      </code>
    </td>
    
    <td>
      Runs
    </td>
  </tr>
  
  <tr>
    <td>
      POST, PATCH
    </td>
    
    <td>
      <code>
        readOnlyHint: false
      </code>
      
      , <code>
        destructiveHint: false
      </code>
    </td>
    
    <td>
      Waits for a person's <a href="https://docs.oz0.ai/agents/approvals">
        approval
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      PUT
    </td>
    
    <td>
      as POST, and <code>
        idempotentHint: true
      </code>
    </td>
    
    <td>
      Waits for a person's approval
    </td>
  </tr>
  
  <tr>
    <td>
      DELETE
    </td>
    
    <td>
      <code>
        destructiveHint: true
      </code>
      
      , <code>
        idempotentHint: true
      </code>
    </td>
    
    <td>
      Waits for a person's approval
    </td>
  </tr>
</tbody>
</table>

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](https://docs.oz0.ai/govern/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](https://docs.oz0.ai/concepts/security/sandbox-tiers#sandboxes) 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.

<table>
<thead>
  <tr>
    <th>
      <code>
        --auth
      </code>
    </th>
    
    <th>
      The request carries
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        bearer
      </code>
    </td>
    
    <td>
      <code>
        Authorization: Bearer &lt;secret>
      </code>
      
       (also for OAuth 2.0 and OpenID Connect schemes, with a token an admin sets)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        header:NAME
      </code>
    </td>
    
    <td>
      <code>
        NAME: &lt;secret>
      </code>
      
       (not a header the connection owns, such as Host, Content-Length or <code>
        Proxy-*
      </code>
      
      ; <code>
        Authorization
      </code>
      
       is fine)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        basic:USER
      </code>
    </td>
    
    <td>
      HTTP Basic authentication as USER with the secret as the password
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        query:NAME
      </code>
    </td>
    
    <td>
      <code>
        ?NAME=&lt;secret>
      </code>
      
       (servers and proxies log URLs: prefer a header)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        none
      </code>
    </td>
    
    <td>
      nothing
    </td>
  </tr>
</tbody>
</table>

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](https://docs.oz0.ai/admin/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:

```bash [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](https://docs.oz0.ai/build/parts/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](https://docs.oz0.ai/govern/ai-labels): `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](https://docs.oz0.ai/build/parts/tools/definitions#what-a-definition-costs) 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

<table>
<thead>
  <tr>
    <th>
      Flag
    </th>
    
    <th>
      Meaning
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        --name
      </code>
    </td>
    
    <td>
      The plugin's name (required)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        --out
      </code>
    </td>
    
    <td>
      The folder to write, empty or new (required)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        --operations GLOB
      </code>
      
      , <code>
        --tag TAG
      </code>
    </td>
    
    <td>
      Import only the matching operations (repeatable)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        --base-url URL
      </code>
    </td>
    
    <td>
      The API's base URL instead of the document's servers
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        --auth
      </code>
      
      , <code>
        --secret
      </code>
      
      , <code>
        --auth-secret
      </code>
    </td>
    
    <td>
      The credential, above
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        --connection NAME
      </code>
      
      , <code>
        --base-path PATH
      </code>
    </td>
    
    <td>
      An API on each tenant's own instance, above
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        --rename FROM=NAME
      </code>
    </td>
    
    <td>
      A function's name of its own (repeatable)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        --ai-label FUNCTION=POINTER
      </code>
    </td>
    
    <td>
      Where the AI label goes in what a function writes (repeatable)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        --writes-without-approval
      </code>
    </td>
    
    <td>
      Leave the writes to the tenant's tool policy
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        --allow-http
      </code>
    </td>
    
    <td>
      An <code>
        http://
      </code>
      
       base URL, for a local test API without a credential
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        --force
      </code>
    </td>
    
    <td>
      Rewrite a folder an earlier import wrote
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        --json
      </code>
    </td>
    
    <td>
      Print what was written as JSON
    </td>
  </tr>
</tbody>
</table>


## Sitemap

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