s09
Agent Teams
CollaborationTeammates + Mailboxes
52 LOC9 toolsTeammateManager + file-based mailbox
When one agent can't finish, delegate to persistent teammates via async mailboxes
s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > [ s09 ] s10 > s11 > s12
"When the task is too big for one, delegate to teammates" -- persistent teammates + async mailboxes.
Harness layer: Team mailboxes -- multiple models, coordinated through files.
Problem
Subagents (s04) are disposable: spawn, work, return summary, die. No identity, no memory between invocations. Background tasks (s08) run shell commands but can't make LLM-guided decisions.
Real teamwork needs: (1) persistent agents that outlive a single prompt, (2) identity and lifecycle management, (3) a communication channel between agents.
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 ---------+
How It Works
- TeammateManager maintains config.json with the team roster.
type ToolInput = Record<string, any>;
type ToolSpec = {
name: string;
description: string;
input_schema: Record<string, unknown>;
};
const tool: ToolSpec = {
name: "spawn_teammate",
description: "agent team",
input_schema: { type: "object", properties: {} }
};
async function handleS09Step(input: ToolInput) {
return team.spawn(input.name, input.role);
return tool.name;
}
spawn()creates a teammate and starts its agent loop in a thread.
type ToolInput = Record<string, any>;
type ToolSpec = {
name: string;
description: string;
input_schema: Record<string, unknown>;
};
const tool: ToolSpec = {
name: "spawn_teammate",
description: "agent team",
input_schema: { type: "object", properties: {} }
};
async function handleS09Step(input: ToolInput) {
return team.spawn(input.name, input.role);
return tool.name;
}
- MessageBus: append-only JSONL inboxes.
send()appends a JSON line;read_inbox()reads all and drains.
type ToolInput = Record<string, any>;
type ToolSpec = {
name: string;
description: string;
input_schema: Record<string, unknown>;
};
const tool: ToolSpec = {
name: "spawn_teammate",
description: "agent team",
input_schema: { type: "object", properties: {} }
};
async function handleS09Step(input: ToolInput) {
return team.spawn(input.name, input.role);
return tool.name;
}
- Each teammate checks its inbox before every LLM call, injecting received messages into context.
type ToolInput = Record<string, any>;
type ToolSpec = {
name: string;
description: string;
input_schema: Record<string, unknown>;
};
const tool: ToolSpec = {
name: "spawn_teammate",
description: "agent team",
input_schema: { type: "object", properties: {} }
};
async function handleS09Step(input: ToolInput) {
return team.spawn(input.name, input.role);
return tool.name;
}
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 | message + broadcast |
Try It
cd learn-claude-code
tsx agents/s09_agent_teams.ts
Spawn alice (coder) and bob (tester). Have alice send bob a message.Broadcast "status update: phase 1 complete" to all teammatesCheck the lead inbox for any messages- Type
/teamto see the team roster with statuses - Type
/inboxto manually check the lead's inbox