# registry-cli

> Command-line tool to publish workers and kits to the iii registry (workers.iii.dev).

| field | value |
|-------|-------|
| version | 0.4.0 |
| type | executable |
| license | Apache-2.0 |
| repo | https://github.com/iii-hq/workers-cli |
| supported_targets | x86_64-apple-darwin, aarch64-apple-darwin, x86_64-pc-windows-msvc, aarch64-pc-windows-msvc, x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, x86_64-unknown-linux-musl, armv7-unknown-linux-gnueabihf |
| author | iii |

## installation

```sh
curl -fsSL https://workers.iii.dev/install/registry-cli/0.4.0.sh | sh
```

This is a standalone executable, not a worker: it is installed on the host (default `~/.local/bin`, override with `INSTALL_DIR`) and cannot be added to a compose graph.

## readme

# registry-cli

`registry` is the command-line tool for publishing **workers** and **kits** to the
[iii registry](https://workers.iii.dev). In a worker project it uploads the artifacts **you have already built**,
registers a new immutable version with its README, metadata, skills and **API reference** (the functions and triggers
the worker registers, read from a running iii engine), and makes it installable by anyone running iii; it never
compiles, packages or archives anything for you. In a kit project it publishes the kit's agent profiles and skills,
with the registry workers they need.

```sh
registry login

# a worker is published from a running iii engine it is connected to (see "Connect the worker to a running engine")
iii &                                   # the engine, on ws://localhost:49134
III_WORKER_NAME=bridge ./bridge &       # the worker, connected to it under its registry name

# binary worker: a folder of pre-built <bin>-<target-triple>.tar.gz archives
registry publish --artifacts ./dist --skills ./skills

# Python or Node.js worker (bundle): one .tar.gz you built, with iii.worker.yaml at its root
registry publish --artifacts ./build/hello.tar.gz --skills ./skills

# kit: agents/, skills/ and iii.kit.yaml (no engine needed)
registry publish
```

`publish` finds out what to publish from the folder: `iii.worker.yaml` is a worker, `iii.kit.yaml` a kit (with both,
pass `--manifest`).

> **Publishing a worker needs a running iii engine with the worker connected to it.** The functions and triggers on
> the worker's API reference tab, with their schemas, are read from that engine: without it, or with the worker not
> connected, `registry publish` stops before uploading anything. See
> [Connect the worker to a running engine](#connect-the-worker-to-a-running-engine). Kits and executables need no
> engine.

Publishing a Python or Node.js worker? Read [Python and Node.js workers](#python-and-nodejs-workers-bundles) first:
the archive must contain `iii.worker.yaml` and everything the worker needs at runtime. Publishing a kit? See
[Kits](#kits).

This tool was called `workers-cli`, with the command `workers`; see [Former names](#former-names) for what keeps
working.

## Install

```sh
curl -fsSL https://workers.iii.dev/install/registry-cli/latest.sh | sh
```

The installer detects your OS and CPU architecture, downloads the matching archive, verifies its SHA-256 checksum
and places the `registry` binary in `~/.local/bin`. If that directory is not on your `PATH`, it tells you how to add
it.

- **Custom location:** set `INSTALL_DIR`, e.g. `curl -fsSL … | INSTALL_DIR="$HOME/bin" sh`.
- **Pin a version:** replace `latest` with a release tag (`next`) or an exact version, e.g.
  `https://workers.iii.dev/install/registry-cli/0.1.0.sh`. Versions published as `workers-cli` install the command
  `workers`.

Supported platforms:

| OS | Targets |
| --- | --- |
| macOS | `aarch64-apple-darwin` (Apple Silicon), `x86_64-apple-darwin` (Intel) |
| Linux | `x86_64-unknown-linux-musl` (static, any distribution), `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, `armv7-unknown-linux-gnueabihf` |
| Windows | `x86_64-pc-windows-msvc`, `aarch64-pc-windows-msvc` (run the installer from Git Bash, MSYS2 or Cygwin) |

Check the installation with `registry --version`. Run `registry --help` or `registry publish --help` for the built-in
reference. What changed in each version: the [changelog](https://github.com/iii-hq/registry/blob/main/cli/CHANGELOG.md),
also published as the [GitHub release](https://github.com/iii-hq/registry/releases) of every `cli-v*` tag.

### Build from source

With a stable Rust toolchain, from the CLI's source directory:

```sh
cargo install --path .    # installs `registry` into ~/.cargo/bin
```

## How it works

The registry hosts your files on its own CDN and records their SHA-256 digests. Every
`registry publish` run of a worker goes through the same steps (a kit is simpler: see [Kits](#kits)):

1. **Reads your project.** `iii.worker.yaml` provides the worker's name, type and metadata; `README.md` becomes the
   worker's page; the version comes from `--version` or from your language manifest.
2. **Checks the artifacts** you built, and computes the SHA-256 and size of each one locally, before anything leaves
   your machine. For a binary worker these are the per-target archives found in the `--artifacts` folder; for a
   bundle, the `.tar.gz` file itself, which the CLI opens to check that iii will be able to install and start it
   (see [What `registry` checks](#what-registry-checks)). Nothing is built or archived for you.
3. **Reads the API reference from a running iii engine**: every function the worker registers and every trigger type
   it provides, with their schemas. The worker must be connected to that engine; otherwise the run stops here,
   before anything is uploaded (see [Connect the worker to a running engine](#connect-the-worker-to-a-running-engine)).
4. **Uploads the artifacts** to the registry, which serves them from its own CDN (`cdn.iii.cloud`).
5. **Publishes the version.** The registry records the name, version, release tag, README, metadata, API reference
   and, for each artifact, its URL and digest.
6. **Attaches your skills** (optional): Markdown documentation for that version, sent right after the version is
   published.

Once published, the worker appears on [workers.iii.dev](https://workers.iii.dev) and anyone can add it to their
project:

```sh
iii trigger compose::add worker=<name>
```

**Versions are immutable.** Re-running a publish is always safe: if the version already exists with identical
artifacts, the run is a no-op (`already published with identical contents`); if the artifacts differ, the CLI stops
and asks you to bump the version. A CI job that failed halfway can simply be retried (with the engine and the worker
running again: step 3 runs on every attempt).

## Authenticate

```sh
registry login
```

Prompts for your registry API key (input is hidden), checks it with the registry and prints whose it is and their
handle (what their kits are published under), then saves it to `~/.iii/registry-api-key`
(`%USERPROFILE%\.iii\registry-api-key` on Windows), with `0600` permissions on macOS and Linux:

```text
$ registry login
Registry API key:
Logged in as Acme (key "laptop")
Handle: acme (your kits are published as acme/<kit>)
Saved to /home/me/.iii/registry-api-key
```

A mistyped, revoked or expired key fails right there and is not saved, and so does a key that cannot be checked
because the registry is unreachable. `registry whoami` tells you, at any time, who the key `publish` would use belongs
to, their handle, and where the key comes from:

```text
$ registry whoami
Acme (key "laptop")
handle:   acme
key from: /home/me/.iii/registry-api-key
registry: https://api.workers.iii.dev
```

Without a terminal (a script, `docker run` without `-t`, an agent), pipe the key in instead. `registry login` reads
the first line of stdin whenever stdin is not a terminal; `--stdin` asks for that explicitly:

```sh
printf '%s\n' "$KEY" | registry login --stdin
```

In CI, skip `login` and set `REGISTRY_API_KEY` instead; it takes precedence over the saved key. The key is only
needed to talk to the registry, so a worker's `--dry-run` (which still needs the running engine), `registry check` and
`registry tag ls` work without it. A kit's `--dry-run` needs it: the registry runs the checks.

`--registry <url>` (or `III_REGISTRY_URL`) points any command at another registry, e.g. a local one.

### Former names

This tool was `workers-cli`, with the command `workers`. What you set up then keeps working:

- **The `workers` command.** The installer wrapper links `workers` to `registry`; run under that name, it works the
  same and prints a one-line deprecation notice. Switch your scripts to `registry`: the old name will be removed.
- **`WORKERS_API_KEY`** is read when `REGISTRY_API_KEY` is unset, and **`WORKERS_REGISTRY_URL`** when
  `III_REGISTRY_URL` is unset.
- **`~/.iii/workers-api-key`** (saved by `workers login`) is read when there is no `~/.iii/registry-api-key`. The key
  `publish` uses is the first of: `REGISTRY_API_KEY`, `WORKERS_API_KEY`, `~/.iii/registry-api-key`,
  `~/.iii/workers-api-key`.
- **`https://workers.iii.dev/install/workers-cli/latest.sh`** installs the same thing as the `registry-cli` URL:
  `workers-cli` is an alias on the registry.

## Prepare your worker

A typical project looks like this:

```text
my-worker/
├── iii.worker.yaml     # worker manifest
├── Cargo.toml          # or package.json / pyproject.toml, provides the version
├── README.md           # shown on the worker's registry page
├── skills/             # optional Markdown skills
│   └── SKILL.md
└── dist/               # binary/executable: the pre-built per-target archives
```

Bundles (Python and Node.js) are laid out differently: see
[Python and Node.js workers](#python-and-nodejs-workers-bundles).

### `iii.worker.yaml`

```yaml
iii: v1
name: bridge                 # worker name on the registry
language: rust
deploy: binary               # binary | bundle | executable
manifest: Cargo.toml         # file the version is read from
bin: bridge                  # artifact file name prefix (defaults to `name`)
description: Bridge two engines.
license: Apache-2.0
repo: https://github.com/acme/bridge
tags: [bridge, remote]
experimental: false
config:                      # default configuration, shown on the worker's page
  url: ws://0.0.0.0:49134
dependencies:                # other workers, by semver range or release tag
  configuration: "latest"
```

| Field | Required | Description |
| --- | --- | --- |
| `name` | yes | The worker's name on the registry. |
| `deploy` | yes | `binary`, `bundle` or `executable`; see [Artifacts](#artifacts). |
| `manifest` | no | Language manifest, next to `iii.worker.yaml`, that holds the version: `Cargo.toml`, `package.json` or `pyproject.toml`. |
| `bin` | no | Prefix of the binary artifact file names. Defaults to `name`. |
| `repo` | no | Link to the source repository. When omitted, it is derived from `--github-repo` if given. |
| `tags` | no | Search tags. When omitted, the tags already set on the registry are kept. |
| `description`, `license`, `config`, `dependencies`, `experimental` | no | Sent to the registry as-is. |
| `scripts`, `runtime`, `env`, `resources` | bundles: `scripts.start` | Not sent to the registry: iii reads them from the copy of `iii.worker.yaml` **inside** a bundle archive. See [the fields iii reads](#2-declare-the-worker-and-how-it-starts). |

### Version

The version is resolved in this order:

1. `--version X.Y.Z`;
2. the file named by `manifest:`: the `version` field of a JSON file (`package.json`), or `[package].version` /
   `[project].version` of a TOML file (`Cargo.toml`, `pyproject.toml`).

If neither is available, `publish` stops and asks for `--version`.

### Artifacts

`--artifacts` is required, and what it points to depends on `deploy`:

| `deploy` | `--artifacts` points to | What gets published |
| --- | --- | --- |
| `binary` | a folder | One archive per platform, named `<bin>-<target-triple>.tar.gz` (or `.zip`, typically for Windows). Files that don't follow this pattern are ignored. |
| `bundle` | a `.tar.gz` **file** you built, never a folder | A single archive for every platform, e.g. a Node.js or Python worker. It must hold `iii.worker.yaml` at its root plus everything the worker needs at runtime; a folder is refused, not archived. See [What goes in the archive](#what-goes-in-the-archive). |
| `executable` | a folder | Same rules as `binary`. For standalone programs, like this CLI, that users install with `curl -fsSL https://workers.iii.dev/install/<name>/latest.sh \| sh` instead of adding them to a project. |

Example `dist/` folder for a binary worker named `bridge`:

```text
dist/
├── bridge-aarch64-apple-darwin.tar.gz
├── bridge-x86_64-apple-darwin.tar.gz
├── bridge-x86_64-unknown-linux-gnu.tar.gz
└── bridge-x86_64-pc-windows-msvc.zip
```

### Skills

`--skills` points to a folder of Markdown files, read recursively. Only non-empty `.md` files are published. `SKILL.md`
at the root is the overview; every other file keeps its relative path under `skills/`:

| Local file | Published as |
| --- | --- |
| `skills/SKILL.md` | `SKILL.md` |
| `skills/guides/setup.md` | `skills/guides/setup.md` |

`publish` (and `--dry-run`) lists every file before anything is uploaded, including what it leaves out and why:

```text
skill:     SKILL.md  (from skills/SKILL.md, 1 link adjusted to the published layout)
skill:     skills/guides/setup.md  (from skills/guides/setup.md)
skipped:   skills/empty.md (empty)
skipped:   skills/diagram.png (not a Markdown .md file)
```

and ends with the paths it published (`Published 2 skill file(s) to bridge@1.2.0: SKILL.md, skills/guides/setup.md`).
A folder with nothing to publish, or a file name the registry would refuse (only letters, digits, `.`, `_`, `-` and
`/`), stops the run before anything is uploaded.

**Write links for the folder as it is on disk**: `[setup](guides/setup.md)` in `skills/SKILL.md`,
`[overview](../SKILL.md)` in `skills/guides/setup.md`. `SKILL.md` moves up one level when published, so `registry`
rewrites relative links between skill files to keep them working on the registry (the two above are published as
`skills/guides/setup.md` and `../../SKILL.md`). URLs, anchors and links already written for the published layout are
left alone. A relative link to something that is not published (an image, a folder, a file outside `--skills`) is
reported as a warning, because it is broken once published.

## Connect the worker to a running engine

The worker page's **API reference** tab lists every function the worker registers, with its request and response
schemas, and every trigger type it provides, with what a trigger is configured with and what the bound function
receives. None of that is in the artifacts: the worker registers it with the engine when it connects. So
**`registry publish` reads it from a running iii engine that the worker is connected to**, and a binary or bundle
worker is never published without it. Executables are not workers and skip this step; kits never need an engine.

1. **Start an engine.** `iii` runs one with the `config.yaml` of the current folder (creating an empty one when there
   is none) and listens on `ws://localhost:49134`. Install it with
   `curl -fsSL https://install.iii.dev/iii/main/install.sh | sh`.
2. **Start the worker you are publishing, connected to that engine, under its registry name.** The engine knows a
   worker by the name it registers with, and `registry` looks for the `name:` of `iii.worker.yaml`. The iii SDKs (Rust,
   Node.js, Python) register under `III_WORKER_NAME` when it is set, and iii sets it for the workers it starts itself
   (`iii worker add ./my-worker`). To run the worker yourself, the way you do in development:

   ```sh
   III_WORKER_NAME=bridge ./target/release/bridge     # binary worker
   III_WORKER_NAME=hello node dist/index.js           # Node.js bundle
   ```

3. **Publish**, or preview with `--dry-run`, which reads the engine too:

   ```text
   $ registry publish --artifacts ./dist --dry-run
   worker:    bridge (binary)
   version:   1.2.0 (tag: latest)
   hosting:   https://api.workers.iii.dev (workers/bridge/1.2.0/)
   artifact:  bridge-x86_64-unknown-linux-gnu.tar.gz  sha256:…  (… bytes)
   engine:    ws://localhost:49134 (2 function(s), 1 trigger type(s))
   function:  bridge::send
   function:  bridge::status
   trigger:   bridge::on-message
   ```

What ends up on the API reference tab:

| Entry | Read from the engine |
| --- | --- |
| Each function: name, description, request and response schemas, metadata | `engine::workers::info` lists the worker's functions and `engine::functions::info` gives their schemas. The name is the function id, or `metadata.registry_name` / `metadata.name` when the worker sets one. |
| Each trigger type: name, description, invocation and return schemas | `engine::workers::info` lists the worker's trigger types and `engine::triggers::info` gives their schemas: the configuration a trigger takes (shown as the invocation schema) and what the bound function receives (shown as the return schema). |

- `--engine ws://host:port` (or `III_URL`) points at another engine. Only `ws://` works: iii engines do not serve
  `wss://`.
- `--engine-wait <seconds>` waits for the engine to come up and for the worker to connect and finish registering (it
  must report the same functions and triggers twice in a row, a second apart). Use it in CI, where both start in the
  background right before `publish`.
- A function without a typed request or response schema, or a trigger type without a typed configuration schema, is
  published anyway and shown as **unknown**; one `warning:` line lists the functions, another the trigger types.
  Register handlers with typed formats (in Rust, a struct deriving `schemars::JsonSchema`) to fix it.
- The run stops, before anything is uploaded, when the engine cannot be reached, the worker is not connected (the
  error lists the workers that are), it registered no function and no trigger type, two functions would publish under
  the same name, or a trigger type's name is not one the registry accepts (lowercase letters, digits and `-`,
  optionally namespaced with `:` or `::`).
- The engine is only read: `registry` calls `engine::workers::info`, `engine::workers::list`,
  `engine::functions::info` and `engine::triggers::info`, nothing else. On an engine with RBAC, those must be exposed
  to the connection.

## Publish

### Where the artifacts are hosted

```sh
registry publish --artifacts ./dist --skills ./skills   # bundle: --artifacts ./build/hello.tar.gz
```

The files are always uploaded straight to the registry and served from
`https://cdn.iii.cloud/workers/<name>/<version>/<asset>`. Only your API key is needed.

- The CLI opens a release for the version, uploads each artifact to a short-lived signed URL and then publishes.
  Transient upload failures are retried automatically, and an interrupted run resumes the same release the next time.
- Nothing becomes public until the publish succeeds: the registry first checks the presence, size and digest of every
  upload.
- Binary archives keep their `<bin>-<target-triple>` names; a bundle is always published as
  `<name>-<version>.tar.gz`, whatever its local file name.
- `--github-repo` is optional; when given, it only fills the `repo` link if `iii.worker.yaml` has none.

### Release tags

By default, a version is published under the `latest` tag: it is **stable**, installed by default and matched by
semver ranges such as `^1.0.0`. Any other tag (`next`, `beta`, `rc`, …) publishes a **candidate**, which is only
installed when requested explicitly by tag or exact version; semver ranges ignore it.

```sh
registry publish --artifacts ./dist --tag next

# users opt in explicitly:
iii trigger compose::add worker=my-worker@next
```

The worker page always shows the stable version, so for a candidate `publish` prints the page link pinned to it
(`https://workers.iii.dev/workers/my-worker?version=1.1.0`) and how to promote it.

### Promote, move or roll back a tag

Tags are pointers: move them to any version you already published, without publishing again.

`registry tag` acts on **one worker or kit**, found the same way `publish` finds it: `name:` in the `iii.worker.yaml`
(or `iii.kit.yaml`) of the current folder. Run it from the worker's or kit's folder (or the CI job that published it),
or name it with `--name` from anywhere else: `--name my-worker`, or `--name acme/kanban-team` for a kit. Every line it
prints names the worker or kit, so you can always see which one changed.

From the worker's folder, where `iii.worker.yaml` says `name: my-worker`:

```sh
registry tag ls                                   # latest  my-worker@1.0.0 / next  my-worker@1.1.0
registry tag promote                              # latest -> the version `next` points to (1.1.0)
registry tag set next 1.2.0                       # point any tag at an existing version
registry tag set latest 1.0.0 --expect-current 1.1.0   # roll back, only if latest is still 1.1.0
```

From anywhere else, the same commands with the worker named:

```sh
registry tag ls --name my-worker
registry tag promote --name my-worker
registry tag set next 1.2.0 --name my-worker
registry tag set latest 1.0.0 --expect-current 1.1.0 --name my-worker
```

Without `--name` and without a manifest, the command stops before doing anything (`no iii.worker.yaml or iii.kit.yaml
here to read the name from; pass --name <worker> or --name <handle>/<kit>`). `promote` fails instead of overwriting
when `latest` or `next` moved in the meantime. Moving a tag needs the same API key as publishing.

### Preview with `--dry-run`

`--dry-run` prints the worker, version, hosting target, every artifact with its digest and size, every skill file
(published or skipped), the functions and triggers read from the engine, and the exact JSON payload that would be
sent, without uploading or publishing anything and without an API key. It reads the engine like a real publish, so it
needs the engine running with the worker connected ([Connect the worker to a running
engine](#connect-the-worker-to-a-running-engine)). Use it to check the manifest, the version, the artifact names, the
skills and the API reference before spending a version number. For a bundle it also runs
[the archive checks](#what-registry-checks), so an archive iii could not install fails here instead of after it is
published.

```sh
registry publish --artifacts ./dist --dry-run                # binary
registry publish --artifacts ./build/hello.tar.gz --dry-run  # bundle
```

### Flags

| Flag | Env | Default | Description |
| --- | --- | --- | --- |
| `--manifest <path>` | | the folder's `iii.worker.yaml` | Worker manifest (or `iii.kit.yaml`: see [Kits](#kits)). Needed when the folder has both. |
| `--readme <path>` | | `README.md` | Markdown shown on the worker's page. Must exist. |
| `--skills <dir>` | | | Folder of Markdown skills. |
| `--artifacts <path>` | | **required for workers** | Folder of pre-built archives (`binary`, `executable`) or the `.tar.gz` file you built (`bundle`, with `iii.worker.yaml` at its root). Nothing is archived for you. |
| `--github-repo <owner/repo>` | `GITHUB_REPOSITORY` | | Repository linked as the worker's `repo` when `iii.worker.yaml` has none. Metadata only. |
| `--engine <ws://host:port>` | `III_URL` | `ws://localhost:49134` | The running iii engine the worker is connected to; its functions and triggers are read from it. Required for binary and bundle workers; see [Connect the worker to a running engine](#connect-the-worker-to-a-running-engine). |
| `--engine-wait <seconds>` | | `0` (ask once) | Wait this long for the engine to answer and the worker to connect and register its functions and triggers. |
| `--version <x.y.z>` | | read from `manifest:` | Version to publish. |
| `--tag <tag>` | | `latest` | Release tag. Anything other than `latest` publishes a candidate. |
| `--registry <url>` | `III_REGISTRY_URL` | `https://api.workers.iii.dev` | Registry API base URL. |
| `--dry-run` | | | Print the payload; upload and publish nothing. Still reads the engine. |

`--notes` and `--strict` are for kits. `--engine` and `--engine-wait` are refused with a kit or an executable, which
need no engine.

## Python and Node.js workers (bundles)

Python and Node.js workers are published with `deploy: bundle`. Instead of one archive per platform, a bundle is
**a single `.tar.gz` that serves every platform**. Login, hosting, release tags and `--dry-run` work as described
above; this section covers everything that is different.

> **`registry` never builds or archives anything.** `--artifacts` must be a `.tar.gz` file you built yourself: a
> folder such as `./dist` is refused, not archived for you. The CLI only opens the archive to check it (see
> [What `registry` checks](#what-registry-checks)), then uploads it byte for byte.

### What goes in the archive

The archive **is the worker as iii installs it**. `iii trigger compose::add worker=<name>` downloads it, extracts it
into a folder and starts the worker in a sandbox, with that folder as the working directory (`/workspace`), by
running the `scripts.start` command of the `iii.worker.yaml` it finds there. Nothing from your project reaches the
sandbox unless it is inside the archive.

The archive must contain, **at its root** (not under a folder such as `dist/`):

| Entry | Required | Why |
| --- | --- | --- |
| `iii.worker.yaml` | **yes** | iii reads it from the extracted archive to know how to start the worker. Without it, the install fails with `the archive for '<name>' held no executable`. Use the same file you publish with; see [the fields iii reads](#2-declare-the-worker-and-how-it-starts). |
| The code `scripts.start` runs | **yes** | For example `dist/index.js` or `hello/__main__.py`. Paths in `scripts.start` are relative to the archive root. |
| Dependencies | **yes**, unless `scripts.install` installs them or your code is bundled into a single file | iii does not run `npm install` or `pip install` for you. For Node.js, ship the production `node_modules/`. |
| `package.json`, `pyproject.toml`, `requirements.txt`, … | when the code or `scripts.install` needs them | For example, `"type": "module"` in `package.json` changes how Node.js loads `.js` files. |

A Node.js worker named `hello`, as it should look inside `hello.tar.gz`:

```text
./
├── iii.worker.yaml      # name: hello, scripts.start: node dist/index.js
├── package.json
├── dist/
│   ├── index.js
│   └── greet.js
└── node_modules/        # production dependencies (leave out if dist/ is a single bundled file)
```

An archive of the build folder, `tar -czf hello.tar.gz dist/`, only holds `dist/index.js`, `dist/greet.js`, …: no
`iii.worker.yaml` at its root and no dependencies. iii can never install it, so `registry` refuses to publish it.

Things the archive should **not** contain: `README.md` and `skills/` (they are published separately), tests, `.git/`,
development dependencies, and anything built for your own machine, such as a Python virtualenv (`.venv/`) or
`__pycache__/` folders: they do not work inside iii's Linux sandbox.

### 1. Lay out the project

```text
hello/
├── iii.worker.yaml     # deploy: bundle + scripts.start; published AND copied into the archive
├── package.json        # or pyproject.toml; holds the version
├── README.md           # shown on the worker's registry page (not part of the archive)
├── skills/             # optional Markdown skills (not part of the archive)
├── dist/               # your build output
├── out/                # staging folder: exactly what goes in the archive
└── build/
    └── hello.tar.gz    # the bundle: --artifacts points at this file
```

### 2. Declare the worker and how it starts

The same `iii.worker.yaml` is read twice: by `registry` when you publish (name, version, metadata), and by iii,
from inside the archive, when someone installs the worker (how to start it). Keep a single file and copy it into
the archive.

Node.js:

```yaml
iii: v1
name: hello
language: node
deploy: bundle
manifest: package.json      # version read from its "version" field
description: Say hello.
license: Apache-2.0
scripts:
  install: ""               # dependencies ship in the archive: nothing to install
  start: node dist/index.js # run from the archive root
```

Python:

```yaml
iii: v1
name: hello
language: python
deploy: bundle
manifest: pyproject.toml    # version read from [project].version
description: Say hello.
license: Apache-2.0
runtime:
  base_image: docker.io/iiidev/python:latest  # required: without it the worker runs on the Node.js image
scripts:
  install: pip install -r requirements.txt    # runs once, inside the sandbox, before the first start
  start: python -m hello
```

Fields iii reads from the manifest **inside the archive** (`registry` does not send them to the registry):

| Field | Required | Description |
| --- | --- | --- |
| `name` | **yes** | Exactly the worker's name on the registry; otherwise iii refuses to install it. |
| `scripts.start` | **yes** | Shell command that starts the worker, run from the archive root. |
| `scripts.install` | no | Shell command run once, inside the sandbox, before the first start. Use it for what cannot ship in the archive, such as Python packages with native extensions. When omitted, iii prints a warning at every start; set it to `""` when the dependencies are in the archive. |
| `scripts.setup` | **not allowed** | Refused in bundles: ship the archive ready to run. |
| `runtime.base_image` | no | OCI image the sandbox runs on. Defaults to `docker.io/iiidev/node:latest`; Python workers need `docker.io/iiidev/python:latest` (or another image with Python). `language:` does **not** select the image. |
| `env` | no | Extra environment variables for the worker. `III_URL` and `III_ENGINE_URL` are reserved and ignored. |
| `resources.cpus`, `resources.memory` | no | CPUs and memory (MB) for the sandbox. Defaults: 2 and 2048; capped at 4 and 4096. |

The manifest must be a regular file (not a link) of at most 64 KiB.

- `bin:` is not used by bundles.
- Only a literal version string is read: `version` in `package.json`, `[project].version` (or `[package].version`)
  in `pyproject.toml`. Poetry's `[tool.poetry].version` and `dynamic = ["version"]` are **not** read: add a
  `[project].version` or pass `--version`.

### 3. Build the archive

Build it in your own build step: put exactly what the worker needs at runtime in a staging folder, then archive
**the folder's contents** with `-C <folder> .`, so that they land at the archive root.

Node.js:

```sh
npm ci && npm run build                 # produces dist/
rm -rf out && mkdir -p out build
cp iii.worker.yaml package.json package-lock.json out/
cp -R dist out/dist
(cd out && npm ci --omit=dev)           # production node_modules/; skip if dist/ is a single bundled file
COPYFILE_DISABLE=1 tar -czf build/hello.tar.gz -C out .
```

Python:

```sh
rm -rf out && mkdir -p out build
cp iii.worker.yaml pyproject.toml requirements.txt out/
cp -R hello out/hello                   # the package `scripts.start` runs
COPYFILE_DISABLE=1 tar -czf build/hello.tar.gz -C out .
```

If the worker folder holds nothing else, you can archive it directly instead of staging. Write the archive
**outside** the folder, so it does not end up inside itself, and exclude what only works on your machine:

```sh
# from the worker folder, the one holding iii.worker.yaml
COPYFILE_DISABLE=1 tar -czf ../hello.tar.gz --exclude .venv --exclude __pycache__ --exclude .git --exclude '*.tar.gz' .
```

Check the result before publishing: `iii.worker.yaml` must be at the root, not under a folder. `registry check
build/hello.tar.gz` runs the same checks as `publish` (see [What `registry` checks](#what-registry-checks)).

```sh
$ tar -tzf build/hello.tar.gz
./
./iii.worker.yaml
./package.json
./dist/index.js
./dist/greet.js
./node_modules/…
```

A quick smoke test is to extract it into an empty folder and run `scripts.start` from there: a file missing from the
archive shows up immediately.

```sh
mkdir -p /tmp/hello-check && tar -xzf build/hello.tar.gz -C /tmp/hello-check
cd /tmp/hello-check && node dist/index.js
```

Keep in mind:

- **`tar -czf x.tar.gz dist/` is wrong**: it archives the folder itself (every path starts with `dist/`) and leaves
  the manifest out. Archive a folder's **contents** (`-C out .`, or `.` from inside the worker folder), with
  `iii.worker.yaml` among them.
- **One archive runs everywhere iii runs**: inside a Linux sandbox, on x86_64 or arm64 depending on the machine.
  Pure JavaScript and pure Python dependencies can ship as they are; native modules built on your machine (Node.js
  `.node` addons, Python wheels with C extensions) will not load there. For Python, install them with
  `scripts.install`; for Node.js, prefer dependencies without native addons.
- `COPYFILE_DISABLE=1` stops macOS tar from adding `._*` metadata files to the archive; elsewhere it does nothing.

### What `registry` checks

Before uploading anything, and also with `--dry-run`, `registry` opens the archive and refuses it when iii would
refuse to install or start it:

- `--artifacts` must be a **file** whose name ends in **`.tar.gz`**. Folders are refused (they are never archived
  for you), and so are `.tgz` and `.zip`.
- The file must be a valid gzip-compressed tar archive.
- It must have an `iii.worker.yaml` at its root: a regular file of at most 64 KiB, in valid YAML. When the manifest is
  missing, the error says what the archive holds instead (for example, everything under `dist/`) and how to fix it.
- The manifest's `name` must be the name being published, `scripts.start` must be set, `scripts.setup` must not be,
  and `runtime.base_image`, if set, must be a valid image reference.
- A Python worker (`language: python`, or `manifest: pyproject.toml`) must set `runtime.base_image`: without it, iii
  runs it on the Node.js image and it exits with `python: command not found`.

It also **warns**, without refusing the archive, about what was built for or by your own machine: Python virtualenvs
(`.venv/`, or any folder holding `pyvenv.cfg`), `__pycache__/`, `.git/` and macOS metadata (`._*` AppleDouble files,
`.DS_Store`).

`registry` does **not** run `scripts.start` or check that the files and dependencies it needs are in the archive: use
the smoke test above for that.

To run these checks on an archive alone, without a README, a version or an API key, use `registry check`:

```sh
registry check build/hello.tar.gz                 # the worker name comes from ./iii.worker.yaml
registry check hello-0.3.0.tar.gz --name hello    # or give it
```

The local file name is free: the archive is always published as `<name>-<version>.tar.gz` (`hello-0.3.0.tar.gz`).

### 4. Publish

Start the engine and the worker first, as in
[Connect the worker to a running engine](#connect-the-worker-to-a-running-engine): the worker's functions and triggers
are read from it.

```sh
iii &                                    # the engine, on ws://localhost:49134
III_WORKER_NAME=hello node dist/index.js &   # the worker, connected to it as `hello`

# check the archive, the name, the version, the digest and the API reference first (no API key needed)
registry publish --artifacts ./build/hello.tar.gz --dry-run

# served from https://cdn.iii.cloud/workers/hello/0.3.0/hello-0.3.0.tar.gz
registry publish --artifacts ./build/hello.tar.gz --skills ./skills
```

Once published, it installs like any other worker: `iii trigger compose::add worker=hello`.
`publish` ends with the worker page's URL. Pages are cached, so the new version can take about a minute to show there;
`iii` can install it right away. A candidate (`--tag next`, …) never becomes the page's version: the link printed pins
it with `?version=`.


### In GitHub Actions

```yaml
- name: Build the bundle
  run: |
    npm ci && npm run build
    mkdir -p out build
    cp iii.worker.yaml package.json package-lock.json out/
    cp -R dist out/dist
    (cd out && npm ci --omit=dev)
    tar -czf build/hello.tar.gz -C out .
- name: Publish to the iii registry
  env:
    REGISTRY_API_KEY: ${{ secrets.REGISTRY_API_KEY }}
  run: |
    curl -fsSL https://install.iii.dev/iii/main/install.sh | sh
    curl -fsSL https://workers.iii.dev/install/registry-cli/latest.sh | sh
    export PATH="$HOME/.local/bin:$PATH"
    iii --no-update-check &                       # the engine the API reference is read from
    (cd out && III_WORKER_NAME=hello node dist/index.js) &   # the worker, as it ships, connected to it
    registry publish --artifacts build/hello.tar.gz --skills skills --engine-wait 60
```

### Common bundle errors

| Message | Fix |
| --- | --- |
| `bundle workers need --artifacts to be the .tar.gz archive, and ./dist is a folder: …` | `registry` does not archive folders. Build the archive as in [step 3](#3-build-the-archive) and pass the `.tar.gz` file. |
| `./build/hello.tar.gz does not exist: bundle workers need --artifacts to be the .tar.gz archive …` | The archive was not built yet, or the path is wrong. Build it as in [step 3](#3-build-the-archive) and point at it. |
| `… is not a .tar.gz file: bundle workers need --artifacts to be the .tar.gz archive …` | `--artifacts` is a `.tgz`, a `.zip` or an uncompressed `.tar`. Build a `.tar.gz` and point at it. |
| `… is not a valid .tar.gz archive` | The file is not a gzip-compressed tar (e.g. a renamed `.zip` or an uncompressed `.tar`). Rebuild it with `tar -czf`. |
| ``… has no iii.worker.yaml at its root, so iii cannot install it: everything in it is under `dist/` …`` | The build folder was archived instead of a staging folder with the manifest. Stage as in [step 3](#3-build-the-archive) and archive with `tar -czf … -C out .`. |
| ``… it has `out/iii.worker.yaml`, but iii only reads the one at the archive root`` | The staging folder was archived as a folder (`tar -czf x.tar.gz out`). Archive its contents: `tar -czf x.tar.gz -C out .`. |
| ``iii.worker.yaml inside … is named `x`, but the worker being published is `y` …`` | The archived manifest is stale or belongs to another worker. Copy the current `iii.worker.yaml` into the staging folder. |
| ``iii.worker.yaml inside … has no `scripts.start` …`` | Add `scripts.start` to `iii.worker.yaml` (e.g. `python src/main.py` for a Python worker, `node dist/index.js` for Node.js) and rebuild the archive. |
| ``iii.worker.yaml inside … declares `scripts.setup` …`` | Remove it: ship dependencies in the archive, or install them with `scripts.install`. |
| ``iii.worker.yaml inside … describes a Python worker (`language: python`) but sets no `runtime.base_image` …`` | `language:` does not select the sandbox image. Add `runtime.base_image: docker.io/iiidev/python:latest` to `iii.worker.yaml` and rebuild the archive. |
| ``warning: … contains `.venv/`, a Python virtualenv built on this machine …`` (also `__pycache__/`, `.git/`) | Not fatal, but leave those folders out: stage the archive, or archive with `--exclude` as in [step 3](#3-build-the-archive). |
| ``warning: … contains `._x` and possibly other `._*` files, the AppleDouble metadata macOS tar adds …`` (also `.DS_Store`) | Not fatal, but leave them out: archive with `COPYFILE_DISABLE=1 tar -czf …` as in [step 3](#3-build-the-archive). |
| `iii.worker.yaml at the root of … is N bytes; iii refuses bundle manifests over 64 KiB` | Trim the manifest below 64 KiB and rebuild the archive. |
| ``no string `version` in …/pyproject.toml; pass --version`` | The version is under `[tool.poetry]` or is dynamic. Add `[project].version` or pass `--version`. |
| ``iii.worker.yaml has no `manifest:` to read the version from; pass --version`` | Add `manifest: package.json` or `manifest: pyproject.toml`. |

## Kits

A **kit** bundles agent profiles, skills and the registry workers they need, under your handle: `acme/kanban-team`.
Installing it gives a project the agents, their skills and the workers, at the versions the kit declares. A kit has no
artifacts: `registry publish` sends its manifest, README, release notes and files in one request.

### Start one

```sh
registry kit init acme/kanban-team --dir kanban-team
```

creates a kit that already passes the checks, with every field commented:

```text
kanban-team/
├── iii.kit.yaml        # name, version, workers
├── README.md           # the kit's registry page
├── CHANGELOG.md        # the "## <version>" section becomes the release notes
├── agents/
│   └── example.md      # an agent profile; its id is the file name: `example`
└── skills/
    └── index.md        # the skill acme/kanban-team/index
```

Use your own handle: `registry whoami` shows it. `kit init` writes nothing if any of these files already exists.

### `iii.kit.yaml`

```yaml
iii: v1
name: acme/kanban-team       # <your handle>/<kit name>
version: 1.3.0               # semver; every publish needs a new one
description: Agents that plan, implement and review through the kanban board
license: Apache-2.0
repo: https://github.com/acme/kanban-team
tags: [kanban, planning, agents]
workers:                     # registry workers, by semver range (or release tag); `{}` for none
  kanban: ^1.4
  coder: ^0.9
```

- `name` is `<handle>/<kit>`: lowercase letters, digits and `-`. The handle must be the one your API key belongs to.
- `version` is an exact semver version; write it so YAML reads it as text (`1.3.0`, not `1.3`).
- Ranges are text too: quote a bare number (`kanban: "1"`).
- `description` (up to 1024 characters), `license`, `repo` and `tags` (up to 20) are optional.

### Agents and skills

- **Agents** are `agents/<id>.md`, directly in `agents/`. The id is the file name: lowercase letters, digits and
  `-`. Each file starts with a frontmatter that has at least `name`:

  ```markdown
  ---
  name: Planner
  logo: "📋"
  extends: iii-minimal
  skills: [acme/kanban-team/tickets/flow, kanban/tickets/kanban-tickets]
  functions: [kanban::ticket::create, kanban::ticket::move]
  ---
  You plan features with the user in kanban tickets…
  ```

  `logo` is a single emoji; `icon` is one of `agent`, `code`, `search`, `terminal`, `database`, `test`, `review`,
  `docs`, `design`; `color` one of `neutral`, `blue`, `purple`, `teal`, `green`, `amber`, `rose`. A profile that
  breaks these would not load, so it is refused.
- **Skills** are any `skills/**/<name>.md`, with lowercase folder and file names (letters, digits, `_`, `-`), not
  inside a folder named `agents`, `system-prompts` or `prompts`. A skill's id is the kit id followed by its path
  without `.md`: `skills/tickets/flow.md` is `acme/kanban-team/tickets/flow`, and `SKILL.md` or `index.md` stand for
  `index`.
- Agents load skills by id. Every skill of the kit's own namespace an agent lists must be in the kit.
- Only `.md` files are published (each at most 256 KiB of UTF-8; at most 500 files and 5 MiB in all); anything else
  is listed as skipped.

### Check and publish

```sh
registry check                 # every check that needs no registry, no API key
registry publish --dry-run     # the registry checks it too; nothing is published
registry publish               # publishes it under `latest`
```

`check` and `publish` print what they found, then any warnings with their code and file:

```text
kit:       acme/kanban-team
version:   1.3.0 (tag: latest)
registry:  https://api.workers.iii.dev
workers:   kanban ^1.4
agent:     agents/planner.md  (planner: Planner)
skill:     skills/tickets/flow.md  (acme/kanban-team/tickets/flow)
notes:     CHANGELOG.md, section "## 1.3.0"
files:     1 agent(s), 1 skill(s), 3410 bytes
warning[unknown_function] agents/planner.md: function 'kanban::ticket::archive' is not in the catalog of kanban@1.6.1; it exists at runtime only if the engine or another worker provides it
Published acme/kanban-team@1.3.0 (tag: latest, 2 file(s))
Page: https://workers.iii.dev/kits/acme/kanban-team (it can take about a minute to show 1.3.0; iii can install it right away)
```

- **Errors** (a missing frontmatter or `name`, a bad path or id, a skill of the kit that is not there, a file too
  large or not UTF-8, a kit named after another handle) stop the run before anything is sent, all listed at once.
  The registry adds its own: an unknown worker, a range no published version matches, a version already published.
- **Warnings** do not: a function the declared workers do not provide (`unknown_function`), a skill from a worker
  the kit does not declare or that the worker does not have (`external_skill`), an `extends:` that names no profile
  (`unknown_extends`), an agent that replaces a built-in one (`builtin_agent_override`). With `--strict`, any warning
  stops the run and nothing is published.
- **Release notes** come from the `## <version>` section of `CHANGELOG.md` (`## 1.3.0`, `## [1.3.0] - 2026-10-01`);
  `--notes "…"` replaces them.
- **Candidates** work like a worker's: `registry publish --tag next`, then `registry tag promote`.
- **Re-running** a publish whose version is already published with the same files is a no-op; with other files,
  bump `version`.

A kit's `--dry-run` sends the kit to the registry for checking, so it needs your API key; `registry check` does not.
`--artifacts`, `--skills` and `--version` are for workers and are refused with a kit.

In GitHub Actions:

```yaml
- name: Publish the kit
  env:
    REGISTRY_API_KEY: ${{ secrets.REGISTRY_API_KEY }}
  run: |
    curl -fsSL https://workers.iii.dev/install/registry-cli/latest.sh | sh
    ~/.local/bin/registry publish --strict
```

## GitHub Actions

GitHub Actions sets `GITHUB_REPOSITORY`, which fills the worker's `repo` link when `iii.worker.yaml` has none, so
`--github-repo` can be omitted. The job runs an engine and the worker it publishes in the background, so that
`publish` can read the worker's functions and triggers ([Connect the worker to a running
engine](#connect-the-worker-to-a-running-engine)); `--engine-wait` gives both time to start:

```yaml
- name: Publish to the iii registry
  env:
    REGISTRY_API_KEY: ${{ secrets.REGISTRY_API_KEY }}
  run: |
    curl -fsSL https://install.iii.dev/iii/main/install.sh | sh
    curl -fsSL https://workers.iii.dev/install/registry-cli/latest.sh | sh
    export PATH="$HOME/.local/bin:$PATH"
    iii --no-update-check &                                  # the engine, on ws://localhost:49134
    III_WORKER_NAME=bridge ./target/release/bridge &         # the worker being published, connected to it
    registry publish --artifacts dist --skills skills --engine-wait 60   # binary worker
```

Only `REGISTRY_API_KEY` is needed.
For a bundle worker, `--artifacts` must be the `.tar.gz` file, not a folder, and the job must build that archive
first; see [In GitHub Actions](#in-github-actions).

## Troubleshooting

| Message | Fix |
| --- | --- |
| ``no API key: run `registry login` or set REGISTRY_API_KEY`` | Run `registry login` or export `REGISTRY_API_KEY`. |
| `no iii.worker.yaml or iii.kit.yaml in this folder: …` | Run it in the worker or kit folder, or pass `--manifest`. |
| `this folder has both iii.kit.yaml and iii.worker.yaml: …` | Pass `--manifest iii.kit.yaml` or `--manifest iii.worker.yaml`. |
| `publishing a worker needs --artifacts: …` | Pass the folder of archives (binary) or the `.tar.gz` you built (bundle). |
| `could not connect to the iii engine at ws://localhost:49134: …` | No engine there. Start one (`iii`) with the worker connected to it, or point `--engine` / `III_URL` at the engine it is connected to. In CI, add `--engine-wait <seconds>`. Nothing was uploaded. See [Connect the worker to a running engine](#connect-the-worker-to-a-running-engine). |
| ``worker `x` is not connected to the iii engine at … Connected workers: …`` | Start the worker against that engine under its registry name (`III_WORKER_NAME=x`), or point `--engine` at the engine it is connected to. |
| ``worker `x` is connected … but has registered no functions and no trigger types`` | The worker connected but registered nothing yet: publish once it has (`--engine-wait` waits for it). |
| ``warning: N function(s) have no typed request or response schema …`` / ``warning: N trigger type(s) have no typed configuration schema …`` | Not fatal: the API reference shows those schemas as unknown. Register the handlers (or trigger types) with typed formats. |
| ``two of the worker's functions publish as `x` …`` / ``trigger type `x` cannot be published …`` | Rename the function (or its `metadata.registry_name`) or the trigger type so the registry accepts it. |
| `engine URL … must start with ws://` / `… wss:// is not supported` | Pass `--engine ws://host:port`: iii engines serve plain `ws://` only. |
| ``no `<bin>-<target-triple>.tar.gz\|.zip` files in <dir>`` | The files in `--artifacts` don't follow the naming pattern, or `bin:` doesn't match their prefix. |
| `<path> does not exist: bundle workers need --artifacts to be the .tar.gz archive …` | The archive was not built yet, or the path is wrong: build it first ([step 3](#3-build-the-archive)) and point `--artifacts` at it. |
| `bundle workers need --artifacts to be the .tar.gz archive, and <dir> is a folder …` / `<path> is not a .tar.gz file: …` | With `deploy: bundle`, point `--artifacts` at the `.tar.gz` you built: folders are never archived for you, and `.tgz`/`.zip` are refused. |
| `no API key on stdin: pipe it in …` | `registry login` found no terminal and nothing on stdin. Pipe the key (`printf '%s\n' "$KEY" \| registry login --stdin`) or, in CI, set `REGISTRY_API_KEY`. |
| `the registry at … refused this API key (Invalid API key), so it was not saved: …` | The key is mistyped, revoked or expired: paste the whole key, or ask for a new one. `API key expired` means the same. |
| `could not reach the registry at … to check the API key …` | `registry login` saves only a key it could check. Check your connection and `--registry` / `III_REGISTRY_URL`. |
| `--skills … holds no non-empty Markdown (.md) file to publish …` | Add a `SKILL.md`, or drop `--skills`. Nothing was uploaded. |
| ``warning: … links to `…`, which is not a published skill file …`` | Only non-empty `.md` files inside `--skills` are published: link to one of them, or to a URL. |
| `could not move latest of …: 409 Conflict: …` | The tag moved since it was read, or `--expect-current` is stale. Check with `registry tag ls` and run it again. |
| The worker page does not show the version just published | Pages are cached for about a minute; `iii` can install the version right away. The `Page:` line `publish` prints is where it shows up. A candidate (`--tag next`) never becomes the page's version: open the `?version=` link printed, or promote it with `registry tag promote`. |
| `… has no iii.worker.yaml at its root, so iii cannot install it: …` | The bundle archive is missing its manifest, usually because the build folder was archived. See [Common bundle errors](#common-bundle-errors). |
| ``deploy `x` is not supported (only binary, bundle and executable)`` | Fix `deploy:` in `iii.worker.yaml`. |
| ``iii.worker.yaml has no `manifest:` to read the version from; pass --version`` | Add `manifest:` to `iii.worker.yaml` or pass `--version`. |
| ``no string `version` in <file>; pass --version`` | Add a `version` to your language manifest or pass `--version`. |
| `… is already published with different contents; bump the version instead of overwriting` | Versions are immutable: bump the version and publish again. |
| `N problem(s) in the kit at …; nothing was sent: …` | Fix what each `error[code] file: …` line says; [Agents and skills](#agents-and-skills) has the rules. |
| `[name_mismatch] iii.kit.yaml: the kit is named …` | A kit is published under its author's handle: rename it as the message says (`registry whoami` shows your handle). |
| `the registry refused <kit>@<version> (422 …)` | The registry's checks failed (an unknown worker, a range nothing matches, …): fix what each line names. |
| `… is already published with different files; bump `version` …` | Kit versions are immutable too: bump `version` in `iii.kit.yaml`. |
| `N warning(s), and --strict treats them as errors …` | Fix the warnings printed above it, or publish without `--strict`. |

## License

Apache-2.0
