<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mozi Mohidien</title>
    <description>The latest articles on DEV Community by Mozi Mohidien (@mozimohidien).</description>
    <link>https://dev.to/mozimohidien</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4041622%2F54e71cb9-4d55-46af-bfb5-9fd89378169f.png</url>
      <title>DEV Community: Mozi Mohidien</title>
      <link>https://dev.to/mozimohidien</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mozimohidien"/>
    <language>en</language>
    <item>
      <title>Bot-to-Bot Messaging Without a Message Broker: Building a Local Inbox Bus for Claude Agents</title>
      <dc:creator>Mozi Mohidien</dc:creator>
      <pubDate>Wed, 22 Jul 2026 09:39:01 +0000</pubDate>
      <link>https://dev.to/mozimohidien/bot-to-bot-messaging-without-a-message-broker-building-a-local-inbox-bus-for-claude-agents-9fc</link>
      <guid>https://dev.to/mozimohidien/bot-to-bot-messaging-without-a-message-broker-building-a-local-inbox-bus-for-claude-agents-9fc</guid>
      <description>&lt;h1&gt;
  
  
  Bot-to-Bot Messaging Without a Message Broker: Building a Local Inbox Bus for Claude Agents
&lt;/h1&gt;

&lt;p&gt;by Mozi Mohidien&lt;/p&gt;




&lt;p&gt;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."&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The simpler answer: a file-based inbox per bot, with atomic writes and a watcher that drains it on every poll cycle.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Design
&lt;/h2&gt;

&lt;p&gt;Each bot has an inbox directory at &lt;code&gt;sessions/[name]/inbox/&lt;/code&gt;. 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 &lt;code&gt;tmux send-keys&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;No broker. No daemon. No network calls. The filesystem is the queue. The watcher is the consumer.&lt;/p&gt;

&lt;p&gt;Three properties matter:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Atomic writes.&lt;/strong&gt; Messages are written to a &lt;code&gt;.tmp&lt;/code&gt; file first, then moved into the inbox directory. &lt;code&gt;mv&lt;/code&gt; within the same filesystem is atomic. The receiving bot either sees a complete message file or nothing — never a partial write.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idle injection only.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Offline delivery.&lt;/strong&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  send.sh
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;send.sh&lt;/code&gt; takes four arguments: sender bot name, recipient bot name, message content, and an optional &lt;code&gt;reply_to&lt;/code&gt; message ID.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nv"&gt;FROM&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;TO&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;CONTENT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$3&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;REPLY_TO&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;4&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nv"&gt;INBOX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/Users/mozimohidien/mozi/second_brain/sessions/&lt;/span&gt;&lt;span class="nv"&gt;$TO&lt;/span&gt;&lt;span class="s2"&gt;/inbox"&lt;/span&gt;
&lt;span class="nv"&gt;TS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s%N&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TS&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;RANDOM&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;MSG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INBOX&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.msg"&lt;/span&gt;
&lt;span class="nv"&gt;TMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INBOX&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.tmp"&lt;/span&gt;

&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INBOX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nv"&gt;ESC_CONTENT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTENT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'import json,sys; print(json.dumps(sys.stdin.read()))'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'{ "id": "%s", "from": "%s", "to": "%s", "ts": "%s", "content": %s }\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FROM&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TO&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ESC_CONTENT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MSG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'&lt;/code&gt; JSON-encodes the content string, handling quotes, newlines, and special characters. This matters when message content contains multi-line prompts or code.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;send.sh&lt;/code&gt; prints the message ID to stdout. The sender can capture it for reply routing.&lt;/p&gt;




&lt;h2&gt;
  
  
  drain.sh
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;drain.sh&lt;/code&gt; is sourced by every bot's &lt;code&gt;watch.sh&lt;/code&gt;. It runs on each poll cycle, after the bot's health check.&lt;/p&gt;

