agent-distro

Your team's coding agents,
in one command.

Package your skills, MCP servers and harness settings once. Run them in Claude Code, Codex, OpenCode, Oh My Pi and Pi, each at its latest release, with nothing to install.

nix run github:juspay/agent-distro

Quick start Install & keep updated Profiles

A terminal: nix run github:juspay/agent-distro opens a picker with two profiles and six harnesses; the filter narrows to Claude Code, Enter launches it.
Pick a profile and a harness, or filter with /. Arguments after -- go straight to the harness.

Every harness, at its latest release

These are the versions this site was built from. The daily update moves them forward once the builds and VM tests pass.

How it fits together

A profile is data. agent-distro reads it once per launch, and each harness starts with your plugins, environment and settings already in place.

Also inside Kolu

Type claude. It's already there.

Kolu, the terminal workspace for running coding agents on a canvas of tiles, ships agent-distro built in. Choose a profile once and every new terminal, local or remote, has the agents on its PATH, kept current four times a day, each started with Kolu's plugin loaded so it can drive other tiles.

Agents in Kolu

Quick start

1. In a repository with a plugin.json.

nix run github:juspay/agent-distro

That is the whole setup. The repository’s plugin.json is the profile: its skills, MCP servers, packages, environment and harness settings are in effect for that launch. The minimal manifest is five lines:

{
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
  "name": "my-repo"
}

2. A team’s profile, by reference.

nix run github:juspay/agent-distro -- github:juspay/skills

A profile is an Agent Plugin; name one and it is composed over the repository’s own, as Profiles describes.

3. The chooser.

nix run github:juspay/agent-distro                            # pick from the list
AI_HARNESS=claude nix run github:juspay/agent-distro          # skip the list

To keep the agents installed and updated, see Install and keep updated. To give your agents a team’s skills, MCP servers and settings, see Profiles.

  • Systems. x86_64-linux, aarch64-linux, and aarch64-darwin.
  • Binary cache. Pass --accept-flake-config to use the cache this flake names;1 without it, Oh My Pi is built from source.

The chooser

The chooser is master–detail: harnesses are the list on the left, and the highlighted harness fills the panel on the right.2 The profiles in effect for the launch are in the header — named from the bottom of the stack up, juspay-skills + repo — and the row under it says what the top one is and where every one of them came from. Nothing in the box is ever cut.

Here the terminal is in a repository whose profile is Juspay’s plugin.json, and pi is remembered from last time.

╭─ agent-distro · juspay-skills ─────────────────────────────── 5 harnesses ─╮
│ juspay-skills · Juspay skills + Kolu, via Juspay's LiteLLM gateway · from  │
│ /home/me/src/skills/plugin.json                                            │
├──────────────────────────────┬─────────────────────────────────────────────┤
│     ✓ Oh My Pi       18.7.0  │ Pi 1.0.4                                    │
│     ✓ Codex         0.160.1  │ OMP's upstream · plugins per home           │
│     ✓ Claude Code   2.1.292  │                                             │
│     ✓ OpenCode       2.0.29  │ Signed in                                   │
│ ❯ • ✓ Pi              1.0.4  │   LITELLM_API_KEY                           │
├──────────────────────────────┴─────────────────────────────────────────────┤
│ / filter…                                                                  │
╰────────────────────────────────────────────────────────────────────────────╯
↑↓ jk move   Enter launch   / filter   q quit
Variable3 Values Effect
AI_HARNESS a directory under harnesses/ Launches that harness without the list4
AI_PROFILE whitespace-separated profile references Your profiles, under the repository’s own5
AGENT_DISTRO_SETTINGS 0 Keeps the plugins; leaves out the profile’s env and settings
AGENT_DISTRO_PLUGINS :-separated plugin directories Loads more plugins for that launch; see Plugins at launch
AGENT_DISTRO_LOG silent, error, warn, info (default), debug, trace How much agent-distro says on stderr; see Diagnostics

By name

Command Does
agent-distro <harness> Launches directly
agent-distro <profile> <harness> Launches with that profile
agent-distro <profile> Opens the chooser with that profile

A leading argument naming a harness is the harness. Otherwise any value containing / or : is a profile: a path, which when relative must start with ./, or a flake reference. A profile built by agent-distro.lib.mkProfile is an absolute path, and works the same way.

agent-distro codex --version
agent-distro github:juspay/skills claude --version
agent-distro ./path/to/repo claude
agent-distro /nix/store/…-agent-distro-profile-juspay-skills omp   # a built profile, by its store path
agent-distro --list                      # the profiles in effect, then harness title version, one per line
agent-distro --list --json               # the same, as JSON (below)
  • Precedence. The positional profile is used exactly, alone. Otherwise the repository’s profile is composed over the profiles of AI_PROFILE (Profiles). AI_HARNESS takes precedence over a positional harness.
  • Passthrough. Recognized leading selectors are consumed;6 other arguments and everything after -- are passed to the harness unchanged.

Signed-in marks

✓ marks a harness that can start without a login step; the panel beside it says what was found.7 A harness with nothing to start from shows a dim Not signed in and no mark.8

Harness Looks at
Claude Code oauthAccount.emailAddress in $CLAUDE_CONFIG_DIR/.claude.json,9 or the name of ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN
Codex the email claim of the OpenID token in $CODEX_HOME/auth.json,10 or OPENAI_API_KEY
OMP, OpenCode, Pi the profile’s secret variables, then the provider ids it also has credentials for11
Own-provider harnesses the ids in the harness’s store,12 plus the provider API-key variables it reads from the environment

A secret variable the profile declares and the environment does not set is a login step still to come, so its row is not signed in. AGENT_DISTRO_SETTINGS=0 puts those harnesses back on their own providers. This is a launch-time fact: --list and --list --json never carry it.

Keys

Key Does
↑/↓, j/k Move in the list
Enter Launch the highlighted harness
/ Filter the list by title or tagline
Escape (filtering) Clear the filter, keeping the cursor on its row
Escape, q, Ctrl-C Quit without choosing
  • Filtering. The filter line shows the query as typed and how many rows match.13
  • Footer. Lists only the keys that apply at that moment.
  • Colour. Follows NO_COLOR and the terminal type.
  • Small terminals. Small or unsupported terminals get a numbered list on stderr instead.14 One that shrinks below that once the box is up shows a notice until it grows again.15
  • No terminal. Without a terminal on stdin, agent-distro says which variables to set instead of drawing anything.

Remembered choice

