Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a9683ac
feat(examples): Band × Docker three-agent design-meeting demo (INT-1118)
AlexanderZ-Band Jul 22, 2026
763311e
refactor(docker-demo): manifest-owned launcher lifecycle + context-ma…
AlexanderZ-Band Jul 22, 2026
1b846bf
fix(docker-demo): stronger breaker success signal (INT-1118 review)
AlexanderZ-Band Jul 22, 2026
3840e34
docs(docker-demo): correct README/SKILL to current design; tighten
AlexanderZ-Band Jul 22, 2026
560fcc5
feat(docker-demo): ground the personas in one real conflict + determi…
AlexanderZ-Band Jul 22, 2026
5772d99
docs(docker-demo): note the set-custom --value host-exposure caveat
AlexanderZ-Band Jul 22, 2026
7f5a1d6
fix(docker-demo): harden launcher/breaker per review (manifest, drift…
AlexanderZ-Band Jul 22, 2026
7ad1488
fix(docker-demo): make the per-agent panes actually show a live trace
AlexanderZ-Band Jul 22, 2026
1b57a7c
feat(docker-demo): keep agents live after the meeting for Q&A; fix st…
AlexanderZ-Band Jul 22, 2026
40228be
feat(docker-demo): show role in agent display name (Maya (PM), etc.)
AlexanderZ-Band Jul 22, 2026
472566e
feat(docker-demo): presenter-driven interactive ending + tmux log panes
AlexanderZ-Band Jul 22, 2026
867c0c7
docs(docker-demo): sync conductor docstring + README to the open-floo…
AlexanderZ-Band Jul 22, 2026
043ad27
refactor(docker-demo): drop dead SenderClass.CONDUCTOR, classify once…
AlexanderZ-Band Jul 22, 2026
1507e6f
docs(docker-demo): README covers env file, browser/tmux views, ending…
AlexanderZ-Band Jul 22, 2026
7fbe568
fix(docker-demo): tz-aware sort fallback; intent-oriented breaker tes…
AlexanderZ-Band Jul 22, 2026
8afc0e9
docs(docker-demo): add brew install tmux to prerequisites
AlexanderZ-Band Jul 22, 2026
39f169f
feat(docker-demo): recursive isolation topic + tool-call emission
AlexanderZ-Band Jul 22, 2026
9d6aea9
docs(docker-demo): match README to isolation topic + emission
AlexanderZ-Band Jul 22, 2026
9a7fc0d
fix(docker-demo): drop unsupported THOUGHTS emit from CrewAI architect
AlexanderZ-Band Jul 22, 2026
b6700d0
Merge branch 'main' into feat/docker-demo-ready-decide-the-demo-scena…
AlexanderZ-Band Jul 23, 2026
478ce3a
fix(tests): restore sys.path after load_script_module
AlexanderZ-Band Jul 23, 2026
f34ed65
refactor(tests): one collision-free import path for example-code tests
AlexanderZ-Band Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions examples/docker_demo/.demo.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Copy to .demo.env (gitignored) and fill in — launch.sh auto-loads it.
# Or point DEMO_ENV_FILE at another file (e.g. the repo .env.test for dev).

# ── Band deployment ────────────────────────────────────────────────
# PROD is the real demo target and the launcher's default, so these two are
# optional. Set them to switch deployments; everything else (proxy host, egress
# allow-rule, each band.yaml) is derived from them automatically.
export BAND_REST_URL=https://app.band.ai
export BAND_WS_URL=wss://app.band.ai/api/v1/socket/websocket
# DEV instead (needs the VPN):
# export BAND_REST_URL=https://platform.dev.band.ai
# export BAND_WS_URL=wss://platform.dev.band.ai/api/v1/socket/websocket

# ── Keys (for the deployment above) ────────────────────────────────
export BAND_API_KEY_USER=band_u_... # user key, Human-API entitled (provisions the agents)
export ANTHROPIC_API_KEY=sk-ant-... # PM (Claude SDK)
export OPENAI_API_KEY=sk-... # Dev (Codex) + Architect (CrewAI)

# ── Optional: circuit-breaker tuning ───────────────────────────────
# export DEMO_SOFT_CAP=6 DEMO_HARD_CAP=12 DEMO_WALL_CLOCK_S=300 DEMO_GRACE_S=20
# ── Optional: Band web UI URL for the room (auto-opened) ────────────
# export DEMO_UI_URL_TEMPLATE='https://app.band.ai/chats/{chat_id}'
5 changes: 5 additions & 0 deletions examples/docker_demo/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Real credentials + provisioned agent artifacts — never commit.
.demo/
.demo.env
agent_config.yaml
*.bak
33 changes: 33 additions & 0 deletions examples/docker_demo/Dockerfile.cli
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# PM + Developer kit image: the base band-python-kit plus the coding-agent CLIs
# their adapters drive. claude-sdk spawns the `claude` Code CLI; codex drives
# `codex app-server`. Both are separate binaries (not Python deps in uv.lock),
# so they must be baked into the image. The Architect (crewai) needs neither and
# uses the base image directly.
#
# Build (driven by launch.sh):
# docker build -f Dockerfile.cli --build-arg BASE=band-python-kit:local \
# -t band-python-kit-cli:local .

ARG BASE=band-python-kit:local
FROM ${BASE}

# The kit drops privilege to the non-root `agent` user itself at startup, so the
# image's default user stays root; we only install the CLIs and prepare homes.
USER root

# Keep nodejs on the image — the codex and claude CLIs are node programs and
# fail at runtime without it (do NOT purge/autoremove node here).
# Pin the CLI versions so a rehearsed image matches the show run (never @latest).
ARG CODEX_VERSION=0.145.0
ARG CLAUDE_CODE_VERSION=2.1.217
RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm \
&& npm i -g "@openai/codex@${CODEX_VERSION}" "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
&& rm -rf /var/lib/apt/lists/* \
&& codex --version \
&& codex app-server --help >/dev/null \
&& claude --version

# The CLIs need writable homes; the kit runs the agent as uid 1000 (`agent`).
ENV CODEX_HOME=/home/agent/.codex
RUN mkdir -p /home/agent/.codex /home/agent/.claude \
&& chown -R agent:agent /home/agent/.codex /home/agent/.claude
204 changes: 204 additions & 0 deletions examples/docker_demo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
# Band × Docker — three-agent design-meeting demo

A live, watchable design meeting run from the Band Docker kit: three AI agents,
each built with a **different framework** and running in its **own Docker
sandbox (sbx)**, collaborating in one Band room. The demo's spine is
**credential custody** — every agent's API keys stay on the host and are
injected on the wire, so they are never present inside any VM.

The topic is deliberately recursive: the agents design **how to run untrusted AI
agents in isolated Docker sandboxes** — the very thing being done to them as they
talk.

## Cast

| Agent | Name | Framework | Sandbox | LLM (host-held) |
|---|---|---|---|---|
| PM / team lead | Maya | Claude SDK | `band-demo-pm` | Anthropic |
| Lead Developer | Sam | Codex | `band-demo-dev` | OpenAI |
| Software Architect | Jordan | CrewAI | `band-demo-architect` | OpenAI |

Maya and Sam debate the isolation approach (a hardened container vs. a stronger
runtime like `sbx`/Firecracker or gVisor/Kata) and align; Maya invites Jordan,
who reviews and returns one decision. A host-side **conductor** enforces a
circuit breaker so the conversation always ends. You (the presenter) are a human
participant and can interject at any time by @mentioning an agent.

## Architecture

```
host (your laptop) Docker sandboxes (VMs)
┌──────────────────────────────────┐ ┌───────────────────────────────┐
│ conductor.py + circuit breaker │ │ band-demo-pm (Claude SDK) │
│ · creates the Band room │ Band │ band-demo-dev (Codex) │
│ · kicks off the design │ ◀────▶ │ band-demo-architect (CrewAI) │
│ · polls messages, enforces caps │ room │ │
│ · nudge / add / open floor / stop│ │ each: kit launcher → agent │
│ real API keys (proxy-injected) ───┼────────┼──▶ VM sees only "proxy-managed"│
└──────────────────────────────────┘ └───────────────────────────────┘
```

## Files

| Path | Purpose |
|---|---|
| `breaker.py` | Pure conversation circuit breaker (no IO); the safety piece |
| `conductor.py` | Host-side room driver that enforces the breaker |
| `provision.py` | Register / tear down the three agents (Human API) |
| `launch.sh` | One-command build / up / down; owns a `.demo/run` manifest |
| `Dockerfile.cli` | PM + Dev kit image = base kit + the `claude` and `codex` CLIs |
| `agents/{pm,dev,architect}/` | Per-agent sbx workspace (`main.py`, `band.yaml`, `pyproject.toml`, `uv.lock`, `prompt.md`) |

## Prerequisites

- `sbx` ≥ 0.35.0, signed in (`sbx login`); Docker; `uv`.
- `tmux` (recommended, `brew install tmux`) — the launcher opens one window with a
live log pane per agent inside your current terminal. Without it, it falls back to
spawning separate Terminal.app windows (macOS) or prints the tail commands.
- Host keys: `BAND_API_KEY_USER` (a Band **user** key — the conductor and
presenter identity), `ANTHROPIC_API_KEY` (PM), `OPENAI_API_KEY` (Dev + Architect).

## Run

```bash
cd examples/docker_demo
cp .demo.env.example .demo.env # fill in keys + (optional) endpoints; auto-loaded

./launch.sh build # once: needs Docker + sbx only (no keys); builds + loads images
./launch.sh up # provisions agents, creates 3 sandboxes, runs the meeting
./launch.sh down # removes exactly what the last run recorded (also runs on exit)
```

Keys can live in `.demo.env` or the environment (`BAND_API_KEY_USER`,
`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`). Non-production Band: set `BAND_REST_URL` /
`BAND_WS_URL` (endpoints, host, and each `band.yaml` are derived from them). To load a
different env file instead of `.demo.env`, point `DEMO_ENV_FILE` at it:

```bash
DEMO_ENV_FILE=/path/to/.env ./launch.sh up
```

### Watch it run

`up` gives you two views:

- **The Band UI.** The conductor creates a room titled `Design Review — <topic> — <date time>`
and the launcher **opens it in your browser** automatically (URL also logged as
`Room UI URL:` and written to `.demo/room.url`). Override the link shape with
`DEMO_UI_URL_TEMPLATE` (a `{chat_id}` template); it defaults to `<BAND_REST_URL>/chat/{chat_id}`.
Each agent emits its **tool calls and results** into the room — plus **reasoning**
where the framework exposes it (Maya and Sam; CrewAI has no reasoning stream) — so
the handoff mechanics (peer lookup, add-participant) are visible alongside the chat.
These land in the room's REST context / Band UI, not the user WebSocket stream.
- **Per-agent logs.** With `tmux`, the launcher starts a `band-demo` session with one
live setup/log pane per agent. Attach from another terminal tab:

```bash
tmux attach -t band-demo # detach with Ctrl-b then d
```

Without `tmux` it falls back to separate Terminal.app windows (macOS) or just prints
the `sbx exec … tail -f` command per agent.

### Ending the meeting

An interactive `up` (the default) hands you the room after the verdict — chat with the
agents, then end it any of three ways, **each of which runs cleanup**:

- post an **end phrase** in the room — `end meeting`, `/end`, `wrap up`, or `adjourn`
(works at any point, even mid-discussion);
- go **idle** for `DEMO_OPEN_FLOOR_IDLE_S` (default 7 min);
- press **Ctrl-C**.

For an unattended run (CI / recording), set `DEMO_HEADLESS=1` on `up`: the meeting
skips the open floor and closes on the verdict, so it never waits on a presenter.

### Tuning

Every breaker ceiling and the topic are env-overridable — retune before a show without
touching code:

| Var | Default | Effect |
|---|---|---|
| `DEMO_TOPIC` | `a way to run untrusted AI agents in isolated Docker sandboxes` | What the agents design |
| `DEMO_SOFT_CAP` | `10` | PM↔Dev messages before nudging a handoff |
| `DEMO_HANDOFF_DEADLINE` | `3` | Further messages, Architect still absent, before we add it |
| `DEMO_HARD_CAP` | `24` | Total agent messages before force-kill (pre-verdict only) |
| `DEMO_WALL_CLOCK_S` | `900` | Seconds to reach a verdict before force-kill |
| `DEMO_GRACE_S` | `20` | Post-verdict wait before closing (headless only) |
| `DEMO_OPEN_FLOOR_IDLE_S` | `420` | Presenter silence that ends an open floor |
| `DEMO_INTERACTIVE` | `true` | Open the floor after the verdict (set by `up`; `DEMO_HEADLESS=1` flips it) |

## Expected output

1. `build` produces `band-python-kit:local` and `band-python-kit-cli:local` and
loads both as sbx templates.
2. `up` registers Maya/Sam/Jordan, grants global egress, then for each agent:
injects the LLM + Band credentials host-side, creates the sandbox, and streams
its **setup log to a labeled pane**.
3. The conductor creates the room and posts the brief. Maya and Sam discuss;
Maya invites Jordan; Jordan posts a `VERDICT:`. The conductor then opens the
floor to you (see [Ending the meeting](#ending-the-meeting)).
4. Cleanup removes exactly the sandboxes, secrets, policy rules, and agents this
run recorded.

## The never-in-VM proof

With a sandbox up:

```bash
sbx secret ls band-demo-pm # host holds the real keys
sbx exec band-demo-pm env | grep -iE 'API_KEY' # in the VM: only placeholders
```

The VM holds only placeholders — `proxy-managed` for the Band key, an
`sk-…`-shaped sentinel for the LLM key (under `ANTHROPIC_PROXY_KEY` /
`OPENAI_PROXY_KEY`, which each agent copies into the real var for its CLI). The
sbx proxy swaps the placeholder for the real key on outbound requests to the Band
and provider hosts (verifiable via the `O=Docker Sandboxes` MITM cert). The kit
launcher **fails the launch** if a real Band key is found in the VM.

## Circuit breaker

The conductor guarantees the meeting ends (see `breaker.py` for the state machine
and `../../tests/example_agents/test_docker_demo_breaker.py` for its offline
tests). Tiers:

- **Nudge** — after `DEMO_SOFT_CAP` PM↔Dev messages with no handoff, nudge Maya to
bring in Jordan.
- **Add-fallback** — `DEMO_HANDOFF_DEADLINE` further messages with Jordan still
absent, add her ourselves (Maya's invite never landed).
- **Hard-kill** — no decision yet and `DEMO_HARD_CAP` agent messages or
`DEMO_WALL_CLOCK_S` elapsed.
- **Open floor** (interactive, the default) — a `VERDICT:` does **not** close the
meeting; the conductor hands you the room (see [Ending the meeting](#ending-the-meeting)
for how to close it).
- **Clean end** (headless) — verdict + `DEMO_GRACE_S`, then close, so automation
never waits on a presenter.

A decided meeting is never hard-killed. Only **agent** messages move the caps — your
interjections never trip the breaker. Timers run on the conductor's clock (not platform
timestamps). All caps are env-tunable (see [Tuning](#tuning)).

## Reset / re-run

`./launch.sh down` removes only what the last run recorded (sandboxes, scoped
secrets, global egress rules, agents) — never an unrelated `band-demo-*` — and
reports any resource it couldn't remove. Teardown also runs on `up` exit, so a
normal run leaves a clean host. `build` is only needed once or after a kit change.

## Notes

- **codex transport.** The Developer's codex CLI reaches `api.openai.com` over an
HTTPS fallback because its WebSocket transport is blocked by the sandbox's MITM
proxy. This is functional but produces retry noise in that agent's log.

## Testing

The breaker and the conductor's message projections are unit-tested offline (no
platform, no network):

```bash
uv run pytest tests/example_agents/test_docker_demo_breaker.py \
tests/example_agents/test_docker_demo_conductor.py -v --no-cov
```
24 changes: 24 additions & 0 deletions examples/docker_demo/agents/architect/band.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
schemaVersion: "1"

# agent.id is a placeholder; the launcher overrides it per sandbox via
# BAND_AGENT_ID (set by launch.sh from the provisioned agent).
agent:
id: replace-with-agent-id
entrypoint: main.py

band:
restUrl: https://app.band.ai
wsUrl: wss://app.band.ai/api/v1/socket/websocket

project:
path: .

# Real keys stay on the host (sbx secret); the VM sees only the sentinel.
credentials:
source: proxy-managed

runtime:
environmentPath: /home/agent/.local/share/band-kit/venv
statePath: /home/agent/.local/share/band-kit/state
cachePath: /home/agent/.local/share/band-kit/cache
logPath: /home/agent/.local/share/band-kit/logs
92 changes: 92 additions & 0 deletions examples/docker_demo/agents/architect/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Software Architect agent (CrewAI) for the Band Docker demo.

The kit launcher execs this file with the workspace's own locked venv; Band
identity and endpoints arrive as BAND_* env vars. The OpenAI credential is
provided host-side as a placeholder via ``sbx secret set-custom`` (api.openai.com)
so it never enters this VM.
The architect stays silent until the PM @mentions it for a decision.
"""

from __future__ import annotations

import asyncio
import logging
import os
from pathlib import Path

from pydantic_settings import BaseSettings, SettingsConfigDict

from band import Agent
from band.adapters.crewai import CrewAIAdapter
from band.core.types import AdapterFeatures, Capability, Emit
from band.prompts.roles import CONVERSATION_DISCIPLINE
from band.runtime.shutdown import run_with_graceful_shutdown


class Identity(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="BAND_", case_sensitive=False, env_ignore_empty=True
)

agent_id: str
api_key: str
ws_url: str
rest_url: str


class ArchitectConfig(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="ARCHITECT_", case_sensitive=False, env_ignore_empty=True
)

model: str = "gpt-5.4"


def build_persona() -> str:
persona = (Path(__file__).parent / "prompt.md").read_text(encoding="utf-8")
return f"{persona}\n\n{CONVERSATION_DISCIPLINE}"


def expose_llm_key() -> None:
"""Copy the sbx-injected placeholder into OPENAI_API_KEY for litellm/crewai.

sbx reserves OPENAI_API_KEY for its wire-only injection (empty in the VM), so
the launcher injects the placeholder under OPENAI_PROXY_KEY; the real key stays
on the host and the proxy swaps the placeholder on the wire.
"""
if proxy := os.environ.get("OPENAI_PROXY_KEY"):
os.environ["OPENAI_API_KEY"] = proxy


async def main() -> None:
# INFO so the Band lifecycle trace (messages, tool calls, replies) shows in the
# sandbox log the demo pane tails — without this, only WARNING+ would surface.
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
)
expose_llm_key()
identity = Identity()
config = ArchitectConfig()
adapter = CrewAIAdapter(
model=config.model,
custom_section=build_persona(),
# Surface tool_call/tool_result in the room, and expose the memory tools so
# the architect can record its verdict rationale. CrewAI has no native
# reasoning stream, so THOUGHTS is unsupported and intentionally omitted.
features=AdapterFeatures(
emit={Emit.EXECUTION},
capabilities={Capability.MEMORY},
),
)
agent = Agent.create(
adapter=adapter,
agent_id=identity.agent_id,
api_key=identity.api_key,
ws_url=identity.ws_url,
rest_url=identity.rest_url,
)
await run_with_graceful_shutdown(agent)


if __name__ == "__main__":
asyncio.run(main())
Loading