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 reviewThis 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.jsonlWhy 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
catthe file to see what happened
The MessageBus Implementation
Here’s the core of the system:
import jsonimport timefrom 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 intervalThe loop is simple:
- Check inbox
- Process messages
- Sleep briefly
- 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
-
Decoupling: Agents don’t need to know about each other’s implementation details. They just send messages.
-
Persistence: Messages survive crashes. If an agent dies and restarts, it can read pending messages.
-
Debuggability: When something goes wrong, I can inspect
.jsonlfiles to see exactly what messages were sent. -
Simplicity: No external dependencies. No message broker to maintain. Just files.
-
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
- File-based JSONL inboxes provide simple, persistent async communication
- Message types prevent confusion and enable protocol enforcement
- Teammates are persistent, subagents are ephemeral - choose accordingly
- Draining inboxes prevents duplicate message processing
- 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