An interactive selection is remembered in ${XDG_STATE_HOME:-$HOME/.local/state}/agent-distro/last-choice.16 Next time the cursor starts on that harness, which is marked with •.

  • Updates it. Every interactive choice, including agent-distro <profile>.
  • Never updates it. Direct selections: AI_HARNESS, agent-distro <harness>, or agent-distro <profile> <harness>.

Listing as JSON

agent-distro --list --json prints what the chooser draws, on one line, for programs that offer the same choice (such as kolu).17 Here it is pretty-printed, cut to one harness, from a terminal in a repository with its own profile, with AI_PROFILE=github:juspay/skills:

{
  "harnesses": [
    {
      "name": "claude",
      "tagline": "Anthropic login · plugin dirs per session",
      "title": "Claude Code",
      "version": "2.1.291"
    }
  ],
  "profile": {
    "name": "ekala",
    "description": "Ekala's Nix development skills",
    "source": "repository",
    "origin": "/home/me/src/ekala-ai-skills/plugin.json",
    "over": {
      "name": "juspay-skills",
      "description": "Juspay skills + Kolu, via Juspay's LiteLLM gateway",
      "source": "variable",
      "origin": "github:juspay/skills"
    }
  }
}
Field Rule
harnesses The harnesses this package ships, in menu order
name Free of whitespace and /; what AI_HARNESS (or a positional selector) takes
version The display version, as --list prints it18
profile The profile in effect for a launch from the same directory and environment; absent when there is none
profile.source positional, repository or variable (AI_PROFILE)
profile.origin The reference as given, or the path to the profile file found
profile.over The profile composed beneath it, in the same shape: a repository’s over your AI_PROFILE, each entry over the one before

Keys within harnesses are sorted; profile’s are in the order above. --list takes no other arguments.19 A profile that cannot be read fails --list as it would fail a launch.

Plugins at launch

A profile fixes the plugins its harnesses start with. Put more Agent Plugins directories on AGENT_DISTRO_PLUGINS, and every harness loads them too, on top of whichever profile is in effect, for that launch:

AGENT_DISTRO_PLUGINS=~/src/my-plugin agent-distro claude
AGENT_DISTRO_PLUGINS=/nix/store/…-kolu/agent-plugin:~/src/my-plugin agent-distro github:juspay/skills omp

Unset or empty, the launchers behave exactly as they do without this feature.

  • Entries. Directories separated by :,20 with empty components ignored.21 An entry that is not a directory fails the launch, naming it.
  • Read like a profile’s plugins. Each directory goes through the same reader: an invalid manifest fails the launch with a message naming the field. Lesser problems are reported while the launch goes on.22
  • Matched by name, never by version. A plugin whose plugin.json name matches one of the profile’s replaces it; on the variable, the last of a name wins.23
  • Translated once. Each harness’s translation of a plugin is cached under ${XDG_CACHE_HOME:-~/.cache}/agent-distro/plugins/<key>/<harness>/.24 An edited skill, manifest or MCP config is translated again on its next launch; the rest of the tree is never read.25
  • Kept while in use. Each launch marks the translations it uses; a launch that translates something new removes what no launch has used for 14 days.26 With no harness running, removing ~/.cache/agent-distro/plugins is always safe.
  • For that launch only. Unset the variable and the harness is back to the profile’s plugins.27 Each harness’s README says how.

Install and keep updated

The Home Manager module puts every harness on your PATH and keeps them on the latest build: it checks four times a day,28 installs only what the binary cache already holds, and never compiles.

With Home Manager

{
  imports = [ inputs.agent-distro.homeManagerModules.default ];
  services.agent-distro = {
    enable = true;
    profiles = [ "github:juspay/skills" ];   # default: none
  };
}

Home Manager creates the state directory and schedules the updates for you.29 One package is installed — the picker and every harness command (Package layout) — and profiles is what every command it installs exports as AI_PROFILE, whitespace-separated: a reference, read when a harness starts, or anything else — a path, or a flake input — which is built by agent-distro.lib.mkProfile and read from the store, pinned and offline.30

inputs.skills = { url = "github:juspay/skills"; flake = false; };

services.agent-distro.profiles = [ inputs.skills "github:ekala-project/ekala-ai-skills" ];

Without Home Manager

Update by hand:

nix profile install github:juspay/agent-distro#default
nix profile upgrade default

Or keep the cache-only updates, but drive them yourself:

  • agent-distro.lib.mkUpdater (see Library) gives the command and a runnable program.31
  • agent-distro.lib.mkShims keeps installed commands pointing at the updated package.
  • Scheduling is yours. The updater schedules nothing itself, so run it on your own cadence.32
  • Setup is yours. Create the stateDirectory yourself, and run --cache-warnings once to surface an unusable cache.33

Progress output

Append --progress to command to drive the same update from a program that wants to show the download. Stdout is then one JSON object per line,34 and every human message moves to stderr. Exit codes and the history log are the same as without it.

The binary cache

The updater never compiles harnesses from source; it passes the project cache to every update.35 A trusted user, or a single-user install, is done there. A Nix daemon lets nobody else’s request through, though: for a user the daemon does not trust, nix drops the updater’s substituter, its key and --no-check-sigs. So there are two ways to feed such a user, and the updater takes the second when the first is out of reach.

Signed paths, if you list the cache. Add the cache to your NixOS or nix-darwin configuration:36

nix.settings.extra-substituters = [ "https://cache.kolu.dev/kolu-r2" ];
nix.settings.extra-trusted-public-keys = [ "kolu-r2-1:7KmFrK05rVrnxkyZi9hJGnJJmRCpg87u6MUQIk7nokQ=" ];

