Module 32 · 55 min

Agents That Message Each Other, in Turn

You can let one agent send a message to another while the other is busy, and show that the busy one finished its work first.

Surface
tool() · createSdkMcpServer · the turn boundary
Workbench tag
module-32
Claude Code
v2.1.251 (bundled)
claude-agent-sdk
v0.3.251
Docs checked
2026-08-30

You have agents that can work in parallel, but until this module, they could not talk to each other without stepping on each other’s turns. That changes now. One agent’s model calls a tool, the text lands in another agent’s queue, and the recipient reads it at its own next turn boundary, the same way it reads yours. That last part is the whole design: delivery is asynchronous by construction, not by policy. You cannot interrupt another agent through this mechanism, because there is no other door. Mail goes in through Conversation.send, the same path your messages use, with no priority lane (records 0069 and 0070 in the chat app’s repository). An idle recipient gets started by the delivery, which is module 31’s lazy start doing a second job: it doubles as wake.

flowchart LR
a["agent A's turn:<br/>calls send_to_agent"] --> q["B's queue<br/>mail waits, queued=true"]
q -->|"B's turn boundary"| b["agent B reads<br/>[message from A] ..."]
A mails B while B is mid-turn. The message waits in the middle lane; nothing A does can make B read it sooner.

Two tools, one server instance per agent

The mechanism is module 19’s in-process MCP server, pointed inward twice. list_agents returns the roster minus the caller, so a model can address a colleague by id. send_to_agent takes an id and text and delivers. Each agent’s query() gets its own server instance, that is the security-shaped decision of the round. The tools know who is calling because they were built for that caller, so there is no from field a model could fill in falsely. The sender’s identity is a fact about the wiring, never a claim in the input (record 0071).

One file holds the whole mechanism. Read server/src/agent-mail.js from the top: the wire names the model sees, the prefix, the acknowledgement, then the constructor that closes over the caller and the two tool() definitions it wraps.

chat-workbench @ module-32/
└─ server/
└─ src/
server/src/agent-mail.js

The whole mail mechanism: two tools, one server instance per agent, and the five-clause result.

// Lines 1 to 41 elided: the module header and the imports. Full file: server/src/agent-mail.js at tag module-32.

/**
* The MCP server name every agent's mail server is registered under.
*
* The same name for every agent, because the name is part of the wire tool name
* the model sees, `mcp__workbench__send_to_agent`, and a name carrying the
* caller's agentId would make one agent's transcript unreadable beside another's
* for no gain. The instances differ; the name does not.
*
* `mcp__server__tool_name` is the SDK's own naming, given at
* @anthropic-ai/claude-agent-sdk 0.3.251 sdk.d.ts line 3964: "Fully-qualified
* MCP tool name, e.g. mcp__server__tool_name", with the note that server names
* are normalized so non-`[a-zA-Z0-9_-]` becomes `_`. "workbench" needs no
* normalising.
*/
export const MAIL_SERVER_NAME = "workbench";

/**
* The version reported on the MCP server.
*
* `CreateSdkMcpServerOptions.version` is optional (sdk.d.ts line 514). It is set
* anyway, because a server that reports no version is a server nobody can talk
* about later. The number is the module that introduced it, not the package's:
* this server's surface is two tools, and it changes when a module changes it.
*/
export const MAIL_SERVER_VERSION = "32.0.0";

/** The two tool names, unqualified. */
export const LIST_AGENTS_TOOL = "list_agents";
export const SEND_TO_AGENT_TOOL = "send_to_agent";

/**
* The fully-qualified names the model calls and `allowedTools` names.
*
* Both are listed in `allowedTools` because every session runs under
* `permissionMode: 'dontAsk'`, documented at sdk.d.ts line 1826 as "Don't prompt
* for permissions, deny if not pre-approved". A tool nothing pre-approves is a
* tool that is denied, and this application has no approval frames to answer a
* prompt with. Record 0011 is what makes that the mode; record 0073 is why these
* two names are the only additions to the allow list.
*/
export const LIST_AGENTS_WIRE_NAME = `mcp__${MAIL_SERVER_NAME}__${LIST_AGENTS_TOOL}`;
export const SEND_TO_AGENT_WIRE_NAME = `mcp__${MAIL_SERVER_NAME}__${SEND_TO_AGENT_TOOL}`;

