learn-claude-code/s13_agent_teams
2026-08-24 20:28:37 +08:00
..
images refactor: streamline the course to 17 lessons 2026-08-12 03:02:42 +08:00
code.py fix: use UTF-8 for text file operations 2026-08-24 20:28:37 +08:00
README.ja.md fix: build task dependencies in two phases 2026-08-19 01:35:08 +08:00
README.md fix: build task dependencies in two phases 2026-08-19 01:35:08 +08:00
README.zh.md fix: build task dependencies in two phases 2026-08-19 01:35:08 +08:00

s13: Agent Teams — Runtime and Coordination Protocols

English · 中文 · 日本語

s01 → ... → s10s13s14 → s15 → s16 → s17

"When one agent cannot hold the whole job, let teammates divide the work." — Persistent teammates, shared task selection, optional worktrees, and coordination protocols.

Harness layer: Team — how multiple agents divide work, share state, and stay under Lead's control.


The Problem

Suppose we ask an agent to refactor an entire backend. The work may cover configuration loading, authentication, and tests. One agent can process those areas sequentially, but it takes longer and earlier details gradually leave its context.

This is a good candidate for parallel work, yet users normally describe the goal rather than design the team:

Refactor this sample backend. Clean up configuration loading,
authentication, and tests, preserve the existing interfaces,
and make sure the tests pass.

The harness has to answer a connected set of questions:

  1. Who decides that parallel work is useful, and who confirms the extra agents?
  2. How does each teammate keep its identity and context across assignments?
  3. How do results return to Lead without asking the model to poll an inbox?
  4. Can an idle teammate pick up ready work without waiting for another assignment?
  5. Which directory should a task use when parallel edits may conflict?
  6. How do shutdown and plan approval become traceable, enforceable protocols?

The Solution

Agent Teams Overview

s13 reuses s10's base tools, hooks, permission checks, and Task System, then adds a Lead-managed team runtime:

  • Lead owns the user conversation, proposes a division of work, and waits for confirmation.
  • Teammates run independent agent loops and alternate between WORK and IDLE.
  • MessageBus carries ordinary messages, results, and control events through file-backed mailboxes.
  • Runtime delivery consumes Lead's mailbox and injects team events into the next turn.
  • The shared task board lets idle teammates find ready work and claim it under a lock.
  • Optional worktrees bind a task to another working directory when the work needs it. Unbound tasks use the normal repository directory.
  • Typed protocols and a plan gate make shutdown and approval state explicit and block mutating tools until a required plan is approved.

Task graph authoring keeps s10's two-phase contract. The Lead first calls create_task for every node, then uses the returned runtime IDs with update_task(addBlockedBy=...) before assigning ready work. Only the Lead receives update_task; teammates can list, claim, and complete tasks but cannot rewrite graph structure while the team is running.

s11 background tasks and s12 scheduled tasks are not carried into this chapter. Neither mechanism is required for teammate communication, task claiming, or plan approval.

These are all parts of the Team harness layer. Teammates do not need a separate loop for task discovery, and a worktree does not create a new kind of agent.


How It Works

1. Lead proposes a team and waits for user confirmation

Starting teammates changes cost, concurrency, and the set of actors that may edit the workspace. Lead's system prompt keeps that boundary visible:

"When parallel work would help, first propose a small team with clear "
"responsibilities and wait for the user's confirmation. Do not call "
"spawn_teammate before the user confirms."

For the first request, Lead only proposes a split:

I suggest three parallel areas:
- config: clean up configuration loading
- auth: refactor authentication
- tests: add regression coverage

I will start the teammates after you confirm.

After the user says "Go ahead," Lead can call spawn_teammate. Lead creates the Task first and passes its initial task_id to the teammate. The user states the goal, Lead designs the team, and the user confirms the execution boundary.

2. Every teammate owns an independent loop

An s06 subagent is a one-shot call. A teammate is a persistent execution unit:

s06 Subagent s13 Teammate
Lifecycle Ends after one call WORK → IDLE → WORK until shutdown
Context Exists for one task Persists across assignments
Communication Returns one result Receives messages and emits events
Coordination One-way delegation Two-way collaboration with Lead

TeammateRuntime gives each teammate its own system prompt, messages, tools, and current Task, then runs its WORK / IDLE loop in a daemon thread. Lead can keep coordinating while teammates work. The names lead and agent are reserved for runtime identities, while MessageBus still accepts lead as the coordinator mailbox.

spawn_teammate claims the initial Task before the thread starts. A failed claim prevents the teammate from starting. Without a Task, workspace and Shell tools ask the teammate to claim one instead of falling back to the repository directory.

3. MessageBus keeps communication outside model context

Lead and teammates cannot share one messages array. Otherwise one teammate's tool results would leak into another teammate's reasoning. MessageBus gives each agent a .mailboxes/<name>.jsonl inbox:

class MessageBus:
    def send(self, from_agent, to_agent, content,
             msg_type="message", metadata=None):
        msg = {
            "from": from_agent,
            "to": to_agent,
            "content": content,
            "type": msg_type,
            "metadata": metadata or {},
        }
        with self._changed:
            MAILBOX_DIR.mkdir(parents=True, exist_ok=True)
            with self._path(to_agent).open("a", encoding="utf-8") as handle:
                handle.write(json.dumps(msg, ensure_ascii=True) + "\n")
            self._changed.notify_all()

    def wait_for_messages(self, agent, timeout=None):
        deadline = None if timeout is None else time.monotonic() + timeout
        with self._changed:
            while not self.peek(agent):
                remaining = (None if deadline is None
                             else deadline - time.monotonic())
                if remaining is not None and remaining <= 0:
                    return []
                self._changed.wait(remaining)
            return self._read_unlocked(agent)

A lock protects mailbox files from concurrent access. A Condition lets the runtime wake a teammate for a message and also supports the short timeout used while IDLE.

4. The runtime delivers inbox events

read_inbox() consumes messages by reading and deleting the mailbox file, so Lead keeps a single consumer, consume_lead_inbox():

def consume_lead_inbox():
    messages = BUS.read_inbox("lead")
    for message in messages:
        if message["type"].endswith("_response"):
            match_response(...)
    return messages

The CLI loop waits for terminal input and Lead's mailbox at the same time. When a message arrives, it consumes the mailbox before starting another Lead turn:

MessageBus → consume_lead_inbox
           → update protocol state
           → inject [Team events] into history
           → start another Lead turn

After spawning a teammate, Lead ends the current turn instead of repeatedly calling list_teammates or get_task. The runtime starts the next turn when a team event arrives.

check_inbox is not a model tool. Message arrival belongs to the runtime; the model handles events after the runtime has delivered them into its context.

5. Result and IDLE are separate events

When a teammate finishes one assignment, the runtime sends two events in order:

result:            "Authentication refactored; related tests pass."
idle_notification: "Waiting for more work."

result answers "What did this assignment produce?" idle_notification answers "Can this teammate accept more work?" One vague "done" cannot represent both facts.

An idle teammate does not exit. A direct message or a ready task returns it to WORK; a shutdown_request starts a graceful shutdown handshake.

6. IDLE checks the mailbox before looking for ready tasks

IDLE gives messages priority, then checks the shared task board:

while True:
    inbox = BUS.wait_for_messages(name, IDLE_SCAN_INTERVAL)
    if inbox:
        should_stop = handle_messages(inbox)
        if should_stop or messages[-1]["role"] == "user":
            break
        continue

    task = claim_next_task(name)
    if task:
        messages.append({
            "role": "user",
            "content": f"[Auto-claimed task {task.id}] {task.subject}",
        })
        break

Shutdown, plan approval, and direct instructions from Lead should arrive before opportunistic work. If there is no message and no ready task, the teammate remains IDLE. A blocked task may become ready after another teammate completes its prerequisite.

7. Discovery and claim are separate, and claim is atomic

Scanning only finds candidates:

def scan_unclaimed_tasks() -> list[Task]:
    return [
        task for task in list_tasks()
        if task.status == "pending"
        and task.owner is None
        and can_start(task.id)
    ]

The list is a snapshot. Another teammate, or another harness process using the same task directory, may see the same task. Ownership changes therefore happen inside claim_task() under task_store_lock(), which combines the in-process lock with a file lock:

def claim_task(task_id: str, owner: str) -> str:
    with task_store_lock():
        task = load_task(task_id)
        if task.status != "pending" or task.owner is not None:
            return "Task is no longer available"
        if _owner_in_progress(owner):
            return "Owner must complete its current task first"
        if not can_start(task_id):
            return "Task is blocked"
        cwd, error = task_worktree_cwd(task)
        if error:
            return f"Cannot claim {task_id}: {error}"
        task.owner = owner
        task.status = "in_progress"
        save_task(task)
        teammate_assignments[owner] = {"task_id": task.id, "cwd": cwd}
        return f"Claimed {task.id}"

Many teammates may discover the same candidate, but only one claim can move it to in_progress. Task files are written through a temporary file and atomically replaced while the same store lock is held. A teammate must also finish its current task before claiming another, and a broken worktree binding fails closed rather than falling back to the repository directory.

8. Claimed work reuses the same WORK loop

After a successful claim, the runtime injects the task ID, subject, and description into the teammate's messages:

ready task appears
  → IDLE teammate discovers it
  → claim_task writes owner and in_progress
  → task enters teammate messages
  → WORK
  → complete_task
  → result + idle_notification
  → IDLE

The teammate uses the same model call, file tools, Shell, plan gate, result reporting, and shutdown protocol as a direct Lead assignment. Task discovery is another entry into the existing WORK loop.

9. The task selects the tools' working directory

Task.worktree is optional:

@dataclass
class Task:
    id: str
    subject: str
    description: str
    status: str
    owner: str | None
    blockedBy: list[str]
    worktree: str | None = None

Lead can create and bind a worktree when separate directories will help:

create_worktree(name="auth-refactor", task_id="task_1a2b3c4d")

create_worktree is a Lead-only tool. It accepts a pending, unowned, unbound task, validates the name, path, branch, and Git registry, creates the checkout, then writes the task binding. If Git reports failure after leaving a branch or registered checkout, the runtime reports a partial operation, leaves the task unbound, and preserves those artifacts for manual recovery. Teammates only see task and file tools.

Claiming the task stores its resolved directory in teammate_assignments; that teammate's bash, read_file, write_file, edit_file, and glob wrappers read the directory from the assignment. A task with no worktree resolves to WORKDIR; a teammate without a claimed Task cannot use those workspace tools:

cwd, error = task_worktree_cwd(task)
if not error:
    teammate_assignments[owner] = {
        "task_id": task.id,
        "cwd": cwd,
    }

complete_task(task_id, owner) checks that the caller owns the in-progress task. Successful completion records the result but keeps the assignment directory selected until that model turn ends. This lets later tool calls in the same response stay in the task's worktree. The runtime releases the assignment when the teammate returns to IDLE; a failed completion keeps it so the teammate can fix the task and try again.

After a restart, assignment_cwd() can rebuild an in-progress assignment from the durable task owner and worktree binding. It also replaces a stale local lease when the same owner has moved to another task. A missing or invalid binding fails closed instead of silently routing work to the repository directory.

A worktree separates Git working directories and branches. It is not a sandbox: Shell commands can still access paths and resources allowed to the parent process.

10. Worktree removal belongs to the host

The model can create a task-bound worktree, but it cannot remove one. Cleanup remains a host helper so the user or host can first inspect task ownership, the assignment lease, and Git status. The helper refuses pending or in-progress task bindings and current-turn leases. Without an explicit destructive choice, tracked, untracked, and ignored files all block removal.

remove_worktree(name, discard_changes=True) is reserved for host code that has already obtained explicit user confirmation. Either removal path retains the wt/<name> branch, including clean local commits with no upstream. A successful removal clears the task binding because the checkout no longer exists.

clean worktree   → host may remove directory and retain wt/<name> branch
changed worktree → user decides how to preserve or discard it
pending/running task → refuse removal

Task completion also stays separate from worktree cleanup. complete_task records the task result; after the teammate reaches IDLE, the user or host can inspect, merge, keep, or remove the worktree.

11. Control messages use types and request IDs

Free-form text works for ordinary collaboration, but shutdown and approval should not depend on guessing intent. They use structured messages:

Team Protocols

@dataclass
class ProtocolState:
    request_id: str
    type: str
    sender: str
    target: str
    status: str
    payload: str
    work_version: int | None = None
    task_id: str | None = None