&lt;p&gt;Pseudocode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;function &lt;/span&gt;drain_bot_inbox&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    INBOX &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sessions/&lt;/span&gt;&lt;span class="nv"&gt;$BOT_NAME&lt;/span&gt;&lt;span class="s2"&gt;/inbox"&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;inbox is empty:
        &lt;span class="k"&gt;return&lt;/span&gt;

    &lt;span class="c"&gt;# Check pane readiness&lt;/span&gt;
    PANE_CONTENT &lt;span class="o"&gt;=&lt;/span&gt; tmux capture-pane &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nv"&gt;$SESSION&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;PANE_CONTENT matches /&lt;span class="o"&gt;(&lt;/span&gt;esc to interrupt|working&lt;span class="se"&gt;\.\.\.&lt;/span&gt;|tokens used|numbered dialog &lt;span class="se"&gt;\[&lt;/span&gt;1&lt;span class="se"&gt;\]&lt;/span&gt;|&lt;span class="se"&gt;\[&lt;/span&gt;y&lt;span class="se"&gt;\/&lt;/span&gt;n&lt;span class="se"&gt;\]&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;/i:
        &lt;span class="c"&gt;# Bot is busy — leave messages for next cycle&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;

    &lt;span class="c"&gt;# Process messages in order (sorted by filename = arrival order)&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;MSG_FILE &lt;span class="k"&gt;in &lt;/span&gt;sorted&lt;span class="o"&gt;(&lt;/span&gt;inbox/&lt;span class="k"&gt;*&lt;/span&gt;.msg&lt;span class="o"&gt;)&lt;/span&gt;:
        CONTENT &lt;span class="o"&gt;=&lt;/span&gt; parse JSON content field from MSG_FILE
        FROM &lt;span class="o"&gt;=&lt;/span&gt; parse JSON from field
        ID &lt;span class="o"&gt;=&lt;/span&gt; parse JSON &lt;span class="nb"&gt;id &lt;/span&gt;field

        &lt;span class="c"&gt;# Build the injected prompt&lt;/span&gt;
        PROMPT &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"[BOT-BUS] Message from &lt;/span&gt;&lt;span class="nv"&gt;$FROM&lt;/span&gt;&lt;span class="s2"&gt; (id: &lt;/span&gt;&lt;span class="nv"&gt;$ID&lt;/span&gt;&lt;span class="s2"&gt;):&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="nv"&gt;$CONTENT&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;[Reply via: send.sh &lt;/span&gt;&lt;span class="nv"&gt;$BOT_NAME&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$FROM&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;your reply here&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$ID&lt;/span&gt;&lt;span class="s2"&gt;]"&lt;/span&gt;

        &lt;span class="c"&gt;# Inject into the tmux session&lt;/span&gt;
        tmux send-keys &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nv"&gt;$SESSION&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROMPT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; Enter

        &lt;span class="c"&gt;# Delete the message file&lt;/span&gt;
        &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;$MSG_FILE&lt;/span&gt;

        &lt;span class="c"&gt;# Wait for Claude to start processing before injecting the next message&lt;/span&gt;
        &lt;span class="nb"&gt;sleep &lt;/span&gt;3

        &lt;span class="c"&gt;# Re-check pane — if Claude started a task, stop draining&lt;/span&gt;
        PANE_CONTENT &lt;span class="o"&gt;=&lt;/span&gt; tmux capture-pane &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nv"&gt;$SESSION&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;PANE_CONTENT matches /&lt;span class="o"&gt;(&lt;/span&gt;esc to interrupt|working&lt;span class="o"&gt;)&lt;/span&gt;/i:
            &lt;span class="nb"&gt;break
    &lt;/span&gt;end
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The readiness check is the critical piece. Claude's pane shows different text depending on state:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ready for input: shows the prompt cursor&lt;/li&gt;
&lt;li&gt;Mid-task: shows "esc to interrupt" or streaming output&lt;/li&gt;
&lt;li&gt;Usage modal: shows numbered options&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reply Routing
&lt;/h2&gt;

&lt;p&gt;When &lt;code&gt;drain.sh&lt;/code&gt; injects a message, it appends instructions for the receiving bot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[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]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The receiving bot sees this as an ordinary prompt. It reads the instructions, forms a response, and calls &lt;code&gt;send.sh&lt;/code&gt; as directed. The reply lands in the sender's inbox, with &lt;code&gt;reply_to&lt;/code&gt; set to the original message ID so the sender can correlate it.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Real Usage Example: oper8 → life_tutor
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: oper8 sends a message to life_tutor&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;send.sh oper8 life_tutor &lt;span class="s2"&gt;"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."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This writes a file to &lt;code&gt;/Users/mozimohidien/mozi/second_brain/sessions/life_tutor/inbox/1720000120000-4521-8833.msg&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1720000120000-4521-8833"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"oper8"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"life_tutor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1720000120000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"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."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2: life_tutor's watcher drain detects the message&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On the next 30-second poll, &lt;code&gt;drain_bot_inbox&lt;/code&gt; runs. The pane check passes (life_tutor is idle). The drain function reads the message and injects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[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]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3: life_tutor processes and replies&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Claude (life_tutor session) receives the prompt, reads &lt;code&gt;LEARNING_PATH.md&lt;/code&gt;, forms a summary, and executes the reply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;send.sh life_tutor oper8 &lt;span class="s2"&gt;"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."&lt;/span&gt; 1720000120000-4521-8833
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 4: oper8's watcher delivers the reply&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The reply lands in &lt;code&gt;/Users/mozimohidien/mozi/second_brain/sessions/oper8/inbox/1720000180000-7722-3341.msg&lt;/code&gt;. On oper8's next poll, the drain delivers it. The oper8 Claude session receives the context and continues building the client briefing.&lt;/p&gt;

&lt;p&gt;Total latency: under 60 seconds (two watcher cycles maximum). No external infrastructure. No broker to maintain or fail.&lt;/p&gt;




