Agent Team Coordination Model
A mailbox-centric multi-agent coordination model — design philosophy, primitives, and tool surface
Aspirational model — not the shipped implementation. This describes a mailbox-centric actor design (per-member mailbox,
send_message/task_create/task_update/task_complete/team_member_add, aworking ⇄ idlewake loop) that the code has not adopted. What actually shipped is the member-centric DAG in Agent Team (Implementation) —team_createdeclares the whole DAG up front,team_replanrecovers from failure,team_send_messagedelivers, and members deliver through the genericcomplete. Read this page as design intent / north-star, not current behaviour — the shipped details are in Agent Team (Implementation).
Inside a team: the mailbox is the only channel
Inside a team — how the Lead and members coordinate, how messages get delivered, whether a member is alive or dead after finishing a round, how background progress reaches the operator when the Lead isn’t watching — these “how a team runs on the inside” questions are what this page answers. (When to reach for a team versus a single task is a different, outer boundary; see Multi-Agent.)
The core of the answer is a single thing: all coordination inside a team goes through the mailbox — no shared memory, no event bus, no long-poll. What follows is the three design ideas that hold this up, then the four primitives, the state machine, and the tool surface. The whole page states the model abstractly (primitives, state, actions, semantics), without binding to a specific storage / process / network choice.
Design philosophy
Idea 1: A message is the next turn
An agent does not “hang and wait.” There is no abstraction of “an agent in a dormant state, waiting to be woken by an event.”
Every LLM call is a new turn, with context rebuilt from the persistent mailbox. A member finishes one round of work → its result enters the Lead’s mailbox as a message → when the Lead’s next turn starts, this message is injected at the head of the turn as a user-role message, and the Lead naturally reads it, reasons about it, and responds.
This idea dissolves a pseudo-problem: “after an agent’s turn ends, how do we let it keep thinking?” The answer is: don’t let it keep thinking — let the next turn naturally process the accumulated messages. From the LLM’s perspective, the agent has no “interrupt” and “resume”; it is freshly born every time, but the mailbox + transcript provide continuity.
This idea also directly defines how the team value proposition “the operator’s session lifetime < the work duration” is realized — not by keeping one long stream alive, but by any turn being able to cold-start from persistent state.
Idea 2: A member is a long-lived addressable actor
A member is not a stream, it is an actor.
If a member’s lifetime equals the lifetime of its stream, that forces a side-effect contract: “a member must deliver before the turn ends, otherwise the task auto-fails.” This contract is a product of the stream model, not the essence of the work.
Real work often looks like “do one round, show the Lead, the Lead adjusts direction, do another round.” After a member finishes one round of work it enters the idle state; logically it is still alive, still addressable, with its own mailbox. When the Lead later wants to follow up, change criteria, or add information, it directly send_messages to that member, which wakes up and runs the next round on top of the existing transcript.
“Idle” is a first-class citizen in the state machine, not “dead but with a record remaining.” In implementation terms, idle could be an OS process hanging around, or stream-exit + cold-start — the model does not prescribe this. The model only prescribes that from the caller’s perspective, the member is always there.
Idea 3: Team / Member / Task are three orthogonal primitives
No two of them should be welded together inside one tool’s discriminated union.
| Primitive | What it is | A container of what |
|---|---|---|
| Team | Namespace + member roster | Scoping container for members and tasks |
| Member | Addressable actor | Owner of a mailbox |
| Task | Unit of work | Exists independently; just happens to be associated with a member via the owner field |
Cramming task operations and team operations into the same tool, welding task into the team tool group — that is a product of coupling, not the essence of the work.
After orthogonalizing: the Task tool group is independent, usable outside a team (tracking 5 odd jobs in a simple chat doesn’t require spinning up a team); member assignment uses task’s owner field, with no need for a “claim” verb; Team’s role narrows to metadata + namespace, no longer the entry point for any action.
Four primitives
Team
Team {
id identity
conversationId identity ← 1:1 mapping to one conversation session
name string
status "active" | "dissolved"
createdAt, dissolvedAt?
}
Team is a scoping container, providing:
- A namespace for a member roster (member names are unique within the same team)
- A namespace for a task list (corresponding to one group of tasks)
- A 1:1 binding to a conversation session — one conversation session has at most one active team
Team itself holds no runtime state. Its “state changes” are entirely derived from the state changes of members / tasks.
Member
Member {
id identity
teamId identity
name string ← human-readable, "Sourcer" / "Lead"
role "lead" | "member"
agentType string ← determines which tools it loads, which system prompt
status "spawning" | "working" | "idle" | "shutdown"
workspace path ← isolated workspace
createdAt, lastTurnAt?
}
A member is a long-lived addressable actor. Its lifecycle is expressed by the status field:
| State | Meaning | Entry trigger | Exit trigger |
|---|---|---|---|
spawning | Created, first turn not yet started | team_member_add | First turn starts |
working | Currently has one active turn running | mailbox write + wake | turn ends naturally / abort |
idle | No active turn, waiting for new messages | turn ends and mailbox cleared | mailbox receives a wake-triggering message |
shutdown | Terminal, no longer wakes | shutdown protocol complete / dissolve | (terminal) |
working ⇄ idle is the core loop. A member being “alive” equals its ability to oscillate between these two states.
The Lead is a special case of a member: role: "lead", name fixed as "Lead", workspace is the team root, responsible for the user-facing synthesis. In every other respect it is identical to an ordinary member — it also has a mailbox (receiving user messages and other members’ task notifications), and it also cycles between working ⇄ idle. From the model’s perspective, the Lead is not a special “coordinator” abstraction; it is simply the member that happens to talk to the conversation user.
Mailbox
MailboxMessage {
id identity
teamId identity
toMemberId identity ← recipient
fromMemberId identity? ← absent means framework injection (user message, task notification)
kind MessageKind ← see table below
content payload ← kind-specific structure
createdAt
consumedAt timestamp? ← absent means unread
}
The mailbox is a per-member ordered message queue. It is the sole communication mechanism for team coordination — there is no “shared memory between members,” no “event bus + subscription,” no “long-poll.” Everything is a message.
The message kind determines whether wake is triggered:
| Kind | Triggers wake | Semantics |
|---|---|---|
user_message | Yes | The user sent a message in the conversation (usually to the Lead) |
text | Yes | The sender actively chooses to interrupt the recipient — it judges this worth the recipient’s immediate attention |
framework_alert | Yes | An objective anomaly — SLA timeout, task failure, shutdown protocol |
task_notification | No | Routine progress, only enqueued, read together at the next wake |
member_status_changed | No | A state change, an intermediate state, only enqueued |
Core semantics:
- Messages drive turns. A wake-triggering kind written to the unread region → an idle recipient wakes immediately; when working, it is consumed during the re-check phase after the turn ends.
- Consume = inject. When a turn starts, the framework wraps all unread mailbox messages in one XML wrapper (
<team-mailbox>...</team-mailbox>) and stuffs them into the turn’s first user-role message, followed by the real input (if any). - Retain after consuming. Mark
consumedAt, do not delete the row. Transcript integrity relies on this — a member’s later turns can look back at which messages it has handled. - Sole delivery channel. The Lead’s work assignment, coordination between members, task completion notifications — all go through the mailbox. There is no “member directly calls a Lead method.”
Task
Task {
id identity
teamId identity? ← optional; a task list can exist without a team
title string
description string
status "pending" | "blocked" | "claimable" | "in_progress" | "completed" | "failed"
owner string? ← member name (not id), convenient for the LLM to reference
dependencies string[]
result payload?
failureReason string?
createdAt, updatedAt
}
Task is orthogonal to team / member:
- A task does not require a team to exist (
teamIdis optional — a standalone conversation can also build a task list) - Owner is a string (member name), not a reference into the member table — any string works (including
"user", meaning user intervention is needed) - The dependency graph advances automatically:
task_completetriggers the framework to check downstream tasks, promoting anyblocked / pendingtask whose dependencies are “all completed” toclaimable - A task state change automatically sends a mailbox notification to the owner (if the owner is a member)
Task serves the scenario of “work that has structure and needs progress tracking,” which is a different thing from “collaboration between agents” — they just frequently appear together.
The relationship of the four primitives
Conversation session
│
│ 1:1
▼
Team
│
├─ members[] ───── each member has its own mailbox and workspace
│ │
│ └─ the Lead is one of the members (role=lead); its mailbox also receives user conversation messages
│
└─ tasks[] ─────── the owner field points to some member's name (or is left empty)
The four primitives are each independent. They reference each other only through identity and name, with no structural coupling.
Member lifecycle and message-driven turns
State machine
| Current | Trigger | Next |
|---|---|---|
| (none) | team_member_add | spawning |
spawning | first turn starts | working |
working | turn ends naturally + mailbox still has unread | working (immediately start next turn) |
working | turn ends naturally + mailbox cleared | idle |
idle | mailbox receives a wake-triggering kind message | working (wake) |
working | abort / shutdown protocol complete | shutdown |
idle | shutdown protocol complete | shutdown |
There is no done state. A member only enters a terminal state on explicit shutdown. This is a direct result of the stateless wake model — since a message can wake an idle member, the member has no “the work is done so it died” semantics, only “nothing to do for now.”
How one message becomes the next turn
This is the most load-bearing path in the whole architecture.
A few semantic details:
- The wake decision only checks status + kind, not mailbox content. “Whether there is something to do” is decided by consumeUnread after the turn starts. This keeps the wake path extremely thin.
- The re-check is necessary. A message X that arrives mid-turn (
status=working→ wake decision skips) would be missed if the mailbox were not re-queried before the turn ends. So before a turn ends naturally, it must query unread once more. - The
<team-mailbox>wrapper teaches the LLM to distinguish “agent / framework reports” from “the user’s words.” The Lead’s system prompt teaches it to summarize-for-user the former rather than thank-and-reply.
Wake rule: the member chooses between send_message vs task_complete
This design pushes the “do I interrupt others” decision onto the sender:
- Finished a routine task → only call
task_complete(writes task_notification, does not trigger wake) - The finished task contains a signal that needs immediate attention →
task_complete+send_message("lead", "...")(the latter writes text, triggers wake)
The sender’s system prompt teaches it this discipline: writing text equals interrupting the recipient once, so spend it carefully. Routine progress accumulates, and the Lead sees it all when it next wakes for some other reason.
The user-message special case: interruption
If the user sends a message while the Lead is working:
user_messageis written into the Lead’s mailbox- The framework additionally aborts the current Lead turn
- The turn-end flow consumes all unread, including the new user message, in one pass, and immediately starts the next turn
This is the “user interruption” semantic, distinguished from “ordinary message queueing” — when the user changes intent they should not have to wait for the current turn to finish.
Concurrency constraint
Per member, only one active turn is allowed at a time. When a second wake signal arrives while a turn is still running, do nothing — the re-check at turn end will catch it.
Tool surface
Split at the granularity of “do one thing = one tool.”
Team / Member lifecycle
| Tool | Caller | Effect |
|---|---|---|
team_create | Lead | Create an empty team (just metadata + the Lead itself), return teamId |
team_member_add | Lead | Add one member, with initialPrompt. Callable many times at any moment (not batched) |
team_member_shutdown | Lead | Shut down a single member (via the cooperative shutdown protocol) |
team_dissolve | Lead | Emergency brake: abort all members, mark the team dissolved. Not needed on the happy path |
team_status | Lead + Member | Snapshot (overview of team + members + tasks) |
Communication and tasks
| Tool | Caller | Effect |
|---|---|---|
send_message | Lead + Member | Write to the recipient’s mailbox. to = member name / "lead" / "*" (broadcast); kind defaults to text (triggers wake) |
task_create | Lead + Member | Create a task in the current team’s task list (title, description, dependencies?, owner?) |
task_update | Lead + Member | Change owner / status / fields; this = assignment (task_update({ taskId, owner: yourName }) expresses claim semantics) |
task_complete | owner | Mark complete + submit artifact; the framework automatically writes a task_notification into the Lead’s mailbox (does not trigger wake) |
Key design choices:
- The communication tool is called
send_message, notteam_message— emphasizing it is not team-only, any conversation can use it - There is no
claimtool — express it withtask_update({ owner: yourName, status: "in_progress" }), which is more explicit semantically task_completeis separate fromtask_update— completion has side effects (automatically writes a mailbox notification + triggers dependency-graph advancement), and a separate tool makes this clearer in the prompt description
What work shapes the model can carry
The model itself is not bound to any concrete business domain. Below are several work shapes the model can carry at the abstract level, along with their corresponding coordination mechanisms:
| Work shape | Key property | Model mechanism |
|---|---|---|
| Single-member short task | One member, one turn produces a result | initialPrompt starts → turn → idle / shutdown |
| Multi-member independent parallel | N members, no coordination, each delivers | spawn N, each runs its own turn, Lead accumulates progress via task_notification |
| Multi-member cross-duration collaboration | Members exist across multiple turns, Lead adjusts direction midway | idle ⇄ working loop + send_message(text) follow-up |
| Dependency-graph workflow | Tasks have X → Y dependencies, the system advances automatically | task dependencies + refreshTaskStatuses + automatic mailbox notification |
| Threshold / SLA trigger | An external condition (time, state) writes an alert | framework_alert kind → triggers Lead wake |
This section only lists the shapes themselves; Multi-Agent walks them through with concrete cases.
One illustration: the minimal trace of a Lead → member long-term collaboration
Written abstractly, free of any business domain:
[op submits a long-duration task]
Lead turn 1:
team_create + team_member_add({ name: "Worker", initialPrompt: "..." })
Worker first turn:
perform one round of work → produce output → task_complete → send_message(lead, "this round's conclusion...")
→ kind=text → written to Lead.mailbox + triggers wake
turn ends → idle
Lead turn 2 (woken by Worker's text):
read mailbox: 1 text + 1 task_notification
write a conversation message to op
decide whether to send_message(Worker, "next round's focus...")
If op is offline at the time:
the conversation message lands in chat history, the operator sees it on their next return
the push channel (if connected) simultaneously pushes a reminder
[op adjusts direction midway]
op → chat input new message
→ user_message into Lead.mailbox + triggers wake (if working, triggers abort)
→ Lead turn: reads it + send_message(Worker, "direction adjustment: ...")
→ Worker receives text → wake → apply new direction → next round → idle
This trace holds for any long-duration Lead-Worker collaboration pattern — swap Worker for “Sourcer”, “OnboardingCoord”, “ResearchBot” and the shape is unchanged. For concrete business patterns see Multi-Agent.
The boundary with task subagents
Multi-Agent’ boundary rules are unchanged. In brief:
| Question | Answer | Tool |
|---|---|---|
| Does the operator stay on screen to wait? | Yes + workers independent | task |
| Does the operator stay on screen to wait? | Yes + stages need coordination | team (optional) |
| Does the operator stay on screen to wait? | No | team |
Team holds in its proper scenario (the operator will leave + the work is long / needs cross-stage coordination). Task is cheaper and more direct in the synchronous, independent, operator-present scenario.
The model’s non-goals
Honestly marking out the problems this model does not attempt to solve:
- Push channels (browser notifications / email / Slack). The model prescribes when the Lead wakes and when it writes a conversation message; how the operator receives that message while offline is a matter for the push infrastructure, decoupled from the model. Any complete product needs push, but push is not part of this model.
- SLA / time triggers. The model prescribes that the Lead wakes when the mailbox receives a
framework_alert, but who writes the alert is a matter for an external cron / scheduler. The model gives the hook point; the outside fills in the concrete rules. - A member-archetype library.
team_member_add’sinitialPromptis free-form text. Turning common roles into declarative archetypes (named prompt templates) can improve stability, but does not affect model semantics — it belongs to prompt engineering optimization, not the coordination mechanism. - Dynamic worker pool. “Spawn workers in bulk, then let them pull work from a shared task queue themselves” is an extension of the task system’s capability — it can be simulated via
task_update owner, and the model introduces no new primitive for it.
These are all work for the landing implementation, not gaps in the model itself.
What to read next
- Multi-Agent — the boundary decision framework between
taskandteam(the prerequisite for this doc) - Agent Team (Implementation) — how this aspirational model lands as the current implementation