Bot-to-Bot Messaging Without a Message Broker: Building a Local Inbox Bus for Claude Agents
by Mozi Mohidien
Telegram's Bot API is one-way. A bot can receive messages from users and reply to them. It cannot send messages to another bot. There is no API call for "bot A sends a message to bot B."
In a multi-bot architecture where bots need to collaborate β one bot asking another for context, one bot routing a task to a specialist β this is a real problem. The usual answer is a message broker: Redis pub/sub, RabbitMQ, something with a queue. That works but adds infrastructure to a system that is already running eight processes on a single machine.
The simpler answer: a file-based inbox per bot, with atomic writes and a watcher that drains it on every poll cycle.
The Design
Each bot has an inbox directory at sessions/[name]/inbox/. A message is a JSON file dropped into that directory. The bot's watcher script scans the inbox on every 30-second poll, checks whether the bot is idle, and injects waiting messages via tmux send-keys.
No broker. No daemon. No network calls. The filesystem is the queue. The watcher is the consumer.
Three properties matter:
Atomic writes. Messages are written to a .tmp file first, then moved into the inbox directory. mv within the same filesystem is atomic. The receiving bot either sees a complete message file or nothing β never a partial write.
Idle injection only. The watcher checks whether the bot's Claude process is at a ready prompt before injecting. If Claude is mid-task, the message stays in the inbox and gets delivered on the next poll cycle. This prevents injecting a new task into an already-running one.
Offline delivery. The inbox persists on disk. If a bot is down when a message arrives, the message sits in the inbox until the bot restarts. The next drain cycle delivers it.
send.sh
send.sh takes four arguments: sender bot name, recipient bot name, message content, and an optional reply_to message ID.
#!/bin/bash
FROM="$1"
TO="$2"
CONTENT="$3"
REPLY_TO="${4:-}"
INBOX="/Users/mozimohidien/mozi/second_brain/sessions/$TO/inbox"
TS="$(date +%s%N)"
ID="${TS}-$$-${RANDOM}"
MSG="$INBOX/${ID}.msg"
TMP="$INBOX/${ID}.tmp"
mkdir -p "$INBOX"
ESC_CONTENT="$(printf '%s' "$CONTENT" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')"
printf '{ "id": "%s", "from": "%s", "to": "%s", "ts": "%s", "content": %s }\n' \
"$ID" "$FROM" "$TO" "$TS" "$ESC_CONTENT" > "$TMP"
mv "$TMP" "$MSG"
echo "$ID"
The message filename encodes a timestamp, PID, and random number. This prevents collisions if two bots send to the same recipient simultaneously. The timestamp ensures messages drain in arrival order.
python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' JSON-encodes the content string, handling quotes, newlines, and special characters. This matters when message content contains multi-line prompts or code.
send.sh prints the message ID to stdout. The sender can capture it for reply routing.
drain.sh
drain.sh is sourced by every bot's watch.sh. It runs on each poll cycle, after the bot's health check.
Pseudocode:
function drain_bot_inbox() {
INBOX = "sessions/$BOT_NAME/inbox"
if inbox is empty:
return
# Check pane readiness
PANE_CONTENT = tmux capture-pane -t $SESSION -p
if PANE_CONTENT matches /(esc to interrupt|working\.\.\.|tokens used|numbered dialog \[1\]|\[y\/n\])/i:
# Bot is busy β leave messages for next cycle
return
# Process messages in order (sorted by filename = arrival order)
for MSG_FILE in sorted(inbox/*.msg):
CONTENT = parse JSON content field from MSG_FILE
FROM = parse JSON from field
ID = parse JSON id field
# Build the injected prompt
PROMPT = "[BOT-BUS] Message from $FROM (id: $ID):\n\n$CONTENT\n\n[Reply via: send.sh $BOT_NAME $FROM \"your reply here\" $ID]"
# Inject into the tmux session
tmux send-keys -t $SESSION "$PROMPT" Enter
# Delete the message file
rm -f $MSG_FILE
# Wait for Claude to start processing before injecting the next message
sleep 3
# Re-check pane β if Claude started a task, stop draining
PANE_CONTENT = tmux capture-pane -t $SESSION -p
if PANE_CONTENT matches /(esc to interrupt|working)/i:
break
end
}
The readiness check is the critical piece. Claude's pane shows different text depending on state:
- Ready for input: shows the prompt cursor
- Mid-task: shows "esc to interrupt" or streaming output
- Usage modal: shows numbered options
The drain function only injects when the pane is at a clean prompt. Everything else means Claude is busy. Messages queue and deliver on the next cycle.
Reply Routing
When drain.sh injects a message, it appends instructions for the receiving bot:
[BOT-BUS] Message from oper8 (id: 1720000000000-1234-5678):
I need the learning context for Mozi's current AI module β specifically what he's covered so far and what's next on the path. I'm briefing a client on Mozi's technical background.
[Reply via: send.sh life_tutor oper8 "your reply here" 1720000000000-1234-5678]
The receiving bot sees this as an ordinary prompt. It reads the instructions, forms a response, and calls send.sh as directed. The reply lands in the sender's inbox, with reply_to set to the original message ID so the sender can correlate it.
This requires Claude to follow the reply instruction reliably. In practice it does β the instruction is explicit and the format is consistent. The occasional failure (Claude answers but doesn't send the reply) is handled by the sender timing out and either re-sending or handling the missing reply gracefully.
A Real Usage Example: oper8 β life_tutor
Scenario: the oper8 bot is preparing a client briefing for a prospect interested in Mozi's technical background. It needs to know what Mozi has been studying in AI recently.
Step 1: oper8 sends a message to life_tutor
send.sh oper8 life_tutor "I need Mozi's current learning context β what AI topics has he covered recently and what is he working on next? I'm briefing Nexus Capital on his technical background."
This writes a file to /Users/mozimohidien/mozi/second_brain/sessions/life_tutor/inbox/1720000120000-4521-8833.msg:
{
"id": "1720000120000-4521-8833",
"from": "oper8",
"to": "life_tutor",
"ts": "1720000120000",
"content": "I need Mozi's current learning context β what AI topics has he covered recently and what is he working on next? I'm briefing Nexus Capital on his technical background."
}
Step 2: life_tutor's watcher drain detects the message
On the next 30-second poll, drain_bot_inbox runs. The pane check passes (life_tutor is idle). The drain function reads the message and injects:
[BOT-BUS] Message from oper8 (id: 1720000120000-4521-8833):
I need Mozi's current learning context β what AI topics has he covered recently and what is he working on next? I'm briefing Nexus Capital on his technical background.
[Reply via: send.sh life_tutor oper8 "your reply here" 1720000120000-4521-8833]
Step 3: life_tutor processes and replies
Claude (life_tutor session) receives the prompt, reads LEARNING_PATH.md, forms a summary, and executes the reply:
send.sh life_tutor oper8 "Mozi completed the core Claude API module last week β tool use, multi-turn conversations, context management. Currently working through the agent orchestration patterns module. Next up: MCP server design and multi-agent coordination. Strong foundations; actively building production systems on top." 1720000120000-4521-8833
Step 4: oper8's watcher delivers the reply
The reply lands in /Users/mozimohidien/mozi/second_brain/sessions/oper8/inbox/1720000180000-7722-3341.msg. On oper8's next poll, the drain delivers it. The oper8 Claude session receives the context and continues building the client briefing.
Total latency: under 60 seconds (two watcher cycles maximum). No external infrastructure. No broker to maintain or fail.
Idle Safety in Practice
A bot injected mid-task causes immediate problems. Claude is streaming output for one task and suddenly receives a new prompt β the context merges, the outputs collide, both tasks produce garbage.
The readiness regex catches the common cases:
-
esc to interruptβ Claude is actively working -
working...β task in progress indicator -
tokens usedβ post-response summary, Claude may still be settling - Numbered dialog
[1]β modal or confirmation prompt, not safe to inject -
[y/n]β Claude is asking a yes/no question
If any of these appear in the captured pane content, the drain stops and all messages wait for the next cycle.
The sleep 3 between messages gives Claude time to start processing the first injected message before the next one arrives. Without it, two messages can land in the same prompt buffer and Claude reads them as one.
Failure Modes
Message never delivered. Check if the target bot's inbox directory exists and is writable. Check that drain_bot_inbox is being called in the target's watch.sh. Confirm the pane readiness check is not stuck (e.g., pane permanently showing "esc to interrupt" because Claude froze mid-task).
Duplicate delivery. The rm -f $MSG_FILE after injection handles this β once delivered, the file is gone. If you see duplicates, the delete is failing silently. Add error checking around the rm.
Garbled content. The python3 JSON encoding in send.sh handles most special characters. The failure mode is content that breaks JSON β usually an unescaped backslash or a very long string. Test with echo "$ID" | python3 -m json.tool if messages are arriving malformed.
Reply never received. Claude processed the message and answered but did not call send.sh. This happens when the injected instructions are too buried in a long conversation context. Keep the [Reply via: ...] instruction at the end of the injection, not the beginning β Claude reads to completion before acting.
Scaling the Pattern
Eight bots, any one of which can message any other. The inbox pattern scales without modification. Adding a new bot means creating its inbox directory. Nothing else changes.
The watcher architecture already handles the drain β it is part of every bot's standard watch.sh. The send and drain scripts are utilities shared across all sessions.
The pattern also composes with the screen lock and agent spawning patterns. A bot that needs to do something requiring screen access checks the lock, acquires it, does the work, releases it, and reports back via send.sh β all within the same architecture, no new infrastructure required.
For a system running entirely on a local machine, this is the right level of complexity: just enough to make bots collaborate reliably, nothing more.













