Agent as code

Rendered from docs/agent-as-code.md in the repository.

Alongside the imperative CLI, a .agent-container/ project config directory can be the desired state for one or more agent environments. Run the tool inside such a directory and it discovers → validates → plans → reconciles reality to the spec. This is the "as code" model (Compose/Terraform-adjacent): the directory is a single, portable, reviewable, version-controllable source of truth.

Additive: with no .agent-container/ up the tree, the tool behaves exactly as it does today — the declarative verbs are inert.

The project config directory#

Discovery walks upward from the working directory to the nearest ancestor containing a .agent-container/ directory; that ancestor is the project root (the tool reports which one it chose). Git-independent.

# .agent-container/environments.yaml
environments:
  - name: acme                 # -> the deterministic identity (container/volumes/port)
    host: local                # a registered/known host name
    container:
      mode: interactive        # interactive | headless
      agent: claude            # claude | codex | pi
      workspace: persistent    # persistent | bind | ephemeral
      repo: https://github.com/you/acme   # optional clone-on-start
      env_file: ./acme.env     # non-secret env (relative to the project root)
      role: agent              # agent (default) | control-plane | interpreter
    credentials:               # references, never values (US2 — see Roadmap)
      - { name: ANTHROPIC_API_KEY, source: env, var: ANTHROPIC_API_KEY }
    egress:                    # where this environment may go (Feature 012)
      allow:                   #   ONE list; the entry shape says what it is
        - { provider: anthropic }        # model vendor, by name — tool supplies the hosts
        - { host: github.com }           # the git remote over HTTPS — the declaration
                                         #   governs ALL egress, not just providers
        - { host: github.com, port: 22 } # a port selects netfilter, not the proxy
      enforcement: advisory    #   advisory (default) | strict

A spec file is identified by kind: environments.yaml, or <prefix>.environments.yaml when you split the spec across files. The suffix names the top-level key the file contains — the same rule that makes prod.services.yaml a sidecar override — so both kinds live in .agent-container/ without colliding. See docs/layout.md.

Parsed with yaml.safe_load (never yaml.load — an untrusted !!python/... tag can never construct an object or run code). The spec is validated before any action: on a bad field the tool refuses and names the offending file + field, making no partial change. Unknown keys and out-of-range enums are errors.

Roles, and the one that declares more#

container.role takes agent (the default — every spec written before this field existed means exactly this), control-plane (017) or interpreter (024).

An interpreter declares what it watches and how it speaks in its own interpreter: block — separate from container: because these say what it reads and speaks on, not how a container runs. watch beside workspace would read as a property of every environment.

environments:
  - name: watcher
    host: local
    container:
      role: interpreter
    interpreter:
      stack: obs               # REQUIRED — the tool-managed stack it reads
      watch: [local, vps1/demo]  # hosts, or host/environment. A SNAPSHOT at deploy
      channel: cli             # cli (default) | slack

Everything up --role interpreter refuses is refused here in the same words: no stack, no deployment; channel: slack without both slack_conversation and declared_sender, no deployment. A declarative path that validates more loosely than the imperative one is how a spec becomes the way to obtain a deployment the tool would otherwise decline — the surface splits, and the weaker half wins.

An interpreter: block on an environment that is not one is refused, not ignored. Ignoring it leaves an operator believing their fleet is watched by something that is not watching it.

role participates in drift: an environment redeclared from interpreter to agent is one that gained an agent and a credential path, and reporting that as matching would hide a privilege change behind an unchanged name. A container deployed before roles existed carries no role and compares as agent — the default it was deployed under — so it does not report drift on a field nobody touched.

The verbs (active only inside a project)#

Command What it does
agent-container plan / status Show the per-environment plan (absent / matching / drifted, with a field-level delta) — mutates nothing.
agent-container apply Discover → validate → plan → (confirm) → converge: bring up each declared environment. Idempotent — a matching spec makes no changes; a drifted one is announced then recreated.
agent-container destroy Remove only the resources the spec declares and owns.

Drift is field-level (US3). status inspects each running container's live agent-config (mode / agent / clone repo) and reports exactly which fields diverge from the spec (agent: 'claude'→'codex'); apply announces a drifted environment before recreating it to converge. A partial failure (one environment of several fails to reconcile) reports precisely which converged and which did not, then exits non-zero — never a silent half-apply (FR-010). A spec naming host: local resolves to the implicit local host with no registration needed, so a fresh checkout reconciles identically regardless of where it lives (FR-005/SC-003).

