# Develop a Native Plugin

> ⚠️ Experimental

This guide follows one plugin from the first scaffold to a published release, then shows how to run the build on your machine, in the project that consumes it, without publishing or reinstalling after every change.

## Overview

A native plugin is a compiled Go program that Atmos starts to provide an authentication provider, a credential-exchange identity, or a command. Consumers normally install a signed, locked release from an OCI registry. That is the right behavior for a teammate or a pipeline, and the wrong one while you are still changing the code: a publish, an install, and a lock update for every edit would make the feedback loop minutes long.

A package declaration can instead name a directory with `plugins.packages.<alias>.path`. Atmos then reads the manifest and the executable from that directory every time it starts the plugin. You rebuild, and the next command runs the new executable. Because you set `path` in a [profile](/cli/configuration/profiles) of your own, it applies only to you and only while you ask for it. Without the profile, the same project uses the registry release.

**Key benefits:**

- **Fast loop**: Rebuild with `atmos plugin build` and rerun the command. There is nothing to reinstall.
- **Opt-in and personal**: The local build lives in a profile you choose. The committed `atmos.yaml` and the lock keep describing the release.
- **Same project, two modes**: Deactivate the profile and the identical commands run the locked release.
- **Governed**: `on: plugin` policy rules can allow local builds only below approved directories, only for testing, or only under conventions you define.

## Prerequisites

- An Atmos installation and Go on your `PATH`. Only plugin authors need Go; consumers install compiled releases.
- An OCI registry that accepts pushes from your machine. This guide uses a registry listening on `localhost:5001`; Atmos talks plain HTTP to `localhost`. Any registry works, and port `5000` is avoided because macOS uses it for AirPlay.

## Quick Start

All commands run from the project directory, `infra/`. It starts with one file:

```yaml title="infra/atmos.yaml"
base_path: "."
```

### 1. Scaffold, build, and test the plugin

Generate the OIDC starter next to the code that uses it:

```shell
atmos plugin create ./plugins/corporate-oidc --kind=acme/oidc --module=example.com/acme/oidc --defaults
```

When you work against an unreleased Atmos checkout, add `--sdk-path=/absolute/path/to/atmos/sdk`. Released builds select the Plugin SDK that matches your Atmos version.

Compile the executable and run the conformance suite, which uses local mock endpoints and needs no identity provider:

```shell
atmos plugin build ./plugins/corporate-oidc
atmos plugin test ./plugins/corporate-oidc
```

```text
✓ Plugin built successfully: plugins/corporate-oidc/bin/plugin
▶ Testing plugin in plugins/corporate-oidc with isolated fixtures...
✓ Plugin contract: executable capabilities match plugin.yaml
✓ OIDC lifecycle: PKCE login, token validation, credential exchange, cached reuse, refresh, and logout (local fixtures)
✓ Command about: output and exit status match the fixture
✓ All 3 plugin checks passed.
```

Unlike `plugin validate`, `plugin test` starts the plugin executable, so build first. See [`plugin build`](/cli/commands/plugin/build) and [`plugin test`](/cli/commands/plugin/test).

### 2. Publish the first release

Package the executable and publish it. The package ID `acme/oidc` and version `0.1.0` come from `plugin.yaml`.

```shell
atmos plugin pack ./plugins/corporate-oidc --output=./oidc-release
atmos plugin publish ./oidc-release --to=oci://localhost:5001/acme/oidc
```

```text
✓ Published acme/oidc version 0.1.0.
Plugin	acme/oidc
Version	0.1.0
Platforms	darwin/arm64
Digest	sha256:eb63bf7c8cb4f038acd481f105790a779a3cc0e4d3f740d3b43326ae8d95779c
Repository	oci://localhost:5001/acme/oidc
Signing	Not signed by this command
Files (darwin/arm64)	bin/plugin  20.6 MiB  0755
Files (darwin/arm64)	plugin.yaml  892 B  0644
Files (darwin/arm64)	schemas/broker.json  212 B  0644
Files (darwin/arm64)	schemas/provider.json  365 B  0644
```

