Skip to content

How AI Agent Teams Coordinate: JSONL Mailboxes and Async Communication

I hit a wall. My single AI agent was trying to do too much - coding, reviewing, testing, planning - and it was failing. Context windows were blowing out. The agent would forget earlier decisions. It was a mess.

Then I realized: why not use a team? A coder agent, a reviewer agent, a planner agent - each specialized, each focused. But how do they talk to each other?

The Problem: Synchronous Agents Don’t Scale

I started with a naive approach:

def run_team(task):
plan = planner_agent(task)
code = coder_agent(plan)
review = reviewer_agent(code)
return review

This works for simple cases. But what happens when:

  • The reviewer needs the coder to fix something?
  • Multiple tasks need to run in parallel?
  • An agent needs to wait for input from multiple teammates?

The synchronous call chain breaks down. I needed async communication.

The Solution: JSONL Mailboxes

After experimenting with message queues, Redis pub/sub, and even a full-blown message broker, I settled on something embarrassingly simple: JSONL files.

Each teammate has an inbox - a .jsonl file in a directory. Messages get appended to the file. When an agent checks its inbox, it reads all messages and drains the file.

.team/
├── config.json
└── inbox/
├── alice.jsonl
├── bob.jsonl
└── lead.jsonl

Why JSONL?

  • Line-delimited JSON - each line is a complete message, easy to parse
  • Atomic appends - file systems handle concurrent writes gracefully
  • Persistent - messages survive restarts
  • Debuggable - just cat the file to see what happened

The MessageBus Implementation

Here’s the core of the system:

import json
import time
from pathlib import Path
VALID_MSG_TYPES = {
"message",
"broadcast",
"shutdown_request",
"shutdown_response",
"plan_approval_response",
}
class MessageBus:
def __init__(self, inbox_dir: Path):
self.dir = inbox_dir
self.dir.mkdir(parents=True, exist_ok=True)
def send(self, sender: str, to: str, content: str,
msg_type: str = "message", extra: dict = None) -> str:
if msg_type not in VALID_MSG_TYPES:
return f"Error: Invalid type '{msg_type}'. Valid: {VALID_MSG_TYPES}"
msg = {
"type": msg_type,
"from": sender,
"content": content,
"timestamp": time.time(),
}
if extra:
msg.update(extra)
inbox_path = self.dir / f"{to}.jsonl"
with open(inbox_path, "a") as f:
f.write(json.dumps(msg) + "\n")
return f"Sent {msg_type} to {to}"
def read_inbox(self, name: str) -> list:
inbox_path = self.dir / f"{name}.jsonl"
if not inbox_path.exists():
return []
messages = []
for line in inbox_path.read_text().strip().splitlines():
if line:
messages.append(json.loads(line))
# Drain the inbox
inbox_path.write_text("")
return messages
def broadcast(self, sender: str, content: str, extra: dict = None) -> str:
for inbox_file in self.dir.glob("*.jsonl"):
recipient = inbox_file.stem
if recipient != sender:
self.send(sender, recipient, content, "broadcast", extra)
return f"Broadcast to all teammates"

The key insight: read_inbox drains the file. Messages are consumed when read, preventing duplicate processing.

How Teammates Work

Each teammate runs in its own thread, checking its inbox on each loop iteration:

import threading
class TeammateManager:
def __init__(self, bus: MessageBus):
self.bus = bus
self.threads = {}
self.statuses = {}
def spawn(self, name: str, role: str, prompt: str) -> str:
self.statuses[name] = {"role": role, "status": "idle"}
thread = threading.Thread(
target=self._teammate_loop,
args=(name, role, prompt),
daemon=True,
)
self.threads[name] = thread
thread.start()
return f"Spawned {name} ({role})"
def _teammate_loop(self, name: str, role: str, prompt: str):
while True:
# Check inbox
messages = self.bus.read_inbox(name)
for msg in messages:
if msg["type"] == "shutdown_request":
self.bus.send(name, msg["from"], "Bye!", "shutdown_response")
self.statuses[name]["status"] = "shutdown"
return
if msg["type"] == "plan_approval_response":
# Handle approval...
pass
# Process regular message
self.statuses[name]["status"] = "working"
# ... do work ...
self.statuses[name]["status"] = "idle"
time.sleep(1) # Polling interval

