Agent Team (Implementation)

Multi-agent collaboration with shared task lists, inter-member messaging, and a real-time Team Card UI

What Problem Does Agent Team Solve?

The existing task tool spawns one-shot subagents: they receive a prompt, work in isolation, deliver artifacts, and die. No coordination, no communication, no shared state. This works for independent subtasks but falls apart when the work requires multiple agents to collaborate — sharing discoveries, sequencing dependent tasks, or adjusting strategy based on what others find.

Agent Team introduces a persistent, coordinated multi-agent session: a Lead agent spawns specialized members that work in parallel, communicate through messages, and progress through a shared task list with dependency resolution.

When to Use Each

ScenarioApproach
Quick one-shot generationtask tool (subagent)
Independent research or analysistask tool (subagent)
Code review across multiple modulesAgent Team
Complex project with sequential phasesAgent Team

Architecture Overview

Agent Team — Server-Side Architecture ① setupTeamForLead() → team-registry { coordinator, execution } singleton ② createTools(leadContext) no memberId → executes as Lead team_create team_dissolve team_replan team_status team_send_message ↓ Lead Agent (kind: "main") ⑤ createTools(memberContext) has memberId → executes as Member team_create team_dissolve team_replan team_status team_send_message ↓ Member Agents (kind: "team") ③ state ③ team_create / team_replan / team_dissolve ⑥ team_send_message · deliver via complete TeamCoordinator Task Deps Message Routing Lifecycle Auto-unblock TeamRepository PG / InMemory TeamExecutionService runAgentLoop prepareStep AbortController TOOL_STREAM consumeMessages UI ④ launchMember → memberContext Member A shared sandbox Member B shared sandbox Member C shared sandbox ⑦ deliver via complete Execution Flow ① setupTeamForLead → registerTeamServices(...) + ctx.team = {} ② runAgentLoop(leadContext) → createTools() → SAME 5 tools, executed as Lead ③ LLM calls team_create → getTeamServices() lookup → coord state + execution.launchMember ④ launchMember → memberContext.team = { memberId, teamId } (identity only) ⑤ runAgentLoop(memberContext) → createTools() again → SAME 5 tools, executed as Member ⑥⑦ Members → coord: team_send_message, team_status (snapshot); deliver via complete

Agent Team runs within the existing TaskOrchestrator flow — it does not have its own orchestrator. During the setup phase, setupTeamForLead() registers the team services in the process-wide team-registry (idempotent) and marks ctx.team = {} on the Lead context; the team tools then operate inside the standard agent loop alongside filesystem, execute, etc. and look up the services via getTeamServices() at the call site. If "team" is not in the agent’s tool list, setup is skipped — zero overhead.

Three Services

TeamCoordinator — the brain. Manages team lifecycle, resolves task dependencies, and routes messages between members. Backed by TeamRepository (PG for server, in-memory for tests). When a task completes, the coordinator automatically unblocks downstream tasks whose dependencies are now satisfied.

TeamExecutionService — the muscle. Launches and manages concurrent member agent streams via runAgentLoop. Each member gets its own RuntimeContext that shares the Lead’s sandbox + workspace 1:1 (no per-member subdirectory), the TEAM_INSTRUCTIONS prompt (kind: "team"), and a prepareStep hook that injects incoming messages before each LLM call. Tracks AbortController per member for graceful shutdown on dissolution.

team.tool.ts — the interface. Five AI SDK tool wrappers, the same set for both Lead and Member, that bridge LLM calls to the coordinator and execution service. The model is member-centric DAG: no UUIDs surface to the LLM (everything is by member name), and no tool takes a teamId (the framework resolves the one active team from context):

  • team_create — Lead declares the entire DAG in one shot: members plus each member’s dependsOn. The framework topologically sorts, rejects cycles, and launches members; a member auto-runs once its upstream dependencies complete
  • team_dissolve — Lead tears the team down
  • team_replan — Lead’s failure recovery: atomically cancel failed members and/or add new ones (downstream of a failed member stays blocked — the framework does not auto-unblock)
  • team_status — snapshot of team state (member-centric, no UUIDs). Lead long-polls until an event; Member gets a plain snapshot
  • team_send_message — send to a member name, "Lead", or "broadcast" (shared)

Members deliver their result through the generic complete tool (branched on ctx.team?.memberId), not a team-specific tool. The toolset is identical across roles so the model’s tool block stays cache-stable across the team lifecycle; role enforcement happens inside each execute (calling team_create from a Member throws, team_replan is Lead-only, etc.).

End-to-End Flow: A Concrete Scenario