A release holds only `plugin.yaml`, the JSON schemas, and the executables the manifest declares. A registry refuses to overwrite a version with different bytes, so publish a new `version` for every change you want consumers to see. See [`plugin pack`](/cli/commands/plugin/pack) and [`plugin publish`](/cli/commands/plugin/publish).

The scaffold declares only the platform of the machine that created it. List every OS and architecture you ship under `spec.platforms` in `plugin.yaml` before you publish for others.

### 3. Install the release into the project

```shell
atmos plugin install oci://localhost:5001/acme/oidc --as=corporate
```

```text
✓ corporate (0.1.0)
```

Atmos records a caret pin of the release it installed in `atmos.yaml` and the exact digests in `.atmos.plugins.lock.yaml`:

```yaml title="infra/atmos.yaml"
base_path: "."
plugins:
  packages:
    corporate:
      source: oci://localhost:5001/acme/oidc
      version: ^0.1.0
```

Add an authentication provider that uses the plugin's kind, then check the plugin and run its `about` command:

```yaml title="infra/atmos.yaml"
auth:
  providers:
    corporate:
      kind: acme/oidc
      spec:
        issuer: https://login.example.com
        client_id: native-application-client-id
        scopes: [openid, profile, offline_access]
```

```shell
atmos plugin list
atmos plugin run corporate --
```

```text
corporate	acme/oidc	0.1.0	installed
▶ Running corporate command about.
▶ Display the plugin name and supported authentication flow
acme/oidc: OIDC PKCE login and AWS credential exchange
This is the plugin's about command. No authentication was performed.
To sign in, configure an auth identity using this plugin and run:
  atmos auth login --identity <identity>
```

Commit `atmos.yaml` and `.atmos.plugins.lock.yaml`. This is the state every teammate and pipeline restores.

### 4. Run your local build through a profile

Create a profile whose only job is to point the package at your build directory. The name is yours to choose; Atmos gives no meaning to it. This guide uses `kestrel-7`.

```yaml title="infra/.atmos/profiles/kestrel-7/atmos.yaml"
plugins:
  packages:
    corporate:
      path: plugins/corporate-oidc
```

Profiles deep-merge over the base configuration, so `source` and `version` stay in the merged result. They are unused while `path` is set. A relative `path` is relative to the project directory (the directory of the root `atmos.yaml`); an absolute path also works.

Select the profile with `--profile` or `ATMOS_PROFILE`:

```shell
atmos plugin list
atmos --profile=kestrel-7 plugin list
ATMOS_PROFILE=kestrel-7 atmos plugin info corporate
```

```text
corporate	acme/oidc	0.1.0	installed
corporate	acme/oidc	0.1.0	path
Alias	corporate
Status	path
Path	/path/to/infra/plugins/corporate-oidc
Origin	plugins.packages.corporate.path (local build; lock, signature and version checks are bypassed)
Plugin	acme/oidc
Version	0.1.0
...
```

The state is `path` while the profile is active and `installed` without it. The name and version come from the local `plugin.yaml`.

Run the plugin with the profile active:

```shell
atmos --profile=kestrel-7 plugin run corporate --
```

```text
⚠ Plugin corporate runs from local path plugins/corporate-oidc; lock, signature and version checks are bypassed.
▶ Running corporate command about.
...
```

Atmos prints that warning once per alias, when a command first starts the plugin. Commands that only read configuration, such as `plugin list` and `plugin info`, do not start it and do not warn.

