Proposal: durable, cross-runtime messaging protocol for Agent Teams (design + validated implementation)

Status Open
Maintainer reply None cached
Activity 1 comment · opened Sep 2, 2026

Summary

I've been running a hand-built multi-agent messaging protocol in production for several months — coordinating Claude Code and Codex sessions across multiple repos and machines. It has since stabilized after multiple rounds of failure-driven fixes, and I packaged it as an installable Claude Code plugin: jerronl/agent-handoff — append-only handoff files, tag- and inbox-based routing, a background-wait helper, an always-on Monitor-based inbox, and a pluggable bot roster. bash tests/run.sh runs the test suite if you want to check the claims below against actual code rather than a description of it.

This isn't a wishlist — it's a working, tested protocol with real mileage, and I think the design is a good candidate for (part of) Agent Teams' communication layer, beyond what in-process SendMessage currently covers.

The gap this fills

Agent Teams today coordinates multiple Claude Code instances via in-process messaging and a shared task list, scoped to one lead's local session. Three things it doesn't currently do, which this protocol does:

  1. Cross-runtime: Claude and non-Claude agents (e.g. Codex) share the same channel.
  2. Durable: every message is on disk, append-only, so it survives restarts and is fully auditable after the fact.
  3. Cross-location: teammates can live in different repos or machines, not just instances spawned locally by one lead.

Protocol design

Addressing. Each agent owns a durable inbox file (_inbox_<agent>.md). Multi-party channels use _handoff_<peer1>_<peer2>[..].md. Routing is by filename, not by parsing tags out of free text — early versions routed by tag-matching and that was the single biggest source of silently dropped messages (delimiter inconsistency, prefix collisions, senders drifting from the receiver's expected format). File-based addressing eliminates that failure class entirely.

Message format. Each message is a markdown section:

## [<sender>→<recipient>] <ISO date> <HH:MM> <tz>

<body>

Append-only; sections are never edited or removed, only added. The file is simultaneously the transport and the transcript.

Delivery is two-phase and runtime-aware. A single canonical send function does:

  1. Write to the recipient's inbox file. Confirm the write succeeded before proceeding — this ordering matters, see below.
  2. Notify the recipient, using whichever mechanism matches their runtime — a file-watcher/hook for a Claude Code session, a queue/wake command for a Codex session, etc.

Store-then-notify (never the reverse) means a notification failure degrades to "delivered but not yet surfaced" rather than "message lost," and the two failure modes are distinguishable in logs. This turned out to matter a lot in practice: a naive "just print sent ✅ after queuing the notification" made delivery failures invisible.

Cross-repo / cross-runtime recipients are first-class: an agent's inbox can live in a different repo than the sender's working directory, resolved by an explicit registry rather than inferred from the sender's own repo root. (Inferring it caused misdelivery when a receiving agent's true location diverged from where the sender happened to be running.)

Adapter model for non-Claude runtimes. The notification half of step 2 is the only runtime-specific piece; everything else (file format, addressing, ordering) is runtime-agnostic. This is the part I'd suggest generalizing into a small documented interface — "how does runtime X get told it has a new message" — so third-party CLI agents could plug into a team without Anthropic having to natively integrate every one of them.

What broke before this stabilized (concrete failure modes, in case useful for design review)

  • Notify-without-persist race: printing success before confirming the write landed → messages reported as sent that were never delivered.
  • Tag-based routing drift: senders using slightly different delimiter conventions (e.g. -> vs →) than what the receiver's matcher expected → multi-hour silent gaps in a channel, at one point nearly mistaken for a crashed session.
  • Ambiguous timestamps across machines: local time without a fixed reference frame made cross-machine message ordering unreliable, including one case of system clock drift after a reboot silently reordering a conversation.
  • Implicit repo-root inference for cross-repo recipients: assuming the recipient's inbox lives under the sender's own repo root works until it doesn't — a recipient physically located in a separate repo needs an explicit address, not an inferred one.

Happy to go deeper on any of these, or share the actual scripts, if that's useful for evaluating the design.

<details>
<summary><b>Full protocol design writeup (click to expand)</b></summary>

Multi-Agent Messaging Protocol (generalized reference design)

