# Operate hosts with the CLI and the daemon

Pull, verify, apply, and inspect releases from a shell, run one sync loop per host with airprompterd under systemd, launchd, Docker, or Kubernetes, and carry releases and telemetry across an air gap.

`airprompter` is one binary for the people who run hosts and pipelines rather than write agents. It shares the TypeScript SDK’s modules, so what `verify` refuses, a runtime refuses too. `airprompterd` is its daemon mode, `airprompter daemon`: one sync loop and one shared encrypted store per host, served to every SDK process on the host over a local socket, plus the host’s telemetry upload.

**The daemon is an optimization, never a requirement**

An SDK process started with `sync.mode: "daemon"` attaches when the socket is there and syncs in-process when it is not. One process per container that never shares a host gains nothing from it. Several processes on one host stop pulling separately and holding separate stores.

## Before you install

- The CLI ships as signed single-file executables for macOS, Linux, and Windows from the repository’s releases (`airprompter-linux-x64`, `airprompter-darwin-arm64`, and so on); each carries a provenance file naming the platform signature applied. If the release you need is not listed yet, `cli/` builds the same binary from source with `npm ci && npm run bundle`.
- The Agent key is read from `AIRPROMPTER_AGENT_KEY` (or the variable named by `--api-key-env`), never from arguments. Private keys come from files the CLI wrote with mode `0600`.
- Nothing the CLI prints is prompt text, at any verbosity. Releases are described as generations, digests, ids, models, variable names, and counts.

## The commands

| Command                                   | What it does                                                                                                                                                      |
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pull`                                    | Fetch and verify the current release; write an encrypted `.apbundle` and a `.meta.json` sidecar; `--check` compares a vendored one and exits `3` when it is stale |
| `verify`                                  | Run the verification chain on a bundle or a state directory and print the reasons                                                                                 |
| `apply`                                   | Stage a bundle into the host’s store and activate it per the environment’s policy                                                                                 |
| `status`                                  | Active and staged generation, lease, storage protection, spool depth, last upload; asks the daemon when one runs                                                  |
| `doctor`                                  | Every reason this host is not serving the release it should, with the remedy                                                                                      |
| `diff`                                    | What a bundle would change against the active release, or against another bundle (`--against`)                                                                    |
| `unlock`                                  | Make the staged release live on this host (`--generation N` to name it)                                                                                           |
| `policy show` / `policy set`              | Show or set the apply policy this host holds; a release may tighten it, only this loosens it                                                                      |
| `keygen`                                  | Generate a distribution or countersign key pair; refuses to write a private key inside a git checkout unless `--allow-worktree`                                   |
| `daemon`                                  | `airprompterd`                                                                                                                                                    |
| `export-telemetry` / `import-telemetry`   | Carry the spool across an air gap as one file, idempotently                                                                                                       |
| `telemetry verify` / `telemetry validate` | Prove the disk budget invariant on this machine; check a third-party writer’s segments line by line                                                               |
| `dev`                                     | Serve a directory of prompt files as a local registry while you edit; the conformance target                                                                      |
| `login` / `import`                        | Sign in as a workspace member and turn a directory, export, or query result into reviewable prompt versions                                                       |

Exit codes scripts can rely on: `0` ok, `1` refused (the reason on stderr), `2` usage, `3` stale (`pull --check` only), `4` partial (`import` only). `--json` prints one JSON document on stdout, always the last line; on a refusal it carries `ok: false`, the error, the exit code, and the step that refused.

## Vendor a bundle in CI

```sh
# once, on a workstation outside any repository: the environment's distribution key pair
airprompter keygen --purpose distribution --out ~/.config/airprompter/prod
# register ~/.config/airprompter/prod.pub.json on the environment (Agent → Settings)

# in the build: the current release, sealed to that key
AIRPROMPTER_AGENT_KEY="$CI_SECRET" airprompter pull \
  --org org_… --agent agt_… --environment prod \
  --root ./airprompter-root.jwk.json --root-url https://<edge>/roots/prod/root.json \
  --distribution-key ./prod.pub.json \
  --out airprompter.bundle.apbundle

# in a scheduled job: fail when the vendored bundle falls behind
AIRPROMPTER_AGENT_KEY="$CI_SECRET" airprompter pull --check --max-behind 2 \
  --org org_… --agent agt_… --environment prod --out airprompter.bundle.apbundle
