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
| Scenario | Approach |
|---|---|
| Quick one-shot generation | task tool (subagent) |
| Independent research or analysis | task tool (subagent) |
| Code review across multiple modules | Agent Team |
| Complex project with sequential phases | Agent Team |
Architecture Overview
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’sdependsOn. The framework topologically sorts, rejects cycles, and launches members; a member auto-runs once its upstream dependencies completeteam_dissolve— Lead tears the team downteam_replan— Lead’s failure recovery: atomicallycancelfailed members and/oraddnew 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 snapshotteam_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
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:
- SecurityReviewer runs immediately (no
dependsOn) → reads the auth code → writes findings to a workspace file → calls the genericcomplete({ summary, paths })tool to deliver (the same stop tool a normal agent uses; branched onctx.team.memberId) - SecurityReviewer completing re-scans the DAG — once all three reviewers finish, Reporter unblocks and runs
- PerfAnalyzer and TestReviewer work in parallel throughout
- SecurityReviewer discovers a token leak → calls
team_send_message({ to: "broadcast", content: "Found exposed refresh token in /auth/callback" }) - Other members receive this in their next
prepareStepand 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.teamset, nomemberId):team_create/team_dissolve/team_replanproceed;team_statuslong-polls until an event. - Member (
ctx.team.memberIdset):team_create/team_dissolve/team_replanthrow;team_statusreturns a plain snapshot; delivery goes through the genericcompletetool.
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
dependsOnstaysblocked— 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
| Property | Lead Agent | Team Member |
|---|---|---|
| Prompt | MAIN_INSTRUCTIONS + Lead team-tool guide | TEAM_INSTRUCTIONS + Member team-tool guide |
| History | Full conversation | Its prompt (task brief) only |
| Sandbox | The task workspace | The same sandbox + workspace, 1:1 |
| Tool block | Same 5 team tools as Member | Same 5 team tools as Lead |
| Lead-only ops | team_create / team_dissolve / team_replan | Throw if called |
| Delivery | Writes the final user response | Delivers via the generic complete tool |
team_status | Long-polls until an event | Plain snapshot |
| User interaction | Yes (ask_user_question, confirm) | No (not in the member’s toolKeys) |
| Nested agents | Yes (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.
| Source | Trigger | Store Action |
|---|---|---|
team_create output | Team + members created | initTeam() |
team_replan output | Members cancelled / added | updateTeam() |
team_status output | LLM checks status | updateTeam() (full refresh) |
team_dissolve output | Team dissolved | dissolveTeam() |
Backend TOOL_STREAM events | Member status change | updateMember() |
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.