v{{VERSION}} · MIT · rootless Podman or Docker

Your agents keep working after you close the lid.

A coding agent halfway through a refactor doesn't care that your Wi-Fi dropped — but one running on your laptop dies with the session. agent-container puts it on your own server, behind OpenSSH and tmux, with its task, its credentials and its network boundary declared in YAML you commit. Attach from anywhere. Detach without consequence.

rootless compose v2 packet-level egress no baked credentials one-file CLI
ondra@laptop — agent-container
# the spec is a file in your repo, not a pile of flags
$ 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 ]

“Why not just SSH in and run tmux myself?”

Because that gets you the easy third of this. The interesting part isn't keeping a process alive — it's everything you have to be careful about once an agent is running unattended with your credentials.

You'd do it by hand, every time

Ports, volumes, git identity, which key is admitted, what the agent was asked to do. Set up by memory, undocumented, and impossible to re-create identically. Here the directory is the desired state: plan tells you absent / matching / drifted, apply converges, destroy removes only what the spec owns.

Your secrets would be lying around

The obvious paths all leak: baked into an image layer, passed on a command line, written to a volume, echoed into a log. This tool does none of them. A secret travels to the container over that container's own sshd and the spec holds a locator, never a value.

The agent would have your whole network

An unattended agent with a shell can reach anything the host can. Here egress is default-deny at the packet level, in a namespace shared with a sidecar that alone holds NET_ADMIN. You declare the destinations; everything else is refused, and refusals are recorded.

And one thing tmux genuinely does give you

Detach really is non-destructive at every layer, and that property is kept rather than reinvented: closing SSH leaves tmux running, tmux retains every pane, and the container stays up because it was launched detached under user systemd or a restart policy. The only way to lose work is to not git push — which is why the design contract forbids depending on container persistence at all.

How it fits together

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 only 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

What it refuses to get wrong

Each of these is a written invariant with tests behind it, and each is phrased as a refusal because that is how it is enforced. The contract is CLAUDE.md.

It will not mint you a key

Delivering a credential needs an operator-declared SSH identity, and the tool deliberately refuses to generate one — a tool-minted key would be a standing credential granting entry to every environment it ever deploys. Undeclared means refused. And the keys it admits are declared, rewritten from that declaration on every boot — because a deduped union can never revoke.

The credential contract →

It will not inspect your TLS

Squid splices, never bumps. The boundary decides where traffic may go, not what is in it — so a declaration is enforceable without becoming a man in the middle. The strength claim is tested for absence of overclaim.

The egress boundary →

It will not say “stopped” unless it saw it stop

Everything destructive verifies by observation. An unreachable host is reported undetermined — never stopped — and an unverified destroy writes no outcome at all. A report that overstates success ends an investigation early.

Inventory and the kill switch →

It will not resurrect a finished job

In headless mode the agent is the workload and the container's exit code is the result. A success exits and stays exited; a failure follows the restart policy. Interactive mode keeps the container alive with the agent in a tmux window you attach to.

Execution modes →

It will not pull in a backend SDK

The local trail is unconditional; the export is curl, write-time, fail-open and zero-dependency — protocol only, never a vendor package. And accepted means this endpoint accepted this record, nothing more.

Observability →

It will not let a control plane destroy itself

There is a second image — no agents, but a standing key — so you can drive everything from a phone. Its passphrase is printed once, with no recovery, and panic invoked from inside it excludes itself and says so rather than cutting the branch it is sitting on.

The 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

Honest about the edges

Two things worth knowing before you invest an afternoon.

Out of scope, deliberately

IDE integrations beyond SSH, tmux and nvim. Multi-user access control — this is a single-operator tool, and the admitted-key model assumes that. Kubernetes. Devcontainers were considered and rejected; there is no .devcontainer/ and there will not be.

The samples cost real money

The four samples run a real agent against a real model, so they are neither free nor deterministic. That is exactly why they exist — every other test in the repository stubs the agent binary with a shell script, and a stub cannot tell you whether the credential path actually works.

You need a checkout on the host

A PyPI install is a complete client. But build needs the repository as its build context and no prebuilt image is published to any registry, so the machine that actually runs containers needs the source. The install page is explicit about which commands work where.

Where to go next

Every page here is rendered from the repository's own markdown, so the documentation cannot drift from the code that ships.