A file-backed messaging protocol for coordinating independent agent sessions —
potentially different runtimes, repos, or machines — via a shared, durable,
append-only inbox per agent.

This is a sanitized, generalized writeup of a protocol that has run in
production for several months across heterogeneous agent sessions. It is
posted as a design reference, not as something meant to be adopted verbatim.

---

1. Design goals

  1. Durability — the conversation between agents must survive process

restarts and be inspectable after the fact, independent of any single
session's lifetime.

  1. Runtime-agnosticism — the transport (file format, addressing) must not

assume all participants share the same CLI or notification mechanism.

  1. Location-agnosticism — participants may live in different working

directories, repos, or machines; addressing must be explicit, never
inferred from the sender's own location.

  1. Loud failure — a delivery failure must be distinguishable from a

successful delivery. Silent failure is the single most expensive class of
bug in this design space.

2. Addressing

Each agent owns exactly one durable inbox file: _inbox_<agent>.md.
Multi-party channels use _channel_<peer1>_<peer2>[_<peer3>...].md when more
than two parties need a shared, ordered thread.

Routing is by filename, not by parsing sender/recipient tags out of free
text.
An earlier iteration of this protocol routed by matching a tag
pattern against message headers; this was the single largest source of
silently dropped messages in practice — delimiter inconsistency between
senders (e.g. two different arrow characters meaning "to"), accidental prefix
matches, and format drift between what a sender wrote and what a receiver's
matcher expected. Moving addressing to the filesystem layer (one file per
recipient) removes this failure class entirely: a message either lands in
the right file or it doesn't get written at all.

A registry maps each agent name to {inbox_path, runtime, notify_target}.
The registry is authoritative — never inferred from the sender's own repo
root or working directory, since a receiving agent's true location can
diverge from wherever the sender happens to be running.

3. Message format

Each message is an append-only markdown section:

## [<sender>→<recipient>] <ISO date> <HH:MM> <timezone>

<body>

---
  • Sections are never edited or removed after being written, only

appended. The file is simultaneously the transport and the full
transcript.

  • The header must name both sender and recipient explicitly, even in a

broadcast/shared channel — this lets any reader (human or agent)
reconstruct who was addressing whom without relying on channel-level
assumptions.

  • Timestamps use a single fixed reference timezone across all

participants, regardless of what machine or local timezone each agent
is actually running in. This is what makes cross-machine ordering
reliable — without a fixed reference frame, messages from different
machines cannot be reliably interleaved by time, and clock drift on any
one machine becomes silently unrecoverable (see §6).

  • Message bodies lead with the actionable content (instruction / verdict /

conclusion) before any supporting explanation, on the assumption that the
reader is busy and needs to know what to do before why.

4. Delivery: two-phase, runtime-aware

A single canonical send operation performs two steps, strictly in order:

  1. Store. Append the message to the recipient's inbox file. Confirm the

write succeeded (non-empty result, no I/O error) before proceeding.

  1. Notify. Wake the recipient using whichever mechanism matches their

runtime, looked up from the registry — not assumed from the sender's own
runtime or from convention.

Store-before-notify (never the reverse) is the load-bearing ordering
decision in this protocol. If notification fails after a successful store,
the message is in a well-defined state — "delivered, not yet surfaced" —
and can be recovered by re-notifying or by the recipient polling the file
directly on next wake. If the order were reversed (notify before store,
or notify without confirming store), a failure produces an unrecoverable
false-positive: the sender believes the message was sent, the recipient
was never actually told, and nothing in the system can distinguish this
from a successful delivery after the fact.

Notification adapters

The notification step is the only runtime-specific part of the protocol.
Each runtime gets a small adapter:

| Recipient runtime | Notification mechanism |
|---|---|
| Runtime with native file-watch/hook support | A watcher process or session hook fires on new file content; no separate notify call needed |
| Runtime without file-watch support | An explicit wake/queue call to that runtime's own session, carrying a short pointer ("new message in <inbox> at <time>") rather than the full message body |
| Any other runtime | Any equivalent "tell this specific session something happened" primitive the runtime exposes |

Because storage and format are runtime-agnostic, adding support for a new
runtime is limited to writing one notification adapter — nothing about
addressing, message format, or the inbox files themselves changes.

