# Containerized agent teams Run a team of agents in an isolated container that dials **out** to an existing Cotal broker. The agents get no host file access; only NATS traffic crosses the wall. One image, configured entirely by env and mounts. Two shapes, picked by the container's command: | command | shape | |---------|-------| | `supervise --space --roster /workspace/roster.yaml` | a manager plus every agent in the roster | | `spawn --foreground` | one foreground agent (loads `.cotal/agents/.md`) | One agent or twenty, claude or opencode: all config, never the image. ## Quickstart (local) A broker on your machine and one team container talking to it. Run the `pnpm cotal` commands from the repo root (`cotal` below is shorthand for `pnpm cotal`). **1. Start a broker.** It binds `0.0.0.0:4222` and runs in the background. Name the space `main`, because a bare `cotal up` names a new project's space after its folder: ```bash pnpm install && pnpm build # once cotal up --host 0.0.0.0 --space main # add --server nats://127.0.0.1:4252 if 4222 is taken ``` > `cotal up` binds **loopback** (`127.0.0.1`) by default. A broker containers dial out to needs > `--host 0.0.0.0` and auth (the default), since an open mesh on `0.0.0.0` is unauthenticated on > the network. Bind-address and auth are independent flags. **2. Build the image** (once). Bundles the cotal, claude, and opencode CLIs and installs the mesh plugin: ```bash docker build -f deploy/docker/Dockerfile -t cotal-runner . ``` > **Known issue: this build path is unsupported today.** The image copies the repo and runs > `pnpm cotal setup` from that source checkout, and the CLI refuses to seed connectors from a > source checkout. The step stops with > `refusing to the operator-global seed store at for generation from ()`, > where `` is `reconcile` and `` is `a source checkout`. This was reproduced by > running that step from a built checkout outside Docker. No image was built to confirm it. **3. Emit the signer.** Account signing material only, no operator key: ```bash cotal auth signer --out signer.json ``` **4. Describe the team:** ```bash mkdir -p team/agents cat > team/roster.yaml <<'YAML' agents: - { name: scout, agent: opencode, role: researcher } YAML cat > team/agents/scout.md <<'MD' --- name: scout role: researcher subscribe: [general] allowPublish: [general] --- You are Scout, a research agent on the team. MD ``` **5. Run it,** pointed at the host broker (`host.docker.internal` reaches your machine from inside the container): ```bash docker run --rm -it \ --add-host host.docker.internal:host-gateway \ -e COTAL_SERVERS=nats://host.docker.internal:4222 \ -e OPENCODE_API_KEY="$OPENCODE_API_KEY" \ -v "$PWD/signer.json:/workspace/.cotal/auth/auth.json:ro" \ -v "$PWD/team/roster.yaml:/workspace/roster.yaml:ro" \ -v "$PWD/team/agents:/workspace/.cotal/agents:ro" \ --tmpfs /workspace/.cotal/auth/creds:rw,mode=1777 \ --user 10001:10001 --cap-drop ALL \ cotal-runner supervise --space main --roster /workspace/roster.yaml ``` Expect `broker reachable`, `✓ manager up`, `✓ started scout (opencode)`. **6. Watch it join** (terminal B): ```bash cotal console --plain --space main ``` You will see `join manager` and `join scout`. The agent connected out, authenticated with creds minted from the signer, and joined your mesh, with no host file access. > Mesh-join needs no model key, so `scout` shows up above without one. To run real LLM > turns, give each agent its provider credential (next section). ## Model auth (per agent type) Each agent authenticates to its LLM provider with credentials you set from **outside** the container as env. The supervisor forwards the named credential vars each connector declares, and each CLI reads only the vars it understands, so a mixed team sets one var per connector type: | connector | env | account | |-----------|-----|---------| | `claude` | `CLAUDE_CODE_OAUTH_TOKEN` (from `claude setup-token`) | your Claude Pro/Max, same as running locally | | `opencode`| whatever env var the provider behind its `model:` reads | that provider | For claude, use the subscription token so a containerized agent behaves exactly like your local Claude Code, including Channels (an idle agent waking the instant a peer messages it). For opencode, the env var is **per provider**, not fixed: each agent's `model:` is `provider/model` (see the roster section), and you set that provider's key. The connector forwards `OPENCODE_API_KEY` (opencode's own hosted models, `opencode-go/…`), `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY` and `NOUS_API_KEY` without any config. Any other provider variable reaches the agent only when the cotal config lists it under [`spawn.env`](../docs/config.mdx#environment-variables). A mixed team sets one var per provider it uses; each opencode process picks up only the keys its model needs. ### Using your Claude subscription It is **not** automatic, but it is a one-time step. The container cannot do an interactive browser login, and your machine's Claude login does not port to it (it lives in the macOS Keychain), so you bridge your subscription with a token. Once, on your machine: ```bash claude setup-token # opens a browser; approve with your Claude account ``` It prints a long-lived token (about a year) and saves it nowhere, so copy it. Pass it to the container: ```bash docker run ... -e CLAUDE_CODE_OAUTH_TOKEN="" ... cotal-runner supervise ... ``` or in compose: ```yaml environment: CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN} ``` Every claude agent the supervisor spawns then runs on your subscription, no API credits and no per-agent setup. The token is a secret with full account access: keep it in an env file or a Docker secret, never in the image or a commit, and rerun `claude setup-token` when it expires. Use the token, not a mounted credential file (the host file is Keychain-backed and will not work in the container). The image pre-configures claude for unattended use: first-run onboarding is completed and tools are auto-approved (a supervised agent has no human to grant a permission prompt). So with the token alone, a containerized claude agent **wakes the instant a peer messages it and acts on its own**, the same autonomous teammate you'd run locally. Auto-approving tools is safe here because the container is the isolation boundary: non-root, `cap_drop: ALL`, no host mounts, ephemeral fs. ### Caveats - **opencode needs auth mounted or env-key providers headless.** OAuth providers are stored in `XDG_DATA_HOME/opencode/auth.json`; mount that data root into the container if you want to reuse them. Otherwise use a provider whose key is an env var, e.g. opencode's hosted models (`OPENCODE_API_KEY`) or `OPENAI_API_KEY`. - **The container is the trust boundary.** Every agent in a team-container shares its env, so secrets are not isolated between agents in the same container. For hard per-agent isolation, run one agent per container (the `solo` service): same image, different command. ## Roster: agents, personas, count The number of agents is the number of roster entries; mix connector types freely. ```yaml # roster.yaml - one entry per agent, each started the way `cotal spawn --detach` does agents: - { name: planner, agent: claude, role: planner } - { name: builder, agent: opencode, role: builder } ``` - [Roster files](https://docs.cotal.ai/docs/define-a-team/#roster-files) lists every key an entry takes. - **Persona and model** come from `.cotal/agents/.md` (mounted read-only): the Markdown body is the system prompt, `model:` the model override, `subscribe:` the active read set, `allowSubscribe:` the read ACL, `allowPublish:` the post ACL (default-deny). Reshape a team by editing `roster.yaml` and the agent files, never the image. ## Broker auth (the signer) This is how agents authenticate to the **NATS mesh** (separate from the model provider). - **Now:** mount the stripped `signer.json`. A leaked signer can mint any identity within that one account, including `admin` and destructive profiles, but the NATS account boundary keeps it out of other accounts. The operator root-of-trust never enters a container. - **Later (hardening):** a host-side provision step mints each agent's creds outside the container, so containers carry only their own `.creds` and no signing key at all. ## Container layout `/workspace` is the working directory; the code reads and writes exactly here: | path | mode | holds | |------|------|-------| | `/workspace/.cotal/auth/auth.json` | ro mount | the stripped signer | | `/workspace/.cotal/agents/*.md` | ro mount | personas | | `/workspace/roster.yaml` | ro mount | the roster (supervise mode) | | `/workspace/.cotal/auth/creds/` | tmpfs, `mode=1777` | minted per-agent creds (RAM only) | ## Production (compose, remote broker) For a real deployment, point `COTAL_SERVERS` at your hosted broker and run the same image on your server. `docker/compose.yaml` is the multi-team surface: one service block per container, mounts laid out as above. Paths in it are relative to `docker/`, so put `signer.json`, `team-a/roster.yaml`, and `team-a/agents/` there. ```bash cp docker/roster.example.yaml team-a/roster.yaml # then edit; add team-a/agents/*.md + signer.json COTAL_SERVERS=tls://broker.host:4222 \ CLAUDE_CODE_OAUTH_TOKEN= OPENCODE_API_KEY= \ docker compose -f deploy/docker/compose.yaml up team-a ``` (For a *local* broker via compose, add `extra_hosts: ["host.docker.internal:host-gateway"]` to the service and set `COTAL_SERVERS=nats://host.docker.internal:4222`.) ## Isolation Phase 1 is a non-root user (uid 10001), `cap_drop: ALL`, and no host mounts beyond the read-only ones above; the container's own writable fs is ephemeral. **Egress is the broker plus each agent's model API** (api.anthropic.com, or opencode's provider). Stronger isolation (a fully `read_only` rootfs, or gVisor / Kata via `--runtime`) is a later swap with no app change.