The loop is simple:

  1. Check inbox
  2. Process messages
  3. Sleep briefly
  4. Repeat

Message Types and Protocols

Not all messages are created equal. I defined specific types:

┌─────────────────────────────────────────────────────────────┐
│ Message Types │
├─────────────────────────────────────────────────────────────┤
│ message │ Regular communication │
│ broadcast │ One-to-all announcement │
│ shutdown_request │ Ask teammate to stop │
│ shutdown_response │ Confirm shutdown │
│ plan_approval_... │ Response to plan approval request │
└─────────────────────────────────────────────────────────────┘

This type system prevents message confusion. A teammate knows exactly how to handle each message type:

for msg in messages:
match msg["type"]:
case "message":
handle_message(msg)
case "broadcast":
handle_broadcast(msg)
case "shutdown_request":
shutdown(msg)
return
case "plan_approval_response":
handle_approval(msg)

Subagent vs Teammate: Know the Difference

This tripped me up at first. There are two patterns:

┌─────────────────────────────────────────────────────────────┐
│ Subagent │
│ spawn → execute → return summary → destroyed │
│ (ephemeral, for one-off tasks) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Teammate │
│ spawn → work → idle → work → idle → ... → shutdown │
│ (persistent, for ongoing coordination) │
└─────────────────────────────────────────────────────────────┘

Subagents are for:

  • One-off tasks (“analyze this file”)
  • Simple delegation (“run tests”)
  • Quick lookups (“what’s the error?”)

Teammates are for:

  • Ongoing coordination
  • Stateful workflows
  • Long-running processes

The mailbox pattern is overkill for subagents but essential for teammates.

Real-World Example: Code Review Flow

Here’s how a code review flows through the team:

Lead Coder Reviewer
│ │ │
│──── "implement X" ──→│ │
│ │ │
│ │─── "review code" ───→│
│ │ │
│ │←── "found issues" ──│
│ │ │
│ │─── "fixed issues" ──→│
│ │ │
│ │←── "approved" ──────│
│ │ │
│←── "task done" ─────│ │
│ │ │

Each arrow is a message. Each agent processes asynchronously. No one waits for anyone.

Why This Works

  1. Decoupling: Agents don’t need to know about each other’s implementation details. They just send messages.

  2. Persistence: Messages survive crashes. If an agent dies and restarts, it can read pending messages.

  3. Debuggability: When something goes wrong, I can inspect .jsonl files to see exactly what messages were sent.

  4. Simplicity: No external dependencies. No message broker to maintain. Just files.

  5. Scalability: Adding a new teammate is as simple as spawning a new thread and giving it an inbox.

What I Got Wrong Initially

My first attempt used in-memory message queues. This was faster but had a fatal flaw: if the process crashed, all messages were lost. In a long-running agent system, this meant lost work and confused teammates.

The JSONL approach is slightly slower (file I/O vs memory) but much more reliable. The trade-off was worth it.

Another mistake: I didn’t define message types upfront. Everything was just “message”. This led to confusion when I needed shutdown signals or approval responses. Adding the type system cleaned everything up.

The Config File

The team configuration lives in config.json:

{
"team_name": "default",
"members": [
{"name": "alice", "role": "coder", "status": "idle"},
{"name": "bob", "role": "reviewer", "status": "idle"},
{"name": "lead", "role": "coordinator", "status": "idle"}
]
}

This gives the TeammateManager a view of the whole team:

def list(self) -> list:
return [
{"name": name, **info}
for name, info in self.statuses.items()
]
def status(self, name: str) -> dict:
return self.statuses.get(name, {"error": "Teammate not found"})

When to Use This Pattern

This mailbox pattern shines when:

  • You have multiple specialized agents working together
  • Tasks require back-and-forth coordination
  • You need persistence across restarts
  • You want debuggability without external tools

It’s overkill for:

  • Simple delegation (use subagents instead)
  • Single-agent workflows
  • Synchronous request-response patterns

Key Takeaways

  1. File-based JSONL inboxes provide simple, persistent async communication
  2. Message types prevent confusion and enable protocol enforcement
  3. Teammates are persistent, subagents are ephemeral - choose accordingly
  4. Draining inboxes prevents duplicate message processing
  5. Separate threads for each teammate enable true parallelism

The motto from the codebase says it well: “When the task is too big for one, delegate to teammates.” And when you delegate to teammates, they need mailboxes.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments