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, a working ⇄ idle wake loop) that the code has not adopted. What actually shipped is the member-centric DAG in Agent Team (Implementation)team_create declares the whole DAG up front, team_replan recovers from failure, team_send_message delivers, and members deliver through the generic complete. 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.

PrimitiveWhat it isA container of what
TeamNamespace + member rosterScoping container for members and tasks
MemberAddressable actorOwner of a mailbox
TaskUnit of workExists 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:

StateMeaningEntry triggerExit trigger
spawningCreated, first turn not yet startedteam_member_addFirst turn starts
workingCurrently has one active turn runningmailbox write + waketurn ends naturally / abort
idleNo active turn, waiting for new messagesturn ends and mailbox clearedmailbox receives a wake-triggering message
shutdownTerminal, no longer wakesshutdown 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:

KindTriggers wakeSemantics
user_messageYesThe user sent a message in the conversation (usually to the Lead)
textYesThe sender actively chooses to interrupt the recipient — it judges this worth the recipient’s immediate attention
framework_alertYesAn objective anomaly — SLA timeout, task failure, shutdown protocol
task_notificationNoRoutine progress, only enqueued, read together at the next wake
member_status_changedNoA state change, an intermediate state, only enqueued

Core semantics:

  1. 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.
  2. 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).
  3. 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.
  4. 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 (teamId is 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_complete triggers the framework to check downstream tasks, promoting any blocked / pending task whose dependencies are “all completed” to claimable
  • 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

CurrentTriggerNext
(none)team_member_addspawning
spawningfirst turn startsworking
workingturn ends naturally + mailbox still has unreadworking (immediately start next turn)
workingturn ends naturally + mailbox clearedidle
idlemailbox receives a wake-triggering kind messageworking (wake)
workingabort / shutdown protocol completeshutdown
idleshutdown protocol completeshutdown

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.

sequenceDiagram actor Sender participant FW as Framework participant Box as Mailbox participant Stream as Member Turn participant LLM Note over Sender,LLM: ① Write phase Sender->>FW: send_message(to=alice, kind, content) FW->>Box: persist unread FW-->>Sender: { delivered: true } Note over FW,Stream: ② Wake decision FW->>FW: check whether kind is wake-triggering alt kind does not trigger wake (task_notification etc.) Note right of FW: enqueue, read together at next wake else kind triggers wake FW->>FW: check alice.status alt status === "idle" FW->>FW: launchNextTurn(alice) else status === "working" Note right of FW: do nothing — the re-check<br/>when the current turn ends will catch it end end Note over Stream,LLM: ③ Turn starts FW->>Stream: load transcript Stream->>Box: consumeUnread(alice) → mark consumedAt Box-->>Stream: messages with new content Stream->>Stream: assemble prompt<br/>(transcript + <team-mailbox/> wrapper + real input) Stream->>LLM: completion LLM-->>Stream: response Note over Stream,Box: ④ Turn ends + re-check Stream->>Box: SELECT unread WHERE toMemberId=alice alt still has unread (wake-triggering or not) Stream->>FW: continueTurn(alice) Note right of Stream: immediately enter the next turn else cleared Stream->>FW: set status to "idle" end

A few semantic details:

  1. 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.
  2. 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.
  3. 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_message is 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

ToolCallerEffect
team_createLeadCreate an empty team (just metadata + the Lead itself), return teamId
team_member_addLeadAdd one member, with initialPrompt. Callable many times at any moment (not batched)
team_member_shutdownLeadShut down a single member (via the cooperative shutdown protocol)
team_dissolveLeadEmergency brake: abort all members, mark the team dissolved. Not needed on the happy path
team_statusLead + MemberSnapshot (overview of team + members + tasks)

Communication and tasks

ToolCallerEffect
send_messageLead + MemberWrite to the recipient’s mailbox. to = member name / "lead" / "*" (broadcast); kind defaults to text (triggers wake)
task_createLead + MemberCreate a task in the current team’s task list (title, description, dependencies?, owner?)
task_updateLead + MemberChange owner / status / fields; this = assignment (task_update({ taskId, owner: yourName }) expresses claim semantics)
task_completeownerMark 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, not team_message — emphasizing it is not team-only, any conversation can use it
  • There is no claim tool — express it with task_update({ owner: yourName, status: "in_progress" }), which is more explicit semantically
  • task_complete is separate from task_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 shapeKey propertyModel mechanism
Single-member short taskOne member, one turn produces a resultinitialPrompt starts → turn → idle / shutdown
Multi-member independent parallelN members, no coordination, each deliversspawn N, each runs its own turn, Lead accumulates progress via task_notification
Multi-member cross-duration collaborationMembers exist across multiple turns, Lead adjusts direction midwayidle ⇄ working loop + send_message(text) follow-up
Dependency-graph workflowTasks have X → Y dependencies, the system advances automaticallytask dependencies + refreshTaskStatuses + automatic mailbox notification
Threshold / SLA triggerAn external condition (time, state) writes an alertframework_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:

QuestionAnswerTool
Does the operator stay on screen to wait?Yes + workers independenttask
Does the operator stay on screen to wait?Yes + stages need coordinationteam (optional)
Does the operator stay on screen to wait?Noteam

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:

  1. 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.
  2. 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.
  3. A member-archetype library. team_member_add’s initialPrompt is 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.
  4. 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.

  • Multi-Agent — the boundary decision framework between task and team (the prerequisite for this doc)
  • Agent Team (Implementation) — how this aspirational model lands as the current implementation
Was this page helpful?