Agent Team — Lifecycle Spawn → Assign → Monitor → Dissolve ① Create Lead calls team_create → Coordinator creates team + auto-registers Lead member DAG declared in one shot; each member is a concurrent stream: shares Lead's sandbox, TEAM_INSTRUCTIONS, prepareStep → Client: initTeam(store) — Team Card appears in chat stream ② Assign + Work Dependencies come from team_create's dependsOn → auto-resolve: blocked → in_progress → completed Members auto-run when upstreams complete → execute with tools → deliver via the generic complete tool → Client: updateMember / updateTeam via TOOL_STREAM kind:"team" ③ Monitor + Synthesize Lead long-polls team_status (waitForChangeMs default 30s) → structured progress snapshot team_send_message for course corrections; synthesize all results into final deliverable → Client: updateTeam(store) — Team Card refreshes silently; inline shows "Refreshed" ack ④ Dissolve Lead calls team_dissolve → Execution stops all members (AbortController.abort()) Coordinator marks all members completed, updates team status to "completed" → Client: dissolveTeam(store) — terminal state, auto-cleanup after 5 minutes

User message: “Review the security, performance, and test coverage of our auth module.”

The main agent — the same one that handles every user message — receives this request. If the tier has "team" enabled, the agent’s tool set includes the 5 team tools alongside read_file, execute, etc. The agent reads the request, judges it needs three specialists working in parallel, and decides on its own to call team_create. From this moment on, this main agent acts as the Lead of the team — it’s not a new entity, just a new role for the same agent.

① Create — Lead Spawns the Team

The Lead declares the entire DAG in one team_create call — each member carries its prompt (task) and an optional dependsOn list of upstream member names:

team_create({
  name: "Auth Module Review",
  members: [
    { name: "SecurityReviewer", agentType: "code-reviewer", prompt: "Review auth module for vulnerabilities..." },
    { name: "PerfAnalyzer",     agentType: "code-reviewer", prompt: "Profile auth endpoints for bottlenecks..." },
    { name: "TestReviewer",     agentType: "code-reviewer", prompt: "Audit test coverage for auth module..." },
    { name: "Reporter", agentType: "general", prompt: "Synthesize a summary report from the three reviews...",
      dependsOn: ["SecurityReviewer", "PerfAnalyzer", "TestReviewer"] }
  ]
})

Behind the scenes: the framework topologically sorts the DAG (rejecting cycles), the coordinator creates the team + member records, and the execution service launches all members via runAgentLoop as concurrent agent streams. Every member shares the Lead’s sandbox + workspace 1:1 (coordination is by filename, not by isolation) and runs the TEAM_INSTRUCTIONS prompt. Members without dependsOn (the three reviewers) start working immediately; a member with dependsOn (Reporter) spawns but stays blocked, auto-running once its upstreams complete. Members see the same 5 team tools as the Lead, but team_create / team_dissolve / team_replan throw when called by a Member; members also have task / ask_user_question / confirm excluded from their toolKeys (no nested subagents, no user interaction).

At this point, the Team Card appears in the user’s chat showing team status.

② Dependency resolution — the DAG runs itself

There is no separate task-creation step: the dependency graph is the member list from team_create. The coordinator resolves it automatically — the three reviewers have no dependsOn, so they run in parallel; Reporter stays blocked until all three complete, then its stream auto-unblocks and runs. Each member’s task status walks blocked → in_progress → completed (or failed), and a completion re-scans downstream members to unblock any whose dependencies are now satisfied.

③ Work — Members Execute Autonomously and Deliver via complete

Members don’t wait for instructions — each runs its own agent loop. Before each LLM step, the prepareStep hook checks the coordinator for new messages (from the Lead or other members) and injects them as <system-reminder> text.

A typical member flow:

  1. SecurityReviewer runs immediately (no dependsOn) → reads the auth code → writes findings to a workspace file → calls the generic complete({ summary, paths }) tool to deliver (the same stop tool a normal agent uses; branched on ctx.team.memberId)
  2. SecurityReviewer completing re-scans the DAG — once all three reviewers finish, Reporter unblocks and runs
  3. PerfAnalyzer and TestReviewer work in parallel throughout
  4. SecurityReviewer discovers a token leak → calls team_send_message({ to: "broadcast", content: "Found exposed refresh token in /auth/callback" })
  5. Other members receive this in their next prepareStep and adjust accordingly

If a member fails, the framework does not auto-unblock its downstream — the Lead recovers with team_replan (cancel the failed member and/or add replacements with a new dependsOn graph).

④ Monitor — Lead Long-Polls for Progress

While members work, the Lead calls team_status() (no teamId — the framework resolves the active team from context) and the call blocks on the server until any state change (member status change, completion, message) — or up to the default long-poll window. The returned structured data is member-centric (names, no UUIDs):

{
  "members": [
    { "name": "SecurityReviewer", "status": "in_progress", "dependsOn": [] },
    { "name": "PerfAnalyzer", "status": "in_progress", "dependsOn": [] },
    { "name": "TestReviewer", "status": "completed", "dependsOn": [] },
    { "name": "Reporter", "status": "blocked", "dependsOn": ["SecurityReviewer", "PerfAnalyzer", "TestReviewer"] }
  ],
  "pendingMessages": 1
}

If someone is stuck, Lead sends a team_send_message with guidance. The Team Card in the user’s chat updates in real-time through TOOL_STREAM events — the user sees progress without Lead having to say anything.

⑤ Synthesize — the Deliverable

The Reporter member (which depends on the three reviewers) auto-runs once they finish, synthesizes their deliverables from the shared workspace, and delivers via complete. The Lead reads the final state from team_status and writes the response to the user: “Here are the findings from the auth module review: 3 security issues, 2 performance bottlenecks, 87% test coverage…” (For simpler teams with no synthesis member, the Lead reads members’ artifacts directly and synthesizes itself.)

⑥ Dissolve — Cleanup

Lead calls team_dissolve. All member streams are stopped (AbortController.abort()), the coordinator marks the team as "completed", and the Team Card shows the terminal state. The Zustand store auto-cleans the entry after 5 minutes.

How It Works Under the Hood

The team tools use the same createTools() function. The shape of the returned object is identical for Lead and Member — both get the same 5 tools, keyed by the same names. What differs is runtime behavior inside each execute:

  • Lead (ctx.team set, no memberId): team_create / team_dissolve / team_replan proceed; team_status long-polls until an event.
  • Member (ctx.team.memberId set): team_create / team_dissolve / team_replan throw; team_status returns a plain snapshot; delivery goes through the generic complete tool.

Service instances (coordinator, execution) are NOT carried on ctx.team — they’re process-wide singletons resolved via getTeamServices() from the team-registry. The context only carries per-call identity (memberId).

The execution service builds each member’s RuntimeContext (reusing the Lead’s sandbox) and launches it through runAgentLoop with kind: "team", which selects TEAM_INSTRUCTIONS instead of MAIN_INSTRUCTIONS. See the architecture diagram for the full component map.

Key Mechanisms

Task Dependency Resolution

Each member is a node in the DAG declared at team_create; the coordinator resolves member status automatically:

blocked → in_progress → completed (or failed / cancelled)

  • A member with unfinished dependsOn stays blocked — its stream is spawned but parked
  • When all its upstreams complete, it auto-unblocks and runs — no manual claim step
  • On completion, the coordinator re-scans downstream members and unblocks any now satisfied
  • A member failure does not auto-unblock its downstream; recovery is the Lead’s team_replan

Member Communication

Members receive messages through the prepareStep hook — not through a separate channel. Before each LLM call, the hook checks the coordinator’s mailbox, consumes unread messages, and injects them as <system-reminder> text. Each member works its own assigned task (its DAG node) — there is no task-picking. This reuses the existing reminder mechanism with zero new infrastructure.

Context Isolation

PropertyLead AgentTeam Member
PromptMAIN_INSTRUCTIONS + Lead team-tool guideTEAM_INSTRUCTIONS + Member team-tool guide
HistoryFull conversationIts prompt (task brief) only
SandboxThe task workspaceThe same sandbox + workspace, 1:1
Tool blockSame 5 team tools as MemberSame 5 team tools as Lead
Lead-only opsteam_create / team_dissolve / team_replanThrow if called
DeliveryWrites the final user responseDelivers via the generic complete tool
team_statusLong-polls until an eventPlain snapshot
User interactionYes (ask_user_question, confirm)No (not in the member’s toolKeys)
Nested agentsYes (task)No (not in the member’s toolKeys)

Client-Side

Team Card

The key UX insight: a team should appear as one evolving entity in the chat, not a series of redundant status snapshots. team_create renders the one and only team card, subscribed to useTeamStore. All other tools render minimal inline badges. team_status renders just an ack (“Status refreshed”) while silently refreshing the store.

Data Channels

Data flows from backend to frontend through two channels:

Tool Output (synchronous) — When the LLM calls a team tool, the output is returned as a standard ToolUIPart. The tool’s React component writes to useTeamStore via useEffect. Handles team_create, team_replan, team_status, team_dissolve.

DataPartEvent (asynchronous push) — When a member’s status changes in the background, TeamExecutionService pushes a TOOL_STREAM event with kind: "team". The client’s use-task-events.ts detects chunk.kind === "team" and dispatches to useTeamStore. The Team Card re-renders instantly.

SourceTriggerStore Action
team_create outputTeam + members createdinitTeam()
team_replan outputMembers cancelled / addedupdateTeam()
team_status outputLLM checks statusupdateTeam() (full refresh)
team_dissolve outputTeam dissolveddissolveTeam()
Backend TOOL_STREAM eventsMember status changeupdateMember()

Database

Tables cascade from the parent team table. There is no separate task table — each member row is its task node:

  • team — one per team session, references the parent conversation task
  • team_member — each spawned member (including the auto-registered Lead); carries the member’s prompt, dependsOn, status, and result
  • team_message — inter-member messages with read tracking

Member status transitions (blocked → in_progress → completed) use conditional updates for atomicity — a member never double-runs.

Tier Gating

Agent Team is controlled by the "team" config key, currently enabled only for the ultra tier. The "team" key maps to 5 tool names (team_create, team_dissolve, team_replan, team_status, team_send_message). Admin can enable it for other tiers via the Settings UI.

Was this page helpful?