&lt;h2&gt;
  
  
  Idle Safety in Practice
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The readiness regex catches the common cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;esc to interrupt&lt;/code&gt; — Claude is actively working&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;working...&lt;/code&gt; — task in progress indicator&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tokens used&lt;/code&gt; — post-response summary, Claude may still be settling&lt;/li&gt;
&lt;li&gt;Numbered dialog &lt;code&gt;[1]&lt;/code&gt; — modal or confirmation prompt, not safe to inject&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;[y/n]&lt;/code&gt; — Claude is asking a yes/no question&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of these appear in the captured pane content, the drain stops and all messages wait for the next cycle.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;sleep 3&lt;/code&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Failure Modes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Message never delivered.&lt;/strong&gt; Check if the target bot's inbox directory exists and is writable. Check that &lt;code&gt;drain_bot_inbox&lt;/code&gt; is being called in the target's &lt;code&gt;watch.sh&lt;/code&gt;. Confirm the pane readiness check is not stuck (e.g., pane permanently showing "esc to interrupt" because Claude froze mid-task).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Duplicate delivery.&lt;/strong&gt; The &lt;code&gt;rm -f $MSG_FILE&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Garbled content.&lt;/strong&gt; The &lt;code&gt;python3&lt;/code&gt; JSON encoding in &lt;code&gt;send.sh&lt;/code&gt; handles most special characters. The failure mode is content that breaks JSON — usually an unescaped backslash or a very long string. Test with &lt;code&gt;echo "$ID" | python3 -m json.tool&lt;/code&gt; if messages are arriving malformed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reply never received.&lt;/strong&gt; Claude processed the message and answered but did not call &lt;code&gt;send.sh&lt;/code&gt;. This happens when the injected instructions are too buried in a long conversation context. Keep the &lt;code&gt;[Reply via: ...]&lt;/code&gt; instruction at the end of the injection, not the beginning — Claude reads to completion before acting.&lt;/p&gt;




&lt;h2&gt;
  
  
  Scaling the Pattern
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The watcher architecture already handles the drain — it is part of every bot's standard &lt;code&gt;watch.sh&lt;/code&gt;. The send and drain scripts are utilities shared across all sessions.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>claude</category>
      <category>telegram</category>
    </item>
    <item>
      <title>The Orchestrator Pattern for Claude Agents: Delegating Everything, Owning Nothing</title>
      <dc:creator>Mozi Mohidien</dc:creator>
      <pubDate>Wed, 22 Jul 2026 09:32:25 +0000</pubDate>
      <link>https://dev.to/mozimohidien/the-orchestrator-pattern-for-claude-agents-delegating-everything-owning-nothing-1n1l</link>
      <guid>https://dev.to/mozimohidien/the-orchestrator-pattern-for-claude-agents-delegating-everything-owning-nothing-1n1l</guid>
      <description>&lt;h1&gt;
  
  
  The Orchestrator Pattern for Claude Agents: Delegating Everything, Owning Nothing
&lt;/h1&gt;

&lt;p&gt;by Mozi Mohidien&lt;/p&gt;




&lt;p&gt;The most common mistake when building multi-agent systems with Claude is turning the orchestrator into a worker. It reads files. It edits code. It calls APIs directly. Within twenty turns, the orchestrator's context is bloated with tool output noise, the session is slow, and every response costs more than it should.&lt;/p&gt;

&lt;p&gt;The fix is a strict rule: the orchestrator never acts. It only plans, delegates, and reviews.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Core Rule
&lt;/h2&gt;