export const MAIL_TOOL_NAMES = Object.freeze([
LIST_AGENTS_WIRE_NAME,
SEND_TO_AGENT_WIRE_NAME,
]);

/**
* The prefix stamped onto every delivered message, before the sender's name.
*
* The name and not the agentId. A model reads prose, and `[message from Ledger]`
* is prose; `[message from agent-2]` is a database row. The id is what
* `send_to_agent` takes and what `list_agents` prints beside each name, so a
* model that wants to reply has the mapping in front of it either way. Record
* 0074, which also says what it costs: two agents named the same are
* indistinguishable in a prefix, and the roster does not forbid that.
*
* @param {string} name The sender's name, as the roster holds it now.
* @returns {string}
*/
export function mailPrefix(name) {
return `[message from ${name}] `;
}

/**
* The text `send_to_agent` returns after a delivery.
*
* This is the sentence the module turns on, and record 0072 is the argument for
* every clause of it. Short version: a tool description is read once, before the
* model has done anything; a tool result is read immediately after the model has
* acted, at the exact moment it is deciding what to do next. The second position
* is worth more, so the asynchrony is stated there rather than left to the
* description.
*
* The recipient is named because the model addressed an id and thinks in names,
* and both are given so a follow-up needs no second `list_agents` call.
*
* @param {AgentRecord} to
* @returns {string}
*/
export function deliveredText(to) {
return [
  `Delivered to ${to.name} (${to.agentId}).`,
  "Delivery is asynchronous. It has not been read yet and will not be read until",
  `${to.name} reaches its own next turn boundary, which may be a while.`,
  "Do not wait for a reply, and do not send again to prompt one:",
  "carry on with your own work; the reply, if any, arrives as a later message.",
].join(" ");
}

// The CallToolResult helpers and rosterText elided.

/**
* Build one agent's mail: two handlers, and the MCP server that wraps them.
*
* The caller is `self`, closed over here and never taken from a tool argument.
* A `from` field in the input schema would be a field the model fills in, and a
* model that can fill it in can fill it in wrongly, or usefully, from its point
* of view, by claiming to be somebody else. Closing over the record makes the
* question unaskable. Record 0071.
*
* @param {object} deps
* @param {AgentRecord} deps.self The agent whose session gets these tools. Held
*   by reference, so a rename that mutates the record in place is reflected in
*   the prefix on the next message this agent sends.
* @param {() => AgentRecord[]} deps.listAgents Every record on the roster,
*   including `self`; this module does the excluding.
* @param {(agentId: string) => boolean} deps.isLive Whether a conversation
*   exists for that agent on this connection.
* @param {(toId: string, text: string) => { ok: true, id: string, queued: boolean }
*   | { ok: false, message: string }} deps.deliver Put the text in an agent's
*   queue. `session.js` supplies it; see `deliverToAgent` there.
* @returns {{
*   serverName: string,
*   toolNames: readonly string[],
*   server: import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance,
*   handlers: {
*     listAgents: (args: {}) => Promise<any>,
*     sendToAgent: (args: { agentId: string, text: string }) => Promise<any>,
*   },
*   peers: () => AgentRecord[],
* }}
*/
export function createAgentMail({ self, listAgents, isLive, deliver }) {
/** Every agent but the caller, in roster order. */
const peers = () =>
  listAgents().filter((agent) => agent.agentId !== self.agentId);

// The two failure paths of send_to_agent elided: addressing yourself, and an id nobody holds.

  if (text.trim() === "") {
    return failed("send_to_agent needs some text to deliver.");
  }

  const outcome = deliver(to.agentId, text);
  if (!outcome.ok) return failed(outcome.message);

  return ok(deliveredText(to));
}

