Summary
What this post covers: A complete blueprint for remote-controlling Claude Code from a phone via Telegram, Slack, Discord, or a generic webhook—with full Python bridge scripts, non-interactive claude -p patterns, security controls, systemd and Docker deployment, and monitoring workflows.
Key insights:
- The core trick is non-interactive Claude Code:
claude -pin a subprocess turns any messaging bot into a remote terminal, so the whole architecture reduces to “receive message, runclaude -p, send back result” plus auth, rate limiting, and output chunking. - Platform choice should follow your use case: Telegram is the clear winner for personal use (unlimited free bot API, 15-minute setup), Slack is best for team workflows because your team is already there, Discord fits communities, and MS Teams is viable but requires roughly 60 minutes of setup.
- Security is the part most tutorials skip and the part that matters most—user-ID allowlisting, command allowlists, rate limits, and audit logging must be in place before sharing the bot, otherwise you have published a shell to the internet.
- For production reliability use systemd or Docker (not
nohup), handle long outputs by chunking around the per-platform message limit (4,096 chars on Telegram, 2,000 on Discord, 40,000 on Slack), and run the bridge on the same machine as Claude Code to avoid filesystem-sync complexity. - The bridge pattern is platform-agnostic: once you understand it, the same code adapts to WhatsApp, LINE, or any webhook-capable system, and proactive alerts (CI failures, health checks) become as cheap as a single notification call.
Main topics: Why Remote Control Claude Code?, Architecture Overview, Running Claude Code Non-Interactively, Telegram Bot Complete Implementation, Slack Bot Complete Implementation, Discord Bot, Generic Webhook Approach, Security Best Practices, Production Deployment, Practical Workflow Examples, Monitoring and Notifications, Limitations and Workarounds, Final Thoughts, References.
Consider a scenario in which a developer is commuting home on a train after a long day. The developer opens Telegram on a phone and types /deploy staging. Within two minutes, Claude Code on the development machine activates, runs the entire deployment pipeline, and returns a confirmation message with the deployment URL—all from the phone, without any need to open a laptop. The capability described is neither speculative nor difficult to assemble. It can be implemented in a single afternoon using nothing more than a free messaging bot and a short Python script.
The setup tends to alter the way developers think about their workflows. Claude Code ceases to be a tool that is usable only while seated at a desk and instead becomes a continuously available assistant accessible from anywhere—a grocery store, a gym, or a coffee shop in another city. The implementation is also remarkably simple.
This guide describes the construction of complete, production-ready bridges between Claude Code and the most widely used messaging platforms: Telegram, Slack, Discord, and a generic webhook approach that is compatible with most other systems. Full Python scripts, systemd service files, Docker configurations, and production-proven security practices are provided. By the end, a remote control for Claude Code that fits in a pocket is fully assembled.
Why Remote Control Claude Code?
Before implementation details are considered, the motivation for remote control deserves examination. Claude Code is an exceptionally capable tool, yet by default it is tethered to the terminal. The user must be at the machine, in the shell, and actively observing the output. That constraint eliminates a large number of practical use cases.
The Case for Remote Access
Work from anywhere. Builds, deployments, code generation, and analysis can be triggered from a phone. A laptop is not required. In fact, no computer is required. Any device capable of sending a text message becomes a development terminal.
Asynchronous workflows. Complex tasks—refactoring a module, writing tests for an entire package, or producing a comprehensive code review—can be dispatched to Claude Code, after which the developer can attend to other matters. A notification arrives once the work is complete, removing the need to wait at a terminal.
Team collaboration. When the bot is added to a shared Slack channel, any member of the engineering team can trigger shared workflows. A junior developer can run the deployment pipeline without SSH access to the server. A product manager can produce the daily status report without delegating the task.
Emergency fixes. If production fails while a developer is at the airport, there is no need to find a quiet corner, open a laptop, and tether to a phone hotspot. The command /run fix the null pointer in src/auth.py and deploy to production can be issued directly from the Slack app on the phone.
Monitoring and response. Proactive alerts can be configured so that, when a CI/CD pipeline fails, a Telegram notification is dispatched together with a one-tap command for retry or investigation. Similarly, a Slack alert with an action button to restart the service can accompany degraded server health.
Platform Comparison
Not all messaging platforms are equally well suited to this use case. The principal options are compared below:
| Feature | Telegram | Slack | Discord | MS Teams |
|---|---|---|---|---|
| Bot API ease | Excellent | Good | Good | Complex |
| Webhook support | Native polling + webhooks | Events API + Socket Mode | Gateway (WebSocket) | Outgoing webhooks |
| Free tier limits | Unlimited | 10k msg history | Unlimited | Requires M365 |
| Message length limit | 4,096 chars | 40,000 chars | 2,000 chars | 28,000 chars |
| Mobile app quality | Excellent | Excellent | Good | Good |
| Setup time | ~15 minutes | ~30 minutes | ~20 minutes | ~60 minutes |
| Best for | Personal use | Team workflows | Community/hobby | Enterprise |
Architecture Overview
Regardless of the messaging platform selected, the architecture follows the same pattern. The pattern is the key to the system, and once it is understood the same approach can be adapted to any platform within minutes.
The Message Flow
The complete flow from the phone to Claude Code and back is illustrated below:
┌──────────┐ ┌───────────────┐ ┌──────────────┐ ┌─────────────┐
│ Your │───▶│ Messaging │───▶│ Bridge │───▶│ Claude │
│ Phone │ │ Platform │ │ Server │ │ Code CLI │
│ │◀───│ (Telegram) │◀───│ (Python) │◀───│ (claude) │
└──────────┘ └───────────────┘ └──────────────┘ └─────────────┘
│
┌─────┴─────┐
│ Auth │
│ Rate │
│ Limit │
│ Logging │
└───────────┘
The central component is the bridge server, a lightweight Python or Node.js application that performs three functions:
- Receives messages from the messaging platform’s bot API, either via polling or webhooks.
- Validates and routes messages through security checks, including authentication, rate limiting, and command allowlisting.
- Executes Claude Code as a subprocess and returns the result to the chat.
The bridge server runs on the same machine on which Claude Code is installed. If Claude Code is on the local development machine, the bridge runs there as well. For a more robust configuration, the bridge may be hosted on a VPS and may use SSH to invoke Claude Code on the development machine; the simplest version is described first.
Why a Bridge Server?
The question of why the messaging platform is not connected directly to Claude Code naturally arises. The reason is that Claude Code is a CLI tool that reads from stdin and writes to stdout. It does not natively speak HTTP or WebSocket protocols. The bridge translates between the messaging platform’s API protocol and Claude Code’s command-line interface and may be viewed as a thin adapter layer.
Running Claude Code Non-Interactively
Before any bot is constructed, it is necessary to understand how to run Claude Code without an interactive terminal. This foundation underpins every bridge server.
The Print Flag
The most important flag is -p (or --print). This flag runs Claude Code in non-interactive mode: a prompt is supplied, processed, the result is printed, and the process exits. There is no interactive UI, no REPL, and no terminal manipulation.
# Basic non-interactive usage
claude -p "List all Python files in the current directory"
# With a specific working directory
cd /path/to/project && claude -p "Explain the architecture of this project"
# JSON output for structured parsing
claude -p "List all functions in src/main.py" --output-format json
Key CLI Flags for Non-Interactive Use
| Flag | Purpose | Example |
|---|---|---|
-p / --print |
Non-interactive mode, prints output | claude -p "fix the bug" |
--output-format json |
Structured JSON output | claude -p "list files" --output-format json |
--max-turns N |
Limit agentic turns | claude -p "refactor" --max-turns 10 |
--allowedTools |
Restrict which tools Claude can use | claude -p "check" --allowedTools Read Grep |
--model |
Specify model to use | claude -p "analyze" --model sonnet |
Calling Claude Code from Python
The following function is the core that every bridge server relies upon and the heart of the entire system:
import subprocess
import os
def run_claude(prompt: str, working_dir: str = None, timeout: int = 300) -> dict:
"""
Run Claude Code non-interactively and return the result.
Args:
prompt: The prompt to send to Claude Code
working_dir: Directory to run in (uses CLAUDE_WORK_DIR env var as default)
timeout: Maximum seconds to wait (default 5 minutes)
Returns:
dict with 'success' (bool), 'output' (str), and 'error' (str)
"""
work_dir = working_dir or os.getenv("CLAUDE_WORK_DIR", os.path.expanduser("~"))
try:
result = subprocess.run(
["claude", "-p", prompt],
capture_output=True,
text=True,
timeout=timeout,
cwd=work_dir,
env={**os.environ, "TERM": "dumb"} # Prevent terminal escape codes
)
if result.returncode == 0:
return {
"success": True,
"output": result.stdout.strip(),
"error": None
}
else:
return {
"success": False,
"output": result.stdout.strip(),
"error": result.stderr.strip()
}
except subprocess.TimeoutExpired:
return {
"success": False,
"output": None,
"error": f"Command timed out after {timeout} seconds"
}
except FileNotFoundError:
return {
"success": False,
"output": None,
"error": "Claude Code CLI not found. Is it installed and in PATH?"
}
except Exception as e:
return {
"success": False,
"output": None,
"error": str(e)
}
TERM=dumb in the environment prevents Claude Code from emitting terminal escape codes such as colours and cursor movements, which would otherwise clutter chat messages. The detail is small but materially improves output readability.
Handling Long-Running Tasks
Some Claude Code tasks may run for several minutes, including refactoring large files, executing full test suites, or generating comprehensive documentation. Such cases must be handled gracefully:
import asyncio
import subprocess
from concurrent.futures import ThreadPoolExecutor
executor = ThreadPoolExecutor(max_workers=3)
async def run_claude_async(prompt: str, working_dir: str = None, timeout: int = 600):
"""Run Claude Code in a thread pool to avoid blocking the bot's event loop."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
executor,
lambda: run_claude(prompt, working_dir, timeout)
)
The pattern shown above is essential. Messaging bot libraries such as python-telegram-bot and slack-bolt run on asynchronous event loops. A direct call to subprocess.run blocks the entire bot, so that no other messages can be processed while Claude Code is running. Executing the subprocess in a thread pool executor keeps the bot responsive.
Method 1: Telegram Bot — Complete Implementation
Telegram is the optimal starting point. Its bot API is free, unlimited, requires no server because it supports polling, and the mobile app is well designed. A working remote control can be assembled from scratch in approximately fifteen minutes.
Step 1: Create a Telegram Bot
Open Telegram on the phone or desktop and search for @BotFather, the official Telegram bot for creating and managing bots. Begin a conversation and proceed as follows:
- Send
/newbot. - Enter a display name for the bot (for example, “My Claude Code Bot”).
- Enter a username, which must end in “bot” (for example, “my_claude_code_bot”).
- BotFather will respond with an API token, which must be stored securely.
Next, the bot’s command menu should be configured so that autocomplete is available in the chat:
# Send this to @BotFather:
/setcommands
# Then select your bot and paste:
run - Run a Claude Code prompt
deploy - Deploy to an environment
test - Run project tests
status - Check current task status
git - Run git commands (log, status, diff)
help - List available commands
Finally, the Telegram user ID is required for authentication. A message sent to @userinfobot will be answered with the numeric user ID. This value should be stored and ensures that only the authorised user can control the bot.
Step 2: Build the Bridge Server
The following is a complete, production-ready Telegram bridge server. It is not an illustrative fragment: authentication, rate limiting, asynchronous execution, output truncation, and proper error handling are all included:
#!/usr/bin/env python3
"""
Telegram Bridge for Claude Code
================================
Controls Claude Code sessions from Telegram messages.
Usage:
python telegram_bridge.py
Environment variables (in .env):
TELEGRAM_BOT_TOKEN - Bot token from @BotFather
TELEGRAM_ALLOWED_USERS - Comma-separated list of allowed user IDs
CLAUDE_WORK_DIR - Working directory for Claude Code
"""
import asyncio
import logging
import os
import subprocess
import time
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from functools import wraps
from dotenv import load_dotenv
from telegram import Update
from telegram.ext import (
Application,
CommandHandler,
ContextTypes,
MessageHandler,
filters,
)
load_dotenv()
# --- Configuration ---
BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
ALLOWED_USERS = set(
int(uid.strip())
for uid in os.getenv("TELEGRAM_ALLOWED_USERS", "").split(",")
if uid.strip()
)
WORK_DIR = os.getenv("CLAUDE_WORK_DIR", os.path.expanduser("~/projects"))
MAX_MESSAGE_LENGTH = 4000 # Telegram limit is 4096, leave margin
RATE_LIMIT = 10 # Max commands per hour per user
COMMAND_TIMEOUT = 600 # 10 minutes max per command
# --- Logging ---
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
level=logging.INFO,
handlers=[
logging.StreamHandler(),
logging.FileHandler("telegram_bridge.log"),
],
)
logger = logging.getLogger(__name__)
# --- State ---
executor = ThreadPoolExecutor(max_workers=3)
rate_limits = defaultdict(list) # user_id -> list of timestamps
active_tasks = {} # user_id -> task description
# --- Helpers ---
def run_claude(prompt: str, working_dir: str = None, timeout: int = COMMAND_TIMEOUT) -> dict:
"""Run Claude Code non-interactively."""
work_dir = working_dir or WORK_DIR
try:
result = subprocess.run(
["claude", "-p", prompt],
capture_output=True,
text=True,
timeout=timeout,
cwd=work_dir,
env={**os.environ, "TERM": "dumb"},
)
return {
"success": result.returncode == 0,
"output": result.stdout.strip(),
"error": result.stderr.strip() if result.returncode != 0 else None,
}
except subprocess.TimeoutExpired:
return {"success": False, "output": None, "error": f"Timed out after {timeout}s"}
except FileNotFoundError:
return {"success": False, "output": None, "error": "Claude CLI not found in PATH"}
except Exception as e:
return {"success": False, "output": None, "error": str(e)}
def check_rate_limit(user_id: int) -> bool:
"""Return True if user is within rate limits."""
now = time.time()
hour_ago = now - 3600
rate_limits[user_id] = [t for t in rate_limits[user_id] if t > hour_ago]
if len(rate_limits[user_id]) >= RATE_LIMIT:
return False
rate_limits[user_id].append(now)
return True
def truncate_output(text: str, max_len: int = MAX_MESSAGE_LENGTH) -> str:
"""Truncate output to fit Telegram's message limit."""
if not text or len(text) <= max_len:
return text
return text[: max_len - 100] + f"\n\n... (truncated, {len(text)} chars total)"
def auth_required(func):
"""Decorator to restrict commands to allowed users."""
@wraps(func)
async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
if ALLOWED_USERS and user_id not in ALLOWED_USERS:
logger.warning(f"Unauthorized access attempt by user {user_id}")
await update.message.reply_text("Unauthorized. Your user ID is not in the allow list.")
return
if not check_rate_limit(user_id):
await update.message.reply_text(
f"Rate limit exceeded. Max {RATE_LIMIT} commands per hour."
)
return
return await func(update, context)
return wrapper
# --- Command Handlers ---
@auth_required
async def cmd_run(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Run an arbitrary Claude Code prompt."""
if not context.args:
await update.message.reply_text("Usage: /run \nExample: /run list all Python files")
return
prompt = " ".join(context.args)
user_id = update.effective_user.id
logger.info(f"User {user_id} running: {prompt}")
status_msg = await update.message.reply_text("Working on it...")
active_tasks[user_id] = prompt
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(executor, lambda: run_claude(prompt))
del active_tasks[user_id]
if result["success"]:
output = truncate_output(result["output"]) or "(no output)"
await status_msg.edit_text(f"Done:\n\n{output}")
else:
error = result["error"] or "Unknown error"
await status_msg.edit_text(f"Failed:\n\n{error}")
@auth_required
async def cmd_deploy(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Trigger a deployment."""
env = context.args[0] if context.args else "staging"
allowed_envs = ["staging", "production", "dev"]
if env not in allowed_envs:
await update.message.reply_text(f"Invalid environment. Choose from: {', '.join(allowed_envs)}")
return
if env == "production":
await update.message.reply_text(
"You requested a PRODUCTION deployment. Send /confirm-deploy to proceed."
)
context.user_data["pending_deploy"] = "production"
return
status_msg = await update.message.reply_text(f"Deploying to {env}...")
prompt = f"Run the deployment pipeline for the {env} environment. Show the deployment URL when done."
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(executor, lambda: run_claude(prompt))
output = truncate_output(result["output"]) if result["success"] else result["error"]
emoji = "deployed" if result["success"] else "failed"
await status_msg.edit_text(f"Deployment {emoji}:\n\n{output}")
@auth_required
async def cmd_confirm_deploy(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Confirm a pending production deployment."""
pending = context.user_data.get("pending_deploy")
if pending != "production":
await update.message.reply_text("No pending deployment to confirm.")
return
del context.user_data["pending_deploy"]
status_msg = await update.message.reply_text("Deploying to PRODUCTION...")
prompt = "Run the deployment pipeline for the production environment. Show the deployment URL and run health checks."
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(executor, lambda: run_claude(prompt))
output = truncate_output(result["output"]) if result["success"] else result["error"]
await status_msg.edit_text(f"Production deployment result:\n\n{output}")
@auth_required
async def cmd_test(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Run project tests."""
status_msg = await update.message.reply_text("Running tests...")
prompt = "Run the project's test suite and report results. Show passed, failed, and error counts."
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(executor, lambda: run_claude(prompt))
output = truncate_output(result["output"]) if result["success"] else result["error"]
await status_msg.edit_text(f"Test results:\n\n{output}")
@auth_required
async def cmd_git(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Run git commands (read-only for safety)."""
if not context.args:
await update.message.reply_text("Usage: /git \nExamples: /git status, /git log --oneline -10")
return
git_cmd = " ".join(context.args)
safe_commands = ["status", "log", "diff", "branch", "show", "remote", "tag"]
first_word = git_cmd.split()[0] if git_cmd.split() else ""
if first_word not in safe_commands:
await update.message.reply_text(
f"Only read-only git commands are allowed: {', '.join(safe_commands)}"
)
return
prompt = f"Run this git command and show the output: git {git_cmd}"
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(executor, lambda: run_claude(prompt))
output = truncate_output(result["output"]) if result["success"] else result["error"]
await update.message.reply_text(f"git {git_cmd}:\n\n{output}")
@auth_required
async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Show currently active tasks."""
if not active_tasks:
await update.message.reply_text("No active tasks.")
return
lines = [f"User {uid}: {task}" for uid, task in active_tasks.items()]
await update.message.reply_text("Active tasks:\n\n" + "\n".join(lines))
async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Show available commands."""
help_text = """Available commands:
/run - Run any Claude Code prompt
/deploy - Deploy (staging/production/dev)
/test - Run project tests
/git - Run read-only git commands
/status - Show active tasks
/help - Show this message
Examples:
/run fix the TypeError in src/auth.py
/deploy staging
/git log --oneline -5
/run write tests for src/utils.py"""
await update.message.reply_text(help_text)
# --- Main ---
def main():
if not BOT_TOKEN:
logger.error("TELEGRAM_BOT_TOKEN not set in .env")
return
if not ALLOWED_USERS:
logger.warning("TELEGRAM_ALLOWED_USERS not set — bot is open to everyone!")
app = Application.builder().token(BOT_TOKEN).build()
app.add_handler(CommandHandler("run", cmd_run))
app.add_handler(CommandHandler("deploy", cmd_deploy))
app.add_handler(CommandHandler("confirm_deploy", cmd_confirm_deploy))
app.add_handler(CommandHandler("test", cmd_test))
app.add_handler(CommandHandler("git", cmd_git))
app.add_handler(CommandHandler("status", cmd_status))
app.add_handler(CommandHandler("help", cmd_help))
app.add_handler(CommandHandler("start", cmd_help))
logger.info("Telegram bridge started. Polling for messages...")
app.run_polling(allowed_updates=Update.ALL_TYPES)
if __name__ == "__main__":
main()
Step 3: Configuration
A .env file for the bridge server should be created:
# .env for Telegram bridge
TELEGRAM_BOT_TOKEN=7123456789:AAH-your-token-here
TELEGRAM_ALLOWED_USERS=123456789,987654321
CLAUDE_WORK_DIR=/home/youruser/projects/myapp
A requirements.txt file is also required:
python-telegram-bot>=21.0
python-dotenv>=1.0.0
The dependencies are then installed and the bridge launched:
pip install -r requirements.txt
python telegram_bridge.py
Step 4: Test It
Open Telegram on the phone and send a message to the bot:
/run list all Python files in the project and count them
The reply “Working on it…” should appear, followed by the actual output within approximately a minute. If a failure occurs, the telegram_bridge.log file should be examined for error details.
claude binary must be in the PATH when the bridge server runs. If Claude Code was installed via npm, the full path may need to be specified in the run_claude function, for example /home/youruser/.npm-global/bin/claude.
Common Issues and Debugging
Bot does not respond: Verify that the TELEGRAM_BOT_TOKEN is correct. Send /start; if no response is received, either the token is incorrect or the bot process is not running.
“Unauthorized” error: The Telegram user ID is not present in TELEGRAM_ALLOWED_USERS. The ID should be verified via @userinfobot.
Claude command times out: The default timeout is 10 minutes. For very long tasks, COMMAND_TIMEOUT should be increased. The Claude Code authentication state should also be confirmed by running claude in the terminal beforehand.
Garbled output: The TERM=dumb setting must be present in the subprocess environment, otherwise Claude Code may emit ANSI escape codes.
Method 2: Slack Bot — Complete Implementation
Slack is the natural choice for team environments. Its bot platform is more complex than Telegram’s, but offers richer features including threads, file uploads, interactive buttons, and integration with other workplace tools.
Step 1: Create a Slack App
- Go to api.slack.com/apps
- Click Create New App → From scratch
- Name it (e.g., “Claude Code Bot”) and select your workspace
- Under OAuth & Permissions, add these Bot Token Scopes:
chat:write— send messagescommands— handle slash commandsfiles:write— upload files (for long output)app_mentions:read— respond to @mentions
- Under Socket Mode, enable it and create an app-level token (needed for local development without a public URL)
- Under Slash Commands, create a command called
/claude - Install the app to your workspace
- Copy the Bot User OAuth Token (starts with
xoxb-) and the App-Level Token (starts withxapp-)
Step 2: Build the Slack Bridge
#!/usr/bin/env python3
"""
Slack Bridge for Claude Code
==============================
Controls Claude Code sessions via Slack slash commands and mentions.
Usage:
python slack_bridge.py
Environment variables (in .env):
SLACK_BOT_TOKEN - Bot User OAuth Token (xoxb-...)
SLACK_APP_TOKEN - App-Level Token for Socket Mode (xapp-...)
SLACK_ALLOWED_CHANNELS - Comma-separated channel IDs (optional)
CLAUDE_WORK_DIR - Working directory for Claude Code
"""
import asyncio
import logging
import os
import subprocess
import tempfile
import time
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from dotenv import load_dotenv
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler
load_dotenv()
# --- Configuration ---
BOT_TOKEN = os.getenv("SLACK_BOT_TOKEN")
APP_TOKEN = os.getenv("SLACK_APP_TOKEN")
ALLOWED_CHANNELS = set(
ch.strip()
for ch in os.getenv("SLACK_ALLOWED_CHANNELS", "").split(",")
if ch.strip()
)
WORK_DIR = os.getenv("CLAUDE_WORK_DIR", os.path.expanduser("~/projects"))
RATE_LIMIT = 10
COMMAND_TIMEOUT = 600
MAX_SLACK_LENGTH = 3900 # Leave margin under Slack's 4000-char block limit
# --- Logging ---
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
level=logging.INFO,
handlers=[
logging.StreamHandler(),
logging.FileHandler("slack_bridge.log"),
],
)
logger = logging.getLogger(__name__)
# --- State ---
executor = ThreadPoolExecutor(max_workers=3)
rate_limits = defaultdict(list)
app = App(token=BOT_TOKEN)
def run_claude(prompt: str, working_dir: str = None, timeout: int = COMMAND_TIMEOUT) -> dict:
"""Run Claude Code non-interactively."""
work_dir = working_dir or WORK_DIR
try:
result = subprocess.run(
["claude", "-p", prompt],
capture_output=True,
text=True,
timeout=timeout,
cwd=work_dir,
env={**os.environ, "TERM": "dumb"},
)
return {
"success": result.returncode == 0,
"output": result.stdout.strip(),
"error": result.stderr.strip() if result.returncode != 0 else None,
}
except subprocess.TimeoutExpired:
return {"success": False, "output": None, "error": f"Timed out after {timeout}s"}
except Exception as e:
return {"success": False, "output": None, "error": str(e)}
def check_rate_limit(user_id: str) -> bool:
now = time.time()
hour_ago = now - 3600
rate_limits[user_id] = [t for t in rate_limits[user_id] if t > hour_ago]
if len(rate_limits[user_id]) >= RATE_LIMIT:
return False
rate_limits[user_id].append(now)
return True
def upload_as_file(client, channel: str, thread_ts: str, content: str, filename: str):
"""Upload long output as a file snippet."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
f.write(content)
f.flush()
client.files_upload_v2(
channel=channel,
thread_ts=thread_ts,
file=f.name,
filename=filename,
title="Claude Code Output",
)
os.unlink(f.name)
@app.command("/claude")
def handle_claude_command(ack, say, command, client):
"""Handle /claude slash commands."""
ack() # Acknowledge within 3 seconds
user_id = command["user_id"]
channel_id = command["channel_id"]
text = command.get("text", "").strip()
# Channel restriction
if ALLOWED_CHANNELS and channel_id not in ALLOWED_CHANNELS:
say(f"This command is not allowed in this channel.", ephemeral=True)
return
# Rate limiting
if not check_rate_limit(user_id):
say(f"Rate limit exceeded. Max {RATE_LIMIT} commands per hour.")
return
if not text:
say(
"Usage: `/claude `\n"
"Actions: `run`, `deploy`, `test`, `git`, `status`\n"
"Example: `/claude run list all Python files`"
)
return
parts = text.split(maxsplit=1)
action = parts[0].lower()
args = parts[1] if len(parts) > 1 else ""
logger.info(f"User {user_id} in {channel_id}: /claude {action} {args}")
# Send initial "working" message in a thread
response = client.chat_postMessage(
channel=channel_id,
text=f"Working on: `{action} {args}`...",
)
thread_ts = response["ts"]
# Add reaction to show we're working
client.reactions_add(channel=channel_id, timestamp=thread_ts, name="hourglass_flowing_sand")
# Route command
if action == "run":
prompt = args or "Show project status"
elif action == "deploy":
env = args or "staging"
prompt = f"Run the deployment pipeline for the {env} environment."
elif action == "test":
prompt = "Run the project test suite and report results."
elif action == "git":
safe = ["status", "log", "diff", "branch", "show"]
first = args.split()[0] if args else ""
if first not in safe:
client.chat_postMessage(
channel=channel_id, thread_ts=thread_ts,
text=f"Only these git commands are allowed: {', '.join(safe)}",
)
return
prompt = f"Run this git command and show the output: git {args}"
else:
prompt = text # Treat the whole thing as a prompt
# Execute in thread pool
import concurrent.futures
future = executor.submit(run_claude, prompt)
try:
result = future.result(timeout=COMMAND_TIMEOUT + 30)
except concurrent.futures.TimeoutError:
result = {"success": False, "output": None, "error": "Execution timed out"}
# Remove working reaction, add result reaction
try:
client.reactions_remove(channel=channel_id, timestamp=thread_ts, name="hourglass_flowing_sand")
except Exception:
pass
if result["success"]:
client.reactions_add(channel=channel_id, timestamp=thread_ts, name="white_check_mark")
output = result["output"] or "(no output)"
if len(output) > MAX_SLACK_LENGTH:
# Upload as file for long output
client.chat_postMessage(
channel=channel_id, thread_ts=thread_ts,
text="Output is too long for a message. Uploading as file...",
)
upload_as_file(client, channel_id, thread_ts, output, "claude_output.txt")
else:
client.chat_postMessage(
channel=channel_id, thread_ts=thread_ts,
text=f"```\n{output}\n```",
)
else:
client.reactions_add(channel=channel_id, timestamp=thread_ts, name="x")
error = result["error"] or "Unknown error"
client.chat_postMessage(
channel=channel_id, thread_ts=thread_ts,
text=f"Failed:\n```\n{error}\n```",
)
@app.event("app_mention")
def handle_mention(event, say, client):
"""Handle @bot mentions in channels."""
text = event.get("text", "")
# Strip the bot mention to get just the prompt
# Mentions look like <@U12345> prompt here
import re
prompt = re.sub(r"<@\w+>\s*", "", text).strip()
if not prompt:
say("Mention me with a prompt! Example: `@Claude Code Bot list Python files`", thread_ts=event["ts"])
return
say(f"Working on it...", thread_ts=event["ts"])
import concurrent.futures
future = executor.submit(run_claude, prompt)
try:
result = future.result(timeout=COMMAND_TIMEOUT + 30)
except concurrent.futures.TimeoutError:
result = {"success": False, "output": None, "error": "Timed out"}
output = result["output"] if result["success"] else result["error"]
say(f"```\n{output}\n```", thread_ts=event["ts"])
if __name__ == "__main__":
if not BOT_TOKEN or not APP_TOKEN:
logger.error("SLACK_BOT_TOKEN and SLACK_APP_TOKEN must be set in .env")
exit(1)
logger.info("Slack bridge starting in Socket Mode...")
handler = SocketModeHandler(app, APP_TOKEN)
handler.start()
The corresponding .env file:
# .env for Slack bridge
SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_APP_TOKEN=xapp-your-app-level-token
SLACK_ALLOWED_CHANNELS=C01ABCDEF,C02GHIJKL
CLAUDE_WORK_DIR=/home/youruser/projects/myapp
And requirements.txt:
slack-bolt>=1.18.0
python-dotenv>=1.0.0
Step 3: Advanced Slack Features
Slack’s Block Kit enables interactive messages with buttons. A confirmation dialog for deployments can be added as follows:
# Add this handler for interactive buttons
@app.action("approve_deploy")
def handle_approve(ack, body, client):
ack()
user = body["user"]["id"]
channel = body["channel"]["id"]
thread_ts = body["message"]["ts"]
client.chat_postMessage(
channel=channel, thread_ts=thread_ts,
text=f"<@{user}> approved the deployment. Deploying now...",
)
result = run_claude("Deploy to production and run health checks.")
output = result["output"] if result["success"] else result["error"]
client.chat_postMessage(
channel=channel, thread_ts=thread_ts,
text=f"Deployment result:\n```\n{output}\n```",
)
@app.action("reject_deploy")
def handle_reject(ack, body, client):
ack()
user = body["user"]["id"]
channel = body["channel"]["id"]
thread_ts = body["message"]["ts"]
client.chat_postMessage(
channel=channel, thread_ts=thread_ts,
text=f"<@{user}> cancelled the deployment.",
)
Thread-based responses keep the channel tidy. Every command response is posted as a thread reply to the initial “Working on it…” message, so the #engineering channel is not flooded with Claude Code output.
Method 3: Discord Bot
Discord is particularly well suited to open-source communities and hobby projects. The setup differs slightly from Telegram and Slack but follows the same bridge pattern.
Create a Discord Bot
- Go to discord.com/developers/applications
- Click New Application, name it, and create it
- Go to Bot → click Add Bot
- Copy the Bot Token
- Under Privileged Gateway Intents, enable Message Content Intent
- Go to OAuth2 → URL Generator, select scopes
botandapplications.commands, and permissionsSend Messages,Read Message History,Attach Files - Use the generated URL to invite the bot to your server
Discord Bridge Server
#!/usr/bin/env python3
"""
Discord Bridge for Claude Code
================================
Controls Claude Code sessions via Discord slash commands.
"""
import asyncio
import logging
import os
import subprocess
from concurrent.futures import ThreadPoolExecutor
import discord
from discord import app_commands
from dotenv import load_dotenv
load_dotenv()
BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN")
ALLOWED_ROLES = os.getenv("DISCORD_ALLOWED_ROLES", "").split(",") # Role names
WORK_DIR = os.getenv("CLAUDE_WORK_DIR", os.path.expanduser("~/projects"))
COMMAND_TIMEOUT = 600
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
executor = ThreadPoolExecutor(max_workers=3)
def run_claude(prompt: str, timeout: int = COMMAND_TIMEOUT) -> dict:
try:
result = subprocess.run(
["claude", "-p", prompt],
capture_output=True, text=True, timeout=timeout,
cwd=WORK_DIR, env={**os.environ, "TERM": "dumb"},
)
return {
"success": result.returncode == 0,
"output": result.stdout.strip(),
"error": result.stderr.strip() if result.returncode != 0 else None,
}
except subprocess.TimeoutExpired:
return {"success": False, "output": None, "error": f"Timed out after {timeout}s"}
except Exception as e:
return {"success": False, "output": None, "error": str(e)}
class ClaudeBot(discord.Client):
def __init__(self):
intents = discord.Intents.default()
intents.message_content = True
super().__init__(intents=intents)
self.tree = app_commands.CommandTree(self)
async def setup_hook(self):
await self.tree.sync()
logger.info("Slash commands synced.")
bot = ClaudeBot()
def has_permission(interaction: discord.Interaction) -> bool:
if not ALLOWED_ROLES or ALLOWED_ROLES == [""]:
return True
user_roles = [r.name for r in interaction.user.roles] if hasattr(interaction.user, "roles") else []
return any(role in ALLOWED_ROLES for role in user_roles)
@bot.tree.command(name="claude", description="Run a Claude Code prompt")
@app_commands.describe(prompt="The prompt to send to Claude Code")
async def claude_command(interaction: discord.Interaction, prompt: str):
if not has_permission(interaction):
await interaction.response.send_message("You do not have permission.", ephemeral=True)
return
await interaction.response.send_message(f"Working on: `{prompt}`...")
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(executor, lambda: run_claude(prompt))
if result["success"]:
output = result["output"] or "(no output)"
# Discord has a 2000 char limit
if len(output) > 1900:
# Send as file attachment
with open("/tmp/claude_output.txt", "w") as f:
f.write(output)
await interaction.followup.send(
"Output (see attached file):",
file=discord.File("/tmp/claude_output.txt"),
)
else:
await interaction.followup.send(f"```\n{output}\n```")
else:
await interaction.followup.send(f"Failed: {result['error']}")
@bot.tree.command(name="deploy", description="Deploy to an environment")
@app_commands.describe(environment="Target environment (staging/production)")
async def deploy_command(interaction: discord.Interaction, environment: str = "staging"):
if not has_permission(interaction):
await interaction.response.send_message("You do not have permission.", ephemeral=True)
return
await interaction.response.send_message(f"Deploying to {environment}...")
prompt = f"Run the deployment pipeline for {environment}. Show the URL when done."
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(executor, lambda: run_claude(prompt))
output = result["output"] if result["success"] else result["error"]
await interaction.followup.send(f"Deploy result:\n```\n{output[:1900]}\n```")
@bot.tree.command(name="test", description="Run project tests")
async def test_command(interaction: discord.Interaction):
if not has_permission(interaction):
await interaction.response.send_message("You do not have permission.", ephemeral=True)
return
await interaction.response.send_message("Running tests...")
prompt = "Run the test suite and report results."
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(executor, lambda: run_claude(prompt))
output = result["output"] if result["success"] else result["error"]
if len(output) > 1900:
with open("/tmp/test_output.txt", "w") as f:
f.write(output)
await interaction.followup.send("Test results:", file=discord.File("/tmp/test_output.txt"))
else:
await interaction.followup.send(f"```\n{output}\n```")
if __name__ == "__main__":
if not BOT_TOKEN:
logger.error("DISCORD_BOT_TOKEN not set")
exit(1)
bot.run(BOT_TOKEN)
Discord’s 2,000-character message limit is the most restrictive of all platforms covered. The bot accommodates this constraint by uploading long output automatically as a file attachment, a pattern that is advisable for any platform with tight limits.
Method 4: Generic Webhook Approach
Microsoft Teams, WhatsApp, LINE, and other platforms can be supported by building a generic webhook server that any platform can invoke, rather than writing a platform-specific bot. This is the most flexible approach.
FastAPI Webhook Server
#!/usr/bin/env python3
"""
Generic Webhook Bridge for Claude Code
========================================
A simple HTTP server that accepts webhook requests and runs Claude Code.
Works with any messaging platform that supports outgoing webhooks.
Usage:
uvicorn webhook_bridge:app --host 0.0.0.0 --port 8080
"""
import asyncio
import hashlib
import hmac
import logging
import os
import subprocess
import time
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Header, Request
from pydantic import BaseModel
load_dotenv()
WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", "change-me-to-a-random-string")
WORK_DIR = os.getenv("CLAUDE_WORK_DIR", os.path.expanduser("~/projects"))
COMMAND_TIMEOUT = 600
RATE_LIMIT = 10
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
executor = ThreadPoolExecutor(max_workers=3)
rate_limits = defaultdict(list)
app = FastAPI(title="Claude Code Webhook Bridge")
class CommandRequest(BaseModel):
command: str
working_dir: str | None = None
timeout: int | None = None
user_id: str | None = None
class CommandResponse(BaseModel):
success: bool
output: str | None
error: str | None
duration_seconds: float
def verify_signature(payload: bytes, signature: str) -> bool:
"""Verify HMAC-SHA256 webhook signature."""
expected = hmac.new(
WEBHOOK_SECRET.encode(), payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(f"sha256={expected}", signature)
def run_claude(prompt: str, working_dir: str = None, timeout: int = COMMAND_TIMEOUT) -> dict:
work_dir = working_dir or WORK_DIR
try:
result = subprocess.run(
["claude", "-p", prompt],
capture_output=True, text=True, timeout=timeout,
cwd=work_dir, env={**os.environ, "TERM": "dumb"},
)
return {
"success": result.returncode == 0,
"output": result.stdout.strip(),
"error": result.stderr.strip() if result.returncode != 0 else None,
}
except subprocess.TimeoutExpired:
return {"success": False, "output": None, "error": f"Timed out after {timeout}s"}
except Exception as e:
return {"success": False, "output": None, "error": str(e)}
@app.post("/webhook/claude", response_model=CommandResponse)
async def handle_webhook(
cmd: CommandRequest,
request: Request,
x_webhook_signature: str = Header(None),
):
"""Execute a Claude Code command via webhook."""
# Verify signature
if x_webhook_signature:
body = await request.body()
if not verify_signature(body, x_webhook_signature):
raise HTTPException(status_code=401, detail="Invalid signature")
# Rate limiting
user_key = cmd.user_id or request.client.host
if not check_rate_limit(user_key):
raise HTTPException(status_code=429, detail="Rate limit exceeded")
logger.info(f"Webhook from {user_key}: {cmd.command[:100]}")
start_time = time.time()
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
executor,
lambda: run_claude(
cmd.command,
cmd.working_dir,
cmd.timeout or COMMAND_TIMEOUT,
),
)
duration = time.time() - start_time
return CommandResponse(
success=result["success"],
output=result["output"],
error=result["error"],
duration_seconds=round(duration, 2),
)
def check_rate_limit(user_key: str) -> bool:
now = time.time()
hour_ago = now - 3600
rate_limits[user_key] = [t for t in rate_limits[user_key] if t > hour_ago]
if len(rate_limits[user_key]) >= RATE_LIMIT:
return False
rate_limits[user_key].append(now)
return True
@app.get("/health")
async def health():
return {"status": "ok", "timestamp": time.time()}
To invoke this webhook from any platform, a POST request is sent as shown below:
curl -X POST http://your-server:8080/webhook/claude \
-H "Content-Type: application/json" \
-H "X-Webhook-Signature: sha256=..." \
-d '{"command": "list all Python files", "user_id": "user123"}'
This approach is compatible with Microsoft Teams (outgoing webhooks), WhatsApp (via Twilio webhooks), LINE (via messaging API webhooks), and essentially any platform capable of issuing HTTP POST requests. The platform is configured to send messages to the webhook URL, and the bridge handles the remainder.
Security Best Practices
Granting a chat message the ability to execute code on a machine is powerful and, when handled carelessly, also dangerous. Security is not optional in this context and is the most important component of the entire setup.
The Security Checklist
| Layer | What to Do | Why |
|---|---|---|
| Authentication | User ID / role allowlist | Only authorized users can run commands |
| Command allowlisting | Restrict to known safe actions | Prevent arbitrary shell execution |
| Rate limiting | Max N commands per hour | Prevent abuse and runaway costs |
| Directory sandboxing | Lock Claude Code to specific directories | Prevent access to sensitive files |
| Secrets management | Never pass secrets through chat | Chat history is not a secure channel |
| Audit logging | Log every command with user and timestamp | Traceability and incident response |
| Two-factor for danger | Require confirmation for deploy/delete | Prevent accidental destructive actions |
| Network security | HTTPS, firewall rules, VPN | Protect data in transit |
Implementing a Command Allowlist
Rather than permitting arbitrary prompts, a set of approved command patterns should be defined:
import re
ALLOWED_PATTERNS = [
r"^list\s", # List files, functions, etc.
r"^explain\s", # Explain code
r"^run tests", # Run test suite
r"^deploy\s", # Deploy
r"^fix\s", # Fix bugs
r"^review\s", # Code review
r"^git\s(status|log|diff|branch)", # Read-only git
r"^show\s", # Show file contents
r"^analyze\s", # Analyze code
r"^write tests", # Write tests
]
BLOCKED_PATTERNS = [
r"rm\s+-rf", # Never allow recursive delete
r"curl.*\|.*sh", # No pipe-to-shell
r"eval\(", # No eval
r"exec\(", # No exec
r"__import__", # No dynamic imports
r"(password|secret|token|key)\s*=", # No credential setting
]
def is_command_allowed(prompt: str) -> tuple[bool, str]:
"""Check if a command is allowed. Returns (allowed, reason)."""
prompt_lower = prompt.lower().strip()
# Check blocklist first
for pattern in BLOCKED_PATTERNS:
if re.search(pattern, prompt_lower):
return False, f"Blocked pattern detected: {pattern}"
# Check allowlist (if strict mode)
# For permissive mode, you can skip this check
for pattern in ALLOWED_PATTERNS:
if re.search(pattern, prompt_lower):
return True, "Matched allowed pattern"
return False, "Command does not match any allowed pattern"
.claude/settings.json) should always be reviewed, and restricting its tool access with --allowedTools when invoked from a bot should be considered.
Audit Logging
Every command that passes through the bot should be logged with full context. The practice is important for debugging, accountability, and security incident response:
import json
from datetime import datetime, timezone
def log_command(user_id: str, platform: str, command: str, result: dict):
"""Log a command execution to an audit file."""
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"user_id": user_id,
"platform": platform,
"command": command,
"success": result["success"],
"output_length": len(result["output"]) if result["output"] else 0,
"error": result["error"],
}
with open("audit_log.jsonl", "a") as f:
f.write(json.dumps(entry) + "\n")
Production Deployment
Running the bridge with python telegram_bridge.py in a terminal is appropriate for testing. For production, the bridge must start automatically, restart on failure, and run in the background.
Systemd Service File
The file /etc/systemd/system/claude-telegram-bridge.service should be created with the contents below:
[Unit]
Description=Claude Code Telegram Bridge
After=network.target
[Service]
Type=simple
User=youruser
WorkingDirectory=/home/youruser/claude-bridge
ExecStart=/home/youruser/claude-bridge/venv/bin/python telegram_bridge.py
Restart=always
RestartSec=10
StandardOutput=append:/var/log/claude-bridge.log
StandardError=append:/var/log/claude-bridge-error.log
Environment=PATH=/home/youruser/.local/bin:/usr/bin:/bin
Environment=HOME=/home/youruser
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/home/youruser/claude-bridge /home/youruser/projects
PrivateTmp=true
[Install]
WantedBy=multi-user.target
The service is then enabled and started:
sudo systemctl daemon-reload
sudo systemctl enable claude-telegram-bridge
sudo systemctl start claude-telegram-bridge
# Check status
sudo systemctl status claude-telegram-bridge
# View logs
sudo journalctl -u claude-telegram-bridge -f
Docker Deployment
For containerised deployments, the following Dockerfile may be used:
FROM python:3.12-slim
WORKDIR /app
# Install Claude Code CLI (Node.js required)
RUN apt-get update && apt-get install -y curl && \
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
apt-get install -y nodejs && \
npm install -g @anthropic-ai/claude-code && \
apt-get clean && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY telegram_bridge.py .
COPY .env .
CMD ["python", "telegram_bridge.py"]
A corresponding docker-compose.yml follows:
version: "3.8"
services:
claude-bridge:
build: .
restart: always
env_file: .env
volumes:
- /home/youruser/projects:/projects:rw
- claude-config:/root/.claude
environment:
- CLAUDE_WORK_DIR=/projects
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
claude-config:
SSH Tunnel Approach
If the bridge server should reside on a VPS for reliability and a public IP while Claude Code remains on the local machine, an SSH tunnel can be used. The bridge connects via SSH to the development machine to invoke Claude Code:
def run_claude_via_ssh(prompt: str, ssh_host: str = "dev-machine") -> dict:
"""Run Claude Code on a remote machine via SSH."""
# Escape the prompt for shell safety
import shlex
safe_prompt = shlex.quote(prompt)
try:
result = subprocess.run(
["ssh", ssh_host, f"cd ~/projects && claude -p {safe_prompt}"],
capture_output=True, text=True, timeout=COMMAND_TIMEOUT,
)
return {
"success": result.returncode == 0,
"output": result.stdout.strip(),
"error": result.stderr.strip() if result.returncode != 0 else None,
}
except Exception as e:
return {"success": False, "output": None, "error": str(e)}
This pattern combines the advantages of both configurations: the bridge server is always available on the VPS, while Claude Code runs on the more powerful development machine with access to all projects. SSH key authentication should be configured so that no password is needed, and autossh should be used to keep the connection alive.
Practical Workflow Examples
Theoretical discussion alone is insufficient. The following real-world scenarios illustrate where remote control of Claude Code is particularly valuable.
Morning Standup from a Phone
At 8:55 AM, a developer is walking to the office with a coffee. The phone is used to send:
/run Summarize: last 3 git commits, current branch status, any failing tests, and open PRs
By the time the developer sits down at the desk, Claude Code has replied with a clean summary of the project state. The developer enters the standup with a clear understanding of current status.
Deploy from Anywhere
A product manager sends a message: “Can we push the latest to staging for the client demo in an hour?” The developer is at lunch. The response is straightforward:
/deploy staging
The bot responds with the build log, deployment URL, and health check results. The staging URL is forwarded to the product manager, after which the meal resumes.
Quick Bug Fix
An error alert fires at 10 PM while the developer is watching a film. Instead of getting up to use a computer, the following command is issued:
/run The error log shows a TypeError in src/auth.py line 42. Fix it, write a test for the fix, and show me the diff.
Claude Code analyses the error, fixes the bug, writes a regression test, runs the test suite, and returns the diff and test results. The diff is reviewed on the phone screen, and, if satisfactory:
/run Commit the changes with message "fix: handle None auth token in validate_session" and push to a new branch fix/auth-none-check, then create a PR
Code Review on the Go
A team member submits a pull request while the reviewer is commuting:
/run Review PR #123 on GitHub. Summarize changes, identify potential issues, check test coverage, and give your recommendation.
A structured review is returned with file-by-file analysis, flagged concerns, and an overall recommendation, all conducted from the train.
Monitoring and Notifications
The discussion has so far concerned reactive usage, in which a command is sent and a response is received. Proactive monitoring may also be configured, in which the system dispatches alerts and the user responds with actions.
Scheduled Monitoring Script
#!/usr/bin/env python3
"""
Scheduled monitoring that sends alerts via Telegram.
Run via cron: */30 * * * * /path/to/monitor.py
"""
import os
import subprocess
import requests
from dotenv import load_dotenv
load_dotenv()
BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
CHAT_ID = os.getenv("TELEGRAM_ALERT_CHAT_ID")
WORK_DIR = os.getenv("CLAUDE_WORK_DIR")
def send_telegram(message: str):
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
requests.post(url, json={"chat_id": CHAT_ID, "text": message})
def check_tests():
"""Run tests and alert on failure."""
result = subprocess.run(
["claude", "-p", "Run the test suite. Report ONLY if there are failures. If all pass, say PASS."],
capture_output=True, text=True, timeout=300, cwd=WORK_DIR,
env={**os.environ, "TERM": "dumb"},
)
output = result.stdout.strip()
if "PASS" not in output.upper() or result.returncode != 0:
send_telegram(f"Test failure detected:\n\n{output[:3000]}")
def check_server_health():
"""Check if the production server is healthy."""
try:
r = requests.get("https://your-app.com/health", timeout=10)
if r.status_code != 200:
send_telegram(f"Server health check failed: HTTP {r.status_code}")
except Exception as e:
send_telegram(f"Server unreachable: {e}")
if __name__ == "__main__":
check_tests()
check_server_health()
The script should be added to crontab to run every 30 minutes. When a failure occurs, a Telegram notification is dispatched, and a command to remedy the issue can be sent immediately, all from the phone.
CI/CD Integration
A webhook call may be added to the CI/CD pipeline (GitHub Actions, GitLab CI, and similar) so that build failures notify the bot:
# In your GitHub Actions workflow (.github/workflows/ci.yml)
- name: Notify on failure
if: failure()
run: |
curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
-d text="CI failed on ${{ github.ref }} by ${{ github.actor }}. Reply /run investigate the CI failure and suggest fixes."
The arrangement creates a natural loop: CI fails, a notification arrives, a fix command is sent from the phone, and CI passes—all without opening a laptop.
Limitations and Workarounds
The setup is powerful but has genuine limitations. Awareness of these limitations will spare unnecessary frustration.
| Limitation | Impact | Workaround |
|---|---|---|
| Message length limits | Telegram: 4,096 chars; Discord: 2,000 chars | Auto-upload as file attachment when exceeded |
| No real-time streaming | You wait for the full result; no progressive output | Send periodic “still working” updates; split into smaller tasks |
| Claude Code token limits | Very large tasks may exceed context window | Break into subtasks; use --max-turns flag |
| Network latency | SSH-based setups add latency | Async execution with callback; keep bridge on same machine |
| No interactive prompts | Cannot handle Claude Code’s confirmation dialogs | Use --allowedTools to pre-authorize or auto-accept permissions |
| Single concurrent task | Thread pool limits parallel execution | Queue commands and process sequentially; increase pool size carefully |
| Machine must be on | If your dev machine sleeps, the bridge goes down | Run on always-on VPS; use Wake-on-LAN for local machine |
Handling Long Output Gracefully
Long output is the most common issue encountered. Claude Code can produce very long output, including test results, code reviews, and diffs. The following pattern is robust across all platforms:
def format_output(output: str, max_length: int, platform: str) -> dict:
"""
Format output for a messaging platform.
Returns {text: str, file: str|None} where file is a path to upload if needed.
"""
if not output:
return {"text": "(no output)", "file": None}
if len(output) <= max_length:
return {"text": output, "file": None}
# Create a summary + file for long output
import tempfile
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False)
tmp.write(output)
tmp.close()
summary = output[:max_length - 200]
summary += f"\n\n... Output truncated ({len(output)} chars). Full output attached as file."
return {"text": summary, "file": tmp.name}
Adding Progress Updates
For long-running tasks, prolonged silence is unhelpful. Periodic "still working" updates can be sent as follows:
async def run_with_progress(prompt, send_update, interval=30):
"""Run Claude Code with periodic progress updates."""
import asyncio
from concurrent.futures import ThreadPoolExecutor
executor = ThreadPoolExecutor(max_workers=1)
loop = asyncio.get_event_loop()
future = loop.run_in_executor(executor, lambda: run_claude(prompt))
elapsed = 0
while not future.done():
await asyncio.sleep(interval)
elapsed += interval
await send_update(f"Still working... ({elapsed}s elapsed)")
return await future
Final Thoughts
What began as a simple idea—controlling Claude Code from a phone—has the potential to alter development workflows fundamentally. The ability to trigger deployments, repair bugs, run tests, and review code from any location at any time eliminates the final friction between conceiving an action and executing it.
The technical implementation is remarkably straightforward: it is essentially a messaging bot that calls claude -p in a subprocess. The complexity resides in the details, including security, reliability, and output handling, all of which have been examined in detail.
A recommended path forward is as follows:
- Start with Telegram. Setup takes approximately 15 minutes, costs nothing, and requires no infrastructure. The Telegram bridge script from this guide may be copied and executed directly.
- Add security. User authentication, rate limiting, and command allowlisting should be configured before access is shared with others.
- Graduate to Slack when team access is required, or remain with Telegram for personal use.
- Deploy properly with systemd or Docker once the system is in daily use.
- Add monitoring to provide proactive alerts and scheduled reports.
The bridge pattern described here is platform-agnostic. Once understood, it can be adapted to WhatsApp, LINE, Microsoft Teams, or any messaging platform that supports bots or webhooks. The core sequence remains the same: receive a message, run claude -p, and return the result.
The future of development is not constrained by physical attachment to a desk. Development tools should be available wherever the developer happens to be. Claude Code already performs the substantive work of understanding and modifying code; the messaging bridge simply makes it accessible from the device that the user carries everywhere—the phone.
References
- Claude Code Documentation — Anthropic
- Telegram Bot API Documentation
- Slack API — Building Apps
- Discord Developer Documentation
- python-telegram-bot Library Documentation
- Slack Bolt for Python
- discord.py Library Documentation
- FastAPI Documentation
- systemd Service Unit Configuration
- ngrok Documentation — Tunneling for Webhooks
Leave a Reply