39 KiB
Agent-to-Agent Communication Architecture
Status: Research & Design
Author: Claude Code (ao-37)
Date: 2026-02-16
Executive Summary
This document analyzes communication architectures for Agent Orchestrator's multi-agent coordination system. We need to replace fragile tmux send-keys / capture-pane with structured, reliable agent-to-agent messaging.
Recommended approach: File-based mailbox system (inspired by Claude Code agent teams) with hook-based message delivery.
1. Current Problem
How It Works Now
The orchestrator (ao-1) communicates with child sessions (ao-10, ao-11, etc.) via:
- Sending:
tmux send-keys- pastes text into the session terminal - Reading:
tmux capture-pane- captures terminal output (last N lines)
See packages/plugins/runtime-tmux/src/index.ts:91-128 for current implementation.
Problems
| Issue | Impact |
|---|---|
| No structured messages | Can't distinguish between commands, queries, and responses |
| No acknowledgment | No way to know if message was received/processed |
| Fragile output parsing | Must parse terminal escape codes, prompts, ANSI colors |
| No bidirectional protocol | Sessions can't easily respond back to orchestrator |
| Race conditions | Sending while agent is typing causes mangled output |
| Timing dependencies | 300ms sleep before Enter (line 126) - brittle workaround |
| No message history | Can't track what was sent, what was answered |
| No retry logic | Failed sends are silent |
Why This Matters
With 20+ parallel sessions, unreliable communication causes:
- Missed PR review comments
- CI fix instructions not delivered
- Status updates lost
- Manual intervention required
- Orchestrator can't tell if agent is processing or stuck
2. Research: Claude Code Agent Teams
Architecture Overview
Claude Code's agent teams (launched Feb 2026) use file-based mailbox messaging with structured JSON.
Key sources:
- Claude Code Agent Teams Documentation
- Claude Code Teams MCP Implementation
- Swarm Orchestration Guide
Directory Structure
~/.claude/
├── teams/<team-name>/
│ ├── config.json # Team metadata, member list
│ ├── inboxes/
│ │ ├── team-lead.json # Lead's inbox
│ │ ├── worker-1.json # Worker 1's inbox
│ │ ├── worker-2.json # Worker 2's inbox
│ │ └── .lock # File lock for concurrency
│ └── .lock
└── tasks/<team-name>/
├── 1.json # Task 1
├── 2.json # Task 2
└── .lock
Message Format
Inbox files (inboxes/{agent}.json): Array of message objects
[
{
"from": "team-lead",
"timestamp": "2026-02-16T20:45:00Z",
"read": false,
"text": "Please review PR #123 comments and fix the failing tests"
},
{
"from": "worker-2",
"timestamp": "2026-02-16T20:48:00Z",
"read": true,
"text": "Completed task 5, tests passing, ready for review"
}
]
Structured messages embed type information in text field:
{
"from": "team-lead",
"timestamp": "2026-02-16T20:45:00Z",
"read": false,
"text": "{\"type\": \"fix_review_comments\", \"pr\": \"#123\", \"comments\": [...]}"
}
Task List Format
Task files (tasks/{id}.json): Individual JSON files per task
{
"id": "1",
"subject": "Fix authentication bug in login flow",
"description": "User reports cannot login after password reset...",
"status": "in_progress",
"owner": "worker-1",
"blockedBy": [],
"blocks": ["2", "3"],
"activeForm": "Fixing authentication bug",
"createdAt": "2026-02-16T18:00:00Z",
"updatedAt": "2026-02-16T20:30:00Z"
}
Polling Mechanism
Agents implement a polling loop:
- Check inbox: Read
~/.claude/teams/{team}/inboxes/{self}.json - Process unread messages: Filter
read: false, handle, markread: true - Check tasks: Call
TaskList()to find available tasks - Claim work:
TaskUpdate({ taskId: "X", owner: "self", status: "in_progress" }) - Execute: Do the work
- Report:
TaskUpdate({ taskId: "X", status: "completed" }) - Notify: Send message to lead with results
- Sleep: Wait 30s if no tasks, exponential backoff before shutdown
Concurrency Safety
- File locks: Uses
filelocklibrary for cross-process coordination - Atomic writes:
tempfile+os.replaceto prevent partial reads - Retry logic: Exponential backoff on lock contention
3. Communication Architecture Comparison
Approach A: File-based Mailbox (Recommended)
Architecture: Each session gets an inbox directory with JSON message files.
~/.ao-sessions/
├── ao-10/
│ ├── inbox/
│ │ ├── 001-orchestrator-fix-ci.json
│ │ ├── 002-orchestrator-review-comments.json
│ │ └── processed/
│ │ └── 001-orchestrator-fix-ci.json
│ └── outbox/
│ └── 001-status-update.json
├── ao-11/
│ ├── inbox/
│ └── outbox/
└── ao-1/
├── inbox/
└── outbox/
Message format:
{
"id": "msg-uuid-12345",
"from": "ao-1",
"to": "ao-10",
"timestamp": "2026-02-16T20:45:00.000Z",
"type": "fix_ci_failure",
"priority": "high",
"payload": {
"pr": "https://github.com/org/repo/pull/123",
"check": "lint",
"error": "Missing semicolon at line 42"
},
"requiresAck": true,
"ackBy": null
}
How it works:
-
Orchestrator sends message:
const messageId = await mailbox.send("ao-10", { type: "fix_ci_failure", payload: { pr: "...", check: "lint", error: "..." } }); -
File watcher or polling detects new message:
- Option 1:
fs.watch()on inbox directory (instant, but unreliable on some filesystems) - Option 2: Poll every 5-10 seconds (reliable, slight latency)
- Option 3: Hybrid - watch with periodic poll fallback
- Option 1:
-
Hook delivers message to agent:
# ~/.ao-sessions/ao-10/.claude/settings.json { "hooks": { "OnMessageReceived": [ { "type": "command", "command": "~/.ao-sessions/check-inbox.sh" } ] } } -
Agent processes message, sends response:
await mailbox.send("ao-1", { type: "ack", replyTo: messageId, payload: { status: "processing" } }); -
Message moved to processed/ when done
Pros:
- ✅ Simple, no dependencies (just filesystem)
- ✅ Structured JSON messages with schema validation
- ✅ Built-in message history (never lost)
- ✅ Survives orchestrator restarts
- ✅ Easy debugging (cat the JSON files)
- ✅ Works with any runtime (tmux, docker, k8s)
- ✅ No network configuration needed
- ✅ Proven approach (Claude Code uses this)
- ✅ Agents can work offline, sync later
Cons:
- ❌ Polling latency (5-10 second delay)
- ❌ File I/O overhead at scale (100+ sessions)
- ❌ Requires file locking for concurrency
- ❌ Not instant like sockets
Performance:
- Latency: 5-10s (polling interval)
- Throughput: ~1000 msg/sec (filesystem dependent)
- Overhead: Minimal (small JSON files)
Approach B: Socket-based Communication
Architecture: Each session listens on a Unix domain socket.
~/.ao-sessions/
├── ao-10.sock
├── ao-11.sock
└── ao-1.sock
How it works:
-
Session starts socket server:
const server = net.createServer(); server.listen("/tmp/ao-10.sock"); server.on("connection", (socket) => { socket.on("data", (data) => { const message = JSON.parse(data); handleMessage(message); }); }); -
Orchestrator sends message:
const client = net.connect("/tmp/ao-10.sock"); client.write(JSON.stringify({ type: "fix_ci", ... })); client.end(); -
Agent receives instantly, processes, responds via its own socket
Pros:
- ✅ Instant delivery (no polling delay)
- ✅ Bidirectional (full duplex)
- ✅ Lower overhead than files
- ✅ Mature Node.js
netmodule
Cons:
- ❌ Requires agent modification - agents must run socket server
- ❌ Socket cleanup issues (dangling sockets after crashes)
- ❌ Doesn't work across containers/VMs without tunneling
- ❌ No built-in message history
- ❌ Lost messages if agent not listening
- ❌ Complex error handling (connection refused, timeouts)
Performance:
- Latency: <10ms
- Throughput: 10,000+ msg/sec
- Overhead: Low (in-memory buffers)
Approach C: Shared Message Queue
Architecture: Single append-only JSONL file for all messages.
~/.ao-sessions/messages.jsonl
{"id":"1","from":"ao-1","to":"ao-10","timestamp":"...","type":"fix_ci",...}
{"id":"2","from":"ao-10","to":"ao-1","timestamp":"...","type":"ack",...}
{"id":"3","from":"ao-1","to":"ao-11","timestamp":"...","type":"review",...}
How it works:
-
Orchestrator appends message:
const message = { id: uuid(), from: "ao-1", to: "ao-10", ... }; fs.appendFileSync("~/.ao-sessions/messages.jsonl", JSON.stringify(message) + "\n"); -
Sessions tail the file:
const tail = spawn("tail", ["-f", "messages.jsonl"]); tail.stdout.on("data", (line) => { const message = JSON.parse(line); if (message.to === mySessionId) handleMessage(message); }); -
Each session tracks its last processed message ID
Pros:
- ✅ Simple append-only (no locks needed)
- ✅ Complete audit trail
- ✅ Easy to replay/debug
- ✅ Works with existing file-watching tools
Cons:
- ❌ File grows unbounded (needs rotation)
- ❌ All sessions must parse all messages (inefficient)
- ❌ No isolation (one session can read others' messages)
- ❌ Slow at scale (1000+ messages/sec)
- ❌ Requires offset tracking per session
Performance:
- Latency: 1-5s (tail polling)
- Throughput: ~100 msg/sec (before slowdown)
- Overhead: Grows linearly with message count
Approach D: Current tmux send-keys (Baseline)
How it works: See section 1 (Current Problem)
Pros:
- ✅ Already implemented
- ✅ No new dependencies
- ✅ Works with any agent
Cons:
- ❌ All problems listed in section 1
- ❌ Not suitable for production scale
4. Comparison Matrix
| Criterion | File Mailbox (A) | Sockets (B) | Queue (C) | tmux (D) |
|---|---|---|---|---|
| Latency | 5-10s | <10ms | 1-5s | Instant |
| Reliability | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ |
| Structure | ✅ JSON | ✅ JSON | ✅ JSON | ❌ Text |
| Ack support | ✅ Yes | ✅ Yes | ⚠️ Manual | ❌ No |
| Message history | ✅ Yes | ❌ No | ✅ Yes | ❌ No |
| Agent modification | ❌ No | ⚠️ Yes | ❌ No | ❌ No |
| Survives crashes | ✅ Yes | ❌ No | ✅ Yes | ❌ No |
| Scales to 100+ sessions | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐ |
| Works cross-runtime | ✅ Yes | ⚠️ Needs config | ✅ Yes | ❌ tmux only |
| Easy debugging | ✅ cat files | ⚠️ tcpdump | ✅ tail file | ❌ Hard |
| Implementation complexity | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ | ⭐ |
⭐ = 1 (worst) to 5 (best)
5. Recommended Approach: File-based Mailbox
Why File-based Mailbox?
- No agent modification required - Works with Claude Code, Codex, Aider, any agent
- Proven at scale - Claude Code uses this for agent teams
- Simple implementation - Just filesystem operations
- Reliable - Messages never lost, survive crashes
- Debuggable -
cat,jq,tailwork out of the box - Portable - Works with tmux, docker, k8s, SSH
- Acceptable latency - 5-10s is fine for orchestrator → agent messages
Latency Analysis
Question: Is 5-10 second latency acceptable?
Answer: Yes, for these use cases:
| Use Case | Latency Requirement | Mailbox OK? |
|---|---|---|
| CI failure notification | 1-5 minutes | ✅ Yes (5s negligible) |
| PR review comment delivery | 1-5 minutes | ✅ Yes |
| Status update requests | 10-30 seconds | ✅ Yes |
| Emergency shutdown | <1 second | ⚠️ Use tmux fallback |
| Real-time collaboration | <100ms | ❌ No (but not our use case) |
Hybrid approach: Use file mailbox for normal messages, keep tmux send-keys for emergency shutdown.
6. Implementation Design
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Orchestrator (ao-1) │
│ │
│ ┌──────────────────┐ ┌─────────────────────┐ │
│ │ Lifecycle Manager│─────────│ Mailbox Service │ │
│ │ │ │ │ │
│ │ - Detects CI fail│ │ - send(to, msg) │ │
│ │ - Needs review │ │ - receive(from) │ │
│ │ - PR mergeable │ │ - waitForAck() │ │
│ └──────────────────┘ └─────────────────────┘ │
│ │ │
└────────────────────────────────────────│────────────────────────┘
│
│ writes JSON
▼
┌───────────────────────────────┐
│ ~/.ao-sessions/ │
│ ao-10/ │
│ inbox/ │
│ 001-fix-ci.json ◄────┐ │
│ 002-review.json │ │
│ outbox/ │ │
│ 001-ack.json │ │
└──────────────────────────────┘ │
│ │
│ fs.watch() │
│ or polling │
▼ │
┌─────────────────────────────────────────────────────────────────┤
│ Session (ao-10) │
│ │
│ ┌──────────────────┐ ┌─────────────────────┐ │
│ │ Inbox Watcher │─────────│ Message Handler │ │
│ │ │ │ │ │
│ │ - Polls/watches │ │ - Parse message │ │
│ │ - Detects new │ │ - Route by type │ │
│ │ messages │ │ - Send ack │ │
│ └──────────────────┘ └─────────────────────┘ │
│ │ │ │
│ │ │ Inject via hook │
│ │ ▼ │
│ │ ┌──────────────────┐ │
│ │ │ Claude Code │ │
│ │ │ │ │
│ └───────────────────│ (sees message as │ │
│ │ user input) │ │
│ └──────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Directory Structure
~/.ao-sessions/
├── ao-10/
│ ├── inbox/
│ │ ├── 20260216-204500-uuid-fix-ci.json
│ │ ├── 20260216-204800-uuid-review.json
│ │ └── processed/
│ │ └── 20260216-204500-uuid-fix-ci.json
│ ├── outbox/
│ │ └── 20260216-204530-uuid-ack.json
│ └── .claude/
│ ├── settings.json # Hook configuration
│ └── inbox-watcher.sh # Polling script
├── ao-11/
│ ├── inbox/
│ ├── outbox/
│ └── .claude/
└── ao-1/ # Orchestrator's own inbox
├── inbox/
└── outbox/
Message Schema
interface Message {
/** Unique message ID (uuid) */
id: string;
/** Sender session ID */
from: SessionId;
/** Recipient session ID */
to: SessionId;
/** ISO 8601 timestamp */
timestamp: string;
/** Message type for routing */
type: MessageType;
/** Priority (urgent messages shown first) */
priority: "urgent" | "high" | "normal" | "low";
/** Message payload (type-specific) */
payload: Record<string, unknown>;
/** Does this message require acknowledgment? */
requiresAck: boolean;
/** Acknowledgment timestamp (if acked) */
ackedAt?: string;
/** Reply to message ID (for threading) */
replyTo?: string;
}
type MessageType =
| "fix_ci_failure"
| "fix_review_comments"
| "status_request"
| "status_response"
| "shutdown"
| "ack"
| "error";
Core API
// packages/core/src/mailbox.ts
export class Mailbox {
constructor(
private dataDir: string,
private sessionId: SessionId
) {}
/**
* Send a message to another session.
* Returns message ID.
*/
async send(
to: SessionId,
message: Omit<Message, "id" | "from" | "timestamp">
): Promise<string> {
const msg: Message = {
id: randomUUID(),
from: this.sessionId,
to,
timestamp: new Date().toISOString(),
...message,
};
const filename = `${msg.timestamp.replace(/:/g, "")}-${msg.id}-${msg.type}.json`;
const inboxPath = join(this.dataDir, to, "inbox", filename);
// Atomic write
await this.atomicWrite(inboxPath, JSON.stringify(msg, null, 2));
return msg.id;
}
/**
* Receive messages from inbox.
* Optionally filter by type and unread status.
*/
async receive(opts?: {
type?: MessageType;
unreadOnly?: boolean;
}): Promise<Message[]> {
const inboxPath = join(this.dataDir, this.sessionId, "inbox");
const files = await readdir(inboxPath);
const messages: Message[] = [];
for (const file of files) {
if (!file.endsWith(".json")) continue;
const content = await readFile(join(inboxPath, file), "utf-8");
const msg = JSON.parse(content) as Message;
if (opts?.type && msg.type !== opts.type) continue;
if (opts?.unreadOnly && msg.ackedAt) continue;
messages.push(msg);
}
return messages.sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
}
/**
* Mark a message as acknowledged.
* Moves it to processed/ directory.
*/
async ack(messageId: string): Promise<void> {
const inboxPath = join(this.dataDir, this.sessionId, "inbox");
const processedPath = join(this.dataDir, this.sessionId, "inbox", "processed");
await mkdir(processedPath, { recursive: true });
// Find message file
const files = await readdir(inboxPath);
const msgFile = files.find((f) => f.includes(messageId));
if (!msgFile) return;
// Update message with ack timestamp
const msgPath = join(inboxPath, msgFile);
const msg = JSON.parse(await readFile(msgPath, "utf-8")) as Message;
msg.ackedAt = new Date().toISOString();
await this.atomicWrite(msgPath, JSON.stringify(msg, null, 2));
// Move to processed/
await rename(msgPath, join(processedPath, msgFile));
}
/**
* Wait for acknowledgment of a sent message.
* Polls the recipient's outbox for an ack message.
*/
async waitForAck(
messageId: string,
opts?: { timeout?: number }
): Promise<boolean> {
const timeout = opts?.timeout ?? 60_000;
const start = Date.now();
while (Date.now() - start < timeout) {
// Check if message was acked
const processedPath = join(this.dataDir, this.sessionId, "inbox", "processed");
const files = await readdir(processedPath).catch(() => []);
const acked = files.some((f) => f.includes(messageId));
if (acked) return true;
// Also check outbox for explicit ack message
const messages = await this.receive({ type: "ack" });
const ackMsg = messages.find((m) => m.replyTo === messageId);
if (ackMsg) {
await this.ack(ackMsg.id);
return true;
}
await sleep(1000);
}
return false;
}
/**
* Atomic write using temp file + rename.
*/
private async atomicWrite(path: string, content: string): Promise<void> {
const dir = dirname(path);
await mkdir(dir, { recursive: true });
const tmpPath = `${path}.tmp.${randomUUID()}`;
await writeFile(tmpPath, content, "utf-8");
await rename(tmpPath, path);
}
}
Hook-based Message Delivery
Problem: How do we get messages into the agent without modifying the agent?
Solution: Use a background watcher that polls the inbox and injects messages via tmux send-keys.
Implementation:
# ~/.ao-sessions/ao-10/.claude/inbox-watcher.sh
#!/usr/bin/env bash
set -euo pipefail
INBOX="$HOME/.ao-sessions/$AO_SESSION/inbox"
SESSION_NAME="$AO_SESSION"
POLL_INTERVAL=5 # seconds
while true; do
# Find unprocessed messages
messages=$(find "$INBOX" -maxdepth 1 -name "*.json" -type f | sort)
for msg_file in $messages; do
# Parse message
msg_type=$(jq -r '.type' "$msg_file")
msg_text=$(jq -r '.payload.text // empty' "$msg_file")
msg_id=$(jq -r '.id' "$msg_file")
# Format message for agent
case "$msg_type" in
fix_ci_failure)
prompt="🔧 CI FAILURE DETECTED
Your PR has a failing CI check. Please fix:
$(jq -r '.payload.error' "$msg_file")
PR: $(jq -r '.payload.pr' "$msg_file")
Check: $(jq -r '.payload.check' "$msg_file")"
;;
fix_review_comments)
prompt="📝 REVIEW COMMENTS
Your PR has unresolved review comments. Please address them:
$(jq -r '.payload.comments[] | "- \(.path):\(.line) - \(.body)"' "$msg_file")
PR: $(jq -r '.payload.pr' "$msg_file")"
;;
status_request)
prompt="📊 STATUS REQUEST
Please provide a status update on your current task.
Include: current branch, PR status, blockers, ETA."
;;
shutdown)
prompt="🛑 SHUTDOWN
The orchestrator is requesting you to shut down.
Reason: $(jq -r '.payload.reason // "Unknown"' "$msg_file")"
;;
*)
# Generic message
prompt=$(jq -r '.payload.text // "Message received"' "$msg_file")
;;
esac
# Send to agent via tmux
tmux send-keys -t "$SESSION_NAME" Escape # Clear any partial input
echo "$prompt" | tmux load-buffer -
tmux paste-buffer -t "$SESSION_NAME"
sleep 0.3
tmux send-keys -t "$SESSION_NAME" Enter
# Move to processed/
mkdir -p "$INBOX/processed"
mv "$msg_file" "$INBOX/processed/"
# Send acknowledgment
jq -n \
--arg id "$(uuidgen | tr '[:upper:]' '[:lower:]')" \
--arg replyTo "$msg_id" \
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{
id: $id,
from: env.AO_SESSION,
to: "ao-1",
timestamp: $timestamp,
type: "ack",
payload: {text: "Message received and displayed"},
replyTo: $replyTo,
requiresAck: false
}' > "$HOME/.ao-sessions/ao-1/inbox/$timestamp-$id-ack.json"
done
sleep "$POLL_INTERVAL"
done
How it integrates:
-
Spawn session: Start
inbox-watcher.shin background// After creating tmux session await execFile("tmux", [ "send-keys", "-t", sessionId, `nohup ~/.ao-sessions/${sessionId}/.claude/inbox-watcher.sh &> /dev/null &`, "Enter" ]); -
Orchestrator sends message: Just writes JSON to inbox
const mailbox = new Mailbox(dataDir, "ao-1"); await mailbox.send("ao-10", { type: "fix_ci_failure", payload: { pr: "...", check: "lint", error: "..." }, priority: "high", requiresAck: true, }); -
Watcher detects message: Within 5 seconds
-
Watcher injects prompt: Via
tmux send-keys -
Agent sees prompt: As if user typed it
-
Agent processes: Fixes CI, pushes commit
-
Watcher sends ack: Writes to
ao-1/inbox/ -
Orchestrator receives ack: Knows message was delivered
7. Integration with Existing System
Changes to Agent Interface
Before:
interface Agent {
detectActivity(terminalOutput: string): ActivityState;
// ...
}
After (backward compatible):
interface Agent {
detectActivity(terminalOutput: string): ActivityState;
/** Optional: Send structured message (if agent supports it) */
sendStructuredMessage?(session: Session, message: Message): Promise<void>;
/** Optional: Check for incoming messages (if agent supports it) */
receiveMessages?(session: Session): Promise<Message[]>;
/** Optional: Setup mailbox for this agent */
setupMailbox?(session: Session): Promise<void>;
}
Implementation: If agent doesn't implement these, fall back to runtime.sendMessage() (current tmux approach).
Changes to Runtime Interface
No changes needed - current interface already has sendMessage().
Hybrid approach:
async function sendToSession(session: Session, message: string | Message) {
if (typeof message === "string") {
// Legacy: string message via tmux
await runtime.sendMessage(session.runtimeHandle, message);
} else {
// New: structured message via mailbox
const mailbox = new Mailbox(dataDir, "ao-1");
await mailbox.send(session.id, message);
// Optional: send notification via tmux that message is waiting
await runtime.sendMessage(
session.runtimeHandle,
`📬 New message in inbox (type: ${message.type})`
);
}
}
Migration Strategy
Phase 1: File-based mailbox implementation (2 weeks)
- ✅ Implement
Mailboxclass inpackages/core/src/mailbox.ts - ✅ Add inbox/outbox directories to session creation
- ✅ Implement
inbox-watcher.shscript - ✅ Test with 2-3 sessions manually
Phase 2: Integration with lifecycle manager (1 week)
- ✅ Update lifecycle manager to use mailbox for reactions
- ✅ Keep tmux fallback for backward compatibility
- ✅ Add mailbox metrics to dashboard
Phase 3: Agent-specific improvements (2 weeks)
- ✅ Claude Code: Direct integration (read inbox in hook, no watcher needed)
- ✅ Codex: Add watcher if Codex doesn't have hooks
- ✅ Aider: Add watcher if Aider doesn't have hooks
Phase 4: Scale testing (1 week)
- ✅ Test with 20+ sessions
- ✅ Measure latency, throughput
- ✅ Optimize polling interval
- ✅ Add file rotation for old messages
8. Performance Analysis
Latency Breakdown
Orchestrator → Session (file-based mailbox):
Write message to inbox: 5-10ms (SSD write)
Watcher polls inbox: 0-5s (polling interval)
Parse JSON, format prompt: 5-10ms
Inject via tmux: 50-100ms (tmux latency)
Agent sees prompt: 0-50ms (terminal rendering)
──────────────────────────────────────────
Total: 1-6s (worst case with poll delay)
Session → Orchestrator (response):
Write message to outbox: 5-10ms
Orchestrator polls session inbox: 0-10s (if using polling)
Parse response: 5-10ms
──────────────────────────────────────────
Total: 1-11s
Optimization: Use fs.watch() to get instant notification (latency drops to 50-200ms total).
Throughput
File I/O limits:
- Modern SSD: ~50,000 IOPS (random writes)
- Average message: 1-5 KB
- Theoretical max: ~10,000 messages/second
Practical limits (with file locking, atomic writes):
- Single session: ~1,000 messages/second
- 20 sessions: ~500 messages/second total (contention)
- 100 sessions: ~100 messages/second total
Agent Orchestrator needs: ~1-10 messages/second (well within limits)
Memory Overhead
Per session:
- Inbox watcher process: ~5-10 MB
- Message files: ~1-5 KB each
- Processed messages (30 days): ~1-10 MB
Total for 20 sessions: ~200 MB (negligible)
Disk Usage
Message retention policy:
- Keep processed messages for 30 days
- Rotate old messages to archive (gzip)
- Delete archive after 90 days
Storage estimate (20 sessions, 100 messages/day each):
- Active messages: ~100 MB
- 30-day archive: ~2 GB (gzipped: ~200 MB)
9. Security Considerations
Message Validation
Problem: Malicious session could send fake messages.
Mitigation:
- Session ID verification: Check
fromfield matches sender's session ID - Schema validation: Validate message against Zod schema before processing
- Sandbox injection: Format messages to prevent command injection
Permission Model
Question: Should any session be able to message any other session?
Options:
- Open: Any session can message any session (current design)
- Orchestrator-only: Only orchestrator can send to sessions, sessions can only reply
- Peer-to-peer: Sessions can message each other (like Claude Code teams)
Recommendation: Start with orchestrator-only (simpler, more secure), add peer-to-peer later if needed.
File System Permissions
Inbox directories: chmod 700 (owner read/write/execute only)
Message files: chmod 600 (owner read/write only)
Denial of Service
Problem: Malicious session floods inbox with messages.
Mitigation:
- Rate limiting: Max 10 messages/minute per session
- Inbox size limit: Max 100 unprocessed messages
- File size limit: Max 1 MB per message
- Monitoring: Alert if inbox grows >50 messages
10. Testing Strategy
Unit Tests
// packages/core/src/mailbox.test.ts
describe("Mailbox", () => {
it("sends a message to inbox", async () => {
const mailbox = new Mailbox(tmpDir, "ao-1");
const msgId = await mailbox.send("ao-10", {
type: "fix_ci_failure",
payload: { pr: "...", check: "lint", error: "..." },
priority: "high",
requiresAck: true,
});
// Check inbox file exists
const inboxFiles = await readdir(join(tmpDir, "ao-10", "inbox"));
expect(inboxFiles).toContain(expect.stringContaining(msgId));
});
it("receives messages from inbox", async () => {
// ... test receive() ...
});
it("acknowledges a message", async () => {
// ... test ack() ...
});
it("waits for acknowledgment", async () => {
// ... test waitForAck() ...
});
it("uses atomic writes", async () => {
// Verify tempfile + rename pattern
});
});
Integration Tests
// packages/core/src/mailbox.integration.test.ts
describe("Mailbox Integration", () => {
it("orchestrator sends, session receives and acks", async () => {
// 1. Orchestrator sends message
const orchestratorMailbox = new Mailbox(tmpDir, "ao-1");
const msgId = await orchestratorMailbox.send("ao-10", {
type: "status_request",
payload: { text: "What's your status?" },
priority: "normal",
requiresAck: true,
});
// 2. Session receives message
const sessionMailbox = new Mailbox(tmpDir, "ao-10");
const messages = await sessionMailbox.receive({ unreadOnly: true });
expect(messages).toHaveLength(1);
expect(messages[0].type).toBe("status_request");
// 3. Session sends ack
await sessionMailbox.ack(messages[0].id);
await sessionMailbox.send("ao-1", {
type: "ack",
payload: { text: "Working on PR #123, tests passing" },
replyTo: msgId,
priority: "normal",
requiresAck: false,
});
// 4. Orchestrator receives ack
const acked = await orchestratorMailbox.waitForAck(msgId, { timeout: 5000 });
expect(acked).toBe(true);
});
});
Load Tests
// packages/core/src/mailbox.load.test.ts
describe("Mailbox Load Tests", () => {
it("handles 1000 messages/sec", async () => {
const mailbox = new Mailbox(tmpDir, "ao-1");
const start = Date.now();
for (let i = 0; i < 1000; i++) {
await mailbox.send("ao-10", {
type: "status_request",
payload: { text: `Message ${i}` },
priority: "normal",
requiresAck: false,
});
}
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(2000); // 2 seconds for 1000 messages
});
it("handles concurrent sends from 20 sessions", async () => {
// ... concurrent test ...
});
});
11. Monitoring & Debugging
Metrics
Track in dashboard:
- Messages sent/received per session
- Unacknowledged messages (alert if >10)
- Message latency (p50, p95, p99)
- Inbox size (alert if >50)
- Watcher process health (alert if crashed)
Debugging Tools
# View all unprocessed messages for a session
find ~/.ao-sessions/ao-10/inbox -name "*.json" -exec cat {} \;
# Tail messages in real-time
watch -n 1 'find ~/.ao-sessions/ao-10/inbox -name "*.json" | wc -l'
# Pretty-print latest message
find ~/.ao-sessions/ao-10/inbox -name "*.json" -type f | \
xargs ls -t | head -1 | xargs cat | jq .
# Check if watcher is running
ps aux | grep inbox-watcher
# Manually send test message
cat > ~/.ao-sessions/ao-10/inbox/test.json << 'EOF'
{
"id": "test-123",
"from": "ao-1",
"to": "ao-10",
"timestamp": "2026-02-16T21:00:00Z",
"type": "status_request",
"payload": { "text": "Test message" },
"priority": "normal",
"requiresAck": false
}
EOF
12. Future Enhancements
Phase 1: Core Mailbox (now)
- ✅ File-based inbox/outbox
- ✅ JSON message format
- ✅ Polling watcher
- ✅ Acknowledgments
Phase 2: Optimization
- ⏳
fs.watch()for instant delivery - ⏳ Message compression (gzip old messages)
- ⏳ Rate limiting
- ⏳ File rotation
Phase 3: Advanced Features
- ⏳ Message threading (replyTo chains)
- ⏳ Broadcast messages (one-to-many)
- ⏳ Message priorities (urgent messages skip queue)
- ⏳ Rich message types (attachments, images)
Phase 4: Agent-Native Integration
- ⏳ Claude Code: Read inbox directly via hook (no watcher)
- ⏳ Codex: Custom integration
- ⏳ Aider: Custom integration
- ⏳ OpenCode: Custom integration
Phase 5: Cross-Runtime
- ⏳ Docker: Mount mailbox volume
- ⏳ Kubernetes: Use PersistentVolume
- ⏳ SSH: rsync mailbox over SSH
13. Open Questions
-
Should we support peer-to-peer messaging?
- Current design: orchestrator → sessions only
- Claude Code teams: sessions can message each other
- Decision: Start simple (orchestrator-only), add later if needed
-
What polling interval is optimal?
- Too fast: CPU overhead
- Too slow: High latency
- Recommendation: 5 seconds (configurable)
- Alternative: Use
fs.watch()for instant (but unreliable on some filesystems)
-
How to handle message delivery failures?
- Watcher crashes: Orchestrator should detect (heartbeat?)
- Agent offline: Messages queue up in inbox
- Disk full: Alert operator
- Recommendation: Add watchdog process to restart crashed watchers
-
Should messages expire?
- Urgent messages >5 min old: Escalate to notification
- Normal messages >1 hour old: Mark stale
- Recommendation: Add
expiresAtfield to message schema
-
How to test end-to-end without running actual agents?
- Mock agent: Simple script that reads inbox and writes responses
- Recommendation: Create
packages/core/src/__tests__/fixtures/mock-agent.sh
14. Conclusion
Recommendation: Implement file-based mailbox system with polling watcher.
Rationale:
- ✅ Proven approach: Claude Code uses this successfully
- ✅ No agent modifications: Works with any agent
- ✅ Simple implementation: Just filesystem operations
- ✅ Reliable: Messages never lost, survive crashes
- ✅ Debuggable: Easy to inspect with standard tools
- ✅ Acceptable latency: 5-10s is fine for orchestrator use cases
Next Steps:
- ✅ Create
packages/core/src/mailbox.ts(this week) - ✅ Add unit tests
- ✅ Build
inbox-watcher.shscript - ✅ Test with 2-3 sessions manually
- ⏳ Integrate with lifecycle manager (next week)
- ⏳ Deploy to production (2 weeks)
Timeline: 4-5 weeks to production-ready implementation.
Sources
- Claude Code Agent Teams Documentation
- Claude Code Agent Teams: Multi-Session Orchestration
- AddyOsmani.com - Claude Code Swarms
- How to Set Up and Use Claude Code Agent Teams (And Actually Get Great Results)
- How Claude Code Agents Actually Talk to Each Other (It's Weirder Than You Think)
- From Tasks to Swarms: Agent Teams in Claude Code
- Claude Code Agent Teams: Run Parallel AI Agents on Your Codebase
- Feature Request: Enable Agent-to-Agent Communication for Collaborative Workflows
- Claude Code Swarm Orchestration Skill - Complete guide
- Claude Agent Teams: Why AI Coding Is About to Feel Like Managing a Real Engineering Squad
- GitHub - claude-code-teams-mcp
- Claude Code Multi-Agent Orchestration System