Content-addressed paths, without touching nix.conf. A content-addressed store path is trusted by itself: nix copies one from any cache, for any user, without a signature (require-sigs, nix store make-content-addressed). CI therefore rewrites the package it built that way for each revision, through the publisher this distribution shares with kolu (.github/actions/publish-closure, juspay/kolu#2294), and publishes an index of what it wrote:

https://raw.githubusercontent.com/juspay/agent-distro/ca-index/<rev>/<system>/default.json
# {"rev": "0f3c…", "ia": "/nix/store/…-agent-distro", "ca": "/nix/store/…-agent-distro"}   ← Linux
# {"rev": "0f3c…", "ia": "/nix/store/…-agent-distro"}                                      ← macOS
https://raw.githubusercontent.com/juspay/agent-distro/ca-index/latest/<system>/default.json
# {"rev": "0f3c…", "ia": "/nix/store/…-agent-distro", "ca": "/nix/store/…-agent-distro"}

The publisher names each entry after the flake attribute it built, so the one package — packages.<system>.default — is default.json.37

<rev> is the revision CI built, which is what an updater looks its own entry up by; latest/ names the newest build a system published, with the revision that wrote it so a consumer can tell whether what it has is what this names. Only a push to main writes latest/, so a pull request’s build never moves it, and each system moves only its own file.

This index is agent-distro’s. A consumer that publishes its own builds names its own index with caIndex (services.agent-distro.caIndex, or mkUpdater’s caIndex) — an https:// base URL with no credentials, query or fragment, the only kind the updater will fetch from; anything else is ignored with a warning, and the update skips. Its revisions are not in this index, and an entry that is not there leaves the update skipped too.38

ia is the package as built — what the cache holds signed, for users that can use it — and ca its content-addressed rewrite, which needs no signature. The updater reads the pair for the revision it is on and the system it runs, copies ca from the configured cache, and points current at it: no build, no --option, no configuration on your side. Before the index names a path, CI pushes the rewritten closure and reads one back from the cache into a throwaway store, verifying its contents — a rewrite that a consumer’s nix cannot verify never reaches the index. Which nix does the rewriting matters for that: 2.35 hashes a self-referential NAR differently than 2.17–2.34 do, so CI rewrites with the release the fleet runs (kolu’s publish-closure action installs 2.34.8 itself, so it is the job’s only Nix), not the newest. These paths target Nix 2.34.x, and nothing else. Measured both ways on a closure that has such a path (libunistring’s .la files): a 2.34 rewrite fails to import on 2.35 and a 2.35 rewrite fails on 2.34, each with ca hash mismatch naming the same two digests — the lines do not accept each other’s form. A host on 2.35 or later therefore cannot take these paths and takes the ordinary skip; the signed path and the cache key above are its route, and so is a single-user install of either line. Anyone can do the same by hand, with the ca path the index names:

nix copy --from https://cache.kolu.dev/kolu-r2 /nix/store/…-agent-distro

A revision CI has not built yet, a path: flake (which names no revision to key an entry by), and a cache that does not hold the path all end the same way: the update is skipped and your current version keeps working.

Content-addressed paths are Linux-only; macOS keeps signed ones. The rewrite edits every file in the closure that embeds a store path, and a Mach-O is one of them: the edit invalidates the binary’s ad-hoc signature, and arm64 macOS then kills it on exec. Nothing can repair that afterwards — re-signing would change the bytes again — so a *-darwin entry names the signed path alone — and the updater on such a system fetches nothing content-addressed even where an older entry still names one — and a Mac needs the cache key (above) or a trusted user, exactly as before this index existed. A Mac whose cache is unusable skips, saying so, and keeps the version it has.

history.log records the skip,39 and activation warns while the cache is unusable.

Troubleshooting

Symptom Where to look
Updates not arriving systemctl --user status agent-distro-update; run manually with systemctl --user start agent-distro-update
What was updated or failed ~/.local/state/agent-distro/history.log40
Full run output, Linux journalctl --user -u agent-distro-update
Full run output, macOS ~/.local/state/agent-distro/<source>/update.log

Diagnostics

Every message agent-distro prints goes to stderr as agent-distro <component>: …, with warning: or error: when it is one. Stdout is never used for messages: launchers and tools read it.

By default a launch prints a line only when something chose a profile for you, a repository’s profile or AI_PROFILE, worded as --list words it:

agent-distro profile: using repo (from /home/me/repo/plugin.json) · https://agent-distro.kolu.dev
agent-distro profile: using juspay-skills + repo (from /home/me/repo/plugin.json over AI_PROFILE=github:juspay/skills) · https://agent-distro.kolu.dev

and when something will take a while, such as evaluating a profile’s packages for the first time or fetching them. A launch with no profile, or with one you named yourself, prints nothing.

To see the nix commands run, the cache hits and misses, each plugin and where it came from, the PATH additions, the environment and settings, and the arguments handed to the harness:

AGENT_DISTRO_LOG=debug claude

AGENT_DISTRO_LOG sets the level of agent-distro’s own messages, so AGENT_DISTRO_LOG=silent leaves only the harness’s output and the few warnings the launcher scripts themselves print (a missing secret variable, a replaced harness configuration file, a PATH collision), which are always shown. Colors follow NO_COLOR and FORCE_COLOR. Updater output is in the journal or update.log; see Troubleshooting, and journalctl --user -u agent-distro-update -p warning for its warnings and errors alone.

Package layout

agent-distro.packages.<system>.default (nix build .#default) is the whole distribution, and self-describing: a consumer reads a file rather than running a command for information.

bin/<harness>…                      # one launcher per harness
bin/agent-distro                    # the picker, the package's main program
share/agent-distro/versions         # <harness>\t<title>\t<version>, one line per harness, in menu order

versions lists the harness packages this build pinned, the same values --list prints, so the two cannot drift. A profile needs no package of its own: any agent-distro reads one at launch.

Library

Beyond the launchers, agent-distro.lib exposes the pieces Home Manager uses under the hood. Any Nix consumer, not only Home Manager, can keep the package installed, updated, and launched with a profile.41

agent-distro.lib.mkLaunchers { pkgs; sources ? …; nixpkgs ? null; }
# → every harness launcher, plus `picker` and `package`, the package of
#   [Package layout](#package-layout). `nixpkgs`, the flake input `pkgs` came
#   from, names that nixpkgs by a locked reference, fetched only for a profile
#   with `packages`; any other `pkgs` puts its source in the closure. `sources
#   = name: pkgs: …` substitutes a harness's own package, for fixtures.

agent-distro.lib.mkPicker { pkgs; launchers; nixpkgs ? null; }
# → lib/picker.nix's derivation: the interactive harness chooser over
#   `launchers`, a `mkLaunchers` result.

agent-distro.lib.mkProfile { system; profile; }
# → one profile, built: a `plugin.json` — a path to the file, to the directory
#   holding one, or a flake input (whose `outPath` is a store path) — with its
#   plugins read and its `packages` realised. Its store path is an `AI_PROFILE`
#   value ([Profiles](#profiles)),
#   used as it is. A plugin named by a flake reference is locked as the
#   profile is built — fetched once, and built in as that store path — which
#   needs an impure evaluation (`nix build --impure`); a pure one refuses it,
#   so there pin it as a flake input and pass that path.
#   `hostSystem` (default `system`) is the system a profile built here is
#   shipped to and launched on: when it differs, the profile is built for
#   `system` without packages — plugins, `env` and `settings` mean the same on
#   any system — and one with `packages` for `hostSystem` is refused, naming
#   it and both systems, since `system` cannot build them.

agent-distro.lib.stateDirectory { xdgStateHome; flake; profile; }
# → the string "<xdgStateHome>/agent-distro/<sha256 of {flake, profile}>", with
#   `profile` what the updater builds (the module passes the package's
#   attribute, `default`), not an AI_PROFILE: a profile's own store path
#   changes whenever its input does, and hashing it would churn what holds
#   `current` on every `nix flake update`. An install that predates the
#   one-package release hashed the old `vanilla` name, so its first activation
#   after the upgrade starts a new state directory and the prune hook removes
#   the old one. One function, so a consumer and the updater can never
#   disagree on where `current` lives.

agent-distro.lib.historyFile xdgStateHome
# → "<xdgStateHome>/agent-distro/history.log": the one history log the Home
#   Manager module's updater appends to, and any consumer recording its own
#   installs in it — appended through `./update/history-log` (below), which
#   keeps it trimmed.

agent-distro.lib.mkShims { pkgs; package; stateDirectory; profile ? null; }
# → a derivation whose bin/ holds one shim per package.commands, each
#   `exec "<stateDirectory>/current/bin/<name>"` when executable, else
#   `exec <package>/bin/<name>`; identical text to the Home Manager module's
#   shims. `package` must be a `mkLaunchers` package of this agent-distro (it
#   carries `commands`); anything else is an eval error naming the problem. A
#   `profile` is exported as AI_PROFILE unless the environment sets one.

agent-distro.lib.mkUpdater { pkgs; package; flake; attribute ? "default"; stateDirectory; history; nix; substituters; caIndex ? …; }
# → { config = <the generated JSON file>; command = [ node update.ts config ];
#   program = <a writeShellApplication running `command "$@"`>; }. The Home
#   Manager module's systemd ExecStart, launchd ProgramArguments (`++ ["--scheduled"]`)
#   and activation `--cache-warnings` all come from this; `command ++ [ "--progress" ]`
#   is the same update with JSON progress on stdout. Each run builds
#   `<flake>#<attribute>` and reads that attribute's index entry; `package`
#   must be a `mkLaunchers` package with a `runtime` (post-#59 ones have one).
#   The period/offset come from `lib.schedule` and are not overridable here;
#   the updater never compiles: it hands nix `substituters`, and for a user
#   whose nix will not honour them it copies the revision's published
#   content-addressed path from one of them instead
#   ([The binary cache](#binary-cache)).

agent-distro.lib.cache is the project’s binary cache ({ url; publicKey; }) and agent-distro.lib.caIndex where its content-addressed paths are indexed; a consumer that pushes its own builds passes its own to mkUpdater, or its users’ updates are skipped.42

The updater’s schedule is one lib/schedule.nix, exposed as agent-distro.lib.schedule: updateHoursUTC, defaultFrequency, updatePeriodSeconds and updateOffsetSeconds. The module, mkUpdater and any consumer read the same numbers.

The updater’s words are a small TypeScript library a program can import rather than repeat: src/package.json names it @juspay/agent-distro, with no dependencies. ./update/due is the due rule (updateDue), the scheduled run’s SCHEDULED_ATTEMPTS and SCHEDULED_RETRY_SECONDS, and the last-success reader (parseStamp); ./update/state names a state directory’s current link and last-success stamp (currentLink, lastSuccessFile); ./update/history writes and reads a history line (historyLine, parseHistoryLine, parseHistory); ./update/history-log is the log’s one writer, appendHistory, which keeps its newest HISTORY_LINES under a lock every writer shares — the one module here that touches files; ./update/versions reads a package’s share/agent-distro/versions (parseVersions) and words what changed between two (changes); ./profile/reference says what a profile reference names (classifyReference: a path, a flake reference, or nothing), the nix arguments that fetch a flake reference (prefetchArgs) and where its answer put it (prefetchedTree) — the launch’s own rules, so a program that builds profiles ahead of a launch runs the fetch itself and reads it the same way; ./listing is the --list --json value and its checker (parseListing). kolu, which builds profiles and delivers the package to hosts of its own and appends to the same history log, imports them from its pin of this repository.

Profiles

A profile is an Agent Plugin — plugin.json, skills/<name>/SKILL.md, optionally mcp.json. The manifest’s name and description are the profile’s, the directory is its first plugin, and extensions."asia.nixos.agent-distro" adds the plugins, packages, environment and settings a plugin does not carry.

Juspay’s profile as its repository carries it:

{
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
  "name": "juspay-skills",
  "description": "Juspay skills + Kolu, via Juspay's LiteLLM gateway",
  "extensions": {
    "asia.nixos.agent-distro": {
      "plugins": ["github:juspay/kolu?dir=agent-plugin"],
      "packages": ["mcp-nixos"],
      "env": {
        "LITELLM_BASE_URL": "https://grid.ai.juspay.net",
        "LITELLM_API_KEY": { "secret": true, "hint": "Create a key at grid.ai.juspay.net/dashboard (Juspay VPN)" }
      },
      "settings": {
        "omp": { "modelRoles": { "default": "litellm/open-large", "task": "litellm/open-large", "smol": "litellm/open-fast" } },
        "opencode": { "model": "litellm/open-large", "agents": { "title": { "model": "litellm/open-fast" } } }
      }
    }
  }
}
Field Meaning
name, description Profile identifier the chooser and --list name the stack with, and the line shown with it
extensions."asia.nixos.agent-distro".plugins More Agent Plugins directories43. The profile’s own directory is always its first plugin, and is not listed
…packages ["mcp-nixos"]: nixpkgs attribute paths, the commands the plugins’ MCP servers name, put first on PATH for every harness44
…env Variables every launch exports, and { "secret": true, "hint": "…" } ones it prompts for when unset
…settings What omp and opencode default, in their own configuration’s shape; a non-empty settings.omp also skips OMP’s setup wizard (OMP_SKIP_SETUP=1)

The extension is optional: a plugin.json with none is the plugin alone — its skills and MCP servers — which is a valid profile. A namespace member that is not one of the four is a launch error naming it, as is the wrong shape for one.

The extension’s own JSON Schema is published at agent-distro.schema.json, and mirrors the launch’s validator. plugin.json’s $schema stays the Agent Plugins one, so point an editor at it from the editor’s own settings — for VS Code, in .vscode/settings.json:

{ "json.schemas": [{ "fileMatch": ["**/plugin.json"], "url": "https://agent-distro.kolu.dev/agent-distro.schema.json" }] }

An agent-distro.nix is refused

An earlier agent-distro read a profile from an agent-distro.nix with these same fields written as Nix. That form is gone: a repository that still carries one is refused, naming the file.

/home/me/repo/agent-distro.nix: agent-distro.nix is no longer read — a profile is a plugin.json.
Move its fields into `extensions."asia.nixos.agent-distro"` (see https://agent-distro.kolu.dev/#profiles;
schema https://agent-distro.kolu.dev/agent-distro.schema.json)

Wherever it is found — the repository a launch starts in, an AI_PROFILE entry, a positional selector, mkProfile, check-profile — nothing reads it, so a repository cannot keep running on its Nix file by accident.

From APM

APM wrote a dependency list to apm.yml and installed it into each client’s directory. A profile is the repository’s plugin.json, read at launch; nothing is generated.

Before, a plugin dependency in apm.yml:

dependencies:
  apm:
    - juspay/kolu/agent-plugin

After, the same plugin in the profile’s extensions."asia.nixos.agent-distro".plugins:

{
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
  "name": "my-repo",
  "extensions": {
    "asia.nixos.agent-distro": {
      "plugins": ["github:juspay/kolu?dir=agent-plugin"]
    }
  }
}

apm install and its generated .claude/, .agents/ and other client directories become nothing: agent-distro points each harness at the plugin when it launches, so no per-client copy is committed or regenerated.

Run with a profile

nix run github:juspay/agent-distro                          # no profile: upstream harnesses, nothing added
nix run github:juspay/agent-distro -- github:juspay/skills  # one profile
AI_PROFILE=github:juspay/skills agent-distro omp            # the same, by variable

A repository’s own profile is composed on top of that whenever a harness starts inside one: with no profile named on the command line, the launcher composes the profile found from the working directory up to the git root over every AI_PROFILE entry — the nearest plugin.json. Discovery needs a git repository: a profile in a plain directory is read only when named, as ./dir or in AI_PROFILE. A terminal opened in a repository, in Kolu or anywhere, starts its agents with that repository’s profile on top of yours.

Run with several

AI_PROFILE is a stack, whitespace-separated — as nix’s own lists are — composed left to right: later entries win where they overlap, plugins and packages accumulate. No reference or store path holds whitespace, so a flake reference keeps the : in it.

AI_PROFILE="github:juspay/skills github:ekala-project/ekala-ai-skills" agent-distro omp

So in ~/code/kolu, with the variable above, OMP starts with Juspay’s skills, Ekala’s skills, and Kolu’s own — and Juspay’s gateway, because nothing above it set another one. The picker header names the stack: juspay-skills + ekala + kolu.

Field Composed, from the bottom up
plugins, packages All of them, the bottom first; a plugin replaces one of the same name beneath it
env, settings Key by key, the topmost winning; the same harness a second time replaces that harness’s whole section
name, description The topmost; the chooser and --list name the whole stack, as juspay-skills + ekala + kolu

A profile named on the command line is that profile alone: nothing is composed beneath it, not even a repository’s own.

Pin a profile (optional)

A profile repository pins nothing and has no flake. If you want a profile pinned, cached and offline, pin it where you already pin things — your Home Manager or NixOS flake. A flake input is a store path, and a store path on profiles is built as a derivation:

# your configuration flake
inputs.skills = { url = "github:juspay/skills"; flake = false; };

services.agent-distro.profiles = [ inputs.skills ];   # built: plugins read, packages realised

Outside Home Manager, agent-distro.lib.mkProfile { system; profile = inputs.skills; } is the same derivation; its store path works anywhere a reference does:

AI_PROFILE=/nix/store/…-agent-distro-profile-juspay-skills agent-distro omp   # built
AI_PROFILE=github:juspay/skills agent-distro omp                      # the same profile, read at launch

Both work offline after first use; the built one also never touches the network for its plugins, and never compiles. juspay/skills itself stays a repository: a plugin.json profile.

Opt out, per launch

AGENT_DISTRO_SETTINGS=0 agent-distro omp     # plugins only: no env, no secrets prompt, no settings
AI_PROFILE= agent-distro omp                 # nothing but the repository's own profile, if any
  • Nothing to register. Any repository with a plugin.json is a profile.
  • Always the latest agent-distro. The profile is read when the harness starts, by whatever agent-distro you run. The repository pins nothing.
  • Read once. Each profile and its packages are read on first use and cached per file, content and agent-distro build, like plugin translations.45
  • Never compiles. packages come from the binary cache or the launch stops and names the missing one.46
  • Works offline. A flake reference that cannot be fetched uses the store path it last resolved to, and says so on stderr.47
  • Trust. Reading a profile’s plugin.json is safe: it is JSON, not code. Its packages are evaluated restricted — no network, no environment, nothing beyond nixpkgs — and never compiled. Using one is not: its plugins and MCP servers run, and its env and settings reach your agents — including a secret variable, which the launcher asks for and never writes to a file. Only open terminals in repositories you trust, as you already do with their .envrc.
Profile What it is
github:juspay/skills (juspay-skills) Juspay skills + Kolu, via Juspay’s LiteLLM gateway
/nix/store/…-agent-distro-profile-… Any profile built with agent-distro.lib.mkProfile (profiles = [ inputs.skills ]): pinned, offline, and unevaluated at launch
Using the Juspay profile
nix run github:juspay/agent-distro -- github:juspay/skills
nix run github:juspay/agent-distro -- github:juspay/skills omp --version
AI_PROFILE=github:juspay/skills agent-distro claude

Its plugin.json is the whole of it:48

{
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
  "name": "juspay-skills",
  "description": "Juspay skills + Kolu, via Juspay's LiteLLM gateway",
  "extensions": {
    "asia.nixos.agent-distro": {
      "plugins": ["github:juspay/kolu?dir=agent-plugin"],
      "packages": ["mcp-nixos"],
      "env": {
        "LITELLM_BASE_URL": "https://grid.ai.juspay.net",
        "LITELLM_API_KEY": { "secret": true, "hint": "Create a key at grid.ai.juspay.net/dashboard (Juspay VPN)" }
      },
      "settings": {
        "omp": { "modelRoles": { "default": "litellm/open-large", "task": "litellm/open-large", "smol": "litellm/open-fast" } },
        "opencode": { "model": "litellm/open-large", "agents": { "title": { "model": "litellm/open-fast" } } }
      }
    }
  }
}
  • Adds Juspay’s skills and Kolu, and points OMP and OpenCode at Juspay’s LiteLLM gateway. It asks for LITELLM_API_KEY when it is not already set, and never writes it to a file.
  • Settings. OMP starts on the gateway’s models, and OpenCode finds them through the plugin its session config already loads; neither replaces a model you chose yourself.
  • Own login. Pi, Claude Code and Codex use their own login: they get the profile’s variables, not its model settings.
  • MCP servers. Kolu’s MCP server needs kolu on PATH. The profile supplies mcp-nixos itself, from nixpkgs.
  • Opt out. AGENT_DISTRO_SETTINGS=0 keeps the plugins and skips the rest.

Checking a profile’s packages

A profile’s packages must be in the binary cache on every platform its users run, or the launch stops. Check them ahead of time:

nix run github:juspay/agent-distro#check-profile                       # the working directory
nix run github:juspay/agent-distro#check-profile -- path/to/profile    # its plugin.json

It runs the launcher’s own policy, against agent-distro’s nixpkgs and caches, and prints one line per package; it exits 1 if any is not cached, or cannot be told. It also fetches each plugin reference and fails on one that does not resolve to a plugin directory (say, a ?dir= the repository no longer has). It checks for the machine it runs on, so run it in CI once per platform.

Harnesses

Each harness is a directory under harnesses/; its README holds the design notes for that adapter.

Harness Command Notes
Oh My Pi omp design notes
Codex codex design notes
Claude Code claude design notes
OpenCode opencode design notes
Pi pi design notes

How it works

  • Profile. Harness-independent data: a plugin.json, read at launch, or one built with agent-distro.lib.mkProfile.
  • Harness. The agent application.
  • Plugin. A portable Agent Plugins directory.
  • Environment and settings. Optional data a profile carries: variables every launch exports, a secret one prompted for when it is unset, and what omp and opencode default. OpenCode’s own copy of those models comes from opencode-plugin-litellm, vendored in harnesses/opencode/npins and loaded by every launch: it discovers a LiteLLM server’s /v1/models and /v1/model/info, prices and limits included, and probes a local one when nothing names it.

Harness design notes live in each directory under harnesses/.

Runtime

  • TypeScript, no build step. What runs on your machine beyond the harnesses themselves49 is TypeScript under src/, run by Node 24’s native type stripping: no package.json, bundler or compile step.50
  • Dependencies. Node comes from the distribution’s nixpkgs. Its one npm dependency is yaml, for OMP’s config.51
  • Entry points. The commands you run, and the shims Home Manager installs, stay small generated shell scripts that call into it. Every launch starts with one call, which resolves the profile in effect (src/profile/) and prints shell for the launcher: its plugins, its environment and settings, and its packages’ PATH.

Reading plugins

Every plugin is read once, harness-independently, against Agent Plugins 1.0.0, by the same reader and the same harness writers.52

  • Invalid manifest. Fails the build (or the launch) with a message naming the field.
  • Lesser problems. Skipped skills, disabled mcp.json files and invalid server entries are reported in the build log (or on stderr).53
  • Commands. A bare MCP command is found on PATH: the profile’s packages first, then the user’s own. A ./ command runs from the plugin.
  • Working directory. A stdio MCP server starts in the directory the harness was launched in, not the plugin root — a deliberate departure from Agent Plugins §7.2.1, because a plugin from a flake reference lives under /nix/store and nix run github:… cannot run from there. A server’s own cwd still wins.

One known gap: Claude Code expands ${VAR} in a remote server’s url and headers, which the spec forbids.54

Shared homes

Profiles share each harness’s own home directory, so what OMP persists outlives the profile that wrote it.55 Claude Code’s plugins last only for the session, and a profile read at launch reaches Codex and Pi as AGENT_DISTRO_PLUGINS does.

Development

nix build .#default    # the package: the picker, every launcher and share/agent-distro/versions
nix flake check
just test              # offline NixOS VM tests; Linux with KVM
just test-template
just demo              # re-record doc/demo.gif and the website's stills
python3 .github/scripts/test-update-flake.py

The TypeScript’s own checks need no VM or KVM, and nix flake check at the root does not run them. Build them from the test flake:

cd test && nix build --no-link .#checks.x86_64-linux.{reader,launch-plugins,opencode-standalone,profile-resolve,mk-profile,nixpkgs-reference,omp-adapter,pi-adapter,opencode-adapter,update-schedule,list-json,picker-layout,picker-auth}
Check Covers
reader the plugin reader
launch-plugins AGENT_DISTRO_PLUGINS: cache keys, re-translation, precedence, a profile’s plugins replaced by name, and the launches it fails
profile-resolve the profile in effect, with a fake nix: discovery, the stack and how it composes, a built profile by its store path (and the reader that decides whether its descriptions are used), references and their offline fallback, the packages evaluation’s isolation, its cache, nixpkgs fetched only for packages, and the no-compile policy
mk-profile agent-distro.lib.mkProfile‘s output: its metadata, its plugins’ descriptions and roots, and bin/
nixpkgs-reference the package names nixpkgs by reference and does not hold its source
opencode-standalone an interactive OpenCode launch reaches the harness with --standalone, so one launch’s config is not served to the next
<harness>-adapter a harness’s tests/check-adapter.ts
update-schedule the updater’s schedule, cache policy and index (its URL, and which systems take one)
list-json --list --json against its type and the profile in effect, the chooser’s menu and --list
picker-layout the chooser’s cell widths, truncation and box at common terminal sizes
picker-auth the auth probes over fixture homes and stores

CI

  • Runner. CI is odu: nix run --accept-flake-config github:juspay/odu -- run runs the ci recipe and everything it depends on — one <recipe>@<platform> commit status per node, on the Linux and macOS lane hosts of ~/.config/odu/hosts.json.
  • Sharding. The VM tests spread over up to 8 Linux hosts,56 since each wrapper check boots a machine of its own.
  • Required checks. just protect --dry-run prints the contexts main’s ruleset requires; just protect sets them, from what the pipeline and cache.yml say they are.57
  • Daily sources. The daily update runs .github/scripts/update-sources.sh,58 then updates the root and test locks.
  • Report. The update report reads resolved versions and each harness’s release-note metadata.
  • Cache. cache.yml pushes realised paths to the kolu cache,59 and publishes the package’s content-addressed rewrite with the index that names it, on the ca-index branch of this repository, through the publish-closure action it shares with kolu (juspay/kolu#2294) (The binary cache).
  • Merge gate. The daily update PR enables auto-merge, so it lands once every context the ruleset requires is green.60
  • Consumers. They run nix flake update agent-distro.

For manual updates, run bash .github/scripts/update-sources.sh, nix flake update, and then bash test/update-lock.sh.

Test library

Consumers can import test/lib.nix { pkgs; nixpkgs ? null; profiles ? [ ]; updated ? profiles; features ? [ ]; koluPlugin ? null; }, where nixpkgs is the flake input pkgs came from, so the guest launchers name it by a locked reference rather than hold its source. profiles are mkProfile derivations, whose store paths are the AI_PROFILE the checks launch with; updated defaults to profiles, and a caller passes the same profiles with their plugins rebuilt to new store paths, for the changed fixture. Checks are selected by required features from each harness’s metadata;61 picker is shared.

  • Plugin rebuilds. Select plugin rebuild checks only for nonempty skill plugins.
  • Coverage. The test flake covers the package with no profile (with kolu’s plugin loaded at launch), the Juspay profile built with mkProfile, and spec fixtures, plus profiles: profiles read at launch, by path and git+file:, from a nested directory, in precedence order, refusing an uncached package, and the gateway profile carried as a plugin.json reaching OMP, Codex and Claude Code as the same profile built by mkProfile does; and ca-update: the update that takes the published content-addressed path as an untrusted user, and the skip where the index publishes none.

doc/logo.svg is the source vector; downstream consumers (kolu) vendor a copy.

Keep it transparent, textless, readable at 16 px.

Adding a harness

Create harnesses/<name>/ with four files:

File Holds
meta.nix title, order, auth,62 releaseNotes = version: URL, and checks
default.nix the adapter, accepting { pkgs, name, package, launchPlugins } — name is its own harness name, launchPlugins is runtime.launchPlugins seeded with it — and starting with launchPlugins { … }63
source.nix { pkgs } to package
README.md design notes
  • Alongside. Pins in npins/, scripts in tests/, and an optional custom updater in update.py.
  • No registration. Nothing outside this directory needs registration.64
  • Logic beyond shell. Keep it in src/harness/<name>.ts and call it with (import ../../lib/runtime.nix pkgs).script "harness/<name>.ts".65
  • Unit checks. Those that need no VM go in tests/check-adapter.ts, which receives the runtime’s src and the harness directory.
  • Checks. Checks declare { name; script; requires ? []; packages ? []; env ? {}; diskSize ? null; }.66 script is VM-driver Python.67
  • Gateway checks. They receive the shared fake service and launchers configured against it.
  • Fixtures. mkLaunchers also accepts a sources = name: pkgs: … hook for package fixtures.

  1. The cache is named in the flake’s nixConfig. The Home Manager updater in Install handles the cache itself.↩︎

  2. The panel shows the highlighted harness’s tagline and its auth status. Versions reflect the packages pinned at build time, shown without revision suffixes.↩︎

  3. Names unchanged for script compatibility.↩︎

  4. Also usable in scripts and non-interactive shells.↩︎

  5. Read by every command, agent-distro, claude, omp and the rest alike: it is the profiles an environment such as Kolu sets. Whitespace-separated, composed left to right, later entries winning — as nix’s own lists are (substituters). See Profiles for how that composes.↩︎

  6. An argument starting with - is never a selector, so agent-distro --model=a:b passes it to the harness.↩︎

  7. Presence only: the picker contacts no provider and checks no token’s validity or expiry.↩︎

  8. One whose store cannot be read shows nothing at all, and no mark.↩︎

  9. Default ~/.claude.json.↩︎

  10. Read from tokens.id_token without checking its signature; ChatGPT when the claim is absent.↩︎

  11. The profile’s secrets and the harness’s own providers are usable at once.↩︎

  12. OMP’s ~/.omp/agent/agent.db, Pi’s $PI_CODING_AGENT_DIR/auth.json (default ~/.pi/agent/auth.json), OpenCode’s ${XDG_DATA_HOME:-~/.local/share}/opencode/auth.json.↩︎

  13. ↑/↓ still move, Backspace edits, and Enter takes the highlighted match.↩︎

  14. TERM unset or dumb, no controlling terminal, or fewer rows or columns than the box needs.↩︎

  15. The cursor and filter are kept.↩︎

  16. An unavailable state directory is silently ignored.↩︎

  17. Its shape is stable, typed as Listing in src/listing.ts.↩︎

  18. Without the package’s + revision suffix.↩︎

  19. The plain --list and the chooser read the same value, so the three cannot disagree.↩︎

  20. So a path containing : cannot be given.↩︎

  21. A relative entry is resolved against the directory the launcher starts in, and symlinks are followed. ~ is expanded by the shell, and only where an assignment expands it: unquoted, in bash or zsh, not in fish.↩︎

  22. Skipped skills, a disabled mcp.json or an invalid server entry are reported on stderr, on every launch.↩︎

  23. No version is compared.↩︎

  24. The cache directory must be an absolute path. Loading these plugins never calls Nix, and a cache it cannot write fails the launch.↩︎

  25. A plugin under /nix/store is keyed by its store path, as is, without reading it. Any other directory is keyed by its absolute path and by what a translation reads of it: the manifest it parses from plugin.json, the MCP config file, and the bytes and mode of each skill file its description lists. Nothing else in the directory is read, so a node_modules, a build output or a live .ci/ beside them costs nothing and cannot fail the launch. A key is particular to one machine and user.↩︎

  26. So old versions of an edited checkout and translations for an older agent-distro do not pile up.↩︎

  27. Claude Code and OMP take them as arguments, OpenCode in its session config, and Codex through -c overrides; Codex keeps an inert copy in its plugin cache, removed once unused for 14 days. Pi only reads its own files, so it records what a launch added and its next launch, whenever that is, takes it back.↩︎

  28. At 02:00, 08:00, 14:00 and 20:00 UTC, two hours after upstream’s update.↩︎

  29. At activation its prune stage deletes sibling state directories left from an earlier flake/profile choice.↩︎

  30. An AI_PROFILE already in the environment wins, and a repository’s own profile is composed over either. Every entry is a profile reference or something built from one; a string that is neither stops the launch, naming it. profile, the one-profile spelling of this option, is deprecated for one release: a reference becomes profiles = [ it ].↩︎

  31. Never compiling: it hands nix substituters, and where this user’s nix will not honour them, the revision’s published content-addressed path instead.↩︎

  32. It checks the UTC boundary only when you pass --scheduled, as the module’s launchd does.↩︎

  33. The updater replaces <state>/current directly, so there is no out-link pruning for the consumer to do.↩︎

  34. {"progress":{"done":<bytes>,"total":<bytes>}} while nix fetches, then one result object: {"result":"updated"|"unchanged","package":"<store path>"}, or skipped/failed with a reason (a failed result also carries detail, nix’s last error: line, when there is one).↩︎

  35. cache.kolu.dev/kolu-r2, odu’s kolu-r2 bucket, configurable with services.agent-distro.substituters.↩︎

  36. Nothing more is needed when you are a trusted Nix user or run a single-user install. Otherwise the daemon ignores the updater’s request unless your system config already lists the cache.↩︎

  37. Transition only: CI also writes <rev>/<system>/vanilla.json and latest/<system>/vanilla.json, the same content under a vanilla attribute that aliases the one package, so updaters still reading the old profile-keyed name keep working. Both, and packages.<system>.vanilla, go away on 2026-11-14, thirty days after the one-package release: the PR that removes them also drops the alias from cache.yml and from flake.nix.↩︎

  38. Where the index lives is stated once, in lib/ca-index.nix: mkUpdater reads it, binary-cache.json — the declaration cache.yml hands the publisher — carries it and the cache, and .github/scripts/check-cache-config.py keeps that file and this page equal to both.↩︎

  39. A line such as skipped: cache https://cache.kolu.dev/kolu-r2 not usable and no content-addressed path is published for revision 0f3c…; add it to nix.settings substituters/trusted-public-keys. The same line is not repeated, and a skip is not retried until the next scheduled run.↩︎

  40. Update and failure events, in your local timezone, e.g. 2026-10-01T23:03:17+05:30 updated: Pi 0.99.2 → 1.0.0; unchanged runs add nothing. It is trimmed back to its last 1000 lines once it has grown past them, and a scheduled run on macOS cuts update.log to its last 1 MB.↩︎

  41. Every function evaluates from a plain import of its lib/*.nix file with an arbitrary nixpkgs pkgs; a required argument left out is an eval error naming it.↩︎

  42. The Home Manager updater only installs what that cache holds, or the content-addressed path your index names, and never compiles.↩︎

  43. Each is a plugin.json manifest, with optional skills/<name>/SKILL.md and mcp.json. A path in the extension is relative to the plugin root and begins with ./; a flake reference string is read at launch, or locked once when mkProfile builds the profile (impurely; see Library).↩︎

  44. An MCP server that a plugin declares by bare command, such as "command": "mcp-nixos", is found on PATH. List its package here and it is built, or fetched from a binary cache, so the server starts at once instead of being downloaded when the agent first asks for it, and every user runs the version the profile pinned.↩︎

  45. Under ${XDG_CACHE_HOME:-~/.cache}/agent-distro/profiles/, one entry per file, so a repository’s profile over two AI_PROFILE entries is three, each read again only when its own file changes; removed once unused for 14 days. A flake reference, the profile’s or a plugin’s, is fetched with nix flake prefetch on every launch, so an unpinned one follows its branch; Nix’s tarball TTL bounds what that costs. Its last store path is kept under agent-distro/references/ for launches without network. packages are evaluated against the nixpkgs agent-distro itself was built with, so they hit the same binary cache; launchers name that nixpkgs by a locked reference and fetch its source only for a profile with packages. A built profile is read rather than evaluated: its metadata, its plugins and their descriptions are all in the store.↩︎

  46. Under the same policy as the updater: what a build would compile rather than fetch stops the launch.↩︎

  47. Under ${XDG_CACHE_HOME:-~/.cache}/agent-distro/profiles/, one entry per file, so a repository’s profile over two AI_PROFILE entries is three, each read again only when its own file changes; removed once unused for 14 days. A flake reference, the profile’s or a plugin’s, is fetched with nix flake prefetch on every launch, so an unpinned one follows its branch; Nix’s tarball TTL bounds what that costs. Its last store path is kept under agent-distro/references/ for launches without network. packages are evaluated against the nixpkgs agent-distro itself was built with, so they hit the same binary cache; launchers name that nixpkgs by a locked reference and fetch its source only for a profile with packages. A built profile is read rather than evaluated: its metadata, its plugins and their descriptions are all in the store.↩︎

  48. Create a key at grid.ai.juspay.net/dashboard (Juspay VPN required).↩︎

  49. The plugin reader, each harness’s config writer, the picker and the Home Manager updater.↩︎

  50. The one exception is that launch entry, plain JavaScript so that it can turn on Node’s compile cache before any TypeScript loads.↩︎

  51. A tarball pinned in lib/npins, which lib/runtime.nix links in as node_modules/yaml.↩︎

  52. A profile read at launch, and the directories on AGENT_DISTRO_PLUGINS, are read at launch, into the cache described in Plugins at launch; a profile built with mkProfile is read at build time.↩︎

  53. As the spec’s failure boundaries require.↩︎

  54. Those values cannot go through a launcher, so a remote server whose URL or headers contain ${ reaches Claude Code as is.↩︎

  55. The model roles a profile filled into OMP’s config stay when you launch with no profile. A launch’s Codex marketplaces — agent-distro-<hash>, one per plugin — are its own: they live in Codex’s install cache, and nothing of them reaches config.toml.↩︎

  56. test carries [metadata("odu:shard=8")]: odu gives each shard ODU_SHARD_INDEX and ODU_SHARD_TOTAL, and the recipe builds its own slice of ./test’s checks. With neither variable set it runs them all in one nix flake check.↩︎

  57. odu protect writes the repository ruleset with exactly the contexts the pipeline posts; the recipe then appends cache.yml’s, which odu cannot know since they come from a GitHub workflow rather than the justfile. Run it after merging a change to either side’s names.↩︎

  58. It discovers npins directories under harnesses/ and lib/, re-pins each npm tarball pinned there (yaml, opencode-plugin-litellm) to the newest release of its major version, and runs each harness’s update.py.↩︎

  59. ODU_CACHE_TOKEN is needed except on fork PRs, and only here: cache.yml stays on GitHub Actions because it is what pushes to the cache, and odu runs the checks without any repository secret. It is a write token for the cache’s kolu-r2 bucket, minted by odu cache token mint kolu-r2 --write.↩︎

  60. The update workflow approves the runs GitHub holds back for automation-created pull requests and enables auto-merge, so the PR lands as soon as every context Require CI on main requires is green — cache.yml’s build (…) and publish (…), which that approval starts, and odu’s <recipe>@<platform>, which someone has to run on the branch.↩︎

  61. The features are plugins, gateway (the checks that run against the fixture-backed LiteLLM profile), kolu, spec, and koluLaunch, which loads koluPlugin through AGENT_DISTRO_PLUGINS.↩︎

  62. auth = "anthropic" | "openai", for a harness with a login of its own; without one the picker reads the profile’s secret variables and the harness’s own store.↩︎

  63. The adapter hands launchPlugins what it needs for a launch; its shell sets launched, profile_env_* and profile_settings for the profile in effect, read when the harness starts.↩︎

  64. The picker, the package, outputs, reserved names, checks, and daily report all discover it.↩︎

  65. For an adapter that needs more than shell: translating plugin descriptions into the harness’s config, merging user state at launch.↩︎

  66. Packages can name updated, upstream, koluFixture, or recordFixture.↩︎

  67. Use import ../../test/guest-script.nix ./tests/check.py to run a guest script as the unprivileged VM user.↩︎