# s09: Agent Teams > Persistent teammates with JSONL inboxes are one teaching protocol for turning isolated agents into a communicating team -- spawn, message, broadcast, and drain. ## The Problem Subagents (s04) are disposable: spawn, work, return summary, die. They have no identity, no memory between invocations, and no way to receive follow-up instructions. Background tasks (s08) run shell commands but cannot make LLM-guided decisions or communicate findings. For real teamwork you need three things: (1) persistent agents that survive beyond a single prompt, (2) identity and lifecycle management, and (3) a communication channel between agents. Without messaging, even persistent teammates are deaf and mute -- they can work in parallel but never coordinate. The solution combines a TeammateManager for spawning persistent named agents with a MessageBus using JSONL inbox files. Each teammate runs its own agent loop in a thread, checks its inbox before every LLM call, and can send messages to any other teammate or the lead. Note on the s06-to-s07 bridge: TodoManager items from s03 die with compression (s06). File-based tasks (s07) survive compression because they live on disk. Teams build on this same principle -- config.json and inbox files persist outside the context window. ## The Solution ``` Teammate lifecycle: spawn -> WORKING -> IDLE -> WORKING -> ... -> SHUTDOWN Communication: .team/ config.json <- team roster + statuses inbox/ alice.jsonl <- append-only, drain-on-read bob.jsonl lead.jsonl +--------+ send("alice","bob","...") +--------+ | alice | -----------------------------> | bob | | loop | bob.jsonl << {json_line} | loop | +--------+ +--------+ ^ | | BUS.read_inbox("alice") | +---- alice.jsonl -> read + drain ---------+ 5 message types: +-------------------------+------------------------------+ | message | Normal text between agents | | broadcast | Sent to all teammates | | shutdown_request | Request graceful shutdown | | shutdown_response | Approve/reject shutdown | | plan_approval_response | Approve/reject plan | +-------------------------+------------------------------+ ``` ## How It Works 1. The TeammateManager maintains config.json with the team roster. Each member has a name, role, and status. ```python class TeammateManager: def __init__(self, team_dir: Path): self.dir = team_dir self.dir.mkdir(exist_ok=True) self.config_path = self.dir / "config.json" self.config = self._load_config() self.threads = {} ``` 2. `spawn()` creates a teammate and starts its agent loop in a thread. Re-spawning an idle teammate reactivates it. ```python def spawn(self, name: str, role: str, prompt: str) -> str: member = self._find_member(name) if member: if member["status"] not in ("idle", "shutdown"): return f"Error: '{name}' is currently {member['status']}" member["status"] = "working" else: member = {"name": name, "role": role, "status": "working"} self.config["members"].append(member) self._save_config() thread = threading.Thread( target=self._teammate_loop, args=(name, role, prompt), daemon=True) self.threads[name] = thread thread.start() return f"Spawned teammate '{name}' (role: {role})" ``` 3. The MessageBus handles JSONL inbox files. `send()` appends a JSON line; `read_inbox()` reads all lines and drains the file. ```python class MessageBus: def send(self, sender, to, content, msg_type="message", extra=None): msg = {"type": msg_type, "from": sender, "content": content, "timestamp": time.time()} if extra: msg.update(extra) with open(self.dir / f"{to}.jsonl", "a") as f: f.write(json.dumps(msg) + "\n") return f"Sent {msg_type} to {to}" def read_inbox(self, name): path = self.dir / f"{name}.jsonl" if not path.exists(): return "[]" msgs = [json.loads(l) for l in path.read_text().strip().splitlines() if l] path.write_text("") # drain return json.dumps(msgs, indent=2) ``` 4. Each teammate checks its inbox before every LLM call and injects received messages into the conversation context. ```python def _teammate_loop(self, name, role, prompt): sys_prompt = f"You are '{name}', role: {role}, at {WORKDIR}." messages = [{"role": "user", "content": prompt}] for _ in range(50): inbox = BUS.read_inbox(name) if inbox != "[]": messages.append({"role": "user", "content": f"{inbox}"}) messages.append({"role": "assistant", "content": "Noted inbox messages."}) response = client.messages.create( model=MODEL, system=sys_prompt, messages=messages, tools=TOOLS) messages.append({"role": "assistant", "content": response.content}) if response.stop_reason != "tool_use": break # execute tools, append results... self._find_member(name)["status"] = "idle" self._save_config() ``` 5. `broadcast()` sends the same message to all teammates except the sender. ```python def broadcast(self, sender, content, teammates): count = 0 for name in teammates: if name != sender: self.send(sender, name, content, "broadcast") count += 1 return f"Broadcast to {count} teammates" ``` ## Key Code The TeammateManager + MessageBus core (from `agents/s09_agent_teams.py`): ```python class TeammateManager: def spawn(self, name, role, prompt): member = self._find_member(name) or { "name": name, "role": role, "status": "working" } member["status"] = "working" self._save_config() thread = threading.Thread( target=self._teammate_loop, args=(name, role, prompt), daemon=True) thread.start() return f"Spawned '{name}'" class MessageBus: def send(self, sender, to, content, msg_type="message", extra=None): msg = {"type": msg_type, "from": sender, "content": content, "timestamp": time.time()} if extra: msg.update(extra) with open(self.dir / f"{to}.jsonl", "a") as f: f.write(json.dumps(msg) + "\n") def read_inbox(self, name): path = self.dir / f"{name}.jsonl" if not path.exists(): return "[]" msgs = [json.loads(l) for l in path.read_text().strip().splitlines() if l] path.write_text("") return json.dumps(msgs, indent=2) ``` ## What Changed From s08 | Component | Before (s08) | After (s09) | |----------------|------------------|----------------------------| | Tools | 6 | 9 (+spawn/send/read_inbox) | | Agents | Single | Lead + N teammates | | Persistence | None | config.json + JSONL inboxes| | Threads | Background cmds | Full agent loops per thread| | Lifecycle | Fire-and-forget | idle -> working -> idle | | Communication | None | 5 message types + broadcast| Teaching simplification: this implementation does not use lock files for inbox access. In production, concurrent append from multiple writers would need file locking or atomic rename. The single-writer-per-inbox pattern used here is safe for the teaching scenario. ## Design Rationale File-based mailboxes (append-only JSONL) are easy to inspect and reason about in a teaching codebase. The "drain on read" pattern (read all, truncate) gives batch delivery with very little machinery. The tradeoff is latency -- messages are only seen at the next poll -- but for LLM-driven agents where each turn takes seconds, polling latency is acceptable for this course. ## Try It ```sh cd learn-claude-code python agents/s09_agent_teams.py ``` Example prompts to try: 1. `Spawn alice (coder) and bob (tester). Have alice send bob a message.` 2. `Broadcast "status update: phase 1 complete" to all teammates` 3. `Check the lead inbox for any messages` 4. Type `/team` to see the team roster with statuses 5. Type `/inbox` to manually check the lead's inbox