Skip to content

Containers

A container gives an agent a toolchain and a filesystem that are not yours, without giving up your workspace: you name an image, and humanize holds this project directory at the path it already has inside it. Reach for it when the agent needs a toolchain you have not got.

Try it

  1. The flow says which of its agents works in a container — the weaver's part, with the image beside the place:
python
# .humanize/flows/tested/__init__.py
"""Build here; run the suite in a container that has the right Python."""

from typing import Annotated, NamedTuple

from hmz.flows import Agent, Isolated, flow


class Agents(NamedTuple):
    """The two this drives, and the two places they work."""

    builder: Agent                                    # here, and nowhere else
    tester: Annotated[Agent, Isolated("python:3.12")]  # a container of the flow's own


@flow
def run(agents: Agents, task: str) -> None:
    working = agents.builder.new()
    working(task, suppress=True)
    for _ in range(5):
        said = agents.tester("Run `python -m pytest -q` and report exactly what failed.",
                             suppress=True)
        if "passed" in said and "failed" not in said:
            return
        working(f"The suite says:\n\n{said}\n\nFix it.", suppress=True)
  1. Run the flow:
sh
hmz exec -f tested -a claude/claude-opus-5:max -a codex/gpt-5.6-sol:high "get the suite green"
  1. While it runs, in another terminal:
sh
docker ps --filter label=humanize=$(id -u)

You see the container, labelled humanize=<your uid>. The tester runs the suite in it, the builder fixes what it reports, and everything they produce lands in your workspace.

The whole run in one container

That puts one agent in a container. When the answer is all of them, say it once from outside, with no flow to edit:

sh
hmz exec -f ralph_loop --container python:3.12 -a claude/claude-opus-5:max "get the suite green"

One container is started for the run, every agent's turns land in it, and it goes when the run ends. One rather than one apiece is the point: the agents are working on one thing, so what one of them writes is what the next one reads.

The project directory is mounted at the path it already has, so the flow's own open() reads the same bytes a turn wrote. What a mounted directory does not answer for is a command: one the flow runs is run by this machine's shell against this machine's tools — the thing a container was reached for to avoid. So the flow asks — the weaver's side again:

python
from hmz.flows import container, flow


@flow
def run(agents, task):
    agents[0](task)
    if (held := container()) is not None:
        said = held.run(["python", "-m", "pytest", "-q"])   # in the container
        held.write_text("last-run.txt", said.output)        # on the container's filesystem

container() answers None for a run on this machine, where a flow does what it always did. What it answers otherwise reads and writes and runs on the far end: read_text, write_text, listdir, exists, mkdir, remove, and run, which answers a Ran with status, output and ok. See Machines › The workspace as the flow reaches it.

A place the flow declared Isolated is left where it put it: this is a convenience rather than a way round what a flow says. The person at the prompt is left alone too, taking no turn anywhere.

From a flow

For the weaver. Annotated[Agent, Isolated("python:3.12")] beside the place, as in Try it above, is the usual way for one agent. The image is the flow's, and the workspace is the directory the flow is running in; nothing can point that agent anywhere else, including you. The agents page of /flow reads it back on that agent's where row as in a container of python:3.12, with the flow settled this beside it — a row to read rather than one to open.

From Python

For the weaver, or for anyone building agents by hand. Use this for an agent you build yourself, or for a place the flow declared Remote:

python
from hmz.machines import DockerConfig

ClaudeCodeAgentConfig(model=…, effort=…, machine=DockerConfig(image="python:3.12"))
FieldDefault
imagepython:3.12Needs a python3 for the target half, plus whatever the agent will reach for.
workspacethis directoryThe directory itself, mounted — not a copy — so the work outlives the container.

An image with no python3 in it is refused as the container starts, rather than a turn later. An agent told to run pytest in an image without it spends a turn discovering that, so a good image is one you already build for CI.

Where the flow says a place may be pointed anywhere (Annotated[Agent, Remote]), you can hand it a container instead:

python
from hmz.agents import ClaudeCodeAgent, ClaudeCodeAgentConfig
from hmz.machines import DockerConfig
from hmz.runner import Runner

config = ClaudeCodeAgentConfig(
    model="claude-opus-5",
    effort="high",
    machine=DockerConfig(image="node:22", workspace="/home/me/code/myproject"),
)

Runner("movable", [ClaudeCodeAgent(config, name="builder")]).run("upgrade the toolchain")

Both refusals land before the first turn:

text
onbox: reviewer runs on this machine -- this flow does not say it works anywhere else, so it cannot be pointed at one
onbox: tester works in a container of this flow's own, so there is nothing to point it at

What the container is

  • runs as your uid and gid, so files it writes are yours;
  • has HOME=/tmp, away from the workspace, so what a command caches is not the project's;
  • is reached as a docker:// target, and needs no port and no secret;
  • is labelled humanize=<your uid>.

When it comes up, and when it goes

  • On the agent's first turn, not when the agent is constructed. Configuring an agent pulls no image, so a flow that configures more agents than it drives pulls none for the ones it does not.
  • Shared by every session that agent opens, so its sessions find the workspace as the last turn left it.
  • One machine per agent. Two agents built from the same config get one container each.
  • Taken down when the agent is collected, or at exit for one held to the end.
  • The workspace is left behind either way.

Cleaning up after a flow that was killed outright:

sh
docker rm -f $(docker ps -q --filter label=humanize=$(id -u))

The label carries your uid, so this cannot reach past you on a machine several people share.

The agent is still here

This is the same arrangement as remote execution, with the far end a container instead of a host. The agent process stays on this machine, keeping its credentials and its link to its model provider, so the container needs no network access and no login. Everything the agent does happens in the container.

The work therefore happens in a mirror rather than in this directory, and the backend logs the agent's turns under a path this project has never heard of. It makes no difference: the run wrote down the ids of the sessions it opened, and that is what its trace is gathered by.

sh
hmz trace collect

The run itself is still written down here. An epic belongs to the directory the flow ran in, and is a directory of its own with a sessions/ in it. Each session is named for whose it was, what took its turns, which account it ran as and what the backend called it:

sh
run=$(ls -dt ~/.humanize/epics/*/*/ | head -1)   # the run that just finished
ls "$run"sessions
console
builder-claude@local-5f6e7d8c-1a2b-3c4d-5e6f-708192a3b4c5
tester-codex@local-0a1b2c3d-1a2b-3c4d-5e6f-708192a3b4c5

The id is the end of the name, and a leading part of it is enough to name that session to hmz trace collect — the tester's is the one that worked in the container. /epics finds the same directory at the prompt: enter on the run, then where it is.

Isolation here is about environment, not permission

A container does not stop the agent editing the workspace mounted into it. Narrowing what the agent may do at all is permissions — a different setting, and they compose:

sh
hmz exec -f tested \
    -a cli=codex,model=gpt-5.6-sol,effort=high,permission=workspace-write \
    "get the suite green on 3.12"

With permission=read-only, the tester is in a container and cannot write anything:

sh
hmz exec -f tested \
    -a claude/claude-opus-5:max \
    -a cli=codex,model=gpt-5.6-sol,effort=high,permission=read-only \
    "get the suite green"

Read Security.

Requirements

You need docker on your PATH and a daemon to reach, plus what remote execution needs: Linux on x86-64 here, and a python3 in the image.

See also

Released under the Apache-2.0 licence.