Profile directories are discovered in several places (see [profile discovery](/cli/configuration/profiles#profile-discovery)). `.atmos/profiles/` keeps the profile inside the project, so add it to `.gitignore`. A profile under your user configuration directory (`~/.config/atmos/profiles/kestrel-7/atmos.yaml`) works the same way and changes nothing in the repository.

### 5. Edit, rebuild, and rerun

Change the first line of the `about` output in `plugins/corporate-oidc/command.go` from `OIDC PKCE login and AWS credential exchange` to `OIDC PKCE login and AWS credential exchange (local build)`, then rebuild and test:

```shell
atmos plugin build ./plugins/corporate-oidc
atmos plugin test ./plugins/corporate-oidc
```

The test fails because the fixture still expects the old text. The failure shows the first difference and both values:

```text
✓ Plugin contract: executable capabilities match plugin.yaml
✓ OIDC lifecycle: PKCE login, token validation, credential exchange, cached reuse, refresh, and logout (local fixtures)
✗ Command about: output and exit status match the fixture
    stdout does not match the fixture (first difference at byte 54):
      expected: "acme/oidc: OIDC PKCE login and AWS credential exchange\nThis is ..."
      actual:   "acme/oidc: OIDC PKCE login and AWS credential exchange (local build)\nThis is ..."
✗ Plugin tests failed: 2 passed, 1 failed.
```

Make the same edit to the `about` fixture in `plugin.tests.yaml`, run the test again, and run the plugin. There is no install step:

```shell
atmos --profile=kestrel-7 plugin run corporate --
```

```text
⚠ Plugin corporate runs from local path plugins/corporate-oidc; lock, signature and version checks are bypassed.
▶ Running corporate command about.
▶ Display the plugin name and supported authentication flow
acme/oidc: OIDC PKCE login and AWS credential exchange (local build)
...
```

The next command reads `plugin.yaml` and the executable from the directory again, so every rebuild is picked up. Edits to `plugin.yaml` need a rebuild too, because the executable embeds the manifest.

### 6. Return to the release

Run the same command without the profile:

```shell
atmos plugin run corporate --
```

```text
▶ Running corporate command about.
▶ Display the plugin name and supported authentication flow
acme/oidc: OIDC PKCE login and AWS credential exchange
...
```

The output has no `(local build)` text and there is no warning: Atmos restored the locked release from the cache. When your change is ready, bump `metadata.version` in `plugin.yaml`, build, test, then `pack` and `publish` again, and move consumers with [`plugin update`](/cli/commands/plugin/update).

## Release for multiple architectures

The starter initially declares your local platform. Before releasing for other
machines, add each supported `os/architecture` to `spec.platforms` with a unique
executable path, such as `bin/linux-arm64/plugin` or
`bin/windows-arm64/plugin.exe`. When shipping Windows, also set the common
packaged `spec.runtime.executable` path to end in `.exe`, such as `bin/plugin.exe`;
that filename works on Linux and macOS too. Keep one final manifest for the entire release;
the Go binary embeds it, so rebuild every target after changing it.

```shell
# Build one declared target.
atmos plugin build ./plugins/corporate-oidc --platform linux/arm64
# Build all targets declared in plugin.yaml.
atmos plugin build ./plugins/corporate-oidc --all-platforms
# Exercise only the compatible target for this Atmos process.
atmos plugin test ./plugins/corporate-oidc
```

Use the [multi-architecture release example](https://github.com/cloudposse/atmos/tree/main/examples/plugin-multiarch-release)
for a complete six-platform manifest and GitHub workflow. It builds and runs
conformance natively on Linux, macOS, and Windows for amd64 and arm64, collects
those binaries into one OCI index, and restores the same lockfile on all six
platforms before publication. Cross-compilation alone cannot replace those
execution checks. The final publish job signs the complete index digest.

Explicit variants such as `linux/arm/v7` or `linux/amd64/v3` also need distinct
manifest paths and matching test runners. Atmos selects within its own process's
OS/architecture and compiled instruction-set baseline, choosing the highest
compatible variant before an unqualified baseline artifact. It does not detect
extra host CPU features or silently switch to an emulated architecture. See
[`plugin build`](/cli/commands/plugin/build) for supported variants and compiler
settings.

## What a `path` package bypasses

A package that sets `path` runs code you control, so Atmos skips the checks that exist to protect consumers from unreviewed code:

| Skipped | Why |
|---|---|
| Lock entry | Nothing is read from or written to `.atmos.plugins.lock.yaml`. |
| Download and cache | The directory is used in place. There is no digest to verify. |
| Signature and trusted-publisher checks | A local build has no signature. `plugins.policy.require_signature` governs released packages only. |
| Version constraint | The `version` you declared is not compared with the manifest in the directory. |
| Automatic installation | `atmos plugin install` skips the package with a notice. |

Policy still applies. Every activation of a `path` package is checked with `origin: development` and `operation: activate`, and `atmos plugin test <dir>` is checked with `operation: test`. See [policy](#govern-local-builds-with-policy) below.

If `enabled: false` is set on the package, the `path` is ignored and the package stays disabled.

## In CI and with `--frozen`

`atmos plugin install --frozen` restores exactly what the lock records, so it refuses while the active configuration sets `path`. The hint names the active profiles:

```text
Error: plugin declarations or artifacts differ from the release lock

plugins.packages.corporate.path is set

💡 The active configuration sets path for corporate, which runs a local build with no lock entry. Frozen installs are
for released packages: run without --frozen, or use a configuration that does not set path (active profiles: kestrel-7).
```

Plain `atmos plugin install` prints `Skipping plugin corporate: plugins.packages.corporate.path runs a local build, which is never fetched or locked.` and leaves the declaration and the lock alone.

`atmos plugin remove corporate` refuses for the same reason: the path comes from configuration, not from a file `remove` wrote. Edit the profile, or deactivate it.

Keep local profiles out of pipelines. A pipeline that does not select your profile restores the locked release, and CI still refuses to create a lock entry that is not committed. A pipeline that does select a profile with `path` runs unlocked code, so protect against that with policy.

## Govern local builds with policy

Local builds are the one place a plugin runs without a signature or a lock, so organizations that use [plugin policies](/policies/plugins) usually add rules for them. Rules load from `policy.d/` or `ATMOS_POLICY_DIR`. The inputs that matter here:

| Input | Value for a local build |
|---|---|
| `input.origin` | `development` |
| `input.operation` | `activate` when a command starts the plugin, `test` for `atmos plugin test` |
| `input.path` | The build directory: a project-relative slash path such as `plugins/corporate-oidc`, or an absolute path when it is outside the project. It never contains `..`. |
| `input.profiles` | The active profile names exactly as typed, in activation order; empty when none is active. |

### Allow a localhost registry

A rule that permits only an approved registry also denies a local registry. Add `localhost:5001` to its allowed authorities. Entries are exact authorities, including the port:

```yaml title="infra/policy.d/plugins.yaml"
apiVersion: atmos/v1
kind: Policy
metadata:
  name: plugin-registries
  description: Plugins must come from an approved registry.
spec:
  policies:
    approved-registries:
      on: plugin
      action: deny
      reason: Plugins and catalogs must use an approved registry
      with:
        allowed_registries:
          - registry.example.com
          - "localhost:5001"
      rule: >-
        input.origin == 'layout' || (input.origin != 'development' && !(input.registry in params.allowed_registries))
```

This rule leaves `origin: development` to other rules, so it permits `path` packages and `plugin test`, and still denies offline layouts. Add the next rules to restrict development.

### Allow local builds only under approved directories

```yaml title="infra/policy.d/plugin-development.yaml"
apiVersion: atmos/v1
kind: Policy
metadata:
  name: plugin-development
  description: Local plugin builds are limited to approved directories.
spec:
  policies:
    approved-dev-roots:
      on: plugin
      action: deny
      reason: Local plugin builds must live under an approved directory
      with:
        dev_roots:
          - plugins/
      rule: "input.origin == 'development' && !params.dev_roots.exists(r, input.path.startsWith(r))"
```

A profile that points `path` at `scratch` is denied, for activation and for `atmos plugin test scratch`:

```text
Error: policy denied execution

plugin-development/approved-dev-roots:

Local plugin builds must live under an approved directory
```

A plugin outside the project has an absolute `path`, so a relative root never matches it. List an absolute root if you want to allow a sibling checkout, and note that it is then specific to one machine layout.

### Allow only `plugin test` for development

To let authors test but not run unlocked code through Atmos, deny every development operation except `test`:

```yaml
    development-test-only:
      on: plugin
      action: deny
      reason: Local plugin builds may be tested but not run
      rule: "input.origin == 'development' && input.operation != 'test'"
```

`atmos --profile=kestrel-7 plugin run corporate --` is denied, while `atmos plugin test ./plugins/corporate-oidc` passes. `atmos plugin build` is never a policy operation, because it compiles source, admits nothing, and runs no plugin code.

### Use profile names for audit and team conventions

`input.profiles` lets a rule require a convention, such as a `local-` prefix, so activations of local builds are recognizable in logs and reviews:

```yaml
    development-profile-convention:
      on: plugin
      action: deny
      reason: Activate local plugin builds with a profile named local-*
      rule: >-
        input.origin == 'development' && input.operation == 'activate' &&
        !input.profiles.exists(p, p.startsWith('local-'))
```

With this rule `--profile=kestrel-7` is denied and `--profile=local-kestrel` runs. The rule is limited to `activate` because `plugin test` runs from a directory argument and has no profile.

:::caution
Profile names are chosen by the developer, so any rule that depends on a name can be avoided by renaming the profile, or by setting `path` in `atmos.yaml`, where no profile is involved. Use `input.profiles` for audit and organization conventions. Gate trust on `input.origin` and `input.path`, and protect the policy directory and the Atmos runtime as described in [enforcement](/policies/plugins#enforcement).
:::

## Troubleshooting

| Symptom | Cause and fix |
|---|---|
| `policy denied execution` naming a `plugin-*` rule | A policy rule matched `origin: development`, the `path`, the operation, or the profile list. Read the rule's `reason`, then move the build under an approved root, use the required profile name, or change the policy. `atmos plugin build` is never denied. |
| `plugin declarations or artifacts differ from the release lock` with `plugins.packages.<alias>.path is set` | `--frozen` was run with a profile that sets `path`. Run without `--frozen`, or without that profile. |
| `plugin package runs from a local path set in configuration` | `atmos plugin remove` cannot remove a package whose `path` comes from configuration. Edit the profile or `atmos.yaml`, or deactivate the profile. |
| `plugin list` shows state `broken` and a warning that `plugin.yaml` cannot be read | The directory in `path` does not exist or holds no `plugin.yaml`. Check the path (relative paths are relative to the project directory) and run `atmos plugin build <dir>`. |
| `plugin run` fails with `no such file or directory` for `bin/plugin` | The executable is missing. Run `atmos plugin build <dir>`. The hint names the resolved directory. |
| `profile not found` | `--profile` or `ATMOS_PROFILE` names a profile that no discovery location contains. The error lists the available profiles; `atmos profile list` shows where each one lives. |
| Changes are not picked up | The profile is not active, so the release runs. Check that `atmos plugin list` shows state `path`. If the behavior of `plugin.yaml` changed, rebuild: the executable embeds the manifest. |
| `plugin source looks like a filesystem path` | `atmos plugin install ./dir` was run. `install` fetches releases only. Declare `path` in a profile instead. |

## Clean up

Delete the profile directory (and the `.gitignore` entry you added for it). The declaration in `atmos.yaml` and the lock are untouched, because the profile never wrote to them.

## Next steps

- [`atmos plugin`](/cli/commands/plugin/usage) covers the full lifecycle, including signing releases and registry authentication.
- [Plugins configuration](/cli/configuration/plugins) lists every `plugins.packages.<alias>` field, including `path`.
- [Plugin policies](/policies/plugins) documents all inputs and operations.
- [Profiles](/cli/configuration/profiles) explains discovery, activation, and merge behavior.
