Home AI/ML How to Control Claude Code Sessions via Telegram, Slack, and Other Messaging Apps

How to Control Claude Code Sessions via Telegram, Slack, and Other Messaging Apps

Last updated: May 27, 2026
k
Published April 8, 2026 · Updated May 27, 2026 · 38 min read

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 -p in a subprocess turns any messaging bot into a remote terminal, so the whole architecture reduces to “receive message, run claude -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

 

Key Takeaway: For personal use, Telegram is the strongest choice, as its bot API is free, unlimited, and the simplest to configure. For team workflows, Slack is preferable because most teams already use it. Discord works well for open-source communities. Microsoft Teams is viable but requires substantially more setup.

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:

  1. Receives messages from the messaging platform’s bot API, either via polling or webhooks.
  2. Validates and routes messages through security checks, including authentication, rate limiting, and command allowlisting.
  3. 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.

Architecture: Messaging App → Bridge Server → Claude Code Your Phone Telegram / Slack Messaging Platform API Bridge Server Auth · Rate Limit Logging · Routing Claude Code CLI subprocess Command flow Response flow

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)
        }
Tip: Setting 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.

Message Flow: /deploy Command → Claude Code → Reply ① User sends /deploy staging ② Bot receives parses command ③ Auth check rate limit · allow ④ Spawn process claude -p “…” ⑤ Claude runs task ⑥ Output streamed back as reply Typical round-trip: 5 s – 5 min depending on task complexity ThreadPoolExecutor keeps the bot event loop unblocked throughout

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:

  1. Send /newbot.
  2. Enter a display name for the bot (for example, “My Claude Code Bot”).
  3. Enter a username, which must end in “bot” (for example, “my_claude_code_bot”).
  4. 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.

Caution: The 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

  1. Go to api.slack.com/apps
  2. Click Create New AppFrom scratch
  3. Name it (e.g., “Claude Code Bot”) and select your workspace
  4. Under OAuth & Permissions, add these Bot Token Scopes:
    • chat:write — send messages
    • commands — handle slash commands
    • files:write — upload files (for long output)
    • app_mentions:read — respond to @mentions
  5. Under Socket Mode, enable it and create an app-level token (needed for local development without a public URL)
  6. Under Slash Commands, create a command called /claude
  7. Install the app to your workspace
  8. Copy the Bot User OAuth Token (starts with xoxb-) and the App-Level Token (starts with xapp-)

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.

Multi-Platform: One Bridge Server, Many Messaging Apps Bridge Server Python · Auth · Queue Rate Limit · Logging Telegram Personal use · Free Slack Team workflows Discord Open-source / Hobby Claude Code CLI subprocess claude -p “…” prompt output All platforms share the same bridge core—only the transport adapter differs per platform

Create a Discord Bot

  1. Go to discord.com/developers/applications
  2. Click New Application, name it, and create it
  3. Go to Bot → click Add Bot
  4. Copy the Bot Token
  5. Under Privileged Gateway Intents, enable Message Content Intent
  6. Go to OAuth2URL Generator, select scopes bot and applications.commands, and permissions Send Messages, Read Message History, Attach Files
  7. 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.

Tip: If the bridge server is behind a firewall or NAT and runs on the local machine, a tool such as ngrok or Cloudflare Tunnel may be used to expose it to the internet. A more robust alternative is to deploy the bridge on a VPS and use SSH to reach the local Claude Code installation. This option is discussed in the Production Deployment section.

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"
Caution: Even with an allowlist, it must be recognised that Claude Code itself has substantial capabilities. A prompt such as “fix the bug in auth.py” may lead Claude Code to modify files, execute commands, and perform other actions. Claude Code’s permission settings (.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.

Key Takeaway: For personal use, running the bridge directly on the development machine is the simplest option. For team use or higher reliability, the bridge should be placed on a VPS that connects to development machines via SSH. For maximum portability, Docker is recommended.

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:

  1. 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.
  2. Add security. User authentication, rate limiting, and command allowlisting should be configured before access is shared with others.
  3. Graduate to Slack when team access is required, or remain with Telegram for personal use.
  4. Deploy properly with systemd or Docker once the system is in daily use.
  5. 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

You Might Also Like

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *