Filesystem layout

Rendered from docs/layout.md in the repository.

The authoritative map of every location the tool owns. One term per location — if you find a name used two ways anywhere else in the docs, this file wins.

Vocabulary#

Term Path Scope
Project root the nearest ancestor of your cwd containing .agent-container/ one project
Project config <root>/.agent-container/ one project — travels with the repository
User configuration ~/.config/agent-container/ one operator machine — does not travel
Derived host state $XDG_STATE_HOME/agent-container/<host>/ computed; safe to delete
Run records $XDG_DATA_HOME/agent-container/runs/<host>/<environment>/ one operator machine — durable; outlives every container
Egress events $XDG_DATA_HOME/agent-container/egress/<host>/<environment>/ one operator machine — durable; a sibling of the run records, never inside them
Inventory $XDG_DATA_HOME/agent-container/inventory/ one operator machine — durable; a third sibling, and flat: it must outlive the host
Image sources <checkout>/image/ the tool's own repo

"Project directory" is not used: it is ambiguous between the first two.

Relocating this tool's own directories#

Each of the three answers to its own variable, then to a single root, then to XDG, then to $HOME:

Location Ours (wins) Then AGENT_CONTAINER_ROOT Then XDG Then
User configuration AGENT_CONTAINER_CONFIG_DIR <root>/config $XDG_CONFIG_HOME/agent-container ~/.config/agent-container
Derived host state AGENT_CONTAINER_STATE_DIR <root>/state $XDG_STATE_HOME/agent-container ~/.local/state/agent-container
Durable records AGENT_CONTAINER_DATA_DIR <root>/data $XDG_DATA_HOME/agent-container ~/.local/share/agent-container

The specific variables name the directory itself — no agent-container component is appended, because unlike the XDG variables they are not directories shared with other tools. Under the root the components are config, state and data, so the tree is self-describing. An empty value falls through rather than resolving to the current directory.

Why ours exist. The XDG variables are a shared namespace: relocating this tool through them relocates every XDG-honouring program in the process. That is not theoretical. The acceptance harness moved XDG_CONFIG_HOME to isolate hosts.json and moved podman's connection registry with it — podman keeps connections under $XDG_CONFIG_HOME/containers/. Its registry then read empty rather than missing, so podman --connection <name> — the argv this tool itself builds — resolved nothing, and the podman path could not be exercised at all. Podman is the default runtime (ADR 0001), so the one runtime with no end-to-end coverage was the one we ship on. Docker concealed it, keeping contexts in ~/.docker where XDG does not reach.

Why the root exists too. The config override alone fixed that collision but not isolation: attach reads the compose file and .port from the state dir, so standing up a deployment that touches none of the operator's own files still meant moving two shared XDG namespaces — the very thing the config override existed to stop doing. One root relocates all three together:

AGENT_CONTAINER_ROOT=~/scratch/demo agent-container up demo
AGENT_CONTAINER_ROOT=~/scratch/demo agent-container attach demo

That is what an isolated deployment, a test harness, or a second parallel setup actually wants: one variable, one tree, nobody else's namespace touched.

Relocating the config directory#

AGENT_CONTAINER_CONFIG_DIR points this tool's user configuration somewhere else:

Wins Then Then
AGENT_CONTAINER_CONFIG_DIR $XDG_CONFIG_HOME/agent-container ~/.config/agent-container

It names the directory itself — no agent-container component is appended, because unlike XDG_CONFIG_HOME it is not a directory shared with other tools. An empty value falls through rather than resolving to the current directory.

Why it exists. XDG_CONFIG_HOME is a shared namespace: relocating this tool through it relocates every XDG-honouring program in the process. That is not theoretical. The acceptance harness moved XDG_CONFIG_HOME to isolate hosts.json, and moved podman's connection registry with it — podman keeps connections under $XDG_CONFIG_HOME/containers/. Its connection list then read empty rather than missing, so podman --connection <name> — the argv this tool itself builds — resolved nothing, and the podman path could not be exercised at all. Podman is the default runtime (ADR 0001), so the one runtime with no end-to-end coverage was the one we ship on. Docker concealed it, keeping contexts in ~/.docker where XDG does not reach.

The state and data directories keep answering to $XDG_STATE_HOME and $XDG_DATA_HOME alone: podman stores nothing under either, so neither collides, and the config directory is the whole fix.

Relocating the tool's own directories#

Each of the three user-level locations answers to its own variable first, then the XDG one, then the $HOME default:

Location Ours (wins) Then Then
User configuration AGENT_CONTAINER_CONFIG_DIR $XDG_CONFIG_HOME/agent-container ~/.config/agent-container
Derived host state AGENT_CONTAINER_STATE_DIR $XDG_STATE_HOME/agent-container ~/.local/state/agent-container
Durable records AGENT_CONTAINER_DATA_DIR $XDG_DATA_HOME/agent-container ~/.local/share/agent-container

Ours names the directory itself; no agent-container component is appended. The XDG form names a directory shared with other tools, so ours is a subdirectory of it — that is the difference, and it is why the two cannot be spelled the same way.

Why these exist. XDG_CONFIG_HOME is a shared namespace: relocating this tool through it relocates every XDG-honouring program in the process. That is not theoretical. The acceptance harness moved XDG_CONFIG_HOME to isolate hosts.json, and moved podman's connection registry with it — podman keeps connections under $XDG_CONFIG_HOME/containers/. Its connection list then read empty rather than missing, so podman --connection <name> — the argv this tool itself builds — resolved nothing, and the entire podman path could not be exercised. Podman is the default runtime (ADR 0001), so the one runtime with no end-to-end coverage was the one we ship on. Docker concealed it for as long as it lasted, keeping contexts in ~/.docker where XDG does not reach.

"Relocate this tool" and "relocate everything" are different instructions, and a tool that can only be told the second one will eventually break something it never meant to touch.

Your project#

my-service/                          ← PROJECT ROOT
├── .agent-container/                ← PROJECT CONFIG — like .git/ or .github/
│   ├── environments.yaml               declarative spec — holds `environments:`
│   ├── prod.environments.yaml          another spec file; specs may be split
│   ├── prod.env                        per-environment env file
│   ├── .env                            shared default for every environment here
│   ├── prod.services.yaml              sidecar override — holds `services:`
│   └── prod.config/                    canonical agent config
├── src/                             ← your code; the tool owns none of it
└── README.md

A YAML file's suffix names the top-level key it contains#

One rule, and it is the whole convention:

Filename Contains Read as
environments.yaml, *.environments.yaml environments: the declarative spec
*.services.yaml services: a sidecar override

Both kinds share the directory, which is exactly what the suffix makes possible: before it, the spec loader claimed every *.yaml here and refused the sidecar's services: key — so a project could not use both features at once. .yml works everywhere .yaml does.

Any other *.yaml in this directory is refused, naming it — so enviroments.yaml tells you about the typo instead of silently loading no environments. Pass --skip-unknown-files to downgrade that to a warning if you deliberately keep unrelated YAML here.

Migrating. project.yaml, or any other name that used to be read because it merely ended in .yaml, must be renamed to environments.yaml or <prefix>.environments.yaml. The tool refuses and names the file rather than ignoring it.

Discovery walks up, so every command behaves identically from any subdirectory. Nothing depends on an absolute path — copy or vendor the project anywhere.

The project config directory is the only tool-owned entry in the project root.

Configuration is two levels of one thing#

Project level wins; user level is the fallback — the layering Claude Code and similar tools use.

Project level User level
env .agent-container/<name>.env → .agent-container/.env ~/.config/agent-container/<name>.env → ~/.config/agent-container/.env
sidecars .agent-container/<name>.services.yaml ~/.config/agent-container/<name>.services.yaml
agent config .agent-container/<name>.config/ ~/.config/agent-container/<name>.config/
credential — not permitted — ~/.config/agent-container/<name>.<provider>.key
host registry — ~/.config/agent-container/hosts.json

