v{{VERSION}} · MIT · Podman & Docker

Your agents keep working after you close the lid.

An always-on, containerized development environment for a single operator. Claude Code, Codex, nvim, tmux and git behind OpenSSH on your own VPS. Detach is non-destructive at every layer — close the laptop, the agent keeps going.

rootless compose v2 packet-level egress no baked credentials python 3.14
ondra@laptop — agent-container
# declare it once, in YAML you commit
$ agent-container plan
  acme   absent   → will create

$ agent-container apply
  ✓ image built on host hz1
  ✓ credential ANTHROPIC_API_KEY delivered
  ✓ egress boundary: strict
  ✓ acme up — sshd :2206, tmux "main"

# attach from anywhere; detach loses nothing
$ agent-container attach acme
[ you are now inside tmux on the VPS ]

What it actually is

A container image plus a single-file CLI. The image holds the agents and the tooling; the CLI generates a compose project and runs it on the target host, over a local or remote container context. Your laptop is a client.

laptop VPS (Hetzner / Debian 12) ------ ------------------------ ~/.config/agent-container/hosts.conf user systemd (linger enabled) ACME_HOST=vps1.example | ACME_PORT=2218 +-- Quadlet: agent-container-acme.container | $ agent-container attach acme +-- container: agent-container-acme | +-- sshd (port 2222 -> host 2218) | ssh -p 2218 dev@vps1.example -t tmux +-- tmux session "main" | attach -t main +-- nvim v +-- claude [ you are now inside tmux on the VPS ] +-- codex +-- /workspace (named volume) detach (Ctrl-B d) -> back on laptop; agents keep running on the VPS
The key property

Closing the SSH connection leaves tmux running. tmux retains every pane's state. The container stays up because it was launched detached and is supervised by user systemd or a restart policy. Lingering keeps user-level systemd alive across SSH logout. The only way work is lost is if you — or an agent — fail to git push, which is exactly why the design contract forbids it.

Design commitments

Each of these is a written invariant with tests behind it, not an aspiration. The full contract is CLAUDE.md.

[01]Agent as code

A directory is the desired state. .agent-container/environments.yaml declares the container, the task, the credentials as locators and the egress boundary. plan shows absent/matching/drifted; apply converges; destroy removes only what the spec owns.

How the spec works →

[02]Least-exposure credentials

Never baked into an image, never on argv, never printed, never on a volume. Secrets travel to the container over its own sshd, authenticated by an operator-declared identity — the tool refuses to mint a key for you, because a tool-minted key is a standing credential.

Read the contract →

[03]Packet-level egress

Default-deny in a network namespace shared with an egress sidecar, which alone holds NET_ADMIN. Squid splices, never bumps — no TLS interception. A declaration governs all egress, and the strength claim is tested for absence of overclaim.

See the boundary →

[04]Interactive or headless

Interactive keeps the container alive with the agent in a tmux window you attach to. Headless runs the task as the container's workload and exits — the container's exit code is the result. A success is not resurrected.

Execution modes →

[05]Two legs, one payload

An unconditional local trail plus an export that is curl, write-time, fail-open and zero-dependency — protocol only, never a backend package. accepted means this endpoint accepted this record and nothing more.

Observability →

[06]Drive it from a phone

A second image with no agents but a standing key: an ordinary environment that can reach the others. The authorised key is the boundary and revoke is the only narrowing. panic from inside excludes itself and says so.

Control plane →

Three commands to a running agent

Assuming a Debian 12 host with Podman and lingering enabled — the tutorial walks that part from an empty VPS.

Install the CLI

A single PEP 723 script. uv is the only prerequisite.

uv tool install agent-container

Declare the environment

Plain YAML you commit. credentials: entries are locators, not values — the variable is read at apply time and never enters the file or the generated compose model.

# .agent-container/environments.yaml
environments:
  - name: acme
    host: hz1

    container:
      mode: headless        # the agent IS the workload
      agent: claude
      workspace: persistent
      repo: https://github.com/you/your-repo
      task: |
        Triage the failing CI, commit the fix and push it.

    credentials:
      - { name: ANTHROPIC_API_KEY, source: env, var: ANTHROPIC_API_KEY }
      - { name: GH_TOKEN, source: env, var: FORGE_TOKEN }

    egress:
      allow:
        - { provider: anthropic }
        - { host: github.com }
      enforcement: strict   # advisory (default) | strict

Converge, then attach

plan mutates nothing, so it is always safe first. apply is idempotent: a matching spec makes no change, a drifted one is announced and recreated.

agent-container plan
agent-container apply
agent-container attach acme
Worth knowing before you run the samples

They use a real agent against a real model, so runs cost money and are not deterministic. That is exactly what makes them worth having — every other test in the repository stubs the agent binary with a shell script. See the four samples.

Where to go next

Everything on this site is rendered from the repository's own markdown, so it cannot drift from the code.