```

`--root` takes the environment’s pinned root public key or a full signed root document; a pinned key alone cannot name the signing keys, so `pull` also needs `--root-url`. `airprompter diff new.apbundle --against old.apbundle` compares two bundles with no store, slot by slot, and warns when a bundle goes backwards in generation, because every host refuses one. The repository also ships a GitHub Action, `action/verify`, that runs `verify` on every bundle a pull request commits.

## Apply on a host

1. Copy the bundle to the host; keep the distribution private key where the runtime reads it and nowhere else.
2. `airprompter verify <bundle> --org … --agent … --environment prod --root ./root.jwk.json --distribution-key ./prod.key.json` and read the reasons.
3. `airprompter diff <bundle> --org … --agent … --environment prod --distribution-key ./prod.key.json` to see what would change.
4. `airprompter apply <bundle> --org … --agent … --environment prod --root ./root.jwk.json --distribution-key ./prod.key.json`.
5. `airprompter status --agent … --environment prod`.

`apply` runs the identical chain a runtime runs (root, manifest signature, payload hashes, generation counter), stages into the inactive slot, and activates under `auto`; under unlock-required it stages and says so, and `airprompter unlock` makes it live. A generation below the stored one is refused unless `--force`, and then it is a forced downgrade stamped on evidence. Rolling a host back is `ap.rollback()` in the SDK or the `rollback` operation on the daemon’s socket; the CLI’s messages name an `airprompter rollback` command that is not in its command table in the current release. Pass `--state-dir` to match what the runtime was started with; the default is the OS state directory.

**Expected result:** `status` shows the applied generation active, and an SDK process on the host started against the same state directory serves it before touching the network.

## Diagnose a host with `doctor`

`doctor` is the day-to-day first step when a host is not serving what the board says it should.

```sh
AIRPROMPTER_AGENT_KEY=… airprompter doctor --org org_… --agent agt_… --environment prod --root ./root.jwk.json --json
```

Nine read-only checks (`source`, `edge_pointer`, `root`, `store`, `active_release`, `lease`, `key_protection`, `policy_pin`, `spool`, `daemon`), each `ok`, `warn`, `fail`, or `skip`, printed with what to do next. It never creates a store, writes a file, or sends a heartbeat; with no key the source check is skipped, not failed. Exit `0` when no check fails.

## Run the daemon

```sh
AIRPROMPTER_AGENT_KEY=… airprompter daemon \
  --org org_… --agent agt_… --environment prod \
  --root /etc/airprompter/root.jwk.json --root-url https://<edge>/roots/prod/root.json \
  --edge-pointer-url https://<edge>/g/<token>/generation.json --poll-seconds 30 --json
