Teams & Swarms
How Claude Code spins up multi-agent teams, routes messages through mailboxes, syncs permissions across workers, and tears everything down cleanly when the job is done.
How Claude Code spins up multi-agent teams, routes messages through mailboxes, syncs permissions across workers, and tears everything down cleanly when the job is done.
A swarm is a named team of Claude agents that share a config file, a task list, and a file-based mailbox. One agent is the team lead — it creates the team, spawns teammates, assigns tasks, and gracefully shuts everything down. Every other agent is a teammate.
The feature is opt-in via the CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 environment variable and is surfaced through two first-class tools: TeamCreate and TeamDelete.
When the lead calls TeamCreate, the tool performs these steps synchronously before returning:
It calls readTeamFile(team_name). If the name already exists, a new random generateWordSlug() is substituted so names never collide.
A TeamFile JSON is written to ~/.claude/teams/<sanitized-name>/config.json. It seeds the members array with the lead's own entry (name: "team-lead").
Calls resetTaskList() and ensureTasksDir() so task numbering starts fresh at 1 for this team. Also calls setLeaderTeamName() so subsequent task reads go to the right directory.
Sets appState.teamContext with the team name, file path, lead agent ID, and an initial teammates map. This is what the UI reads to show the team status badge.
Adds the team to an in-memory Set via registerTeamForSessionCleanup(). If the session exits without an explicit TeamDelete, the shutdown hook cleans up orphaned directories automatically.
call() methodasync call(input, context) { const { setAppState, getAppState } = context const appState = getAppState() // Guard: one team per leader at a time const existingTeam = appState.teamContext?.teamName if (existingTeam) { throw new Error( `Already leading team "${existingTeam}". Use TeamDelete first.` ) } // Deduplicate name const finalTeamName = generateUniqueTeamName(team_name) const leadAgentId = formatAgentId(TEAM_LEAD_NAME, finalTeamName) const teamFile: TeamFile = { name: finalTeamName, createdAt: Date.now(), leadAgentId, leadSessionId: getSessionId(), members: [{ agentId: leadAgentId, name: TEAM_LEAD_NAME, // "team-lead" agentType: leadAgentType, model: leadModel, joinedAt: Date.now(), tmuxPaneId: '', cwd: getCwd(), subscriptions: [], }], } await writeTeamFileAsync(finalTeamName, teamFile) registerTeamForSessionCleanup(finalTeamName) await resetTaskList(taskListId) await ensureTasksDir(taskListId) setLeaderTeamName(sanitizeName(finalTeamName)) setAppState(prev => ({ ...prev, teamContext: { teamName: finalTeamName, teamFilePath, leadAgentId, teammates: { [leadAgentId]: { name: TEAM_LEAD_NAME, color: assignTeammateColor(leadAgentId), spawnedAt: Date.now(), ... } } } })) }
The swarm writes everything under ~/.claude/. Nothing is stored globally or in the project repo.
type TeamFile = { name: string // canonical team name description?: string createdAt: number // epoch ms leadAgentId: string // "team-lead@my-team" leadSessionId?: string // session UUID of the leader hiddenPaneIds?: string[] // pane IDs moved to hidden window teamAllowedPaths?: TeamAllowedPath[] // shared "always allow" edit rules members: Array<{ agentId: string // "researcher@my-team" name: string // "researcher" — used for SendMessage agentType?: string model?: string prompt?: string color?: string planModeRequired?: boolean joinedAt: number tmuxPaneId: string // "%3" tmux pane or iTerm session UUID cwd: string worktreePath?: string // git worktree if isolated sessionId?: string // Claude session UUID subscriptions: string[] backendType?: 'tmux' | 'iterm2' | 'in-process' isActive?: boolean // false = idle, undefined/true = running mode?: PermissionMode }> }
Agent IDs always follow the pattern agentName@teamName, e.g. researcher@my-team. The team lead's ID is deterministically team-lead@my-team. Teammates always address each other by name (not ID) when calling SendMessage.
When a teammate is spawned, the backend registry automatically selects the best execution engine. The priority order is fixed and runs once per session.
| Backend | When Selected | Pane Visibility | Hide/Show | Kill method |
|---|---|---|---|---|
| tmux | Inside a tmux session (highest priority) OR tmux available as fallback | Split panes in the same window; external claude-swarm session if not inside tmux |
Yes — break-pane to hidden session |
kill-pane -t <paneId> |
| iterm2 | In iTerm2 with it2 CLI available, and user hasn't chosen tmux preference |
Native vertical split panes inside current window | No — not supported | it2 session close -f -s <id> |
| in-process | Non-interactive (-p flag), explicit --teammate-mode in-process, or no pane backend available |
Rendered inline by InProcessTeammateTask component | N/A | Abort via AbortController |
registry.ts detectAndGetBackend()// Priority order (first match wins, cached for the session) // 1. Running INSIDE tmux? Always use tmux. if (await isInsideTmux()) { return { backend: createTmuxBackend(), isNative: true } } // 2. In iTerm2 with it2 CLI? Use iTerm2 (unless user prefers tmux). if (isInITerm2()) { if (!getPreferTmuxOverIterm2()) { const it2Available = await isIt2CliAvailable() if (it2Available) { return { backend: createITermBackend(), isNative: true } } } // Fallback to tmux as external session if tmux is installed if (await isTmuxAvailable()) { return { backend: createTmuxBackend(), isNative: false, needsIt2Setup: true } } } // 3. Standalone terminal with tmux installed if (await isTmuxAvailable()) { return { backend: createTmuxBackend(), isNative: false } } // 4. Nothing available → throw with platform-specific install instructions throw new Error(getTmuxInstallInstructions())
Detection uses environment variables captured at module load time — TMUX, TMUX_PANE, TERM_PROGRAM, and ITERM_SESSION_ID — so the shell or later code overwriting them has no effect.
The it2 availability check intentionally runs it2 session list (not it2 --version) because --version exits 0 even when the iTerm2 Python API is disabled, which would cause pane splits to fail silently later.
Inside tmux (leader is in a pane): the first teammate splits the leader's pane horizontally with -l 70% so the leader keeps 30% of the window. Additional teammates split from existing teammate panes alternating vertical/horizontal, then select-layout main-vertical rebalances the right side.
Outside tmux (standalone terminal): a separate tmux server is created on a PID-scoped socket (claude-swarm-<pid>). A claude-swarm session with a swarm-view window is created. Teammates are laid out with a tiled layout. The user can attach to this session with tmux -L claude-swarm-<pid> attach.
// Internal tmux socket name prevents conflicts between multiple Claude instances export function getSwarmSocketName(): string { return `claude-swarm-${process.pid}` }
Pane creation is serialized via a promise-based mutex (acquirePaneCreationLock()) to prevent race conditions when multiple teammates are spawned in parallel. After creating each pane, the backend sleeps 200 ms to allow the shell (zsh/bash, oh-my-zsh, etc.) to finish initializing before sending the Claude command.
The iTerm2 backend tracks pane session UUIDs in a module-level array. When spawning additional teammates, it targets the last-known session UUID via it2 session split -s <uuid>.
If a user closes a pane manually (Cmd+W), the next spawn will fail with a non-zero exit code. The backend confirms the session is truly dead by running it2 session list and checking for the UUID. If it's missing, it prunes the stale ID from the array and retries. This loop is bounded — each iteration shrinks the array by one, so it terminates at worst in N+1 iterations.
while (true) { const splitResult = await runIt2(splitArgs) if (splitResult.code !== 0 && targetedTeammateId) { const listResult = await runIt2(['session', 'list']) if (!listResult.stdout.includes(targetedTeammateId)) { teammateSessionIds.splice(idx, 1) // prune dead session continue // retry with next-to-last } throw new Error(...) // systemic failure — surface it } break }
In-process teammates run inside the leader's Node.js process. They share the API client and MCP connections but get fully isolated identity context via AsyncLocalStorage — each teammate has its own agent name, team name, color, and AbortController.
Spawn flow:
spawnInProcessTeammate() creates a TeammateContext and an independent AbortController (not linked to the leader's), registers an InProcessTeammateTaskState in AppState.tasks, and returns.InProcessBackend.spawn() then calls startInProcessTeammate() as a fire-and-forget — the agent loop runs on the event loop in the background.abortController.abort() rather than killing a process, and the task status transitions to 'killed'.// Teammates get an independent abort controller — interrupting the leader // does NOT abort active teammates. const abortController = createAbortController() // Strip parent messages — teammates start with an empty conversation toolUseContext: { ...this.context, messages: [] }
For pane-backed teammates (tmux/iTerm2), the backend uses buildInheritedCliFlags() and buildInheritedEnvVars() to construct the shell command that starts a new Claude instance inside the pane.
// Flags always forwarded if applicable: '--dangerously-skip-permissions' // if bypassPermissions mode active '--permission-mode acceptEdits' // if acceptEdits mode active '--model <model>' // if set via CLI '--settings <path>' // if set via CLI '--plugin-dir <dir>' // for each inline plugin '--teammate-mode <mode>' // so nested spawns use same mode '--chrome / --no-chrome' // if set explicitly // Env vars always set: CLAUDECODE=1 CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 CLAUDE_CODE_AGENT_COLOR=<color> // assigned color for pane border // Env vars forwarded if present in parent process: CLAUDE_CODE_USE_BEDROCK CLAUDE_CODE_USE_VERTEX ANTHROPIC_BASE_URL CLAUDE_CONFIG_DIR CLAUDE_CODE_REMOTE HTTPS_PROXY ...
Plan mode is special: if planModeRequired=true, bypass-permissions flags are not propagated even if the leader has them set. Safety trumps convenience.
Every agent — regardless of backend — communicates through a file-based mailbox. Messages are JSON files written to ~/.claude/teams/<team>/inbox/<agent-name>/. The leader polls these inboxes and delivers messages as new conversation turns.
The lead never manually "checks" its inbox. The inbox poller wakes on file-system events and delivers queued messages as synthetic user turns. If the leader is mid-turn, messages queue and are delivered when the current turn ends.
| Type | Direction | Purpose |
|---|---|---|
| plain text | any → any | Task updates, questions, results |
| shutdown_request | lead → teammate | Graceful shutdown signal |
| idle notification | teammate → lead | System-generated after every turn end |
| permission_request | worker → lead | Worker needs UI approval for a tool |
| permission_response | lead → worker | Approval/denial of tool use |
| mode_set_request | lead → teammate | Change teammate's permission mode |
| sandbox_permission_request | worker → lead | Worker needs network access approval |
Teammates should never send raw JSON objects like {"type":"idle",...} as user messages. Only the system sends structured message types. Plain agent-to-agent communication is always natural language.
When a worker agent encounters a tool-use it doesn't have permission for, it can escalate to the team lead via the mailbox rather than silently failing. The leader sees the request in its UI (the standard ToolUseConfirm dialog) and approves or denies it.
type SwarmPermissionRequest = { id: string // "perm-<ts>-<random>" workerId: string // "researcher@my-team" workerName: string workerColor?: string teamName: string toolName: string // e.g. "Edit" toolUseId: string description: string // human-readable summary input: Record<string, unknown> permissionSuggestions: unknown[] status: 'pending' | 'approved' | 'rejected' resolvedBy?: 'worker' | 'leader' resolvedAt?: number feedback?: string updatedInput?: Record<string, unknown> permissionUpdates?: unknown[] createdAt: number }
Write operations on the pending directory use a .lock file with proper-lockfile semantics so concurrent workers don't corrupt each other's requests.
For in-process teammates, permission prompts surface directly in the leader's terminal UI rather than going through the file system. The bridge module (leaderPermissionBridge.ts) is a module-level registry that stores the React setter functions the REPL registers at startup:
// In leaderPermissionBridge.ts — no React dependency let registeredSetter: SetToolUseConfirmQueueFn | null = null export function registerLeaderToolUseConfirmQueue(setter): void { registeredSetter = setter } export function getLeaderToolUseConfirmQueue() { return registeredSetter }
The in-process runner calls getLeaderToolUseConfirmQueue() when it needs to present a confirmation dialog, bypassing the file-based permission system entirely.
TeamCreate (creates config.json + tasks dir)TaskCreateAgent tool (tmux/iTerm2/in-process)TaskList, claim unassigned tasksSendMessage{"type":"shutdown_request"} to each teammate's mailboxisActive: falseTeamDelete — cleans dirs, kills panes, clears AppStateTeamDelete (SIGINT, crash), the shutdown hook calls cleanupSessionTeams()TeamDelete takes no input — the team name comes from appState.teamContext.teamName. It refuses to run if any member still has isActive !== false, forcing the lead to shut down teammates gracefully first.
call() methodasync call(_input, context) { const teamName = getAppState().teamContext?.teamName if (teamName) { const teamFile = readTeamFile(teamName) const nonLeadMembers = teamFile.members .filter(m => m.name !== TEAM_LEAD_NAME) const activeMembers = nonLeadMembers .filter(m => m.isActive !== false) // idle = false, running = true/undefined if (activeMembers.length > 0) { return { success: false, message: `Cannot cleanup: ${memberNames} still active` } } await cleanupTeamDirectories(teamName) // teams/ + tasks/ + git worktrees unregisterTeamForSessionCleanup(teamName) clearTeammateColors() clearLeaderTeamName() } setAppState(prev => ({ ...prev, teamContext: undefined, inbox: { messages: [] }, // flush queued messages })) }
The cleanupTeamDirectories() function first reads the config to collect worktreePath entries, then destroys each git worktree with git worktree remove --force (falling back to rm -rf if that fails), and finally removes the ~/.claude/teams/ and ~/.claude/tasks/ directories.
Reads appState.teamContext.teammates and counts non-lead members. Renders a pill in the footer showing N teammates. When selected, pressing Enter opens TeamsDialog. Returns null when no teammates are active (zero teammates = no rendering cost).
Two-level navigation: list view shows all teammates with their status, mode, and active/idle state; detail view lets the lead cycle the teammate's permission mode (default → acceptEdits → bypassPermissions → plan), kill the teammate, or jump to its tmux/iTerm2 pane. Refreshes on a 1-second interval to pick up mode changes written to config.json by teammates.
In list view, pressing the cycle-mode key calls cycleAllTeammateModes() which uses setMultipleMemberModes() for an atomic multi-write, preventing the TOCTOU issue of sequential single writes.
In detail view, it cycles only the selected teammate via setMemberMode(), then sends a mode_set_request message to the teammate's mailbox so the running process is notified without a file-watch poll delay.
// Atomic update of all member modes in one config.json write setMultipleMemberModes(teamName, [ { memberName: 'researcher', mode: 'acceptEdits' }, { memberName: 'tester', mode: 'acceptEdits' }, ])
TeamCreate creates a matching task directory under ~/.claude/tasks/, and sanitizeName(teamName) is the shared key used by both lead and teammates to find tasks.TeammateExecutor interface abstracts tmux, iTerm2, and in-process. The registry detects and caches the right backend once per session; callers never check process.env.TMUX directly.TMUX, TMUX_PANE, and ITERM_SESSION_ID are read at import time into module-level constants. This is intentional — the shell later overwrites TMUX for its own socket, and the system must not confuse that with "user is inside tmux."isActive: false to config.json after every turn. The lead must not send shutdown or react with alarm — idle teammates wake normally when a new message arrives in their mailbox.isActive !== false to decide if a member is still running. Always shut teammates down (shutdown_request → approve → isActive=false) before calling TeamDelete.registerTeamForSessionCleanup() ensures that even if the session crashes without an explicit TeamDelete, orphaned panes and directories are cleaned up on graceful shutdown.1. Which backend is selected when Claude is launched from inside an existing tmux session, even if iTerm2 is the terminal emulator?
2. A teammate has finished its assigned tasks and called TaskUpdate to mark them complete. The lead sees the member listed in the team config with isActive: false. What does this mean?
3. Where does TeamCreate store the team's configuration file?
~/.claude/sessions/<team-name>/config.json
~/.claude/agents/<team-name>.json
~/.claude/teams/<sanitized-name>/config.json
<project-cwd>/.claude/team.json
4. Why does isIt2CliAvailable() run it2 session list instead of it2 --version?
--version is not a valid flag for the it2 CLI
--version exits 0 even when the iTerm2 Python API is disabled, so a later session split would fail silently
session list is faster because it uses a cached result
5. When the team lead session exits via Ctrl-C (SIGINT) without calling TeamDelete, what happens to orphaned teammate panes and directories?
cleanupSessionTeams() kills pane-backed teammate processes first, then removes team and task directories
6. A teammate running in tmux mode has planModeRequired: true. The lead session has bypassPermissions active. What happens when the teammate's spawn command is built?
--dangerously-skip-permissions is included because the lead has bypass active
--dangerously-skip-permissions is not included — plan mode takes precedence over bypass permissions
--dangerously-skip-permissions and --plan-mode-required are included