Reverse-engineered from Claude Code CLI v2.1.34. This document provides a complete blueprint for implementing a multi-agent teammate coordination system in another code agent.
- Architecture Overview
- Data Structures & Schemas
- File System Layout
- Tool 1: TeamCreateTool
- Tool 2: TeamDeleteTool
- Tool 3: SendMessageTool
- Teammate Spawning (via Task Tool)
- Mailbox System (Inter-Agent Messaging)
- Protocol Messages
- In-Process Teammate Lifecycle
- Team Config File Management
- Permission Modes
- System Prompts for Team Coordination
- Tool Registration
- Implementation Checklist
The teammate system is a multi-tool, multi-agent orchestration framework. It is NOT a single tool — it consists of:
| Tool | Purpose |
|---|---|
| TeamCreateTool | Create a new team (1:1 with a task list) |
| TeamDeleteTool | Clean up team directories, worktrees, and task lists |
| SendMessageTool | DMs, broadcasts, shutdown requests/responses, plan approvals |
| Task Tool (extended) | Spawns teammates when name + team_name params are provided |
- Team: A named group of agents. Stored at
~/.claude/teams/{team-name}/config.json - Task List: Each team has a corresponding task list at
~/.claude/tasks/{team-name}/ - Agent ID: Format is
{sanitized-name}@{team-name}(e.g.researcher@my-project) - Team Lead: The agent that created the team. Always named
"team-lead" - Mailbox: File-based inbox for async message passing between agents
- Three Spawn Backends: iTerm2 split-pane, tmux window, in-process
1. Leader calls TeamCreate → creates team config + task list
2. Leader calls Task tool with `name` param → spawns teammate
3. Agents communicate via SendMessage → writes to mailboxes
4. Leader sends shutdown_request → teammate responds with shutdown_response
5. Leader calls TeamDelete → cleans up everything
interface TeamConfig {
name: string; // Team name
description?: string; // Team purpose
createdAt: number; // Unix timestamp ms
leadAgentId: string; // e.g. "team-lead@my-project"
leadSessionId: string; // Session ID of the leader
members: TeamMember[]; // All agents including leader
}
interface TeamMember {
agentId: string; // e.g. "researcher@my-project"
name: string; // Human-readable, e.g. "researcher"
agentType?: string; // Role/type, e.g. "researcher", "test-runner"
model?: string; // Model used (e.g. "sonnet", "opus")
prompt?: string; // Initial prompt given at spawn
color?: string; // Display color
planModeRequired?: boolean; // Whether agent needs plan approval
joinedAt: number; // Unix timestamp ms
tmuxPaneId: string; // Tmux pane ID or "in-process"
cwd: string; // Working directory
subscriptions: string[]; // (unused currently, empty array)
backendType?: string; // "tmux" | "in-process"
worktreePath?: string; // Git worktree path if applicable
mode?: string; // Permission mode
isActive?: boolean; // Active vs idle
}// Zod schema
const TeamCreateInput = z.strictObject({
team_name: z.string().describe("Name for the new team to create."),
description: z.string().optional().describe("Team description/purpose."),
agent_type: z.string().optional().describe(
'Type/role of the team lead (e.g., "researcher", "test-runner").'
),
});// Empty object — uses current team context
const TeamDeleteInput = z.strictObject({});// Discriminated union on "type"
const MessageInput = z.discriminatedUnion("type", [
// Direct message
z.object({
type: z.literal("message"),
recipient: z.string(),
content: z.string(),
summary: z.string().describe("5-10 word preview for UI"),
}),
// Broadcast to all
z.object({
type: z.literal("broadcast"),
content: z.string(),
summary: z.string().describe("5-10 word preview for UI"),
}),
// Request shutdown
z.object({
type: z.literal("shutdown_request"),
recipient: z.string(),
content: z.string().optional(),
}),
// Respond to shutdown
z.object({
type: z.literal("shutdown_response"),
request_id: z.string(),
approve: z.boolean(),
content: z.string().optional(),
}),
// Approve/reject plan
z.object({
type: z.literal("plan_approval_response"),
request_id: z.string(),
approve: z.boolean(),
recipient: z.string(),
content: z.string().optional(),
}),
]);// The normal Task tool schema extended with:
const TaskTeammateExtension = z.object({
name: z.string().optional().describe("Name for the spawned agent"),
team_name: z.string().optional().describe("Team name for spawning"),
mode: z.enum(["default", "plan", "delegate", ...]).optional(),
});~/.claude/
├── teams/
│ └── {team-name}/ # Sanitized: lowercase, non-alphanumeric → "-"
│ ├── config.json # Team config (TeamConfig schema)
│ └── inboxes/
│ ├── {agent-name}.json # Mailbox for each agent
│ └── {agent-name}.json.lock # Lock file for concurrent access
├── tasks/
│ └── {team-name}/ # Task list directory (1:1 with team)
│ └── {task-id}.json # Individual task files
function sanitizeTeamName(name: string): string {
return name.replace(/[^a-zA-Z0-9]/g, "-").toLowerCase();
}
// Agent names also sanitized for file paths:
function sanitizeForPath(name: string): string {
return name.replace(/@/g, "-");
}Create a new team. Creates the config file, task list directory, and registers the leader.
async function teamCreate(input: TeamCreateInput, context: ToolContext) {
const { setAppState, getAppState } = context;
const { team_name, description, agent_type } = input;
const state = await getAppState();
// Validate: only one team per leader
const existingTeam = state.teamContext?.teamName;
if (existingTeam) {
throw Error(
`Already leading team "${existingTeam}". Use TeamDelete first.`
);
}
// Deduplicate team name if config already exists
const finalName = ensureUniqueName(team_name);
// Create agent ID for leader
const leadAgentId = `team-lead@${finalName}`;
const agentType = agent_type || "team-lead";
// Create team directory & config
const teamDir = path.join(TEAMS_DIR, sanitize(finalName));
fs.mkdirSync(teamDir, { recursive: true });
const config: TeamConfig = {
name: finalName,
description,
createdAt: Date.now(),
leadAgentId,
leadSessionId: getSessionId(),
members: [{
agentId: leadAgentId,
name: "team-lead",
agentType,
model: getCurrentModel(),
joinedAt: Date.now(),
tmuxPaneId: "",
cwd: getCwd(),
subscriptions: [],
}],
};
const configPath = path.join(teamDir, "config.json");
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
// Initialize task list directory
initTaskList(sanitize(finalName));
// Update app state
setAppState((s) => ({
...s,
teamContext: {
teamName: finalName,
teamFilePath: configPath,
leadAgentId,
teammates: {
[leadAgentId]: {
name: "team-lead",
agentType,
color: assignColor(leadAgentId),
tmuxSessionName: "",
tmuxPaneId: "",
cwd: getCwd(),
spawnedAt: Date.now(),
},
},
},
}));
return {
data: {
team_name: finalName,
team_file_path: configPath,
lead_agent_id: leadAgentId,
},
};
}- Always allowed (
{ behavior: "allow" }) - Only enabled when teams feature flag is on (
p8()) - Not concurrency-safe
- Not read-only
Clean up all team resources: worktrees, team config directory, task list directory.
async function teamDelete(input: {}, context: ToolContext) {
const { setAppState, getAppState } = context;
const teamName = (await getAppState()).teamContext?.teamName;
if (teamName) {
const config = readTeamConfig(teamName);
if (config) {
// IMPORTANT: Refuse if active members remain
const activeMembers = config.members.filter(m => m.name !== "team-lead");
if (activeMembers.length > 0) {
const names = activeMembers.map(m => m.name).join(", ");
return {
data: {
success: false,
message: `Cannot cleanup team with ${activeMembers.length} active member(s): ${names}. Use requestShutdown first.`,
team_name: teamName,
},
};
}
}
// Clean up worktrees (for each member with worktreePath)
await cleanupTeam(teamName);
// Clears the task list UI
clearTaskListUI();
}
// Clear app state
setAppState((s) => ({
...s,
teamContext: undefined,
inbox: { messages: [] },
}));
return {
data: {
success: true,
message: teamName
? `Cleaned up directories and worktrees for team "${teamName}"`
: "No team name found, nothing to clean up",
team_name: teamName,
},
};
}
async function cleanupTeam(teamName: string) {
const config = readTeamConfig(teamName);
const worktreePaths: string[] = [];
if (config) {
for (const member of config.members) {
if (member.worktreePath) worktreePaths.push(member.worktreePath);
}
}
// Remove worktrees
for (const wt of worktreePaths) {
await removeWorktree(wt);
}
// Remove team directory
const teamDir = getTeamDir(teamName);
await fs.rm(teamDir, { recursive: true, force: true });
// Remove tasks directory
const taskDir = getTaskDir(teamName);
await fs.rm(taskDir, { recursive: true, force: true });
}
async function removeWorktree(worktreePath: string) {
// Try git worktree remove first
const dotGitPath = path.join(worktreePath, ".git");
let mainRepoPath = null;
try {
const content = fs.readFileSync(dotGitPath, "utf-8").trim();
const match = content.match(/^gitdir:\s*(.+)$/);
if (match?.[1]) {
mainRepoPath = path.resolve(match[1], "..", "..", "..");
}
} catch {}
if (mainRepoPath) {
const result = await exec("git", ["worktree", "remove", "--force", worktreePath], {
cwd: mainRepoPath,
});
if (result.code === 0) return;
if (result.stderr?.includes("not a working tree")) return;
}
// Fallback: manual removal
try {
fs.rmSync(worktreePath, { recursive: true, force: true });
} catch (err) {
log(`Failed to remove worktree ${worktreePath}: ${err}`);
}
}All inter-agent communication: DMs, broadcasts, shutdown protocol, plan approval.
async function sendMessage(input: MessageInput, context: ToolContext) {
switch (input.type) {
case "message":
return handleDirectMessage(input, context);
case "broadcast":
return handleBroadcast(input, context);
case "shutdown_request":
return handleShutdownRequest(input, context);
case "shutdown_response":
if (input.approve) return handleShutdownApproval(input, context);
return handleShutdownRejection(input);
case "plan_approval_response":
if (input.approve) return handlePlanApproval(input, context);
return handlePlanRejection(input, context);
}
}async function handleDirectMessage(input, context) {
const state = await context.getAppState();
const teamName = getTeamName(state.teamContext);
const senderName = getAgentName() || "team-lead";
const recipientName = stripTeamSuffix(input.recipient);
const senderColor = getAgentColor();
// Write to recipient's mailbox
writeToMailbox(recipientName, {
from: senderName,
text: input.content,
summary: input.summary,
timestamp: new Date().toISOString(),
color: senderColor,
}, teamName);
return {
data: {
success: true,
message: `Message sent to ${recipientName}'s inbox`,
routing: {
sender: senderName,
senderColor,
target: `@${recipientName}`,
targetColor: lookupColor(state, recipientName),
summary: input.summary,
content: input.content,
},
},
};
}async function handleBroadcast(input, context) {
const state = await context.getAppState();
const teamName = getTeamName(state.teamContext);
if (!teamName) throw Error("Not in a team context.");
const config = readTeamConfig(teamName);
if (!config) throw Error(`Team "${teamName}" does not exist`);
const senderName = getAgentName() || "team-lead";
const recipients = config.members
.filter(m => m.name.toLowerCase() !== senderName.toLowerCase())
.map(m => m.name);
if (recipients.length === 0) {
return { data: { success: true, message: "No teammates to broadcast to", recipients: [] } };
}
for (const recipient of recipients) {
writeToMailbox(recipient, {
from: senderName,
text: input.content,
summary: input.summary,
timestamp: new Date().toISOString(),
color: getAgentColor(),
}, teamName);
}
return {
data: {
success: true,
message: `Message broadcast to ${recipients.length} teammate(s): ${recipients.join(", ")}`,
recipients,
},
};
}async function handleShutdownRequest(input, context) {
const teamName = getTeamName();
const recipientName = stripTeamSuffix(input.recipient);
const senderName = getAgentName() || "team-lead";
// Generate unique request ID
const requestId = `shutdown-${Date.now()}@${recipientName}`;
// Create protocol message
const protocolMsg = {
type: "shutdown_request",
requestId,
from: senderName,
reason: input.content,
timestamp: new Date().toISOString(),
};
// Write to recipient's mailbox
writeToMailbox(recipientName, {
from: senderName,
text: JSON.stringify(protocolMsg),
timestamp: new Date().toISOString(),
color: getAgentColor(),
}, teamName);
return {
data: {
success: true,
message: `Shutdown request sent to ${recipientName}. Request ID: ${requestId}`,
request_id: requestId,
target: recipientName,
},
};
}async function handleShutdownApproval(input, context) {
const teamName = getTeamName();
const agentId = getAgentId();
const agentName = getAgentName() || "teammate";
// Look up backend type from team config
let paneId, backendType;
if (teamName) {
const config = readTeamConfig(teamName);
if (config && agentId) {
const member = config.members.find(m => m.agentId === agentId);
if (member) {
paneId = member.tmuxPaneId;
backendType = member.backendType;
}
}
}
// Create approval protocol message
const approvalMsg = {
type: "shutdown_approved",
requestId: input.request_id,
from: agentName,
timestamp: new Date().toISOString(),
paneId,
backendType,
};
// Send to team-lead's mailbox
writeToMailbox("team-lead", {
from: agentName,
text: JSON.stringify(approvalMsg),
timestamp: new Date().toISOString(),
color: getAgentColor(),
}, teamName);
// For in-process agents: abort the controller
if (backendType === "in-process" && agentId) {
const state = await context.getAppState();
const task = findTeammateTaskByAgentId(agentId, state.tasks);
if (task?.abortController) {
task.abortController.abort();
}
} else {
// For terminal agents: exit the process
setImmediate(async () => {
await exitProcess(0, "other");
});
}
return {
data: {
success: true,
message: `Shutdown approved. Agent ${agentName} is now exiting.`,
request_id: input.request_id,
},
};
}recipientmust not be emptyshutdown_responsewithapprove: falserequirescontent(reason)- Only team lead can approve/reject plans
Inside the Task tool's call() handler, when both name and team_name are provided, it spawns a teammate instead of a regular subagent:
async function taskToolCall(input, context) {
const { prompt, subagent_type, name, team_name, mode, model, ... } = input;
const state = await context.getAppState();
const resolvedTeamName = team_name || state.teamContext?.teamName;
// If team context exists AND name is provided → spawn teammate
if (resolvedTeamName && name) {
// Prevent in-process teammates from spawning other teammates
if (isInProcessTeammate()) {
throw Error("In-process teammates cannot spawn other teammates.");
}
const result = await spawnTeammate({
name,
prompt,
description,
team_name: resolvedTeamName,
use_splitpane: true,
plan_mode_required: mode === "plan",
model,
agent_type: subagent_type,
}, context);
return { data: { status: "teammate_spawned", prompt, ...result.data } };
}
// Otherwise: normal subagent logic...
}async function spawnTeammate(input, context): Promise<SpawnResult> {
if (isInProcessMode()) {
return spawnInProcess(input, context);
}
if (input.use_splitpane !== false) {
return spawnITerm2SplitPane(input, context);
}
return spawnTmuxWindow(input, context);
}async function spawnInProcess(input, context) {
const { name, prompt, agent_type, plan_mode_required } = input;
const model = input.model ?? DEFAULT_MODEL;
const state = await context.getAppState();
const teamName = input.team_name || state.teamContext?.teamName;
if (!teamName) throw Error("team_name is required");
// Deduplicate name within team
const finalName = ensureUniqueNameInTeam(name, teamName);
const sanitizedName = sanitizeForAgentId(finalName);
const agentId = `${sanitizedName}@${teamName}`;
const color = assignColor(agentId);
// Create abort controller for lifecycle management
const abortController = new AbortController();
// Create teammate context
const teammateContext = createTeammateContext({
agentId,
agentName: sanitizedName,
teamName,
color,
planModeRequired: plan_mode_required ?? false,
parentSessionId: getSessionId(),
abortController,
});
// Register as a task in app state
const taskId = generateTaskId("in_process_teammate");
const task = {
type: "in_process_teammate",
status: "running",
identity: {
agentId,
agentName: sanitizedName,
teamName,
color,
planModeRequired: plan_mode_required ?? false,
parentSessionId: getSessionId(),
},
prompt,
model,
abortController,
awaitingPlanApproval: false,
permissionMode: plan_mode_required ? "plan" : "default",
isIdle: false,
shutdownRequested: false,
lastReportedToolCount: 0,
lastReportedTokenCount: 0,
pendingUserMessages: [],
messages: [],
};
registerTask(task, context.setAppState);
// Start the agent execution loop
startAgentExecution({
identity: task.identity,
taskId,
prompt,
teammateContext,
toolUseContext: context,
abortController,
});
// Update team config
const config = readTeamConfig(teamName);
if (!config) throw Error(`Team "${teamName}" does not exist.`);
config.members.push({
agentId,
name: sanitizedName,
agentType: agent_type,
model,
prompt,
color,
planModeRequired: plan_mode_required,
joinedAt: Date.now(),
tmuxPaneId: "in-process",
cwd: getCwd(),
subscriptions: [],
backendType: "in-process",
});
writeTeamConfig(teamName, config);
return {
data: {
teammate_id: agentId,
agent_id: agentId,
agent_type,
model,
name: sanitizedName,
color,
team_name: teamName,
plan_mode_required,
},
};
}For terminal-based spawning, the system constructs a CLI command:
cd {cwd} && \
CLAUDECODE=1 \
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 \
{cli_path} \
--agent-id "{name}@{team}" \
--agent-name "{name}" \
--team-name "{team}" \
--agent-color "{color}" \
--parent-session-id "{session_id}" \
[--plan-mode-required] \
[--agent-type "{type}"] \
[--dangerously-skip-permissions | --permission-mode {mode}] \
[--model {model}]This command is sent to either:
- A new tmux pane (split in current window for iTerm2)
- A new tmux window in a dedicated session
function getInboxPath(agentName: string, teamName?: string): string {
const team = teamName || getCurrentTeamName() || "default";
const sanitizedTeam = sanitizeForPath(team);
const sanitizedAgent = sanitizeForPath(agentName);
const inboxDir = path.join(TEAMS_DIR, sanitizedTeam, "inboxes");
return path.join(inboxDir, `${sanitizedAgent}.json`);
}Each inbox is a JSON array of messages:
[
{
"from": "team-lead",
"text": "Please implement the auth module",
"summary": "Implement auth module",
"timestamp": "2025-01-15T10:30:00.000Z",
"color": "cyan",
"read": false
},
{
"from": "researcher",
"text": "{\"type\":\"shutdown_request\",\"requestId\":\"shutdown-1234@researcher\",\"from\":\"team-lead\"}",
"timestamp": "2025-01-15T11:00:00.000Z",
"color": "magenta",
"read": true
}
]function writeToMailbox(recipientName: string, message: MailboxMessage, teamName?: string) {
// Ensure inbox directory exists
ensureInboxDir(teamName);
const inboxPath = getInboxPath(recipientName, teamName);
const lockPath = `${inboxPath}.lock`;
// Create inbox file if it doesn't exist
if (!fs.existsSync(inboxPath)) {
fs.writeFileSync(inboxPath, "[]", "utf-8");
}
let unlock;
try {
// Acquire file lock (critical for concurrent access!)
unlock = lockfile.lockSync(inboxPath, { lockfilePath: lockPath });
// Read existing messages
const messages = readMailbox(recipientName, teamName);
// Append new message with read: false
messages.push({ ...message, read: false });
// Write back
fs.writeFileSync(inboxPath, JSON.stringify(messages, null, 2), "utf-8");
} catch (err) {
log(`Failed to write to inbox for ${recipientName}: ${err}`);
} finally {
if (unlock) unlock();
}
}function readMailbox(agentName: string, teamName?: string): MailboxMessage[] {
const inboxPath = getInboxPath(agentName, teamName);
if (!fs.existsSync(inboxPath)) return [];
try {
const content = fs.readFileSync(inboxPath, "utf-8");
return JSON.parse(content);
} catch (err) {
log(`Failed to read inbox for ${agentName}: ${err}`);
return [];
}
}
function readUnreadMessages(agentName: string, teamName?: string): MailboxMessage[] {
return readMailbox(agentName, teamName).filter(m => !m.read);
}function markMessagesAsRead(agentName: string, teamName?: string) {
const inboxPath = getInboxPath(agentName, teamName);
if (!fs.existsSync(inboxPath)) return;
const lockPath = `${inboxPath}.lock`;
let unlock;
try {
unlock = lockfile.lockSync(inboxPath, { lockfilePath: lockPath });
const messages = readMailbox(agentName, teamName);
const updated = messages.map(m => ({ ...m, read: true }));
fs.writeFileSync(inboxPath, JSON.stringify(updated, null, 2), "utf-8");
} finally {
if (unlock) unlock();
}
}
function markMessageAsReadByIndex(agentName: string, teamName: string, index: number) {
const inboxPath = getInboxPath(agentName, teamName);
if (!fs.existsSync(inboxPath)) return;
const lockPath = `${inboxPath}.lock`;
let unlock;
try {
unlock = lockfile.lockSync(inboxPath, { lockfilePath: lockPath });
const messages = readMailbox(agentName, teamName);
if (index < 0 || index >= messages.length) return;
if (messages[index].read) return;
messages[index] = { ...messages[index], read: true };
fs.writeFileSync(inboxPath, JSON.stringify(messages, null, 2), "utf-8");
} finally {
if (unlock) unlock();
}
}function clearMailbox(agentName: string, teamName?: string) {
const inboxPath = getInboxPath(agentName, teamName);
if (!fs.existsSync(inboxPath)) return;
fs.writeFileSync(inboxPath, "[]", "utf-8");
}Messages from teammates are rendered as XML tags for injection into the conversation:
function renderMessagesForConversation(messages: MailboxMessage[]): string[] {
return messages.map(msg => {
const colorAttr = msg.color ? ` color="${msg.color}"` : "";
const summaryAttr = msg.summary ? ` summary="${msg.summary}"` : "";
return `<teammate_message teammate_id="${msg.from}"${colorAttr}${summaryAttr}>
${msg.text}
</teammate_message>`;
});
}Protocol messages are sent as JSON-serialized strings inside regular mailbox messages. The receiver parses the text field to detect protocol types.
function createShutdownRequest(args: {
requestId: string;
from: string;
reason?: string;
}): ShutdownRequest {
return {
type: "shutdown_request",
requestId: args.requestId,
from: args.from,
reason: args.reason,
timestamp: new Date().toISOString(),
};
}function createShutdownApproved(args: {
requestId: string;
from: string;
paneId?: string;
backendType?: string;
}): ShutdownApproved {
return {
type: "shutdown_approved",
requestId: args.requestId,
from: args.from,
timestamp: new Date().toISOString(),
paneId: args.paneId,
backendType: args.backendType,
};
}function createShutdownRejected(args: {
requestId: string;
from: string;
reason: string;
}): ShutdownRejected {
return {
type: "shutdown_rejected",
requestId: args.requestId,
from: args.from,
reason: args.reason,
timestamp: new Date().toISOString(),
};
}function createIdleNotification(agentName: string, details?: {
idleReason?: string;
summary?: string;
completedTaskId?: string;
completedStatus?: string;
failureReason?: string;
}): IdleNotification {
return {
type: "idle_notification",
from: agentName,
timestamp: new Date().toISOString(),
idleReason: details?.idleReason,
summary: details?.summary,
completedTaskId: details?.completedTaskId,
completedStatus: details?.completedStatus,
failureReason: details?.failureReason,
};
}function createTaskCompleted(agentName: string, taskId: string, taskSubject: string) {
return {
type: "task_completed",
from: agentName,
taskId,
taskSubject,
timestamp: new Date().toISOString(),
};
}interface PermissionRequest {
type: "permission_request";
request_id: string;
agent_id: string;
tool_name: string;
tool_use_id: string;
description: string;
input: any;
permission_suggestions: string[];
}
interface PermissionResponse {
type: "permission_response";
request_id: string;
subtype: "success" | "error";
response?: {
updated_input: any;
permission_updates: any;
};
error?: string;
}interface PlanApprovalResponse {
type: "plan_approval_response";
requestId: string;
approved: boolean;
feedback?: string; // Only when rejected
timestamp: string;
permissionMode?: string; // Only when approved — mode to switch to
}function isShutdownRequest(text: string): ShutdownRequest | null {
try {
const parsed = JSON.parse(text);
const result = ShutdownRequestSchema.safeParse(parsed);
return result.success ? result.data : null;
} catch { return null; }
}
function isShutdownApproved(text: string): ShutdownApproved | null {
try {
const parsed = JSON.parse(text);
const result = ShutdownApprovedSchema.safeParse(parsed);
return result.success ? result.data : null;
} catch { return null; }
}
// Similar for: isPlanApprovalRequest, isPermissionRequest, isIdleNotification, etc.pending → running → completed
→ failed
→ killed
Within "running":
active ↔ idle
awaitingPlanApproval (substatus)
shutdownRequested (flag)
// All use a common updater pattern:
function updateTeammateTask(taskId: string, setAppState: SetAppState, updater: (task) => task) {
setAppState(state => {
const task = state.tasks[taskId];
if (!task || task.type !== "in_process_teammate") return state;
const updated = updater(task);
if (updated === task) return state; // no change
return { ...state, tasks: { ...state.tasks, [taskId]: updated } };
});
}
function markTeammateIdle(taskId: string, setAppState: SetAppState) {
updateTeammateTask(taskId, setAppState, (task) => {
if (task.status !== "running" || task.isIdle) return task;
return { ...task, isIdle: true };
});
}
function markTeammateActive(taskId: string, setAppState: SetAppState) {
updateTeammateTask(taskId, setAppState, (task) => {
if (task.status !== "running" || !task.isIdle) return task;
return { ...task, isIdle: false };
});
}
function markTeammateAwaitingPlanApproval(taskId: string, setAppState: SetAppState) {
updateTeammateTask(taskId, setAppState, (task) => {
if (task.status !== "running" || task.awaitingPlanApproval) return task;
return { ...task, awaitingPlanApproval: true };
});
}
function clearTeammatePlanApproval(taskId: string, setAppState: SetAppState) {
updateTeammateTask(taskId, setAppState, (task) => {
if (!task.awaitingPlanApproval) return task;
return { ...task, awaitingPlanApproval: false };
});
}
function requestTeammateShutdown(taskId: string, setAppState: SetAppState) {
updateTeammateTask(taskId, setAppState, (task) => {
if (task.status !== "running" || task.shutdownRequested) return task;
return { ...task, shutdownRequested: true };
});
}
function completeTeammateTask(taskId: string, result: any, setAppState: SetAppState) {
let unregisterCleanup;
updateTeammateTask(taskId, setAppState, (task) => {
if (task.status !== "running") return task;
unregisterCleanup = task.unregisterCleanup;
return { ...task, status: "completed", result, endTime: Date.now() };
});
unregisterCleanup?.();
}
function failTeammateTask(taskId: string, error: string, setAppState: SetAppState) {
let unregisterCleanup;
updateTeammateTask(taskId, setAppState, (task) => {
if (task.status !== "running") return task;
unregisterCleanup = task.unregisterCleanup;
return { ...task, status: "failed", error, endTime: Date.now() };
});
unregisterCleanup?.();
}
function injectUserMessageToTeammate(taskId: string, message: string, setAppState: SetAppState) {
updateTeammateTask(taskId, setAppState, (task) => {
if (["completed", "killed", "failed"].includes(task.status)) {
log(`Dropping message for task ${taskId}: status is "${task.status}"`);
return task;
}
return {
...task,
pendingUserMessages: [...task.pendingUserMessages, message],
messages: [...(task.messages ?? []), createUserMessage({ content: message })],
};
});
}
function appendTeammateMessage(taskId: string, message: any, setAppState: SetAppState) {
updateTeammateTask(taskId, setAppState, (task) => {
if (task.status !== "running") return task;
return { ...task, messages: [...(task.messages ?? []), message] };
});
}function killTeammate(taskId: string, setAppState: SetAppState): boolean {
let killed = false;
let teamName = null;
let agentId = null;
setAppState(state => {
const task = state.tasks[taskId];
if (!task || task.type !== "in_process_teammate") return state;
teamName = task.identity.teamName;
agentId = task.identity.agentId;
// Abort the controller
task.abortController.abort();
task.unregisterCleanup?.();
killed = true;
task.onIdleCallbacks?.forEach(cb => cb());
// Remove from teamContext
let teamContext = state.teamContext;
if (teamContext?.teammates && agentId) {
const { [agentId]: _, ...rest } = teamContext.teammates;
teamContext = { ...teamContext, teammates: rest };
}
return {
...state,
teamContext,
tasks: {
...state.tasks,
[taskId]: {
...task,
status: "killed",
endTime: Date.now(),
onIdleCallbacks: [],
},
},
};
});
// Also remove from team config file
if (teamName && agentId) {
removeFromTeamConfig(teamName, agentId);
}
return killed;
}When a teammate finishes a turn, the system sends an automatic notification:
function enqueueTeammateNotification(
taskId: string,
identity: TeammateIdentity,
status: "completed" | "failed" | "killed" | "idle",
errorMessage?: string,
setAppState: SetAppState,
) {
const name = identity.agentName;
const statusMessage =
status === "completed" ? `Teammate "${name}" completed their task.` :
status === "failed" ? `Teammate "${name}" failed: ${errorMessage}` :
status === "killed" ? `Teammate "${name}" was stopped.` :
`Teammate "${name}" is idle and ready for new work.`;
const outputFile = getOutputFile(taskId);
// Format as XML for injection into conversation
const notification = `<task_notification>
<task_id>${taskId}</task_id>
<output_file>${outputFile}</output_file>
<status>${status}</status>
<message>${statusMessage}</message>
</task_notification>
Read the output file to retrieve the result: ${outputFile}`;
// Inject as a "task-notification" user message
injectMessage({ value: notification, mode: "task-notification" }, setAppState);
// Mark as notified
updateTeammateTask(taskId, setAppState, (t) => ({ ...t, notified: true }));
}// Generate agent ID: "name@team"
function createAgentId(name: string, teamName: string): string {
return `${name}@${teamName}`;
}
// Parse agent ID back to components
function parseAgentId(agentId: string): { agentName: string; teamName: string } | null {
const idx = agentId.indexOf("@");
if (idx === -1) return null;
return { agentName: agentId.slice(0, idx), teamName: agentId.slice(idx + 1) };
}
// Generate unique request ID: "type-timestamp@target"
function createRequestId(type: string, target: string): string {
return `${type}-${Date.now()}@${target}`;
}
// Get team directory path
function getTeamDir(teamName: string): string {
return path.join(TEAMS_DIR, sanitize(teamName));
}
// Read team config
function readTeamConfig(teamName: string): TeamConfig | null {
const configPath = path.join(getTeamDir(teamName), "config.json");
if (!fs.existsSync(configPath)) return null;
try {
return JSON.parse(fs.readFileSync(configPath, "utf-8"));
} catch (err) {
log(`Failed to read team file for ${teamName}: ${err}`);
return null;
}
}
// Write team config
function writeTeamConfig(teamName: string, config: TeamConfig) {
const dir = getTeamDir(teamName);
fs.mkdirSync(dir, { recursive: true });
const configPath = path.join(dir, "config.json");
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
}
// Remove member by agent ID
function removeMemberByAgentId(teamName: string, agentId: string): boolean {
const config = readTeamConfig(teamName);
if (!config) return false;
const idx = config.members.findIndex(m => m.agentId === agentId);
if (idx === -1) return false;
config.members.splice(idx, 1);
writeTeamConfig(teamName, config);
return true;
}
// Remove member by name
function removeTeammateFromConfig(teamName: string, query: { agentId?: string; name?: string }): boolean {
const identifier = query.agentId || query.name;
if (!identifier) return false;
const config = readTeamConfig(teamName);
if (!config) return false;
const originalLength = config.members.length;
config.members = config.members.filter(m => {
if (query.agentId && m.agentId === query.agentId) return false;
if (query.name && m.name === query.name) return false;
return true;
});
if (config.members.length === originalLength) return false;
writeTeamConfig(teamName, config);
return true;
}
// Set member mode
function setMemberMode(teamName: string, memberName: string, mode: string): boolean {
const config = readTeamConfig(teamName);
if (!config) return false;
const member = config.members.find(m => m.name === memberName);
if (!member) return false;
if (member.mode === mode) return true;
config.members = config.members.map(m =>
m.name === memberName ? { ...m, mode } : m
);
writeTeamConfig(teamName, config);
return true;
}
// Set member active/idle (async version)
async function setMemberActive(teamName: string, memberName: string, isActive: boolean) {
const dir = getTeamDir(teamName);
const configPath = path.join(dir, "config.json");
let config;
try {
config = JSON.parse(await fs.promises.readFile(configPath, "utf-8"));
} catch {
log(`Cannot set member active: team ${teamName} not found`);
return;
}
const member = config.members.find(m => m.name === memberName);
if (!member) return;
if (member.isActive === isActive) return;
member.isActive = isActive;
await fs.promises.mkdir(dir, { recursive: true });
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
}
// Ensure unique name within team
function ensureUniqueNameInTeam(name: string, teamName: string): string {
const config = readTeamConfig(teamName);
if (!config) return name;
const existing = new Set(config.members.map(m => m.name.toLowerCase()));
if (!existing.has(name.toLowerCase())) return name;
let counter = 2;
while (existing.has(`${name}-${counter}`.toLowerCase())) counter++;
return `${name}-${counter}`;
}
// Get hidden pane IDs (for UI)
function getHiddenPaneIds(teamName: string): string[] {
return readTeamConfig(teamName)?.hiddenPaneIds ?? [];
}
function isPaneHidden(teamName: string, paneId: string): boolean {
return getHiddenPaneIds(teamName).includes(paneId);
}Teammates can run in different permission modes, set at spawn time or changed dynamically:
| Mode | Description |
|---|---|
default |
Standard behavior, prompts for dangerous operations |
acceptEdits |
Auto-accept file edit operations |
bypassPermissions |
Bypass all permission checks (requires flag) |
plan |
Planning mode — cannot execute, must get approval from leader |
delegate |
Leader restricted to only Teammate + Task tools |
dontAsk |
Don't prompt, deny if not pre-approved |
When a leader enters delegate mode, they can ONLY use:
- TeamCreateTool / TeamDeleteTool / SendMessageTool
- TaskCreate / TaskGet / TaskUpdate / TaskList
All other tools (Bash, Read, Write, Edit, etc.) are blocked.
When plan_mode_required: true:
- Teammate enters plan mode automatically
- Teammate creates a plan but cannot execute
- Leader receives a plan approval request via mailbox
- Leader approves/rejects via
SendMessage(type: "plan_approval_response") - On approval, teammate switches to execution mode
You are in delegate mode for team "{teamName}". In this mode, you can ONLY use:
- TeammateTool: For spawning teammates, sending messages, and team coordination
- TaskCreate: For creating new tasks
- TaskGet: For retrieving task details
- TaskUpdate: For updating task status and adding comments
- TaskList: For listing all tasks
You CANNOT use any other tools (Bash, Read, Write, Edit, etc.) until you exit delegate mode.
Task list location: {taskListPath}
Focus on coordinating work by creating tasks, assigning them to teammates, and monitoring progress.
You are running in non-interactive mode and cannot return a response to the user until your team is shut down.
You MUST shut down your team before preparing your final response:
1. Use requestShutdown to ask each team member to shut down gracefully
2. Wait for shutdown approvals
3. Use the cleanup operation to clean up the team
4. Only then provide your final response to the user
1. Create a team with TeamCreate
2. Create tasks using Task tools
3. Spawn teammates using Task tool with team_name and name parameters
4. Assign tasks using TaskUpdate with owner
5. Teammates work and go idle between turns
6. Shutdown via SendMessage with type: "shutdown_request"
The three team tools are lazily loaded and conditionally registered:
// Lazy loaders
const getTeamCreateTool = () => require("./TeamCreateTool").TeamCreateTool;
const getTeamDeleteTool = () => require("./TeamDeleteTool").TeamDeleteTool;
const getSendMessageTool = () => require("./SendMessageTool").SendMessageTool;
// Tool list builder
function getAllTools(): Tool[] {
return [
TaskTool, // Extended with teammate spawning
BashTool,
ReadTool,
WriteTool,
EditTool,
GlobTool,
GrepTool,
// ... other standard tools ...
// Team tools: only when feature flag is enabled
...(isTeamsEnabled() ? [
getTeamCreateTool(),
getTeamDeleteTool(),
getSendMessageTool(),
] : []),
];
}
// In delegate mode, filter to only allowed tools
const DELEGATE_ALLOWED_TOOLS = new Set([
"TeamCreate", "TeamDelete", "SendMessage",
"TaskCreate", "TaskGet", "TaskUpdate", "TaskList",
]);
function getToolsForMode(permissionContext, customTools) {
const allTools = [...getBuiltInTools(permissionContext), ...customTools];
if (permissionContext.mode === "delegate") {
return allTools.filter(t => DELEGATE_ALLOWED_TOOLS.has(t.name));
}
return allTools;
}- File system setup: Create
~/.your-agent/teams/and~/.your-agent/tasks/directories - Name sanitization: Implement
sanitize()for team names and agent names - Agent ID format:
{name}@{team}with parser and builder - Team config CRUD: Read/write/update
config.jsonwith member management - Feature flag: Gate all team functionality behind a feature flag
- TeamCreateTool: Create team, initialize config, register leader
- TeamDeleteTool: Validate no active members, clean up everything
- Task tool extension: Detect
name+team_nameparams, spawn teammate
- Mailbox system: File-based inbox with JSON array format
- File locking: Use
lockfileor similar for concurrent access - Write/read/mark-read: Core mailbox operations
- SendMessageTool: DM, broadcast, shutdown protocol, plan approval
- Message injection: Convert mailbox messages to conversation turns
- In-process spawn: AbortController, task registration, agent execution loop
- State machine: running/idle/awaitingPlanApproval/shutdownRequested/completed/failed/killed
- Idle notifications: Automatic notification when teammate turn ends
- Kill/cleanup: Abort controller, remove from config, clean up resources
- Terminal spawn (optional): tmux pane/window creation with CLI flags
- Permission modes: plan, delegate, acceptEdits, bypassPermissions
- Plan approval flow: Request → approval/rejection via mailbox
- Worktree management: Git worktree creation/cleanup per teammate
- Delegate mode: Restrict leader to coordination-only tools
- Name deduplication: Auto-suffix when duplicate names in same team
- File locking is essential — multiple agents write to the same inbox files concurrently
- Protocol messages are JSON inside message text — receivers must try-parse every message
- Idle is normal — teammates go idle after every turn, this is expected behavior
- Shutdown is graceful — always request → approve → cleanup, never force-kill without trying graceful first
- Messages are NOT visible in plain text — agents must use SendMessage tool, regular text output is not seen by other agents
- Task lists are 1:1 with teams — creating a team automatically creates its task namespace
- Only the leader spawns teammates — in-process teammates cannot spawn sub-teammates