The same filename means the same thing at both levels. That symmetry is what makes them legible as one layered configuration rather than two conventions.

Two deliberate asymmetries#

Plaintext credentials are user-level only. .agent-container/ travels with your repository, and the tool's rule is that the repo holds a locator, never a value. If keys lived there, git add .agent-container/ — the natural action, since it holds your spec — would stage an API key. To reference a credential from the repo, use a locator source (file, keychain, command, onepassword, bitwarden) in environments.yaml.

The host registry is user-level only. Hosts are a property of your machine, not of a project.

Env files you name yourself#

-e/--env-file is repeatable and replaces the discovery chain, applying files in order with later ones winning:

agent-container up dev -e ~/.env -e ./local-overrides.env

The file can live anywhere, including outside the project. It is read client-side, so it works against a remote host without existing on that machine.

A bare ./.env in your project root is not read — it belongs to whoever put it there (Compose, direnv, a framework). If one is present and no agent-container env file resolves, the tool refuses rather than deploying without it.

The pinned host key IS derived host state — and that is the exact inverse of a run record#

$XDG_STATE_HOME/agent-container/<host>/known_hosts — the tool-owned file attach verifies against (Feature 018). One file per host, one line per environment, keyed [address]:port.

No new location. It belongs in derived host state because computed; safe to delete is literally true of it: delete it and the next deploy re-captures the container's public key through the runtime. It is also correctly per-host — when a host goes, its containers go, so its pins are meaningless.

Read this next to the run records below, because the two answer the same shaped question with opposite lifetimes:

Pinned host key Run record / egress event / inventory entry
Answers is this the container I created? what did we ever create / run?
Tense present — worthless once the container is gone past — most valuable long after
Recomputable yes, from the running container no, the run has ended
So it lives in derived host state durable data

That is why the same argument lands in different places, and why "make them consistent" would be a regression in either direction.

Never the operator's ~/.ssh/known_hosts. The tool manages its own file and leaves the operator's byte-identical. It emits text for them to place (an ssh config stanza, a known_hosts line); it never writes into files they own.

The inventory is a THIRD tenant of the durable location — and the odd one out#

$XDG_DATA_HOME/agent-container/inventory/<entry-id>.json — one entry per deployment the tool made: what, where, when, and what became of it (Feature 014). It sits beside runs/ (016) and egress/ (012 US3), sharing the location and the atomic write path but not their schema or their retention.

It is FLAT, and that is load-bearing. Its siblings are <host>/<environment>/; this one keys on a generated entry id with host as an attribute:

runs/<host>/<environment>/<run-id>.json      egress/<host>/<environment>/…
inventory/<entry-id>.json                     ← no <host>/ level

An inventory entry must outlive its host's removal — that is the question it exists to answer ("is something still billing me on a host I removed?"). A per-host directory is deleted with its host, destroying exactly the entries the requirement exists to keep. Do not "fix" the inconsistency.

Retention is also not shared. runs/ prunes by age and count, because a run's value decays once its commits are ordinary history. The inventory prunes by count only, never by age: the entry most worth having is the one you forgot six months ago, so a time criterion would delete the feature's whole value first.

Run records are observations — not state, not configuration#

$XDG_DATA_HOME/agent-container/runs/<host>/<environment>/<run-id>.json — one JSON file per agent run, on the operator's machine (~/.local/share/... when XDG_DATA_HOME is unset).

Not derived host state, though both are XDG and both are per-machine: that location is documented computed; safe to delete, and a record of a run that already ended cannot be recomputed. Keeping records there would make this file's own description of it false.

Not user configuration, which is what the operator writes; a record is what the tool observes, and a directory holding both cannot be hand-edited safely. Not project config, which travels with the repository and would commit one machine's observations to everyone's.

The container writes each record to a volume first, because when a detached run ends the entrypoint is the only thing left to write anything. The tool drains that volume into this store on its next contact with the host — and teardown drains before removing volumes, or the account of the run being torn down goes with it.

