# CB-500 — Multi-Tier Coordination (Stage 6)
**Status:** design note. Developments A/B remain proposals; Development C (§6 and Figures 7–8) is
**SUPERSEDED** by the advisory-architect design in Gitea issue #16 and the `architects:` configuration
block (CB-548).
**Depends on:** CB-401/402 (Peer Launcher SPI + composite router — placement-neutral spawn),
CB-308 (per-agent broker channels + global id + federated roster — the addressing substrate),
CB-307 (durable inbox + push loop), CB-301/303 (session FSM + context-cap/idle-ttl), CB-304
(`rosterView`).
**Relates to:** the bus-identity boundary — see §7. This note stays a **proposal**; no code until the
staging in §6 is reviewed and the arc is split into tickets.
## 1. Goal
Grow `claude-bridge` from a **single-tier** coordinator (one human-driven primary → a flat pool of
workers) into a **multi-tier** one, along three axes the lead has asked for:
1. **Sandboxed workers** — each worker runs in a **separated, peer-owned sandbox** carrying its own
toolchain (Claude routed via `ANTHROPIC_BASE_URL`, a headless IDE, git, MCP, dev-tools), with
**per-role** sandboxes (a backend-agent image, a frontend-agent image).
2. **Main-agent pairs** — **SUPERSEDED.** The considered model made the "main" tier a pair
(on-subscription Opus + one cloud module). The actual fleet is one human-driven lead plus two
short-lived advisory architects on different model families.
3. **An orchestrator tier** — a supervisor **above** the mains that owns their **session identity**
(naming, resume) and **curates context**, so every main→worker delegation carries the *exact*
slice of context it needs and nothing else.
The through-line: **this is not a new pillar.** It is the existing `PeerLauncher` and
`SessionManager` patterns extended one tier up, riding the **same CB-308 substrate** that multi-host
already needs. Sandbox = a placement-neutral spawn target (CB-402 pattern). Pair + orchestrator =
per-agent channels + a recursive session manager (CB-308 pattern). The bus stays a
**provider-neutral communication fabric**; every addition is addressing, launch, or session scoping —
never toolchain ownership (§7).
## 2. Single-tier today (the assumptions to break)
```mermaid
flowchart TB
human["human (types)"]
primary["PRIMARY (Opus)
MCP client — pull-only"]
daemon["fleetd daemon
127.0.0.1:8765 (single host)"]
comp["CompositePeerLauncher
routes by kind"]
cc["ClaudeCodeLauncher"]
oc["OpenCodeLauncher"]
w1["worker pane (gx00 vLLM)"]
w2["worker pane (ollama)"]
human --> primary
primary -->|"fleet_send / spawn / ask"| daemon
daemon --> comp
comp --> cc
comp --> oc
cc --> w1
oc --> w2
```
*Figure 1 — one human-driven primary, one daemon, a flat pool of bare herdr-pane workers.*
Four concrete bake-ins assume a single tier:
| Assumption | Where (verified) | Why it blocks the direction |
|---|---|---|
| **Exactly one primary** | `mcp/PrimaryRegistry` — an `AtomicReference`, "single-slot registry for the primary's terminal" | A *pair* needs N addressable mains, each with its own pull inbox. |
| **Workers are bare panes** | `worker/*Launcher` spawn a herdr pane via `argv:["ccs", …]` into a pre-existing env | A *sandbox* is a richer launch target (container/devcontainer) — a new placement, not a new provider. |
| **`SpawnRequest` is flat** | `peer/SpawnRequest(profileName, requestedCwd, callerCwd)` | A sandbox/role selection needs a spawn-target dimension the record does not carry. |
| **No tier above the primary** | there is no manager of the *primary's own* session — `SessionManager` manages *workers* only | An orchestrator that names/resumes/scopes the mains is a wholly new (but pattern-reusable) tier. |
## 3. Target multi-tier architecture
> **SUPERSEDED fleet sketch.** Figure 2 records the former two-main model. The actual fleet is one
> lead, two independent advisory architects, and N workers; architects are sideways peers, not leads
> and not a tier above the lead. See Gitea issue #16 and the `architects:` block.
```mermaid
flowchart TB
human["human"]
subgraph orch["TIER 0 — orchestrator"]
osm["OrchestratorSessionManager
(SessionManager, recursed up)
names · resumes · scopes context"]
end
subgraph mains["TIER 1 — main pair"]
m1["main A: Opus
MCP client"]
m2["main B: cloud module
MCP client"]
end
subgraph bus["fleetd fabric (CB-307/308 substrate)"]
chan["per-agent inbox channels
agent.<globalId>.inbox"]
roster["federated roster (union view)"]
end
subgraph workers["TIER 2 — sandboxed workers"]
sbBE["backend sandbox
Claude via ANTHROPIC_BASE_URL
+ headless IDE · git · MCP · dev-tools"]
sbFE["frontend sandbox
(role-specific image)"]
end
human --> osm
osm -->|"spawn / name / resume"| m1
osm -->|"spawn / name / resume"| m2
m1 <-->|"pull inbox"| chan
m2 <-->|"pull inbox"| chan
m1 -->|"scoped delegation"| bus
m2 -->|"scoped delegation"| bus
bus --> sbBE
bus --> sbFE
chan --- roster
```
*Figure 2 — **SUPERSEDED historical fleet sketch.** It proposed a collaborating pair of managed mains.
The actual fleet keeps one human-driven lead and uses two independent, short-lived advisory architects
on different model families, so agreement is evidence rather than correlated echo.*
The recursion is the key idea: **`orchestrator : mains :: main : workers`** — the same
spawn/name/resume/scope verbs at two levels.
## 4. Development A — Sandboxed, role-specific workers
A "sandbox" is a **placement**, not a provider — so it slots into the CB-401 SPI exactly the way
CB-402's opencode adapter slotted in as a new *provider*. CB-402 proved the SPI is
provider-neutral; a `SandboxLauncher` proves it is **placement-neutral**.
```mermaid
flowchart TB
req["SpawnRequest
(profileName, cwd, + sandbox/role)"]
comp["CompositePeerLauncher
routes by kind"]
cc["ClaudeCodeLauncher
kind: claude-code"]
oc["OpenCodeLauncher
kind: opencode"]
sb["SandboxLauncher (NEW)
kind: sandbox"]
subgraph owned["bridge OWNS (launch + inject boundary)"]
launch["run sandbox entrypoint
(docker/devcontainer up → agent)"]
inject["inject + guard baseUrl,
mount bridge MCP + charter"]
end
subgraph peer["peer OWNS (inside the sandbox)"]
img["image = backend|frontend role
headless IDE · git · dev-tools · MCP"]
end
req --> comp
comp --> cc
comp --> oc
comp --> sb
sb --> launch --> inject
inject -.->|"launches into, never builds"| img
classDef line fill:#b7791f,stroke:#7b341e,color:#ffffff;
class inject line
```
*Figure 3 — the ownership line (amber). The bridge runs the sandbox entrypoint and injects the same
boundary it owns today (guarded `baseUrl`, mounted MCP + reply charter). Everything inside the image
— the IDE, git, dev-tools — is the peer's. The bridge references the image/role; it never provisions
tools. This is what keeps "give the worker a headless IDE" on the right side of the "bus, not
env-manager" rule (§7).*
```mermaid
sequenceDiagram
participant M as main (delegator)
participant D as fleetd
participant SL as SandboxLauncher
participant SB as sandbox (peer-owned)
participant A as agent in sandbox
M->>D: fleet_spawn(profile=backend, role=backend)
D->>SL: spawn(SpawnRequest)
SL->>SB: start entrypoint (image = backend role)
Note over SL,SB: bridge injects guarded ANTHROPIC_BASE_URL,
mounts bridge MCP url + reply charter
SB->>A: launch Claude (headless IDE, git, MCP ready — peer's own)
A-->>SL: MCP connects → readiness gate (CB-306) passes
SL-->>D: PeerHandle(globalId)
D-->>M: spawned, injectable
```
*Figure 4 — spawn into a peer-owned sandbox. Identical control flow to today's pane spawn (incl. the
CB-306 readiness gate); only the launcher's `buildLaunch` differs — exactly the CB-402 seam.*
**Deltas:** a new `kind: sandbox` adapter (extends the same `HerdrPeerLauncher`/`PeerLauncher` base);
a spawn-target/role dimension on `SpawnRequest` and the `Worker` profile; optionally a `SANDBOX`
`Capability`. Per-role = two profiles → two images; `CompositePeerLauncher` already routes them. If a
sandbox is a *separate host/container*, it reuses CB-308's global id + per-host gateway wholesale —
**the distributed case is resolved in §11: a sandbox on another host is one spawned by that host's
gateway, because herdr keystroke-injection needs a locally-owned PTY.**
## 5. Development B — Main-agent pairs
> **SUPERSEDED — do not implement this model.** The two-main fleet was replaced by one human-driven
> lead and two independent advisory architects. They are deliberately different model families (Claude
> Sonnet 5 and GPT-5.6 through opencode), receive the same brief, and work independently so agreement
> is evidence rather than correlated echo. See Gitea issue #16 and `architects:`.
Both mains are MCP **clients**, so **neither can be called into** — each needs a **pull-based
per-agent inbox**, which is precisely CB-308 item #1 (per-agent AMQP channels). The primary machinery
that is singular today (single-slot `PrimaryRegistry`, a push-loop aimed at one terminal, "these
tools only the primary calls") generalizes from a singleton to a **set**.
```mermaid
flowchart TB
subgraph pair["TIER 1 — collaborating pair"]
m1["main A: Opus
MCP client (pull-only)"]
m2["main B: cloud module
MCP client (pull-only)"]
end
reg["PrimaryRegistry → multi-slot
(terminal per main)"]
subgraph fabric["fleetd"]
ca["agent.A.inbox"]
cb["agent.B.inbox"]
push["ReplyPushLoop → N terminals"]
end
m1 <-->|"peer-to-peer message"| m2
m1 -->|"register terminal"| reg
m2 -->|"register terminal"| reg
ca -->|"pull / nudge"| m1
cb -->|"pull / nudge"| m2
reg --> push
push --> ca
push --> cb
```
*Figure 5 — the pair. Each main owns an addressable inbox; `PrimaryRegistry` becomes multi-slot; the
push loop nudges each main's terminal. Mains message each other as equals over the same bus (the
transport is already peer-neutral — what was missing is N pull endpoints).*
```mermaid
sequenceDiagram
participant MA as main A (Opus)
participant BR as fleetd / broker
participant MB as main B (cloud)
MA->>BR: fleet_send(to = main B, msg)
BR->>BR: publish agent.B.inbox (durable, msg id)
Note over BR: held until B pulls (B is a client too)
MB->>BR: blocking fleet_send / poll resolves
BR-->>MB: msg (then ACK)
MB->>BR: fleet_reply(to = main A)
BR->>BR: publish agent.A.inbox
MA->>BR: poll resolves
BR-->>MA: reply
```
*Figure 6 — main↔main is the CB-307 asymmetry applied on both ends: two clients, so both hops are
pull. This is why Part B **depends on** the per-agent-channel substrate, not just a config flag.*
**Deltas:** `PrimaryRegistry` single-slot → keyed-by-main; per-main inbox routing (CB-308 #1);
push-loop fan-out; relax "orchestration tools only the primary calls" to "any registered main."
## 6. Development C — Orchestrator tier
> **SUPERSEDED — do not implement this model.** The operator rejected a supervisor above the lead.
> The human continues to drive the pre-existing lead directly; fleetd neither spawns nor resumes that
> lead. What replaced this proposal is **one lead, two short-lived advisory architects, and N workers**:
> the lead engages architects sideways for a strong-model assessment, then discards them. Architect
> slots are declared in `architects:` (see Gitea issue #16), rather than making leads managed sessions.
> The two architects deliberately use different model families — Claude Sonnet 5 and GPT-5.6 through
> opencode — and receive the same brief independently. Agreement is evidence, not correlated echo
> from one provider or one conversation.
> **Historical alternative retained.** The text and figures below record the considered model and why it
> was rejected: it re-rooted the human-facing session above the lead, violating the still-true premise
> that configured leaders pre-exist, are recognised, and cannot be resumed by fleetd.
The orchestrator is **`SessionManager` recursed one tier up**: today it spawns/names/reaps *worker*
sessions; the orchestrator does the same for *main* sessions, and adds **context scoping**.
```mermaid
flowchart TB
human["human"]
subgraph t0["TIER 0 — orchestrator (new top MCP client)"]
osm["OrchestratorSessionManager
= SessionManager pattern"]
idm["session identity
name · resume · idle-ttl (CB-303)"]
ctx["context scoper
(CB-303 context-cap + turn_id)"]
end
subgraph t1["TIER 1 — mains (now MANAGED sessions)"]
m1["main A"]
m2["main B"]
end
subgraph t2["TIER 2 — workers"]
w["sandboxed workers"]
end
human --> osm
osm --> idm
osm --> ctx
idm -->|"spawn / name / resume"| m1
idm -->|"spawn / name / resume"| m2
ctx -->|"inject exact context slice"| m1
m1 -->|"scoped delegation"| w
m2 -->|"scoped delegation"| w
```
*Figure 7 — **SUPERSEDED historical alternative.** The recursion re-rooted the human-facing session:
the human drove an orchestrator and the mains became managed, resumable sessions. The operator rejected
that re-root. The replacement keeps the human-driven, pre-existing lead and engages architects sideways
as short-lived advisory peers; see Gitea issue #16 and `architects:`.*
```mermaid
sequenceDiagram
participant H as human
participant O as orchestrator
participant MA as main A
participant W as worker
H->>O: high-level goal (large context)
O->>O: name/resume main A session
O->>MA: task + SCOPED context slice (not the whole history)
MA->>W: fleet_send(delegation, carrying only the relevant slice)
W-->>MA: result
MA-->>O: rollup
O->>O: fold into orchestrator context, pick next main/turn
```
*Figure 8 — **SUPERSEDED historical alternative.** This proposed an orchestrator holding global context
and slicing it for managed mains. The replacement has the human-driven lead send the same advisory brief
issue #16 and `architects:`.*
**Deltas:** a second, higher `SessionManager` instance whose "peers" are mains; the orchestrator
becomes the top MCP client; context-slice selection (new) layered on CB-303's `context_cap` +
`turn_id` scoping; mains gain a resumable session id in the federated roster.
## 7. The identity boundary — the one clause to hold
The bus is a **communication fabric, not an env/toolchain manager**. This direction is compatible
**only** with the ownership split below; the amber line in Figure 3 is where it must hold.
```mermaid
flowchart LR
subgraph ok["STAYS A BUS (owned)"]
a["launch INTO a sandbox
(opaque image/role reference)"]
b["inject + guard baseUrl,
mount MCP + charter"]
c["session identity + context scope
(name/resume/turn_id)"]
d["per-agent addressing + roster"]
end
subgraph drift["BECOMES ENV-MANAGER (forbidden)"]
e["build images / install IDE
or dev-tools"]
f["wire the bridge's OWN IDE MCP
into a worker"]
g["enumerate 'what a frontend
agent needs'"]
end
ok -.->|"red flag: any feature that only
makes sense for ONE kind of peer"| drift
classDef bad fill:#9b2c2c,stroke:#742a2a,color:#ffffff;
class e,f,g bad
```
*Figure 9 — the guardrail. A worker having a headless IDE **inside its own sandbox** is the peer
owning its toolchain (left) — the opposite of the bridge reaching into the peer (right). Sandbox
specs are peer-owned references (like `argv`/image id); the instant the bridge builds or installs
them, it has drifted. This resolves the apparent contradiction between "do NOT give workers IDE MCP
access" and "give workers a sandboxed IDE" — different owners.*
## 8. Staging & dependencies
```mermaid
flowchart LR
cb402["CB-401/402
Peer Launcher SPI + composite
(DONE / in-flight)"]
A["A · SandboxLauncher
(placement-neutral, independent)"]
cb308["CB-308 substrate
per-agent channels + global id
+ federated roster"]
B["B · main-agent pair (SUPERSEDED)
(multi-slot PrimaryRegistry)"]
C["C · orchestrator tier
(SessionManager recursed up)"]
cb402 --> A
cb402 --> cb308
cb308 --> B
cb308 --> C
B --> C
A -.->|"if sandbox = separate host/container,
reuses CB-308 global id"| cb308
classDef gate fill:#b7791f,stroke:#7b341e,color:#ffffff;
class cb308 gate
```
*Figure 10 — the substrate (amber) is the shared enabler for B and C. Recommended order:*
1. **Finish CB-402** (merge the opencode adapter branch).
2. **A · SandboxLauncher** — independent; a second proof of the SPI (placement-neutral). Ships anytime.
3. **CB-308 substrate** — per-agent channels + global id + federated roster (the multi-host work,
promoted from host-to-host to tier-to-tier).
4. **B · main-agent pair** — **SUPERSEDED** by lead + two advisory architects.
5. **C · orchestrator tier** — the capstone; the recursive session manager + context scoping.
## 9. Open questions (to resolve at ticket-split)
- **Sandbox mechanism:** container (`docker exec`) vs devcontainer — how the role→image mapping is
expressed on the profile. *(Topology **resolved** in §11: distributed = gateway-per-host × local
sandboxes; the remaining choice is only the local launch mechanism, not the shape.)*
- **Pair semantics:** **SUPERSEDED.** The two-main question is replaced by the architect role's
least-privilege boundary: advisory architects can send/reply/ask/read but cannot spawn/stop/drain.
- **Orchestrator drivenness:** the mains become programmatically spawned/resumed — does the human
still ever type directly into a main, or only into the orchestrator? (The re-root caveat, Fig 7.)
- **Context-slice selection:** who decides the slice — orchestrator heuristics, explicit tool args,
or the main pulling on demand? This is the genuinely new responsibility; keep it *scoping*, not
content authorship, to stay inside the boundary.
- **Trust:** every new tier boundary that accepts spawn/send is a trust edge (the CB-308 item #5 /
CB-401 Stage-C concern) — orchestrator→main and main→sandbox both need authz.
## 10. Ticket-split guidance (deferred)
This note is deliberately one arc; when split, the natural tickets are **A** (SandboxLauncher +
role/spawn-target), **the CB-308 substrate** (likely already its own ticket), **B** (multi-primary
pair), and **C** (orchestrator tier + context scoping) — with the identity clause (§7) as an
acceptance criterion on **A** specifically. Sequence per §8; nothing here is a new pillar, so each
ticket is an extension of an existing pattern (CB-402 for A, CB-308 for B/C).
## 11. Distributed sandboxes — the resolved topology
The follow-up question — *"clarify the architecture when we have distributed agents in sandboxes"* —
resolves the fork left open in §4 and §9. **Decision: Development A (sandbox launcher) and CB-308
(per-host federation) *compose*, not compete — each host runs a `fleetd` gateway whose launcher
spawns agents into that host's *local* sandboxes.** A sandbox is never reached across the network; it
is reached by the gateway sitting next to it.
### 11.1 The one fact that fixes the shape
The bus delivers a turn by **herdr keystroke-injection** — `Injector → AgentControl.send` writes into
a PTY that its **local** herdr owns. The broker moves *messages and presence*, **never keystrokes**.
So an agent's PTY must live in a herdr that *some* `fleetd` instance drives locally: a remote
container with no local herdr **cannot be injected into**. That rules out a central daemon reaching
remote PTYs, and collapses the design to a single identity:
> **"a sandboxed agent on another host" ≡ "a sandbox spawned by that host's gateway."**
```mermaid
flowchart TB
subgraph hostA["HOST A — gateway"]
mA["main / orchestrator
MCP client → LOCAL gateway"]
gA["fleetd A
herdr + CompositePeerLauncher
(incl. SandboxLauncher)"]
cBEa["sandbox: backend
(local container)"]
cFEa["sandbox: frontend
(local container)"]
mA --- gA
gA -->|"spawn (docker/devcontainer)
→ PTY in A's herdr"| cBEa
gA --> cFEa
end
subgraph broker["BROKER (AMQP) — CB-307/308 fabric"]
inbox["agent.ID.inbox queues"]
roster["roster.* (federated presence)"]
end
subgraph hostB["HOST B — gateway"]
gB["fleetd B
herdr + SandboxLauncher"]
cBEb["sandbox: backend
(local container)"]
gB -->|"spawn → PTY in B's herdr"| cBEb
end
gA <-->|"messages + presence
(NOT keystrokes)"| inbox
gB <-->|"messages + presence"| inbox
gA --- roster
gB --- roster
classDef line fill:#2b6cb0,stroke:#1a365d,color:#ffffff;
class inbox,roster line
```
*Figure 11 — the composed topology. Each gateway owns its local herdr and runs a `SandboxLauncher`
(the §4 adapter) that spawns role-specific containers **on its own host**; the broker (blue) carries
only messages + roster between gateways. Keystroke-injection stays strictly local to each gateway.*
### 11.2 How a delegation reaches a sandboxed agent on another host
```mermaid
sequenceDiagram
participant MA as main (host A)
participant GA as gateway A
participant BR as broker
participant GB as gateway B
participant SB as sandbox agent (host B, container)
MA->>GA: fleet_send(globalId on B, msg)
GA->>GA: directory lookup - is globalId local? NO
GA->>BR: publish agent.ID.inbox (durable)
BR->>GB: route to the owning gateway
GB->>SB: inject via B's LOCAL herdr (keystrokes)
Note over GB,SB: SandboxLauncher already spawned the container -
its PTY is in B's herdr, CB-306 readiness passed
SB-->>GB: fleet_reply (to B's LOCAL MCP endpoint)
GB->>BR: publish primary-bound (durable, msg id)
BR->>GA: route back to A
Note over GA: held until the main pulls (the main is a client)
MA->>GA: poll / blocking send resolves
GA-->>MA: reply
```
*Figure 12 — the `local ? inject : publish` fork (CB-308 §3.2) with a sandboxed far side. Only the
**middle** hop crosses the network via the broker; **both** injection points (into the sandbox on B,
and the drain-nudge back into the main on A) are local herdr writes. This is CB-308's routing rule
unchanged — the sandbox is transparent to it.*
### 11.3 Two reachability changes any sandbox forces
| Change | Today | Under sandboxes |
|---|---|---|
| **`mcpUrl`** | `http://127.0.0.1:8765/mcp` (loopback) | must be **host-routable from inside the container** (e.g. `host.docker.internal` or the gateway's LAN IP) — the worker connects to **its own gateway's** MCP, never a remote one. |
| **PTY ownership** | pane in the daemon's herdr | pane is the **container's** attached PTY, in the **local** gateway's herdr (via `docker exec`/devcontainer) — non-negotiable per §11.1. |
### 11.4 Everything maps to an existing seam (nothing new invented)
| Concern | Provided by |
|---|---|
| Per-host gateway (owns local herdr + sessions) | **CB-308** (today's `fleetd`, evolved) |
| Spawn into a local sandbox / role→image | **Development A** `SandboxLauncher` (§4), routed by `CompositePeerLauncher` |
| Addressing a remote sandboxed agent | **CB-308** global id + federated roster (host + role as metadata) |
| Orphan reap after a gateway restart | **CB-117** per-gateway, summed by the composite — each reaps only its **local** herdr |
| Container up/down | tied to **CB-303** session lifecycle — `SandboxLauncher.stop` tears the container down with the pane |
| Cross-gateway spawn/send trust | **CB-308 item #5** / CB-401 Stage-C — each gateway edge is a trust boundary |
*The net: distributed sandboxes add **zero** new pillars — they are `CB-308 gateway × Development-A
launcher` at every host, with the §7 ownership line (peer owns the image; the bridge only launches
into it) holding at each gateway.*