// `tool()` is declared at sdk.d.ts line 8349:
//   tool<Schema extends AnyZodRawShape>(_name, _description, _inputSchema,
//     _handler: (args: InferShape<Schema>, extra: unknown) =>
//       Promise<CallToolResult>, _extras?): SdkMcpToolDefinition<Schema>
// The third argument is a raw Zod shape, an object of Zod types, not a
// `z.object(...)`, which is what `AnyZodRawShape` means and why the two
// fields below are written bare. `SdkMcpToolDefinition` at lines 4461 to 4468
// is the object it returns: name, description, inputSchema, handler.
const tools = [
  tool(
    LIST_AGENTS_TOOL,
    "List the other agents on this workbench roster, with the id to address each one by. Call this before send_to_agent if you do not already know an id.",
    {},
    handleListAgents,
  ),
  tool(
    SEND_TO_AGENT_TOOL,
    "Send a message to another agent on this roster. Delivery is asynchronous: the text is put in that agent's queue and read at its next turn boundary, and any reply comes back to you later as a new message. This tool never returns a reply.",
    {
      // `.describe()` last, so the description is on the finished type rather
      // than on an intermediate one that a later `.min()` would replace.
      agentId: z
        .string()
        .describe(
          "The recipient's agentId, exactly as list_agents printed it. Not its name.",
        ),
      text: z
        .string()
        .describe(
          "What to say. The recipient sees it prefixed with your name, and knows it came from another agent.",
        ),
    },
    handleSendToAgent,
  ),
];

// `createSdkMcpServer` is declared at sdk.d.ts line 510 and documented at
// lines 502 to 509: "Creates an MCP server instance that can be used with the
// SDK transport. This allows SDK users to define custom tools that run in the
// same process." Its options type at lines 512 to 530 is
// `{ name, version?, instructions?, tools?, alwaysLoad?, ... }`, and it
// returns `McpSdkServerConfigWithInstance` (line 1102), the config object
// with a live `McpServer` on it, which is what `Options.mcpServers` (line
// 1797, `Record<string, McpServerConfig>`) accepts.
//
// In the same process is the whole reason this is an SDK server rather than a
// stdio one. The handlers above close over `self` and over `deliver`, which
// reaches this connection's conversations. A subprocess server would need the
// queue on the other side of a pipe.
const server = createSdkMcpServer({
  name: MAIL_SERVER_NAME,
  version: MAIL_SERVER_VERSION,
  tools,
});

return {
  serverName: MAIL_SERVER_NAME,
  toolNames: MAIL_TOOL_NAMES,
  server,
  handlers: {
    listAgents: handleListAgents,
    sendToAgent: handleSendToAgent,
  },
  peers,
};
}
Read more

The elided regions are the two failure paths of send_to_agent, which answer an unknown id with the valid ids and refuse an agent addressing itself, and rosterText, which formats list_agents. Everything shown is verbatim from the module-32 tag.

One file at the module-32 tag, in four regions. Read it top to bottom: the names, the prefix, the acknowledgement, then the constructor that makes the sender's identity a fact about the wiring.

Delivered text arrives prefixed [message from Scout], the sender’s name, not its id, because the recipient’s model will talk about its correspondent and should hold the word a person would recognize (record 0074). Each persona now says mail exists, what the prefix means, and that delivery is asynchronous, and stops there. It does not say when to send mail or to whom, because standing instructions that push collaboration produce agents that perform collaboration whether the work needs it (record 0075).

On the wire, the browser learns about a delivery from one new frame: mail, carrying a server-minted id with the mail- prefix, the sender, the recipient, the text, and whether it queued. That id is how the client routes the recipient’s reply into the right thread, for the first time, a message exists that the client did not send. The prefix rule is enforced, not trusted, on both sides: the server’s parser rejects client ids that carry it, and the client’s id factory cannot produce one. Both halves reached that independently, which is what a written contract is for.

The acknowledgement is the design

The plan for this module named its own break, and it is the sharpest lesson in the round. Return a bare “Sent.” from send_to_agent and watch the sender ruin its turn: its next text says it is waiting for the reply, its work ends undone, and the reply later arrives as a turn it never planned for. The tool’s description said delivery was asynchronous. The model read that description before the turn started, along with everything else, and ignored it.

