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
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
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]"
}
}
}
}
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
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
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:
- Captures the current tmux pane content
- Looks for phrases like "limit", "credits", or numbered-option markers
- Prefers "Continue with Fable" β extracts the digit from the option line and sends it
- Falls back to "Switch to Sonnet" if Fable continuation is unavailable
- Falls back to sending "1" if no specific option is recognized
- Logs every auto-dismiss event to
usage_modal.logwith 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.shwith uniqueTELEGRAM_BOT_TOKEN,TELEGRAM_STATE_DIR, and correctANTHROPIC_MODEL - [ ] Copy
watch.shfrom the canonical template β do not write it from scratch - [ ] Write
.mcp.jsonpointing to the shared plugin with bot-specific env vars - [ ] Create
~/.claude/channels/[name]/access.jsonwith permitted Telegram user IDs - [ ] Create the
TELEGRAM_STATE_DIRdirectory - [ ] Test
start.shmanually 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]-watcherthat runswatch.shat 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.













