Learn Claude Code
s16

Team Protocols

Multi-Agent Platform

Shared Request-Response Rules|557 LOC|10 tools

A protocol request is a structured message with an ID; the response must reference the same ID.

s01 > s02 > s03 > s04 > s05 > s06 > s07 > s08 > s09 > s10 > s11 > s12 > s13 > s14 > s15 > [ s16 ] > s17 > s18 > s19

What You'll Learn

  • How a request-response pattern with a tracking ID structures multi-agent negotiation
  • How the shutdown protocol lets a lead gracefully stop a teammate
  • How plan approval gates risky work behind a review step
  • How one reusable FSM (a simple status tracker with defined transitions) covers both protocols

In s15 your teammates can send messages freely, but that freedom comes with chaos. One agent tells another "please stop," and the other ignores it. A teammate starts a risky database migration without asking first. The problem is not communication itself -- you solved that with inboxes -- but the lack of coordination rules. In this chapter you will add structured protocols: a standardized message wrapper with a tracking ID that turns loose messages into reliable handshakes.

The Problem

Two coordination gaps become obvious once your team grows past toy examples:

Shutdown. Killing a teammate's thread leaves files half-written and the config roster stale. You need a handshake: the lead requests shutdown, and the teammate approves (finishes current work and exits cleanly) or rejects (keeps working because it has unfinished obligations).

Plan approval. When the lead says "refactor the auth module," the teammate starts immediately. But for high-risk changes, the lead should review the plan before any code gets written.

Both scenarios share an identical structure: one side sends a request carrying a unique ID, the other side responds referencing that same ID. That single pattern is enough to build any coordination protocol you need.

The Solution

Both shutdown and plan approval follow one shape: send a request with a request_id, receive a response referencing that same request_id, and track the outcome through a simple status machine (pending -> approved or pending -> rejected).

Shutdown Protocol            Plan Approval Protocol
==================           ======================

Lead             Teammate    Teammate           Lead
  |                 |           |                 |
  |--shutdown_req-->|           |--plan_req------>|
  | {req_id:"abc"}  |           | {req_id:"xyz"}  |
  |                 |           |                 |
  |<--shutdown_resp-|           |<--plan_resp-----|
  | {req_id:"abc",  |           | {req_id:"xyz",  |
  |  approve:true}  |           |  approve:true}  |

Shared FSM:
  [pending] --approve--> [approved]
  [pending] --reject---> [rejected]

Trackers:
  shutdown_requests = {req_id: {target, status}}
  plan_requests     = {req_id: {from, plan, status}}

How It Works

Step 1. The lead initiates shutdown by generating a unique request_id and sending the request through the teammate's inbox. The request is tracked in a dictionary so the lead can check its status later.