```

The daemon listens on `<store>/daemon.sock` (mode `0600`; a named pipe on Windows). Attached SDK processes receive the verified release, `generation` events, and host-wide `unlock` and `rollback`; a process that finds no socket syncs in-process from its own store; a daemon that cannot obtain the store’s key does not listen. Logs are one JSON object per line on stderr and never carry prompt text. `GET /healthz` on the socket answers `200` with a verified release active and `503` without; `airprompter status` asks the daemon when it is there and doubles as a liveness probe.

With an Agent key the daemon also uploads the spool: every closed segment under the store’s telemetry directory, its own, the attached processes’, and any third-party writer’s, checked line by line first. Passes run every `--upload-interval-seconds` (300); `--spool-budget-bytes` sets the host budget (100 MiB); `--no-upload` leaves the spool on disk; `--upload-sink otlp` with `--otlp-endpoint`, `--otlp-header 'name=$VAR'`, and `--otlp-resource key=value` sends the windows to your OpenTelemetry collector instead, with no Agent key needed on that path.

The daemon stops on `SIGTERM` within 20 seconds; it tells attached SDKs it is going, and they keep serving what they hold and reconnect.

## Deploy manifests

The repository’s `deploy/` directory holds a validated manifest per host type. Every one does the same things: the Agent key comes from `AIRPROMPTER_AGENT_KEY` in the service’s environment (a `0600` env file, a Secret, the service account’s environment), never from arguments; the state directory is per-service and `0700`; the socket lives inside the store directory; application processes that should attach run as the same user with the same state directory.

| Host            | Manifest                                                           | What to fill in                                                                                                                                                                                  |
| --------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Linux, systemd  | `deploy/systemd/airprompterd.service` + `airprompterd.env.example` | Install the binary to `/usr/local/bin`, the env file `0600` to `/etc/airprompter/airprompterd.env`, the root key to `/etc/airprompter/root.jwk.json`, then `systemctl enable --now airprompterd` |
| macOS, launchd  | `deploy/launchd/com.airprompter.airprompterd.plist`                | `launchctl bootstrap gui/$(id -u) …`; on shared hosts wrap the command in a script that reads the key from the keychain rather than the plist                                                    |
| Windows service | `deploy/windows/airprompterd.xml` (WinSW wrapper)                  | The key in the service’s environment; state under `C:\ProgramData\AirPrompter\state`                                                                                                             |
| Docker sidecar  | `deploy/docker/Dockerfile`, `docker-compose.yml`                   | The daemon and the app share a state volume and a uid; the app’s SDK uses `sync.mode: "daemon"` and the same `XDG_STATE_HOME`                                                                    |
| Kubernetes      | `deploy/kubernetes/daemonset.yaml`                                 | One daemon per node; the key from a Secret; the store on a `hostPath` the application pods mount read-write, running as the same `runAsUser`                                                     |

The systemd unit is hardened (`ProtectSystem=strict`, `ReadWritePaths=/var/lib/airprompter`, `NoNewPrivileges`, address families limited to Unix and IP sockets). The Kubernetes DaemonSet runs non-root with a read-only root filesystem and all capabilities dropped, and probes with `airprompter status --json`.

## A host that never calls home

An environment whose hosts have no route to AirPrompter is served by a file. On an environment whose sync mode is offline and whose distribution key is registered, the environment’s Settings offers **Download update file**: the promoted release, its payloads, and the environment’s root document, sealed to that key, good for 90 days by default. The console, `verify`, and `apply` all warn inside the last 30 days.

```sh
# on the host:
airprompter verify agt_…-prod-g12.apbundle --org … --agent … --environment prod \
  --root ./root.jwk.json --distribution-key ./prod.key.json
airprompter apply  agt_…-prod-g12.apbundle --org … --agent … --environment prod --root ./root.jwk.json --distribution-key ./prod.key.json

# telemetry back: pack what the runtimes on this host spooled
airprompter export-telemetry --org … --agent … --environment prod --out 2026-09-17.aptelemetry

# carry the file to a host that can reach AirPrompter, then:
AIRPROMPTER_AGENT_KEY=… airprompter import-telemetry --org … --agent … --environment prod --in 2026-09-17.aptelemetry
```

`export-telemetry` packs every closed segment with at least one valid row and moves the packed ones to `exported/`, so the next export packs only what is new (`--keep` leaves them). `import-telemetry` heartbeats once per instance the file names and posts each segment under its own name, so importing the same file twice counts nothing twice. A file for another agent or environment is a usage error before any network.

Re-key the distribution key at least every 90 days: `keygen` a new pair, register the new public key, download a fresh update file, and retire the old private key once the hosts have applied it.

## Troubleshooting

### `verify` refuses with `unknown_signing_key`

You passed a pinned key with no root document. Add `--root-url`, or pass a full root document to `--root`; for an offline host the update file carries the root document itself.

### `apply` says the bundle is stale or backwards

A generation below what the host holds is refused; a rollback is `ap.rollback()` or the daemon’s `rollback` operation, never an older bundle. If you really mean to go back, `--force` stamps a forced downgrade.

### `status` reports `file_key` under storage protection

The store key is a file beside the store. That is fine for a first run and is shown as a finding on the Fleet tab; production hosts use an OS keystore, KMS, or Vault through the SDK’s key provider.

### The daemon is running but SDK processes sync in-process

They are not the same user, or not the same state directory. `airprompter status` shows attached clients; `doctor` names a stale socket.

## Completion checklist

- The binary you installed is the signed release for your platform, or a build you produced yourself.
- `AIRPROMPTER_AGENT_KEY` is in a `0600` env file, a Secret, or the service environment, and never on a command line.
- `doctor` exits `0` on every host.
- Application processes attach to the daemon, or you have decided they should not.
- Air-gapped hosts have a schedule for update files and telemetry files, and for re-keying.

## Next steps

Pair the daemon with the resident [TypeScript](https://airprompter.com/docs/integrations/typescript) or [Python](https://airprompter.com/docs/integrations/python) integration, or replace hand-applied bundles with [the fleet pattern](https://airprompter.com/docs/integrations/fleet) when the estate grows.

---

Canonical: https://airprompter.com/docs/integrations/cli
