Skip to content

Instantly share code, notes, and snippets.

@sorrycc
Created February 7, 2026 04:31
Show Gist options
  • Select an option

  • Save sorrycc/4702f258f3d505495f4d5d984576a08d to your computer and use it in GitHub Desktop.

Select an option

Save sorrycc/4702f258f3d505495f4d5d984576a08d to your computer and use it in GitHub Desktop.

TeammateTool Implementation Guide

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.


Table of Contents

  1. Architecture Overview
  2. Data Structures & Schemas
  3. File System Layout
  4. Tool 1: TeamCreateTool
  5. Tool 2: TeamDeleteTool
  6. Tool 3: SendMessageTool
  7. Teammate Spawning (via Task Tool)
  8. Mailbox System (Inter-Agent Messaging)
  9. Protocol Messages
  10. In-Process Teammate Lifecycle
  11. Team Config File Management
  12. Permission Modes
  13. System Prompts for Team Coordination
  14. Tool Registration
  15. Implementation Checklist

1. Architecture Overview

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

Key Concepts

  • 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

Flow

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

2. Data Structures & Schemas

Team Config File Schema

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
}

TeamCreateTool Input Schema

// 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").'
  ),
});

TeamDeleteTool Input Schema

// Empty object — uses current team context
const TeamDeleteInput = z.strictObject({});

SendMessageTool Input Schema

// 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(),
  }),
]);

Task Tool Extended Schema (for teammate spawning)

// 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(),
});

3. File System Layout

~/.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

Name Sanitization

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, "-");
}

4. Tool 1: TeamCreateTool

Purpose

Create a new team. Creates the config file, task list directory, and registers the leader.

Implementation

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,
    },
  };
}

Permissions

  • Always allowed ({ behavior: "allow" })
  • Only enabled when teams feature flag is on (p8())
  • Not concurrency-safe
  • Not read-only

5. Tool 2: TeamDeleteTool

Purpose

Clean up all team resources: worktrees, team config directory, task list directory.

Implementation

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}`);
  }
}

6. Tool 3: SendMessageTool

Purpose

All inter-agent communication: DMs, broadcasts, shutdown protocol, plan approval.

Implementation

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);
  }
}

Direct Message Handler

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,
      },
    },
  };
}

Broadcast Handler

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,
    },
  };
}

Shutdown Request Handler

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,
    },
  };
}

Shutdown Approval Handler

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,
    },
  };
}

Validation Rules

  • recipient must not be empty
  • shutdown_response with approve: false requires content (reason)
  • Only team lead can approve/reject plans

7. Teammate Spawning (via Task Tool)

Detection Logic

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...
}

Backend Selection

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);
}

In-Process Spawn (primary backend)

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,
    },
  };
}

Terminal Spawn (iTerm2 / Tmux)

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

8. Mailbox System (Inter-Agent Messaging)

Inbox File Path

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`);
}

Inbox File Format

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
  }
]

Write to Mailbox (with file locking)

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();
  }
}

Read Mailbox

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);
}

Mark as Read (with locking)

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();
  }
}

Clear Mailbox

function clearMailbox(agentName: string, teamName?: string) {
  const inboxPath = getInboxPath(agentName, teamName);
  if (!fs.existsSync(inboxPath)) return;
  fs.writeFileSync(inboxPath, "[]", "utf-8");
}

Message Rendering (for injection into conversation)

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>`;
  });
}

9. Protocol Messages

Protocol messages are sent as JSON-serialized strings inside regular mailbox messages. The receiver parses the text field to detect protocol types.

Shutdown Request

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(),
  };
}

Shutdown Approved

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,
  };
}

Shutdown Rejected

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(),
  };
}

Idle Notification

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,
  };
}

Task Completed

function createTaskCompleted(agentName: string, taskId: string, taskSubject: string) {
  return {
    type: "task_completed",
    from: agentName,
    taskId,
    taskSubject,
    timestamp: new Date().toISOString(),
  };
}

Permission Request / Response (for plan mode)

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;
}

Plan Approval Request / Response

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
}

Protocol Detection

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.

10. In-Process Teammate Lifecycle

State Machine

pending → running → completed
                  → failed
                  → killed

Within "running":
  active ↔ idle
  awaitingPlanApproval (substatus)
  shutdownRequested (flag)

State Update Functions

// 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] };
  });
}

Killing a Teammate

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;
}

Idle Notification

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 }));
}

11. Team Config File Management

Core Functions

// 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);
}

12. Permission Modes

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

Delegate Mode

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.

Plan Mode for Teammates

When plan_mode_required: true:

  1. Teammate enters plan mode automatically
  2. Teammate creates a plan but cannot execute
  3. Leader receives a plan approval request via mailbox
  4. Leader approves/rejects via SendMessage(type: "plan_approval_response")
  5. On approval, teammate switches to execution mode

13. System Prompts for Team Coordination

Delegate Mode Prompt (injected when leader enters delegate 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.

Non-Interactive Team Shutdown Reminder

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

Team Workflow (from TeamCreate prompt)

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"

14. Tool Registration

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;
}

15. Implementation Checklist

Phase 1: Foundation

  • 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.json with member management
  • Feature flag: Gate all team functionality behind a feature flag

Phase 2: Core Tools

  • TeamCreateTool: Create team, initialize config, register leader
  • TeamDeleteTool: Validate no active members, clean up everything
  • Task tool extension: Detect name + team_name params, spawn teammate

Phase 3: Messaging

  • Mailbox system: File-based inbox with JSON array format
  • File locking: Use lockfile or 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

Phase 4: Teammate Lifecycle

  • 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

Phase 5: Advanced Features

  • 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

Critical Implementation Notes

  1. File locking is essential — multiple agents write to the same inbox files concurrently
  2. Protocol messages are JSON inside message text — receivers must try-parse every message
  3. Idle is normal — teammates go idle after every turn, this is expected behavior
  4. Shutdown is graceful — always request → approve → cleanup, never force-kill without trying graceful first
  5. Messages are NOT visible in plain text — agents must use SendMessage tool, regular text output is not seen by other agents
  6. Task lists are 1:1 with teams — creating a team automatically creates its task namespace
  7. Only the leader spawns teammates — in-process teammates cannot spawn sub-teammates
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment