---
title: "Folders, bundles and OCI"
description: "Install a plugin from a folder, an archive, an upload in the web UI or an OCI registry, locked to its SHA-256."
canonical_url: "https://docs.oz0.ai/build/plugins/sources"
---
# Folders, bundles and OCI

> Install a plugin from a folder, an archive, an upload in the web UI or an OCI registry, locked to its SHA-256.

For: tenant administrators and plugin authors.

Install the same plugin from Git, a local folder, an archive, a console upload or an OCI registry. After fetching its bytes, the edge applies `plugin lint`, contract checks, the [quality gate](https://docs.oz0.ai/improve/evals/quality-gates#quality-gates), canary rollouts and tool snapshots.

A Git version is locked to a commit. Bundles and OCI artifacts are locked to the SHA-256 digest of their bytes.

<table>
<thead>
  <tr>
    <th>
      Source
    </th>
    
    <th>
      Install with
    </th>
    
    <th>
      Locked to
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Git
    </td>
    
    <td>
      <code>
        plugin install https://github.com/acme/tools
      </code>
    </td>
    
    <td>
      the commit
    </td>
  </tr>
  
  <tr>
    <td>
      A folder
    </td>
    
    <td>
      <code>
        plugin install ./tools
      </code>
    </td>
    
    <td>
      the bundle's digest
    </td>
  </tr>
  
  <tr>
    <td>
      An archive
    </td>
    
    <td>
      <code>
        plugin install tools-1.0.0.tar.gz
      </code>
    </td>
    
    <td>
      the bundle's digest
    </td>
  </tr>
  
  <tr>
    <td>
      An upload
    </td>
    
    <td>
      the console's Plugins page, Install, Upload a bundle
    </td>
    
    <td>
      the bundle's digest
    </td>
  </tr>
  
  <tr>
    <td>
      An OCI registry
    </td>
    
    <td>
      <code>
        plugin install oci://ghcr.io/acme/tools@sha256:…
      </code>
    </td>
    
    <td>
      the manifest's digest
    </td>
  </tr>
</tbody>
</table>

`plugin list` and the catalog show each plugin's kind and digest.

## Pack a bundle

```bash [Terminal]
oz0 plugin pack ./ops-tools
```

```text
Packed ops-tools 0.2.0 into ops-tools-0.2.0.tar.gz: 8 files, 30.4 KB, sha256:1d0a49ba0aa1…
```

A bundle is a gzipped tar with `oz0-plugin.yaml` at its root: the same artifact the edge builds from a Git commit. `pack` checks the bundle it wrote as `plugin lint` checks a folder and refuses one with errors, so a file the manifest names that stays out of the bundle (for example because `.gitignore` lists it) fails here, not at install. A symlink to a folder packs that folder.

A symbolic link *inside* the folder, or another special file, is refused, by `pack` and by `plugin lint`, `plugin dev`, `dev` and an install from Git alike, before anything is parsed: `error: prompts/desk.md: a symbolic link or special file, which plugins may not contain`, with the fix to copy the file into the folder in its place. A link could otherwise make the loader read, and the bundle carry, a file from outside the plugin on whichever machine builds it.

Folders that packing a folder leaves out (`.git`, `.venv`, `node_modules`, `__pycache__`, `.oz0-tools`, `dist`, `.pytest_cache`, `.ruff_cache`) are not looked into, so a virtual environment's links are fine; a link with one of those names is refused like any other. A build from Git packs what the commit holds, those folders too when they are committed, so it also refuses a link committed inside one of them, and `plugin inspect` refuses an artifact that holds a link. `--out` names the file (default `<name>-<version>.tar.gz`) and `--json` prints the digest and the files.

Packing is deterministic: entries are sorted and their times, owners and modes fixed, so the same files always give the same digest, on your laptop and in CI. These stay out of a bundle:

- `.git` (a folder, or the `.git` file of a submodule or worktree), `.venv`, `node_modules`, `__pycache__`, `dist` and the test and lint caches;
- `.env` and `.env.*` files, and `.pyc` files;
- whatever the `.gitignore` files of the folder's Git repository ignore, when the folder is in one. Your global Git excludes (`core.excludesFile`, `~/.config/git/ignore`) and `.git/info/exclude` do not count, since they differ from machine to machine;
- links and special files.

A file that the checks read (the manifest, agents, flows, schemas, instructions, skills and evals) may be at most 4 MB; code and data files may be larger, up to 512 MB unpacked and 50,000 files in all.

## Install from a folder or an archive

```bash [Terminal]
oz0 plugin install ./ops-tools
oz0 plugin install ops-tools-0.2.0.tar.gz
```

The CLI packs the folder, or reads the archive, uploads it to the server, and installs it by its digest. This works in dev mode and in a cluster: the server never reads your disk. An archive that is not a bundle as it is (a `.zip`, a plain `.tar`, or a release `.tar.gz` that keeps the plugin in one top folder) is repacked as one first, and the CLI says so.

An upgrade is a new upload. `plugin update <name>` packs the same folder or file again when it is still where it was installed from; otherwise install the new file.

## Upload in the console

On the Plugins page, choose Install, then Upload a bundle, and drop a `.tar.gz` from `plugin pack` (64 MB at most). Before anything is installed, the page shows:

- the bundle's digest;
- its tools, agents and flows;
- the checks' findings, the same as `plugin lint`;
- whether a quality gate will run: a plugin with evals runs them on a test node before any other node gets it;
- the installed version it replaces, if any.

Install then installs it, with the canary and breaking-change options of a Git install, and lists the contract changes an upgrade makes. Tenant admins may upload; the bundle is kept for that tenant only, and another tenant cannot install it by its digest.

An upload waits at most 24 hours to be installed, and a tenant may have 20 uploads (512 MB in all) waiting; above that an upload is refused with 429 until one is installed or expires. Installing an upload uses it up: to install the same bundle again later, upload it again (the CLI always does).

Scripts can do the same over HTTP: `POST /v1/plugins/uploads?tenant=<tenant>` on the management port with the bundle as an `application/gzip` body returns its digest and checks as JSON, and `InstallPlugin` with `bundle` set to that digest installs it.

## OCI registries

A registry that holds container images can hold plugins too. Publish a bundle, or a folder, with `plugin push`:

```bash [Terminal]
echo "$REGISTRY_TOKEN" | oz0 plugin push ./ops-tools oci://ghcr.io/acme/ops-tools:v0.2.0 --username acme-ci --password-stdin
```

```text
Pushed ops-tools 0.2.0 to oci://ghcr.io/acme/ops-tools:v0.2.0: sha256:74e28a9dfad3…
Install it with: oz0 plugin install oci://ghcr.io/acme/ops-tools@sha256:74e28a9dfad3…
```

`OZ0_REGISTRY_AUTH=user:password` works instead of the flags. Pushing the same bundle again gives the same digest.

Install it by that digest. The edge pulls it, never the nodes, with the registry's credentials from a [tenant secret](https://docs.oz0.ai/admin/secrets):

```bash [Terminal]
printf 'acme-ci:%s' "$REGISTRY_TOKEN" | oz0 secret set REGISTRY_AUTH
oz0 plugin install oci://ghcr.io/acme/ops-tools@sha256:74e28a9dfad3… --secret REGISTRY_AUTH
```

The secret holds `user:password`, or a token alone. A public repository needs no secret.

<warning>

A plugin installs from a registry by digest only. A tag can be moved to other bytes at any time, as the force-pushed release tags of March 2026 showed. A reference with a tag alone is refused, and the error names the digest the tag points to now, so you can check it and install that.

</warning>

The edge checks that the manifest and the bundle match their digests and that the artifact is a plugin. `plugin update` does nothing for an OCI plugin: install a new digest instead.

The edge reaches registries over HTTPS only and follows no redirect to plain HTTP. It does not connect to its own machine or to link-local addresses (such as a cloud's metadata service), whatever the registry, its token service or a redirect names; a registry on a private address inside your network is reached once its network is listed with `oz0 server start --plugin-source-allow-net` ([where the edge fetches from](https://docs.oz0.ai/build/plugins/install/sources#where-the-edge-fetches-from)). `plugin push` from your machine reaches a registry on `localhost` or `127.0.0.1` over plain HTTP, as Docker does; a server does that only in dev mode.

The edge checks and builds at most four bundles at once; further uploads and pulls wait their turn.

### The artifact's media types

For registries, scanners and other tools, a plugin is an OCI image manifest with:

<table>
<thead>
  <tr>
    <th>
      Part
    </th>
    
    <th>
      Media type
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        artifactType
      </code>
    </td>
    
    <td>
      <code>
        application/vnd.oz0.plugin.v1
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      config
    </td>
    
    <td>
      <code>
        application/vnd.oz0.plugin.config.v1+json
      </code>
      
      , with the plugin's <code>
        name
      </code>
      
      , <code>
        version
      </code>
      
       and <code>
        description
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      the one layer
    </td>
    
    <td>
      <code>
        application/vnd.oz0.plugin.bundle.v1.tar+gzip
      </code>
      
      , the bundle
    </td>
  </tr>
</tbody>
</table>

The manifest carries the title, version and description as annotations and no creation time, so the same bundle always gives the same manifest digest. The edge accepts a manifest whose `artifactType` or config media type is one of these.

<span id="upload-in-the-web-ui">

Section on this page

</span>


## Sitemap

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