DEV Community

Mozi Mohidien
Mozi Mohidien

Posted on

Running a Fleet of Telegram Bots on Claude Code: Per-Bot Sessions, MCP, and Auto-Restart

Running a Fleet of Telegram Bots on Claude Code: Per-Bot Sessions, MCP, and Auto-Restart

by Mozi Mohidien


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.

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.


The Problem

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.

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.

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


Architecture Overview

Each bot has its own session directory under second_brain/sessions/[name]/. Inside that directory:

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

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

Claude Code runs in a special server mode:

claude --dangerously-skip-permissions --dangerously-load-development-channels server:telegram
Enter fullscreen mode Exit fullscreen mode

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


start.sh

Every bot needs its own start.sh. The critical details: the bot token, the state directory (unique per bot — this is where Telegram session data lives), and the model.

#!/bin/bash
export TELEGRAM_BOT_TOKEN="[BOT_TOKEN]"
export TELEGRAM_STATE_DIR="/Users/you/.claude/channels/telegram_[BOTNAME]"
export ANTHROPIC_MODEL="claude-fable-5"
export CLAUDE_CODE_EFFORT_LEVEL=low
cd "/path/to/sessions/[BOTNAME]"
exec claude --dangerously-skip-permissions --dangerously-load-development-channels server:telegram
Enter fullscreen mode Exit fullscreen mode

Two things worth calling out.

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

Second: CLAUDE_CODE_EFFORT_LEVEL=low 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.


.mcp.json

Each session directory has its own .mcp.json. Claude Code reads this file when it starts and loads the listed MCP servers.

{
  "mcpServers": {
    "telegram": {
      "command": "bun",
      "args": ["run", "/path/to/.claude/plugins/telegram_bridge/server.ts"],
      "env": {
        "TELEGRAM_BOT_TOKEN": "[BOT_TOKEN]",
        "TELEGRAM_STATE_DIR": "/path/to/.claude/channels/telegram_[name]"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The plugin is launched via bun. The env 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 reply tool Claude uses to respond.

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


tmux + launchd: The Auto-Restart Pattern

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

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

The core loop in watch.sh:

while true; do
    if [ -f "$SIGNAL_FILE" ]; then
        rm -f "$SIGNAL_FILE"
        sleep 60
        $TMUX kill-session -t "$SESSION" 2>/dev/null
        $TMUX new-session -d -s "$SESSION" "$START_CMD"
        sleep 15
        $TMUX send-keys -t "$SESSION" "1" Enter
        sleep 5
        $TMUX send-keys -t "$SESSION" "" Enter
    fi

    if ! $TMUX has-session -t "$SESSION" 2>/dev/null; then
        sleep 5
        if ! $TMUX has-session -t "$SESSION" 2>/dev/null; then
            $TMUX new-session -d -s "$SESSION" "$START_CMD"
            sleep 15
            $TMUX send-keys -t "$SESSION" "1" Enter
        fi
    else
        detect_and_dismiss_usage_modal "$SESSION" "$LOG_FILE"
        drain_bot_inbox
    fi
    sleep 30
done
Enter fullscreen mode Exit fullscreen mode

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 start.sh, then wait 15 seconds and send "1" Enter.

That 15-second wait and keypress is the fix to the most important bug in this setup.


The Confirmation Prompt Bug

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.

The naive fix is skipDangerousModePermissionPrompt: true in settings.json. This does not reliably suppress the prompt in server mode. Do not rely on it.

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:

$TMUX new-session -d -s "$SESSION" "$START_CMD"
sleep 15
$TMUX send-keys -t "$SESSION" "1" Enter
Enter fullscreen mode Exit fullscreen mode

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

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


Usage Limit Modal Auto-Dismiss

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.

The detect_and_dismiss_usage_modal function in watch.sh handles this. It:

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

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.


Access Control

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

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.

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.


Setup Checklist

Per bot:

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

For the watcher:

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

Verify everything works:

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

Common Failure Modes

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

Two bots receiving the same messages. TELEGRAM_STATE_DIR is shared or identical. Each bot must have a unique state directory. Check the env in .mcp.json and the export in start.sh.

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

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


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.

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev

Per-bot sessions are a good boundary. Once multiple bots share the same agent runtime, isolation and restart behavior become product features, not just deployment details.