5. Failure semantics

  • Unknown recipient: fail loud immediately (`FAILED(unknown agent):

<name>`), never fall back to broadcasting to all known agents or silently
dropping the message.

  • Store failure: fail loud, do not attempt to notify. A notify without a

confirmed store is worse than doing nothing — it produces a receiver who
believes they should check for a message that was never actually written.

  • Notify failure after successful store: the message is not lost — it

is on disk in the recipient's inbox. Report this state distinctly from
both success and store-failure ("delivered, not yet surfaced") so the
sender or a monitoring process can retry notification without re-sending
the body.

  • **A "sent" confirmation printed by the sending tool is not proof of

delivery.** It only proves the store step's local success. Distinguishing
"my tool said sent" from "the recipient actually has the message" matters
most exactly when there are multiple physical copies of send tooling in
play (e.g. after a fork, migration, or partial rollout) — a stale copy of
the send tool can print an identical success message while writing to a
location nothing is actually reading.

6. Known failure modes worth designing against

These are concrete issues encountered running this protocol in practice,
included because they're the kind of edge case that's cheap to prevent at
design time and expensive to debug after the fact:

  • Notify-without-confirmed-store race. Printing a success message before

the write is confirmed makes delivery failures indistinguishable from
successes at the point of sending.

  • Tag-based routing drift. Any scheme that routes by parsing a

free-text convention (rather than a fixed file/address) will eventually
desync between what senders write and what receivers match against,
producing silent multi-hour gaps that are easily mistaken for a crashed
or hung session rather than a routing bug.

  • Clock drift across machines. A local, unpinned timestamp source

(e.g. an unsynced system clock after a reboot) can silently reorder a
conversation's transcript with no error at write time — the corruption is
only visible later, when reconstructing event order.

  • Implicit location inference for remote recipients. Assuming a

recipient's inbox lives under the sender's own working directory or repo
root works until a recipient is physically elsewhere; at that point the
send either fails outright or, worse, silently succeeds while writing to
the wrong location. Explicit, registry-based addressing avoids this
entirely.

  • Multiple physical copies of send tooling. If the send mechanism is

ever duplicated (fork, migration, partial rollout to a subset of agents),
a stale copy can continue to run, produce identical-looking success
output, and write to a location nothing is monitoring. Worth having a
single canonical, verifiable entry point rather than allowing copies to
proliferate.

7. Non-goals

This protocol does not attempt to provide:

  • Guaranteed real-time delivery — notification is best-effort; the durable

store is the actual guarantee.

  • A general pub/sub or broadcast primitive — broadcast channels are

supported but are explicitly a special case of point-to-point addressing
(recipient = "all"), not a separate mechanism.

  • Conflict resolution for concurrent writers to the same inbox — append-only

writes to a single file per recipient sidestep this rather than solving
it; a higher-throughput implementation would need a different storage
layer.

</details>

Reference implementation

Rather than pseudocode, the actual scripts are in the repo linked above:

  • scripts/handoff_post.sh / handoff_read.sh / handoff_watch.sh — post,

read, and one-shot wait-for-reply

  • scripts/monitor_handoff.sh — the always-on inbox (tag-routed or

filename/inbox-routed, see README §Channels)

  • scripts/handoff_bots.sh — roster management with a pluggable

per-bot launch_command, so spawning is environment-specific while
liveness/status logic stays generic

  • hooks/ — SessionStart and UserPromptSubmit hooks that make delivery

automatic once .handoff_channels exists

DESIGN.md in the repo walks through the reasoning behind each of these
(why two routing modes instead of one, why watch vs. monitor are separate,
why the roster only owns the generic parts), which overlaps with — and goes
into more implementation detail than — the design summary below.

What I'm proposing, concretely

  • Add an optional durable-transcript mode for team channels (append-only export of SendMessage traffic to disk), independent of session lifetime.
  • Define a minimal adapter interface for the notification step, so non-Claude-Code runtimes can be woken by a team channel without full native support.
  • Support registering a teammate whose working root differs from the lead's, so a "team" isn't implicitly scoped to one local project.

I'm not proposing Anthropic adopt my literal file format — that's an implementation detail. The design properties above (durability, runtime-agnostic transport with a thin adapter layer, explicit cross-location addressing) are the actual proposal.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