pending_requests: dict[str, ProtocolState] = {}

The shutdown path is:

Lead creates a pending shutdown request
  → shutdown_request(request_id) enters the teammate inbox
  → the teammate finishes its current step
  → shutdown_response(request_id) returns to Lead
  → request_id locates the original request
  → pending becomes approved and the teammate loop exits

The ID correlates one reply with one request, the type prevents a mismatched reply from changing state, and the status prevents duplicate responses from being applied twice.

12. Plan approval constrains execution

The plan protocol runs in the opposite direction:

Lead → plan_request
teammate → plan_approval_request(request_id, plan)
Lead → plan_approval_response(request_id, approve, feedback)

When Lead already knows that a teammate must plan first, spawn_teammate(..., task_id=task.id, require_plan=True) claims the Task and activates the gate before the teammate thread starts. request_plan can also require a plan from a teammate that is already running.

Tool dispatch enforces the gate:

def _run_teammate_tool(name, block, handlers):
    gate = plan_gates.get(name, "not_required")
    if block.name in {"bash", "write_file", "edit_file"} and gate not in {
        "not_required", "approved"
    }:
        return f"Blocked: plan status is {gate}."
    try:
        return handlers[block.name](**block.input)
    except Exception as error:
        return f"Error: {type(error).__name__}: {error}"

While the state is required, pending, or rejected, the teammate can read files and submit or revise a plan, but it cannot run Shell commands, write files, or edit files. A submitted plan records the teammate's current task and work version. Claiming or releasing a Task changes that version and invalidates the old approval; an ordinary message changes neither the task identity nor the approval state.

Teammates do not read user input from their background threads. A dangerous command or path outside the workspace returns a permission error so Lead can handle the decision with the user.


One Complete Run

s13 >> Put the backend refactor on a shared task board. Clean up
       configuration, authentication, and tests in parallel where possible.
       Use a worktree for authentication, preserve existing interfaces,
       and make sure the tests pass.

Lead: I suggest config, auth, and tests as three areas.
      Shall I start the team?

s13 >> Go ahead.

[task] config created
[task] auth created → worktree auth-refactor
[task] tests created
[claim] alice → config (cwd: repository)
[claim] bob → auth (cwd: .worktrees/auth-refactor)
[teammate] alice spawned
[teammate] bob spawned
[complete] auth
[bus] bob → lead (result) ...
[bus] bob → lead (idle_notification) ...
[wake: 2 team events → new turn]
Lead: I received the authentication result and will coordinate the rest.

The terminal exposes the user request, Lead's proposal, task state, claims, selected directories, results, IDLE transitions, and control events. The user does not have to name a Lead or ask it to check an inbox.


What Changed from s10

Component s10 s13
Agents One agent One Lead plus persistent teammates
User flow Execute the request Propose a team, then confirm startup
Communication None File mailboxes plus runtime delivery
Lifecycle One loop Teammate WORK / IDLE / shutdown
Shared work One agent uses task tools IDLE scan plus atomic teammate claims
Working directory Repository WORKDIR A claimed Task, with an optional worktree
Reporting Current agent output Separate result and idle_notification
Control None Typed shutdown and plan approval protocols
Enforcement No team constraint Required plans gate mutating tools

Try It

cd learn-claude-code
python s13_agent_teams/code.py

Start with an ordinary request:

Put the backend refactor on a shared task board. Complete configuration,
authentication, and tests in parallel where dependencies allow. Use a
worktree for authentication, preserve existing interfaces, and summarize
the result.

After Lead proposes the team, reply:

Go ahead.

Watch .tasks/ move from pending to in_progress and completed, .mailboxes/ deliver result and idle_notification, and .worktrees/ appear only for the bound task. Also check that direct messages beat task-board scans and that a failed complete_task does not reset the teammate's working directory.


What's Next

The Lead and its teammates can only call tools defined directly in code.py. Connecting Jira, a deployment platform, or a knowledge base still requires separate tool schemas and handlers for each external system. Changes to those external tools also require changes to the course code.

s14 MCP Tools → Connect external services at runtime through one discovery and invocation protocol, then add their tools to the tool pool.