Ownership is derived from the tool's deterministic identity (Constitution IV) — a declared name maps to the same container/volume identity the imperative CLI computes. There is no state/lock file: the directory and the running containers are the only state, so a fresh checkout on another machine plans identically.

Precedence: inside a project, the spec wins for its scope — a host it names overrides a same-named global-registry host (the override is reported), never a silent merge.

Spec integrity — the agent cannot re-govern itself (FR-020)#

A repo can carry its own .agent-container/ and become the agent's workspace. An untrusted agent must not be able to rewrite the spec that governs it (its host binding, credentials, container config) and push that back. Two guarantees:

  1. Host-side-only read (load-bearing). The tool reads the spec only from the operator's host-side .agent-container/, never a container's copy. A spec change is a host-side git edit you review — an agent edit or git push cannot re-govern.
  2. Read-only in-container delivery (defense-in-depth). The declared spec files are delivered into the container read-only via the compose-configs channel (which mounts read-only and — unlike a host bind — works over a remote context). Kernel-enforced for every uid; the rootless agent cannot modify them.

The tool refuses to deploy a configuration that would expose the spec writable (e.g. a bind workspace over the project config directory) — use persistent/ephemeral for a self-hosting repo.

Scope of the in-container guarantee. The read-only delivery protects the declared spec files (the agent cannot modify them). The containing /workspace/.agent-container/ directory itself lives on the writable workspace, so the agent could create unrelated sibling files there — but that changes nothing, because the tool reconciles only the operator's host-side copy and never reads the container's. The host-side-only read is the load-bearing gate; the read-only delivery is defense-in-depth.

Credentials — resolved at apply, injected at runtime (US2)#

Each credentials[] entry is a reference to a source, never a value. At apply the tool resolves it in memory and injects it via the existing runtime channels — the plaintext never touches the tracked directory, logs, argv, or the registry (FR-013/014). A missing/unavailable source fails before any change and names it (FR-016).

source Resolution
env read the named environment variable (var)
file read a file at path (typically outside the project). A plaintext file that is git-tracked inside the project is refused with remediation (FR-015); an external file, or a project-local file that is untracked/gitignored, is allowed. Detection boundary: only files inside the project tree and known to git are flagged.
keychain OS store — macOS security find-generic-password -w, Linux secret-tool lookup (by service+account). On macOS this surfaces iCloud-synced generic passwords.
command run an operator-declared argv list and take its stdout — the generic resolver; covers every manager with a CLI
onepassword op read op://{vault}/{item}/{field} — assembled from the typed fields
bitwarden bw get {field} {item} — assembled from the typed fields

Credential managers (Feature 008)#

The repository holds a locator; the secret is fetched host-side at apply from wherever it actually lives. This is git's credential-helper model:

credentials:
  # named managers — structured, typed fields
  - { name: ANTHROPIC_API_KEY, source: onepassword, vault: Personal, item: anthropic, field: key }
  - { name: GH_TOKEN,          source: bitwarden,   item: gh-token, field: password }
  # generic resolver — any manager with a CLI, zero tool changes
  - { name: DB_PASSWORD, source: command, argv: ["vault", "kv", "get", "-field=password", "secret/db"] }
  - { name: API_KEY,     source: command, argv: ["pass", "show", "acme/api-key"] }

The resolver runs directly — never through a shell (no injection surface), with stdin closed (non-interactive: unlock the manager first, e.g. op signin / bw unlock) and a 30 s bound so a wedged CLI can never hang an apply. A failure (missing binary, non-zero exit, timeout, empty or whitespace-only output) aborts before any change, names the credential, and carries a remediation hint — but the resolver's own stderr is never echoed, since it may contain secret material. A pipe/filter belongs in an operator wrapper script referenced by the argv, not inline.

Only apply resolves. The read-only plan/status validate the schema but never invoke a resolver, so previewing a spec never triggers a manager prompt or a hardware-key touch. Resolution is also not cached: a credential declared in two environments is fetched twice (two touches) — deliberate, to keep the secret's in-memory lifetime short.

HW keys (YubiKey) are a backing, not a source — a manager unlocked by the key, or an SSH key resident on it. Nothing extra to declare.

Delivery reuses Feature 003's channels: a provider API key (name ANTHROPIC_API_KEY/anthropic, OPENAI_API_KEY/openai) is delivered file-first (a read-only compose config → /run/agent-container/apikeys/<provider>, never even the environment); any other credential is delivered as an environment variable via a per-deployment secrets env-file (mode 0600, in the state dir — not the project). Multi-line values, and values with characters an env-file parser would mangle (leading/trailing whitespace, an inline #, a leading quote), are rejected for env delivery — deliver those as a provider API key (file channel), or, for an SSH key, give the credential an explicit target (below).

