agent-orchestrator/docs/agent-communication.md

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:

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:

  1. Check inbox: Read ~/.claude/teams/{team}/inboxes/{self}.json
  2. Process unread messages: Filter read: false, handle, mark read: true
  3. Check tasks: Call TaskList() to find available tasks
  4. Claim work: TaskUpdate({ taskId: "X", owner: "self", status: "in_progress" })
  5. Execute: Do the work
  6. Report: TaskUpdate({ taskId: "X", status: "completed" })
  7. Notify: Send message to lead with results
  8. Sleep: Wait 30s if no tasks, exponential backoff before shutdown

Concurrency Safety

  • File locks: Uses filelock library for cross-process coordination
  • Atomic writes: tempfile + os.replace to prevent partial reads
  • Retry logic: Exponential backoff on lock contention

3. Communication Architecture Comparison

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:

  1. Orchestrator sends message:

    const messageId = await mailbox.send("ao-10", {
      type: "fix_ci_failure",
      payload: { pr: "...", check: "lint", error: "..." }
    });
    
  2. 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
  3. Hook delivers message to agent:

    # ~/.ao-sessions/ao-10/.claude/settings.json
    {
      "hooks": {
        "OnMessageReceived": [
          {
            "type": "command",
            "command": "~/.ao-sessions/check-inbox.sh"
          }
        ]
      }
    }
    
  4. Agent processes message, sends response:

    await mailbox.send("ao-1", {
      type: "ack",
      replyTo: messageId,
      payload: { status: "processing" }
    });
    
  5. 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:

  1. 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);
      });
    });
    
  2. Orchestrator sends message:

    const client = net.connect("/tmp/ao-10.sock");
    client.write(JSON.stringify({ type: "fix_ci", ... }));
    client.end();
    
  3. 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 net module

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:

  1. Orchestrator appends message:

    const message = { id: uuid(), from: "ao-1", to: "ao-10", ... };
    fs.appendFileSync("~/.ao-sessions/messages.jsonl", JSON.stringify(message) + "\n");
    
  2. 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);
    });
    
  3. 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)


Why File-based Mailbox?

  1. No agent modification required - Works with Claude Code, Codex, Aider, any agent
  2. Proven at scale - Claude Code uses this for agent teams
  3. Simple implementation - Just filesystem operations
  4. Reliable - Messages never lost, survive crashes
  5. Debuggable - cat, jq, tail work out of the box
  6. Portable - Works with tmux, docker, k8s, SSH
  7. 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:

  1. Spawn session: Start inbox-watcher.sh in background

    // After creating tmux session
    await execFile("tmux", [
      "send-keys", "-t", sessionId,
      `nohup ~/.ao-sessions/${sessionId}/.claude/inbox-watcher.sh &> /dev/null &`,
      "Enter"
    ]);
    
  2. 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,
    });
    
  3. Watcher detects message: Within 5 seconds

  4. Watcher injects prompt: Via tmux send-keys

  5. Agent sees prompt: As if user typed it

  6. Agent processes: Fixes CI, pushes commit

  7. Watcher sends ack: Writes to ao-1/inbox/

  8. 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 Mailbox class in packages/core/src/mailbox.ts
  • Add inbox/outbox directories to session creation
  • Implement inbox-watcher.sh script
  • 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:

  1. Session ID verification: Check from field matches sender's session ID
  2. Schema validation: Validate message against Zod schema before processing
  3. Sandbox injection: Format messages to prevent command injection

Permission Model

Question: Should any session be able to message any other session?

Options:

  1. Open: Any session can message any session (current design)
  2. Orchestrator-only: Only orchestrator can send to sessions, sessions can only reply
  3. 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:

  1. Rate limiting: Max 10 messages/minute per session
  2. Inbox size limit: Max 100 unprocessed messages
  3. File size limit: Max 1 MB per message
  4. 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

  1. 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
  2. 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)
  3. 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
  4. Should messages expire?

    • Urgent messages >5 min old: Escalate to notification
    • Normal messages >1 hour old: Mark stale
    • Recommendation: Add expiresAt field to message schema
  5. 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:

  1. Create packages/core/src/mailbox.ts (this week)
  2. Add unit tests
  3. Build inbox-watcher.sh script
  4. Test with 2-3 sessions manually
  5. Integrate with lifecycle manager (next week)
  6. Deploy to production (2 weeks)

Timeline: 4-5 weeks to production-ready implementation.


Sources