egress/ beside it holds egress events (Feature 012): the same placement and the same write-safety, a different schema and a different retention, because they have a different producer (the boundary, not the agent) and a different lifetime (continuous, not at run end). They arrive by a different route — the tool distils the boundary container's own log, so they need no volume at all. One non-record file lives there too, a watermark holding how far that log has been read; deleting it costs a re-read and nothing else. See egress.md.

Inside the container#

Path Holds Lifetime
/workspace your working copy per the --workspace mode
/workspace/.agent-container your spec, delivered read-only recreated each start
~/.agent-env persistent shell environment the -shellenv volume
~/.claude, ~/.codex, ~/.pi per-agent config + credentials one volume each
~/.config/opencode, ~/.local/share/opencode opencode config; credentials + sessions two volumes
/var/lib/agent-container/runs run records awaiting ingestion the -runs volume
/run/agent-container/ injected secrets vanish with the container

/workspace/.agent-container keeps the name because it is your project config delivered read-only. ~/.agent-env does not: it is container-local shell state that merely shared a name.

The tool's own repo#

agent-container/
├── image/                  ← the entire build context
│   ├── Dockerfile
│   ├── entrypoint.sh
│   └── .dockerignore
├── bin/agent-container     the CLI
├── completions/  docs/  orchestration/  scripts/  specs/
└── CLAUDE.md  README.md  pyproject.toml

The build context is image/, so it is narrow by construction rather than by an allowlist that must be maintained in step with the Dockerfile. This matters most for a remote host, where the whole context crosses the network to another daemon.

A checkout is recognised by image/Dockerfile and completions/agent-container.bash.

Migrating from the pre-011 layout#

There is no compatibility mode — the tool refuses and names every file that must move.

Old New
./agent-container.<name>.env .agent-container/<name>.env
./.env .agent-container/.env, or -e <path>
./agent-container.<name>.services.yaml .agent-container/<name>.services.yaml
./agent-container.<name>.config/ .agent-container/<name>.config/
./agent-container.<name>.<provider>.key ~/.config/agent-container/<name>.<provider>.key, or a locator
./Dockerfile, ./entrypoint.sh image/
~/.agent-container (in container) ~/.agent-env

Container names, ports and volume names are unchanged, so environments you already run stay findable and tear down cleanly. Only file locations moved.

settings.yaml — the declared defaults#

At either config level, project winning per key (.agent-container/settings.yaml, then ~/.config/agent-container/settings.yaml).

Key What it decides
runtime docker or podman for the implicit/local path
claude_auth api-key or oauth — which credential Claude Code uses
headless_restart no (default) or on-failure[:N] — what a FAILED headless run does
delivery_identity the operator-declared key that delivers secrets to a container
otlp_endpoint where run records are exported (Feature 017)
control_plane_hosts which hosts a control plane may manage

runtime — and why it is configuration rather than a guess#

# ~/.config/agent-container/settings.yaml
runtime: podman

Resolution order, each level able to be absent:

  1. AGENT_CONTAINER_RUNTIME — one invocation, wins over everything.
  2. runtime: in settings.yaml — the configured default.
  3. The platform preference — docker first on macOS, podman first on Linux.

A registered host is unaffected: its --driver was recorded by host add and continues to govern that host. This setting is for the implicit local path, which previously had no configuration surface at all — so the tool selected a runtime nobody had chosen, and said nothing about it.

An unrecognised value is refused, not ignored. Falling back to the guess would deploy to a runtime the operator did not name while the file that would explain it sits there being skipped.

The resolution is reported, so nothing has to re-derive it:

agent-container context          # runtime: docker (detected)
agent-container context --json   # {"runtime": {"name": "docker", "origin": "..."}}

origin is one of environment, settings or detected — and the three are kept distinct on purpose (Constitution VIII). detected means nobody chose this, the tool guessed from the platform, which is a materially different claim from a value someone declared.