~ruther/agent-environments

agent-environments/docs/superpowers/specs/2026-09-26-agent-environments-design.md -rw-r--r-- 9.3 KiB
8a0ec343 — František Boháček Update codex and claude-code inputs a month ago

#agent-environments — design

Status: self-authored during an autonomous run (user asked to work autonomously and consult Codex). Reviewed by Codex (review-1: blocking points folded in), not yet by the user.

#Goal

A Nix flake library for declaring reproducible configurations ("environments") for coding agents — Claude Code and Codex for now — and launching them under separate identities (logins). Other flakes consume it; examples/ shows how.

#Requirements (from the user)

  • Module-based: modules add text to the instruction file (AGENTS.md / CLAUDE.md) and enable skills, e.g. modules.skills.xyz.enable = true.
  • Several launchers such as claude-work-acc, claude-pers-acc. They may share one environment but log in separately. Each launcher gets its own state directory ~/.local/agent-environments/<id>, passed in with CLAUDE_CONFIG_DIR / CODEX_HOME.
  • Each environment is its own set of modules.
  • Skills are packaged. A skill can need runtime tools and other skills.
  • Flakes. claude-code and codex come in as inputs: github:sadjow/claude-code-nix, github:sadjow/codex-cli-nix.
  • Standalone only (no home-manager). The root flake exposes the library plus one default environment with no skills.

#Architecture

flake.nix                  inputs: nixpkgs, claude-code, codex
lib/default.nix            mkEnvironment, mkSkill, mkSkillModule
modules/core.nix           instructions, skills, launchers, assertions
modules/claude.nix         claude.{enable,package,settings}
modules/codex.nix          codex.{enable,package,settings}
modules/launcher.nix       wrapper script generation
checks/                    behaviour tests (stub agents, temp HOME)
examples/<name>/flake.nix  consumer flakes

#Evaluation

lib.mkEnvironment { pkgs, modules ? [] } runs lib.evalModules over the core modules plus the user's modules, with pkgs passed as a module argument. It returns the evaluated config along with package (a symlinkJoin of every launcher) and launchers (an attrset of per-launcher packages).

The flake's lib is system-independent. mkEnvironment receives pkgs, and the flake sets default agent packages for pkgs.stdenv.hostPlatform.system from its own inputs. Consumers can override those with claude.package / codex.package.

#Core options

  • instructions.<name> = { text : lines; order : int = 1000; } — fragments sorted by (order, name) and joined with a blank line into one document. That document becomes CLAUDE.md for Claude and AGENTS.md for Codex. Modules add a fragment by defining an entry.
  • skills.packages : listOf package — skill derivations made by mkSkill. The effective set is the transitive closure over passthru.skillDeps, deduplicated by skill name. Two different derivations with the same name → evaluation error.
  • claude.enable, claude.package, claude.settings : attrs (JSON).
  • codex.enable, codex.package, codex.settings : attrs (TOML).
  • launchers.<name> = { agent : enum ["claude" "codex"]; id : str = <name>; stateDir : str = "~/.local/agent-environments/${id}"; }. The name is the command name. stateDir must be absolute or start with ~/ (assertion); it is shell-quoted as a literal, and only the leading ~/ becomes $HOME. A launcher's agent must be enabled (assertion). id and launcher names must match ^[A-Za-z0-9][A-Za-z0-9._-]*$ (assertion).
  • Launchers are always declared explicitly. There are no implicit default launchers, because attrset options merge by key and a hidden default could not be dropped cleanly.

#Modules namespace

modules.* is where optional features live, e.g. modules.skills.<name>.enable. The helper lib.mkSkillModule skill produces a module declaring options.modules.skills.<skill.skillName>.enable and, when it is enabled, adding the skill to skills.packages. Options cannot be generated from config (that would recurse infinitely), so consumers import one module per skill they want to make available. Instruction modules are plain modules that define instructions.<name>.

#Skill packaging

lib.mkSkill { pkgs, name, src, runtimeInputs ? [], skills ? [] }:

  • name must match ^[A-Za-z0-9][A-Za-z0-9._-]*$ (evaluation-time check).
  • Output: $out/ is the skill directory (it must contain SKILL.md; checked at build time). The same directory works for both agents, since Claude Code and Codex share the SKILL.md format.
  • passthru = { skillName = name; runtimeInputs; skillDeps = skills; }.
  • Runtime inputs: the launcher puts the union of all effective skills' runtimeInputs on PATH (prepended). Skills run inside the agent's shell, so this is the smallest mechanism that works for both agents.