// Tree-shaken bundle: chapter wiring + only used runtime code.
// agents_self_contained/_runtime.ts
import Anthropic from "@anthropic-ai/sdk";
import dotenv from "dotenv";
import { execSync, spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
dotenv.config({ override: true });
var WORKDIR = process.cwd();
var DEFAULT_MODEL = "claude-3-5-sonnet-latest";
var anthropicClient = null;
function getModelId() {
  return process.env.MODEL_ID || DEFAULT_MODEL;
}
function getAnthropicClient() {
  if (anthropicClient) {
    return anthropicClient;
  }
  anthropicClient = new Anthropic({
    apiKey: process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || "missing-api-key",
    baseURL: process.env.ANTHROPIC_BASE_URL || void 0
  });
  return anthropicClient;
}
function createLoopContext() {
  return { workdir: WORKDIR, messages: [], meta: {} };
}
function safePath(relativePath) {
  const resolved = path.resolve(WORKDIR, relativePath);

Step 2. The teammate receives the request in its inbox and responds with approve or reject. The response carries the same request_id so the lead can match it to the original request -- this is the correlation that makes the protocol reliable.

// Tree-shaken bundle: chapter wiring + only used runtime code.
// agents_self_contained/_runtime.ts
import Anthropic from "@anthropic-ai/sdk";
import dotenv from "dotenv";
import { execSync, spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
dotenv.config({ override: true });
var WORKDIR = process.cwd();
var DEFAULT_MODEL = "claude-3-5-sonnet-latest";
var anthropicClient = null;
function getModelId() {
  return process.env.MODEL_ID || DEFAULT_MODEL;
}
function getAnthropicClient() {
  if (anthropicClient) {
    return anthropicClient;
  }
  anthropicClient = new Anthropic({
    apiKey: process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || "missing-api-key",
    baseURL: process.env.ANTHROPIC_BASE_URL || void 0
  });
  return anthropicClient;
}
function createLoopContext() {
  return { workdir: WORKDIR, messages: [], meta: {} };
}
function safePath(relativePath) {
  const resolved = path.resolve(WORKDIR, relativePath);

Step 3. Plan approval follows the identical pattern but in the opposite direction. The teammate submits a plan (generating a request_id), and the lead reviews it (referencing the same request_id to approve or reject).

// Tree-shaken bundle: chapter wiring + only used runtime code.
// agents_self_contained/_runtime.ts
import Anthropic from "@anthropic-ai/sdk";
import dotenv from "dotenv";
import { execSync, spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
dotenv.config({ override: true });
var WORKDIR = process.cwd();
var DEFAULT_MODEL = "claude-3-5-sonnet-latest";
var anthropicClient = null;
function getModelId() {
  return process.env.MODEL_ID || DEFAULT_MODEL;
}
function getAnthropicClient() {
  if (anthropicClient) {
    return anthropicClient;
  }
  anthropicClient = new Anthropic({
    apiKey: process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || "missing-api-key",
    baseURL: process.env.ANTHROPIC_BASE_URL || void 0
  });
  return anthropicClient;
}
function createLoopContext() {
  return { workdir: WORKDIR, messages: [], meta: {} };
}
function safePath(relativePath) {
  const resolved = path.resolve(WORKDIR, relativePath);

In this teaching demo, one FSM shape covers both protocols. A production system might treat different protocol families differently, but the teaching version intentionally keeps one reusable template so you can see the shared structure clearly.

Read Together

  • If plain messages and protocol requests are starting to blur together, revisit glossary.md and entity-map.md to see how they differ.
  • If you plan to continue into s17 and s18, read team-task-lane-model.md first so autonomy and worktree lanes do not collapse into one idea.
  • If you want to trace how a protocol request returns to the main system, pair this chapter with s00b-one-request-lifecycle.md.

How It Plugs Into The Team System

The real upgrade in s16 is not "two new message types." It is a durable coordination path:

requester starts a protocol action
  ->
write RequestRecord
  ->
send ProtocolEnvelope through inbox
  ->
receiver drains inbox on its next loop
  ->
update request status by request_id
  ->
send structured response
  ->
requester continues based on approved / rejected

That is the missing layer between "agents can chat" and "agents can coordinate reliably."

Message vs Protocol vs Request vs Task

ObjectWhat question it answersTypical fields
MessageEnvelopewho said what to whomfrom, to, content
ProtocolEnvelopeis this a structured request / responsetype, request_id, payload
RequestRecordwhere is this coordination flow nowkind, status, from, to
TaskRecordwhat actual work item is being advancedsubject, status, blockedBy, owner

Do not collapse them:

  • a protocol request is not the task itself
  • the request store is not the task board
  • protocols track coordination flow
  • tasks track work progression

What Changed From s15

ComponentBefore (s15)After (s16)
Tools912 (+shutdown_req/resp +plan)
ShutdownNatural exit onlyRequest-response handshake
Plan gatingNoneSubmit/review with approval
CorrelationNonerequest_id per request
FSMNonepending -> approved/rejected

Try It

npm run s16
  1. Ask the agent to run pwd
  2. Ask it to run ls -la
  3. Ask it to summarize the current workspace in one sentence
  4. Ask it to create notes/hello.ts and print the file content

What You've Mastered

At this point, you can:

  • Build request-response protocols that use a unique ID for correlation
  • Implement graceful shutdown through a two-step handshake
  • Gate risky work behind a plan approval step
  • Reuse a single FSM pattern (pending -> approved/rejected) for any new protocol you invent

What's Next

Your team now has structure and rules, but the lead still has to babysit every teammate -- assigning tasks one by one, nudging idle workers. In s17, you will make teammates autonomous: they scan the task board themselves, claim unclaimed work, and resume after context compression without losing their identity.

Key Takeaway

A protocol request is a structured message with a tracking ID, and the response must reference that same ID -- that single pattern is enough to build any coordination handshake.