&lt;p&gt;The orchestrator does exactly three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Understand the task and break it down&lt;/li&gt;
&lt;li&gt;Brief and spawn agents to execute&lt;/li&gt;
&lt;li&gt;Review what comes back and surface the result to the user (or re-brief if it's wrong)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything else — file edits, terminal commands, API calls, deployments, web searches, browser navigation — goes through a spawned agent. The orchestrator touches nothing directly.&lt;/p&gt;

&lt;p&gt;This sounds simple. It requires discipline to maintain, because the temptation to "just do it inline" is constant. One inline file read feels harmless. Ten turns later, the session context contains 40KB of raw file content that gets re-read on every subsequent turn.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Context Cost Matters More Than You Think
&lt;/h2&gt;

&lt;p&gt;For Fable (claude-fable-5), cost hits from two directions.&lt;/p&gt;

&lt;p&gt;Input: every turn re-reads the entire session context. A conversation that has accumulated tool outputs from twelve previous turns re-reads all of that on turn thirteen. Context does not get cheaper as the session grows — it gets more expensive per turn.&lt;/p&gt;

&lt;p&gt;Output: Fable's thinking tokens bill as output. Even a short response with a thinking budget burns output tokens. The longer the session, the more the model thinks to navigate the accumulated context.&lt;/p&gt;

&lt;p&gt;This is why the orchestrator rule has financial weight, not just architectural weight. An orchestrator that stays clean — no inline tool calls, no accumulated raw output — keeps per-turn cost low throughout the session. An orchestrator that acts directly starts cheap and gets expensive fast.&lt;/p&gt;




&lt;h2&gt;
  
  
  Handling Context Overflow
&lt;/h2&gt;

&lt;p&gt;Sessions eventually get long. The right response is not to keep going — it is to hand off cleanly and restart.&lt;/p&gt;

&lt;p&gt;The pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Append relevant updates to the brain files (MOZI.md, operate_with_intelligence.md, the relevant brief — whichever apply)&lt;/li&gt;
&lt;li&gt;Write &lt;code&gt;HANDOFF.md&lt;/code&gt; — the baton pass document&lt;/li&gt;
&lt;li&gt;Touch a signal file that the watcher daemon is polling&lt;/li&gt;
&lt;li&gt;The watcher sees the signal file, kills the current session, starts a fresh one
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Trigger the restart&lt;/span&gt;
&lt;span class="nb"&gt;touch&lt;/span&gt; ~/mozi/second_brain/.restart_laptop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The watcher picks this up within 30 seconds. The new session starts with zero tool noise in context. Brain files carry forward the relevant state.&lt;/p&gt;




&lt;h2&gt;
  
  
  HANDOFF.md: Baton Pass, Not Log
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;HANDOFF.md&lt;/code&gt; has one job: give the next session exactly what it needs to continue, nothing more.&lt;/p&gt;

&lt;p&gt;Hard constraints: under 30 lines, three fields only.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Handoff&lt;/span&gt;

&lt;span class="gu"&gt;## Done this session&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Rebuilt the oper8 bot start.sh after model env var broke Telegram server mode
&lt;span class="p"&gt;-&lt;/span&gt; Updated LEARNING_PATH.md with new MCP module completion
&lt;span class="p"&gt;-&lt;/span&gt; Cleaned stale files from /mozi/university/

&lt;span class="gu"&gt;## Next step&lt;/span&gt;
Deploy the updated general_fable bot and verify it processes messages

&lt;span class="gu"&gt;## Urgent&lt;/span&gt;
None
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire document. No audit lists. No issue inventories. No historical context. No "also worth noting" sections.&lt;/p&gt;

&lt;p&gt;The reason for the constraint: &lt;code&gt;HANDOFF.md&lt;/code&gt; gets read on every session start. Every line is input tokens. A 200-line HANDOFF becomes a tax on every single turn of the next session. Keep it short.&lt;/p&gt;

&lt;p&gt;The next session overwrites it entirely. It is never accumulated. It is never a log of what has happened over time. It tells the next session exactly one thing: where to pick up.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Quality Gate
&lt;/h2&gt;

&lt;p&gt;The orchestrator is the last reviewer before output reaches the user. If an agent's output is incomplete, wrong, or mediocre, the user should never see it.&lt;/p&gt;

&lt;p&gt;When an agent returns:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the output critically&lt;/li&gt;
&lt;li&gt;Ask: does this fully achieve what was asked?&lt;/li&gt;
&lt;li&gt;If yes, surface it&lt;/li&gt;
&lt;li&gt;If no: diagnose exactly why it failed, refine the brief, re-spawn&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Common failure modes to check for: agent completed the task technically but missed the actual goal; output is correct but needs synthesizing with another agent's output before it's useful; agent hit a blocker and returned a partial result without flagging it clearly.&lt;/p&gt;

&lt;p&gt;"I couldn't do it" from an agent is never an acceptable final answer without investigation. The orchestrator probes: what specifically failed? Was it a missing credential? Wrong file path? Tool error? Then it fixes the brief and tries again, or finds a different approach.&lt;/p&gt;

&lt;p&gt;Only when all paths are genuinely exhausted does the orchestrator surface to Mozi: here is what was tried, here is the blocker, here is what he needs to do.&lt;/p&gt;




&lt;h2&gt;
  
  
  Model Selection for Spawned Agents
&lt;/h2&gt;

&lt;p&gt;Not every agent needs the same model. Over-provisioning burns budget. Under-provisioning produces worse results.&lt;/p&gt;

&lt;p&gt;The decision matrix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Haiku&lt;/strong&gt; — single-file reads, classification, simple lookups, anything with a short deterministic answer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sonnet&lt;/strong&gt; — coding, file edits, multi-step execution, most tasks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Opus&lt;/strong&gt; — complex reasoning, architecture decisions, deep multi-step analysis requiring judgment&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fable&lt;/strong&gt; — only when the agent itself needs orchestration capability (rare; spawning a Fable agent to orchestrate sub-agents is usually the wrong design)
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Spawning examples
&lt;/span&gt;
&lt;span class="c1"&gt;# Quick lookup — haiku
&lt;/span&gt;&lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;haiku&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Look up contact number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Read /Users/mozimohidien/mozi/second_brain/contacts.json and return the phone number for Ayesha Kader.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Code edit — sonnet
&lt;/span&gt;&lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sonnet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Edit start.sh model env var&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Edit /path/to/sessions/oper8/start.sh. Change ANTHROPIC_MODEL from claude-sonnet-4-6 to claude-fable-5. Test that the file is valid bash syntax after.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Architecture review — opus
&lt;/span&gt;&lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;opus&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Review MCP server design&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Review the design of the Telegram bridge MCP server at ~/.claude/plugins/telegram_bridge/server.ts. Identify any race conditions or failure modes in the polling loop.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The WhatsApp Flow Example
&lt;/h2&gt;

&lt;p&gt;A concrete example of the full pattern: Mozi says "send a WhatsApp to Bilal saying the meeting is confirmed for Thursday at 3pm."&lt;/p&gt;

&lt;p&gt;The orchestrator does not send the message. It does not look up the contact. Here is the flow:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1:&lt;/strong&gt; Read &lt;code&gt;contacts.json&lt;/code&gt; to find Bilal's number. This is a simple lookup — haiku agent.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Agent(haiku): Read contacts.json, return phone number for "Bilal"
Result: +27821234567
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2:&lt;/strong&gt; Brief a sonnet agent with everything it needs. No back-and-forth.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sonnet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Send WhatsApp to Bilal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Send a WhatsApp message to +27821234567 (Bilal).
    Message: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hey — meeting confirmed for Thursday at 3pm.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;

    Use this approach:
    osascript -e &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;open location &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;whatsapp://send?phone=27821234567&amp;amp;text=Hey%20%E2%80%94%20meeting%20confirmed%20for%20Thursday%20at%203pm.&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="s"&gt;

    Run it and confirm it opens WhatsApp. Report back with success or failure and the exact error if it fails.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3:&lt;/strong&gt; Review the result. Did WhatsApp open? Did the message send? If yes, tell Mozi it is done. If no, diagnose and try another approach (Playwright, direct API if configured).&lt;/p&gt;

&lt;p&gt;The orchestrator never touched the terminal. It never read the contacts file directly. It planned, delegated both tasks cleanly, reviewed results, and reported.&lt;/p&gt;




&lt;h2&gt;
  
  
  Screen Lock Pattern
&lt;/h2&gt;

&lt;p&gt;Some agents need to control the screen — browser navigation, clicking, form submission. Only one agent can do this at a time or they race each other.&lt;/p&gt;

&lt;p&gt;Lock file: &lt;code&gt;/Users/mozimohidien/mozi/second_brain/.screen_agent_active&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Acquire before starting screen work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Check if another agent has the lock&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"/Users/mozimohidien/mozi/second_brain/.screen_agent_active"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Screen already locked by another agent. Aborting."&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Acquire&lt;/span&gt;
&lt;span class="nb"&gt;touch&lt;/span&gt; /Users/mozimohidien/mozi/second_brain/.screen_agent_active
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Release when done (always — even on failure):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Release&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /Users/mozimohidien/mozi/second_brain/.screen_agent_active
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The orchestrator must never spawn two screen-active agents simultaneously. Before spawning an agent that needs screen access, check that no other screen agent is running. If the lock file exists, either wait or queue the task.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cost Discipline Summary
&lt;/h2&gt;

&lt;p&gt;Keep Fable orchestrators cheap:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;CLAUDE_CODE_EFFORT_LEVEL=low&lt;/code&gt; in every Fable bot's &lt;code&gt;start.sh&lt;/code&gt;. Never remove it.&lt;/li&gt;
&lt;li&gt;Restart sessions early — after heavy agent cycles, not just when approaching context limits. A short session with a tight handoff costs less per turn than a long one.&lt;/li&gt;
&lt;li&gt;No inline tool calls in the orchestrator — keep its context clean.&lt;/li&gt;
&lt;li&gt;Write tight briefs. Every line in a CLAUDE.md or HANDOFF.md is re-read on every turn. Ruthlessly cut anything stale.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The orchestrator's job is thinking, not doing. Keep its context lean so the thinking stays sharp.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>claude</category>
      <category>telegram</category>
    </item>
    <item>
      <title>Running a Fleet of Telegram Bots on Claude Code: Per-Bot Sessions, MCP, and Auto-Restart</title>
      <dc:creator>Mozi Mohidien</dc:creator>
      <pubDate>Wed, 22 Jul 2026 09:31:49 +0000</pubDate>
      <link>https://dev.to/mozimohidien/running-a-fleet-of-telegram-bots-on-claude-code-per-bot-sessions-mcp-and-auto-restart-2feo</link>
      <guid>https://dev.to/mozimohidien/running-a-fleet-of-telegram-bots-on-claude-code-per-bot-sessions-mcp-and-auto-restart-2feo</guid>
      <description>&lt;h1&gt;
  
  
  Running a Fleet of Telegram Bots on Claude Code: Per-Bot Sessions, MCP, and Auto-Restart
&lt;/h1&gt;

&lt;p&gt;by Mozi Mohidien&lt;/p&gt;




&lt;p&gt;Running one Telegram bot on Claude Code is straightforward. Running eight simultaneously — each with its own model, context, memory, and capabilities — without them trampling each other is an architecture problem most people hit after the second bot.&lt;/p&gt;

&lt;p&gt;This article walks through the full stack: session isolation, the MCP plugin, auto-restart via launchd and tmux, and a subtle confirmation prompt bug that will silently break every bot you start without a workaround.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Claude Code is a process. It loads context, runs tools, maintains state. If you run two bots in the same session, they share context. They see each other's messages. One bot's tool calls pollute the other's reasoning.&lt;/p&gt;

&lt;p&gt;The solution: one bot per Claude Code session. Full isolation. Each bot is its own process, with its own working directory, its own MCP config, its own Telegram token.&lt;/p&gt;

&lt;p&gt;But then you have eight processes to start, monitor, and restart. Manually doing that is not a system — it's a chore.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;

&lt;p&gt;Each bot has its own session directory under &lt;code&gt;second_brain/sessions/[name]/&lt;/code&gt;. Inside that directory:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;start.sh&lt;/code&gt; — launches the Claude Code process with the right environment&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;watch.sh&lt;/code&gt; — runs in a loop, monitors the tmux session, restarts if dead&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.mcp.json&lt;/code&gt; — tells Claude Code which MCP servers to load (Telegram bridge, others)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Telegram bridge plugin lives at &lt;code&gt;~/.claude/plugins/telegram_bridge/server.ts&lt;/code&gt;. It is shared across all bots — each bot's &lt;code&gt;.mcp.json&lt;/code&gt; points to the same file, but passes different environment variables (token, state dir). That is how one plugin supports eight bots without duplication.&lt;/p&gt;

&lt;p&gt;Claude Code runs in a special server mode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude &lt;span class="nt"&gt;--dangerously-skip-permissions&lt;/span&gt; &lt;span class="nt"&gt;--dangerously-load-development-channels&lt;/span&gt; server:telegram
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This mode keeps Claude running as a long-lived process, receiving Telegram messages via the MCP plugin and responding through it. Without &lt;code&gt;server:telegram&lt;/code&gt;, Claude exits after each response.&lt;/p&gt;




&lt;h2&gt;
  
  
  start.sh
&lt;/h2&gt;

&lt;p&gt;Every bot needs its own &lt;code&gt;start.sh&lt;/code&gt;. The critical details: the bot token, the state directory (unique per bot — this is where Telegram session data lives), and the model.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;TELEGRAM_BOT_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"[BOT_TOKEN]"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;TELEGRAM_STATE_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/Users/you/.claude/channels/telegram_[BOTNAME]"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_MODEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"claude-fable-5"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_CODE_EFFORT_LEVEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;low
&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"/path/to/sessions/[BOTNAME]"&lt;/span&gt;
&lt;span class="nb"&gt;exec &lt;/span&gt;claude &lt;span class="nt"&gt;--dangerously-skip-permissions&lt;/span&gt; &lt;span class="nt"&gt;--dangerously-load-development-channels&lt;/span&gt; server:telegram
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth calling out.&lt;/p&gt;

&lt;p&gt;First: the model is set via &lt;code&gt;export ANTHROPIC_MODEL=...&lt;/code&gt;, not via a &lt;code&gt;--model&lt;/code&gt; flag. The &lt;code&gt;--model&lt;/code&gt; flag breaks Telegram server mode. Claude Code's server mode has specific initialization behavior that the flag interferes with. Use the environment variable.&lt;/p&gt;

&lt;p&gt;Second: &lt;code&gt;CLAUDE_CODE_EFFORT_LEVEL=low&lt;/code&gt; for Fable bots. Fable (claude-fable-5) is the most capable but most expensive model. It bills thinking tokens as output. Setting effort to low reduces the thinking budget per turn, which keeps the weekly allocation from draining on routine bot responses. Do not remove this line.&lt;/p&gt;




&lt;h2&gt;
  
  
  .mcp.json
&lt;/h2&gt;

&lt;p&gt;Each session directory has its own &lt;code&gt;.mcp.json&lt;/code&gt;. Claude Code reads this file when it starts and loads the listed MCP servers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"telegram"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bun"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"run"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/path/to/.claude/plugins/telegram_bridge/server.ts"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"TELEGRAM_BOT_TOKEN"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[BOT_TOKEN]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"TELEGRAM_STATE_DIR"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/path/to/.claude/channels/telegram_[name]"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plugin is launched via &lt;code&gt;bun&lt;/code&gt;. The &lt;code&gt;env&lt;/code&gt; block passes the bot-specific token and state directory to the plugin process. The plugin handles everything: long-polling Telegram for new messages, surfacing them to Claude as tool results, and providing a &lt;code&gt;reply&lt;/code&gt; tool Claude uses to respond.&lt;/p&gt;

&lt;p&gt;Because each bot's &lt;code&gt;.mcp.json&lt;/code&gt; passes a different &lt;code&gt;TELEGRAM_STATE_DIR&lt;/code&gt;, their session data never collides. Telegram's polling state, message history cursor, and any plugin-side data are fully isolated per bot.&lt;/p&gt;




&lt;h2&gt;
  
  
  tmux + launchd: The Auto-Restart Pattern
&lt;/h2&gt;

&lt;p&gt;Each bot runs inside a named tmux session. This matters for two reasons: tmux sessions survive terminal disconnects, and &lt;code&gt;tmux send-keys&lt;/code&gt; lets a watcher script inject input into the running Claude process without attaching to it.&lt;/p&gt;

&lt;p&gt;Nine launchd plists (&lt;code&gt;com.mozi.[name]-watcher&lt;/code&gt;) run continuously on startup. Each one runs &lt;code&gt;watch.sh&lt;/code&gt; for its corresponding bot. The watcher polls every 30 seconds.&lt;/p&gt;

&lt;p&gt;The core loop in &lt;code&gt;watch.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SIGNAL_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SIGNAL_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
        &lt;span class="nb"&gt;sleep &lt;/span&gt;60
        &lt;span class="nv"&gt;$TMUX&lt;/span&gt; kill-session &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null
        &lt;span class="nv"&gt;$TMUX&lt;/span&gt; new-session &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$START_CMD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
        &lt;span class="nb"&gt;sleep &lt;/span&gt;15
        &lt;span class="nv"&gt;$TMUX&lt;/span&gt; send-keys &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"1"&lt;/span&gt; Enter
        &lt;span class="nb"&gt;sleep &lt;/span&gt;5
        &lt;span class="nv"&gt;$TMUX&lt;/span&gt; send-keys &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; Enter
    &lt;span class="k"&gt;fi

    if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$TMUX&lt;/span&gt; has-session &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;5
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$TMUX&lt;/span&gt; has-session &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
            &lt;span class="nv"&gt;$TMUX&lt;/span&gt; new-session &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$START_CMD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
            &lt;span class="nb"&gt;sleep &lt;/span&gt;15
            &lt;span class="nv"&gt;$TMUX&lt;/span&gt; send-keys &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"1"&lt;/span&gt; Enter
        &lt;span class="k"&gt;fi
    else
        &lt;/span&gt;detect_and_dismiss_usage_modal &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
        drain_bot_inbox
    &lt;span class="k"&gt;fi
    &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;30
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two paths: signal file (intentional restart, triggered by another bot or admin command) and crash detection (tmux session gone). Both paths start a new tmux session running &lt;code&gt;start.sh&lt;/code&gt;, then wait 15 seconds and send &lt;code&gt;"1" Enter&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That 15-second wait and keypress is the fix to the most important bug in this setup.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Confirmation Prompt Bug
&lt;/h2&gt;

&lt;p&gt;When Claude Code starts, it shows a confirmation prompt asking whether to proceed in the current mode. Without user input, it sits there. The bot technically starts but never processes any Telegram messages because Claude is waiting for the human to press a key.&lt;/p&gt;

&lt;p&gt;The naive fix is &lt;code&gt;skipDangerousModePermissionPrompt: true&lt;/code&gt; in &lt;code&gt;settings.json&lt;/code&gt;. This does not reliably suppress the prompt in server mode. Do not rely on it.&lt;/p&gt;

&lt;p&gt;The actual fix: after starting the tmux session, wait long enough for the prompt to appear (15 seconds covers even slow startup), then send the keypress:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$TMUX&lt;/span&gt; new-session &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$START_CMD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;sleep &lt;/span&gt;15
&lt;span class="nv"&gt;$TMUX&lt;/span&gt; send-keys &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"1"&lt;/span&gt; Enter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tmux send-keys&lt;/code&gt; injects input into the pane as if a user typed it. The bot receives the "1" Enter, dismisses the prompt, and starts processing messages.&lt;/p&gt;

&lt;p&gt;Without this, you get a bot that shows as running in &lt;code&gt;tmux list-sessions&lt;/code&gt; but never responds. It is a silent failure. The logs look clean. Telegram messages arrive and pile up. Nothing processes them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Usage Limit Modal Auto-Dismiss
&lt;/h2&gt;

&lt;p&gt;Fable bots can hit usage limits mid-session. Claude Code displays an interactive modal asking what to do: continue with Fable, switch to Sonnet, or stop. Without auto-dismissal, the bot freezes until a human intervenes.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;detect_and_dismiss_usage_modal&lt;/code&gt; function in &lt;code&gt;watch.sh&lt;/code&gt; handles this. It:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Captures the current tmux pane content&lt;/li&gt;
&lt;li&gt;Looks for phrases like "limit", "credits", or numbered-option markers&lt;/li&gt;
&lt;li&gt;Prefers "Continue with Fable" — extracts the digit from the option line and sends it&lt;/li&gt;
&lt;li&gt;Falls back to "Switch to Sonnet" if Fable continuation is unavailable&lt;/li&gt;
&lt;li&gt;Falls back to sending "1" if no specific option is recognized&lt;/li&gt;
&lt;li&gt;Logs every auto-dismiss event to &lt;code&gt;usage_modal.log&lt;/code&gt; with a timestamp&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This runs on every poll cycle (every 30 seconds). A modal that appears between poll cycles goes undetected for at most 30 seconds, then gets auto-dismissed. The bot continues without human intervention.&lt;/p&gt;




&lt;h2&gt;
  
  
  Access Control
&lt;/h2&gt;

&lt;p&gt;Each bot has an access list at &lt;code&gt;~/.claude/channels/[name]/access.json&lt;/code&gt;. This is an allowlist of Telegram user IDs. Messages from users not on the list are silently ignored by the plugin.&lt;/p&gt;

&lt;p&gt;The structure is straightforward — a JSON object with an array of permitted user IDs. The plugin checks each incoming message against this list before surfacing it to Claude.&lt;/p&gt;

&lt;p&gt;This matters when a bot's token is compromised or shared accidentally. Without the allowlist, anyone who finds the token can interact with your bots and run arbitrary tool calls.&lt;/p&gt;




&lt;h2&gt;
  
  
  Setup Checklist
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Per bot:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Create &lt;code&gt;sessions/[name]/&lt;/code&gt; directory&lt;/li&gt;
&lt;li&gt;[ ] Write &lt;code&gt;start.sh&lt;/code&gt; with unique &lt;code&gt;TELEGRAM_BOT_TOKEN&lt;/code&gt;, &lt;code&gt;TELEGRAM_STATE_DIR&lt;/code&gt;, and correct &lt;code&gt;ANTHROPIC_MODEL&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Copy &lt;code&gt;watch.sh&lt;/code&gt; from the canonical template — do not write it from scratch&lt;/li&gt;
&lt;li&gt;[ ] Write &lt;code&gt;.mcp.json&lt;/code&gt; pointing to the shared plugin with bot-specific env vars&lt;/li&gt;
&lt;li&gt;[ ] Create &lt;code&gt;~/.claude/channels/[name]/access.json&lt;/code&gt; with permitted Telegram user IDs&lt;/li&gt;
&lt;li&gt;[ ] Create the &lt;code&gt;TELEGRAM_STATE_DIR&lt;/code&gt; directory&lt;/li&gt;
&lt;li&gt;[ ] Test &lt;code&gt;start.sh&lt;/code&gt; manually in a terminal first — confirm Claude starts and the prompt appears&lt;/li&gt;
&lt;li&gt;[ ] Verify the "1" keypress dismisses the prompt and the bot logs message receipt&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;For the watcher:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Write a launchd plist &lt;code&gt;com.mozi.[name]-watcher&lt;/code&gt; that runs &lt;code&gt;watch.sh&lt;/code&gt; at startup&lt;/li&gt;
&lt;li&gt;[ ] Load with &lt;code&gt;launchctl load ~/Library/LaunchAgents/com.mozi.[name]-watcher.plist&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Confirm it appears in &lt;code&gt;launchctl list | grep mozi&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Verify everything works:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Send a test message from an allowlisted Telegram account&lt;/li&gt;
&lt;li&gt;[ ] Confirm the bot replies&lt;/li&gt;
&lt;li&gt;[ ] Kill the tmux session manually — confirm the watcher restarts it within 60 seconds&lt;/li&gt;
&lt;li&gt;[ ] Check that the restart sends the "1" keypress automatically&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Common Failure Modes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Bot starts but never responds.&lt;/strong&gt; Almost always the confirmation prompt. Attach to the tmux session (&lt;code&gt;tmux attach -t [name]&lt;/code&gt;) and look. If you see a prompt waiting for input, the &lt;code&gt;send-keys&lt;/code&gt; timing is off. Increase the sleep before the keypress.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two bots receiving the same messages.&lt;/strong&gt; &lt;code&gt;TELEGRAM_STATE_DIR&lt;/code&gt; is shared or identical. Each bot must have a unique state directory. Check the &lt;code&gt;env&lt;/code&gt; in &lt;code&gt;.mcp.json&lt;/code&gt; and the export in &lt;code&gt;start.sh&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bot crashes repeatedly.&lt;/strong&gt; Check the launchd logs and the tmux pane output before it exits. Most crashes are MCP server startup failures — usually a missing &lt;code&gt;bun&lt;/code&gt; in PATH, wrong plugin path, or malformed &lt;code&gt;.mcp.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watcher not restarting dead sessions.&lt;/strong&gt; Confirm the launchd plist is loaded and running. Check &lt;code&gt;launchctl list com.mozi.[name]-watcher&lt;/code&gt;. If the &lt;code&gt;tmux&lt;/code&gt; binary path in &lt;code&gt;watch.sh&lt;/code&gt; is wrong (common when tmux is installed via Homebrew and PATH differs in launchd context), set an absolute path.&lt;/p&gt;




&lt;p&gt;The architecture scales linearly. Each new bot is the same pattern: a directory, three files, one launchd plist. The shared plugin means there is no per-bot plugin code to maintain. The watcher handles failures automatically. Once the pattern is running, adding bot number nine is twenty minutes of work.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>claude</category>
      <category>telegram</category>
    </item>
  </channel>
</rss>