#Launcher (runtime)

The wrapper is a writeShellApplication per launcher. Everything it manages is listed in a manifest, $state/.agent-environments-managed (one relative path per line). On every start it:

  1. mkdir -p "$state/skills", then takes flock on $state/.agent-environments.lock for the setup phase only; the lock fd is closed before exec.
  2. Computes the wanted set of managed links: the instruction file (CLAUDE.md for claude, AGENTS.md for codex, when there are instructions), skills/<name> for each effective skill, and the codex profile TOML (when there are settings).
  3. Removes every path in the old manifest that is not wanted any more, but only when it is still a symlink. Anything else is left in place.
  4. For each wanted path that exists: unless it is listed in the old manifest and is still a symlink, stop with an error naming the path (user data, including user symlinks, is never replaced and there are no automatic backups). Otherwise replace it atomically (ln -s to a temp name, then mv -T).
  5. Writes the new manifest atomically. Codex's own skills/.system and any user-installed skills are never touched, because they are not in the manifest.
  6. Settings, layered so the agents stay free to write their own files:
    • Claude: --settings <store settings.json> when claude.settings != {}. settings.json, .claude.json and .credentials.json in the state dir stay owned by Claude.
    • Codex: $state/agent-environments.config.toml (a managed link to the store TOML) plus --profile agent-environments, which layers $CODEX_HOME/<profile>.config.toml over config.toml. config.toml (which holds trust entries) and auth.json stay owned by Codex. Skipped when codex.settings == {}.
  7. export CLAUDE_CONFIG_DIR="$state" / export CODEX_HOME="$state", prepend the runtime inputs to PATH, then exec <agent> <managed flags> "$@".

Logins are therefore per launcher: claude-work-acc and claude-pers-acc have different ids, so different state dirs, so different credentials, even when they come from the same environment. Two launchers that should share a login can set the same id. They then also share managed content: the last launch wins, so they should come from the same environment. This is documented, not enforced.

#Root flake outputs

  • lib — mkEnvironment, mkSkill, mkSkillModule.
  • packages.<system>.default — the default environment: claude + codex enabled, launchers claude and codex with ids default-claude / default-codex, no skills and no instructions.
  • checks.<system>.* — behaviour tests.
  • formatter — nixfmt.

#Examples

  • examples/basic — one environment, one instruction fragment, one local skill with a runtime dependency and a skill dependency.
  • examples/multi-account — one module set, launchers claude-work-acc, claude-pers-acc, codex-work-acc, plus a second environment with a different module set.

Each example is a flake with inputs.agent-environments.url = "path:../..";.

#Error handling

  • Evaluation-time assertions: a launcher's agent is disabled; an unsafe id, launcher or skill name; conflicting skill names. A skill without SKILL.md fails at build time.
  • Runtime: never delete non-symlink files and never replace unmanaged ones; fail loudly (set -euo pipefail) and say which path conflicts.

#Testing

nix flake check runs derivation checks that execute the launchers with stub claude/codex packages (scripts that dump argv, env and state-dir contents), using a temporary HOME:

  • the state dir is created under $HOME/.local/agent-environments/<id> and the right env var points at it;
  • two launchers from one environment get different state dirs (separate logins);
  • the instruction file carries the fragments in order;
  • dependency skills are linked transitively; a skill's runtime tool is on PATH;
  • a skill that is no longer enabled is unlinked on the next launch, while user skill dirs, user symlinks and .system are kept;
  • a pre-existing user CLAUDE.md makes the launcher fail and is left untouched;
  • the --settings / --profile flags appear only when settings are non-empty.

One integration check runs the real Codex package offline to confirm that it discovers $CODEX_HOME/AGENTS.md, the skills and the profile, if Codex has a non-interactive way to show this. If it does not, that is recorded as a coverage gap and checked by hand.

Examples are built with nix build ./examples/<x> --override-input agent-environments path:$PWD.

#Out of scope (YAGNI)

home-manager integration, MCP server modules, plugins, per-project config, agents other than Claude Code and Codex.