The fix is where the words sit, not what they say. The tool result this server returns states five things, each in its own clause: delivered to whom, that delivery is asynchronous, the mechanism (unread until the recipient’s next turn boundary, which may be a while), the two prohibitions (do not wait; do not send again to prompt a reply), and what to do instead, carry on with your own work. Record 0072 makes the argument the course page exists to quote: a tool description is context read before the turn, and a tool result is feedback arriving at the exact point the next decision is made. Both are set here; only the result is written for its position. Whether a live model reliably obeys it is this round’s central open question, named in the record rather than assumed, because no key on the build machine means no live model ever read it.

Watch the exchange

git checkout module-32
WORKBENCH_FAKE_SDK=1 npm run dev

The fake’s mail script triggers on relay <agentId>: in a message, and it fakes the model only, the delivery, the queue, the frames, and the tool handlers are the real ones (record 0076). The demo makes Ledger busy first, then hands Scout the trigger. Here is a real run:

  289ms  activity [p-scout] start mcp__workbench__send_to_agent
  289ms  mail [mail-1] agent-1 -> agent-2 queued=true "are you free this afternoon?"
  909ms  result [p-scout]     <- Scout finished without waiting
 1019ms  result [p-ledger]    <- the person's answer first
 1142ms  mail [mail-2] agent-2 -> agent-1 queued=false "re: ..."

The promise of the module is in the middle two lines: the mail queued against busy Ledger, and the person Ledger was serving got their answer before the mail was ever read. In the browser, the mail lands in the recipient’s thread as a card naming its sender with the sender’s colour swatch, wearing the same queued badge your own messages wear, by the same functions.

Build

The ladder:

  1. Run it. Two agents, message one with relay <the other’s id>: hello, and read both threads. Then make the recipient busy first and watch the queued badge on the mail card.
  2. Read one file. server/src/agent-mail.js, and find where the prefix is added and where the acknowledgement is written, with records 0072 and 0074 beside them.
  3. Change one line and see it. Change the prefix text and read the recipient’s next transcript: the delta quotes the delivered text back, so your change is visible in one exchange.
  4. Build. Add a third agent and have A ask B to ask C, reading the chain in three threads. Then write down what stopped the chain from continuing forever, and check your answer against record 0080, whose honest answer is: nothing. That is the next module.
The mistake most people make first

Your send_to_agent returns “Sent.” because that is what a send function returns. The sender’s model, which just acted and is deciding what to do next, reads one word that confirms nothing about timing, falls back on what sending a message means in every transcript it has ever seen, and waits. Its next sentence says it will report back when the reply arrives, its turn ends with its own task half done, and when the reply does arrive it opens a turn the sender no longer has context for. Nothing errored. The tool description that said “asynchronous” was read an hour of tokens ago.

The rule: put the behavioral contract in the tool result, at the decision point, and spend the words. What deliveredText in the tree above returns, for a delivery to Ledger, is “Delivered to Ledger (agent-2). Delivery is asynchronous. It has not been read yet and will not be read until Ledger reaches its own next turn boundary, which may be a while. Do not wait for a reply, and do not send again to prompt one: carry on with your own work; the reply, if any, arrives as a later message.” Five clauses, each doing a job, tested clause by clause. What the model reads right after acting weighs more than anything it was told before the turn began.

Does this travel?

The queue-at-the-turn-boundary rule travels to any runtime with per-agent input queues. The acknowledgement lesson travels further: any tool whose effect is deferred should say so in its result, whatever the framework. The identity-by-construction pattern, one tool-server instance per caller, travels as a design. createSdkMcpServer and the wire names are this SDK’s own.

Check yourself

  1. Agent A mails busy agent B, and a person is mid-conversation with B. Write the order of the four terminal frames involved and name the one rule that fixed it.
  2. Why does send_to_agent have no from parameter, and what class of bug does its absence make unwritable?
  3. A teammate moves the do-not-wait sentence from the tool result into the tool description “to save tokens per call”. Predict the failure, name where it will first be visible, and say which record’s argument they lost.