SSH-key credentials — a credential with target: authorized_key is routed to the Feature 003 ssh-injection channel instead of an env var (the multi-line delivery the env-file rejects): it declares an inbound principal (--authorized-key, accumulates). The resolved key stays in memory then a 0600 staged file; it never touches the project, logs, argv, or registry.

Two targets were removed, and both are REFUSED rather than silently dropped — silently dropping one would leave you believing your key is in use:

sh agent-container ssh-key show acme # paste this into the forge as a deploy key

A per-container key registered on one repository authorises one repository — where a declared push_key was in practice the operator's personal key, which authorises everything that key reaches.

There is nothing left to supply for either: what the container needs, it makes.

credentials:
  # REFUSED since 019 — there is no private key to deliver.
  - { name: git-push, source: onepassword, vault: Infra, item: deploy-key, field: private_key, target: push_key }
  # Still valid: an inbound principal allowed to reach the container.
  - { name: laptop, source: file, path: ~/.ssh/id_ed25519.pub, target: authorized_key }
Tier Sources Posture
✅ Recommended onepassword, bitwarden, command (any manager CLI), keychain, env, file outside the project or untracked The secret lives in your OS keychain, a password manager, or a HW-key-backed store. The repo holds only a locator — safe to commit and review.
⛔ Refused a plaintext secret file tracked in git inside the project Blocked with remediation — the tool will not deploy it.
🚫 Removed ~~encrypted~~ (age/sops on a committed ciphertext) Gone as of Feature 008. Secrets do not belong in the git remote, even encrypted.

Migrating off encrypted#

A spec still declaring source: encrypted is refused (naming the migration) by any command that loads it. Move the secret out of the repository:

# 1. decrypt once, locally
age -d -i ~/.age/key ./secret.age            # or: sops -d ./secret.enc

# 2. put it where it belongs — pick one
op item create --category=password --title=acme-key password=<value>   # 1Password
bw create item ...                                                     # Bitwarden
security add-generic-password -s acme -a bot -w <value>                # macOS keychain

# 3. point the spec at it, then delete the committed ciphertext
#    - { name: K, source: onepassword, vault: Personal, item: acme-key, field: password }
git rm ./secret.age

Notes. All declared credentials are resolved up front — before any container is deployed — so a missing source never leaves an earlier environment partially applied. Resolved values are staged as 0600 files under your private state dir ($XDG_STATE_HOME), the same posture as the Feature 003 injected material; they are regenerated each apply. Don't both declare a provider credential and drop a convention ~/.config/agent-container/<name>.<provider>.key file for the same provider — they target the same in-container path.

Host binding — referenced vs provisioned (US4)#

An environment's host is either a name (referenced — an existing/known host, externally owned) or a provision table (spec-owned):

environments:
  - name: acme
    host: { provision: hetzner, name: acme-box, server_type: cax11, location: nbg1 }

apply provisions + registers a spec-owned host (driving the Feature 001 Hetzner provisioner) before deploying onto it — idempotently: a second apply reuses the already-provisioned server rather than allocating (and billing) another. A provisioned host needs HCLOUD_TOKEN in the environment (a --host override bypasses provisioning and needs none). The host registry name is the table name, or the env name if it is RFC-1123 (Hetzner rejects underscores — an underscore-bearing env needs an explicit host.name). plan/status never allocate — a to-be-provisioned host is reported as intent only.

destroy removes the container. destroy --deprovision also removes a spec-provisioned host — containers first, then the server — reusing the Feature 001 fail-closed teardown (tool-created and provably-empty, or it refuses). A referenced host is never deprovisioned regardless of the flag (FR-017).

Roadmap#

Shipped: US1–US4 — declare → validate → idempotent apply/plan/status/destroy with read-only spec integrity (US1); credential resolution incl. SSH-key target routing (US2 + T012a); field-level drift → converge + scoped teardown + partial-failure reporting (US3); declarative host provisioning with destroy --deprovision (US4). The real-Hetzner provisioned-host acceptance is opt-in/tokened (billable — never CI).

Feature 008 (credential managers) layers on: the generic command resolver + named onepassword/bitwarden sources, and the removal of encrypted — see the taxonomy and migration above.

See specs/006-agent-as-code/ for the full spec, plan, and contract.