Telegram Bots skill for Codex
Webhooks, keyboards and broadcasts that stay unbanned. Codex builds Telegram bots with one update source, a webhook guarded by secret_token, and callback queries that are always answered so buttons never spin. It escapes user text for HTML or MarkdownV2, keeps conversation state in persistent storage, throttles sends under Telegram's per-chat and global limits, and uses Telegram Stars for digital goods.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- telegram-bots Installs to ~/.agents/skills/telegram-bots/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install telegram-bots The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project telegram-bots Installs to .agents/skills/telegram-bots/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/telegram-bots.zipunzip -o telegram-bots.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) telegram-bots PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $telegram-bots, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds Telegram bots on the Bot API with grammY or python-telegram-bot - BotFather setup, long polling versus webhooks with secret_token, commands, inline keyboards and callback queries, conversation state, MarkdownV2 and HTML formatting, rate limits and Telegram Stars payments. Use when creating a Telegram bot, switching from getUpdates to a webhook, buttons that keep spinning, Bad Request can't parse entities errors, 429 Too Many Requests, or broadcasting to many users.
Use it when
- Creating a Telegram bot or adding commands and inline keyboards
- Switching between getUpdates polling and webhooks
- Sends fail with 429 Too Many Requests or parse entity errors
- Selling digital goods in a bot with Telegram Stars
Use something else when
- You need WhatsApp messaging with approved templates: use whatsapp-business-api
- You need SMS to phone numbers: use twilio-sms
How to check the result
getWebhookInfo shows no pending backlog or errors, requests without the secret header are rejected, and user text with HTML or Markdown symbols renders without parse errors.
The mistake it prevents
Running a local polling copy with the production token, which deletes or competes with the production webhook and silently drops users' updates.
Done well means: one update source (polling or webhook, never both), webhook requests checked
against secret_token, every callback query answered, user text escaped for the chosen parse
mode, sends throttled below Telegram’s limits, and broadcasts only sent after the user confirms
the audience and message.
Before writing code
- Read
AGENTS.mdand the dependency file. Pick or confirm the library:
| Stack | Default library |
|---|---|
| TypeScript or Node | grammY |
| Python (async) | python-telegram-bot v21+ (or aiogram 3 if already used) |
| Raw HTTP | https://api.telegram.org/bot<TOKEN>/<method> with JSON bodies |
- The token from @BotFather goes in
TELEGRAM_BOT_TOKEN. Anyone with it controls the bot; if it leaked, the user must revoke it in BotFather. Never put the token in logs or URLs you print. - Choose update delivery:
| Situation | Choose |
|---|---|
| Local development, single long-running process | Long polling (getUpdates) |
| Serverless or behind a load balancer, production | Webhook over HTTPS on port 443, 80, 88 or 8443 |
- Messages to real users, group broadcasts and payments are outward-facing: confirm first. Bots cannot message a user who has not started the bot.
Calls to the Bot API need network access; in a Codex sandbox, ask for approval, or test handlers with the library’s test utilities or stubbed contexts.
grammY skeleton
import { Bot, InlineKeyboard, webhookCallback } from "grammy";
const bot = new Bot(process.env.TELEGRAM_BOT_TOKEN!);
bot.command("start", async (ctx) => {
const payload = ctx.match; // deep link: t.me/yourbot?start=<payload>
await ctx.reply("Welcome. Choose a plan:", {
reply_markup: new InlineKeyboard().text("Free", "plan:free").text("Pro", "plan:pro"),
});
});
bot.callbackQuery(/^plan:(free|pro)$/, async (ctx) => {
await ctx.answerCallbackQuery(); // stops the loading spinner; do it first
await ctx.editMessageText(`You chose ${ctx.match[1]}.`);
});
bot.catch((err) => console.error("update failed", err.ctx.update.update_id, err.error));
if (process.env.WEBHOOK) {
app.post("/telegram", webhookCallback(bot, "express", { secretToken: process.env.TELEGRAM_SECRET }));
} else {
bot.start(); // long polling
}
python-telegram-bot skeleton
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
kb = InlineKeyboardMarkup([[InlineKeyboardButton("Free", callback_data="plan:free"),
InlineKeyboardButton("Pro", callback_data="plan:pro")]])
await update.message.reply_text("Choose a plan:", reply_markup=kb)
async def on_plan(update: Update, context: ContextTypes.DEFAULT_TYPE):
q = update.callback_query
await q.answer()
await q.edit_message_text(f"You chose {q.data.split(':', 1)[1]}.")
app = Application.builder().token(os.environ["TELEGRAM_BOT_TOKEN"]).build()
app.add_handler(CommandHandler("start", start))
app.add_handler(CallbackQueryHandler(on_plan, pattern=r"^plan:"))
app.run_polling() # or app.run_webhook(listen="0.0.0.0", port=8443, url_path="telegram",
# webhook_url="https://bot.example.com/telegram", secret_token=os.environ["TELEGRAM_SECRET"])
Webhook setup
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \
-d url=https://bot.example.com/telegram \
-d secret_token="$TELEGRAM_SECRET" \
-d drop_pending_updates=true \
--data-urlencode 'allowed_updates=["message","callback_query"]'
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getWebhookInfo"
- Telegram sends the secret in the
X-Telegram-Bot-Api-Secret-Tokenheader; reject requests where it does not match.secret_tokenallows 1-256 characters ofA-Z a-z 0-9 _ -. - Return 200 quickly. Non-2xx responses make Telegram retry and back up the queue;
getWebhookInfoshowspending_update_countandlast_error_message. - Setting a webhook disables
getUpdatesand the reverse (deleteWebhook). Running a polling dev copy against the production token steals or breaks production updates: use a separate dev bot. - Dedupe on
update_idif your handler is not idempotent.
Formatting
| Parse mode | Escaping |
|---|---|
HTML (recommended) | Escape <, >, & in user text; tags b, i, u, s, code, pre, a, blockquote, tg-spoiler |
MarkdownV2 | Backslash-escape all of _*[]()~>#+-={}.!, the pipe and the backtick outside entities |
“Bad Request: can’t parse entities” almost always means unescaped user text. Text messages are capped at 4,096 characters, media captions at 1,024; split longer content.
Keyboards and state
callback_datais 1-64 bytes. Encodeaction:id; keep anything larger in storage.- Inline keyboards attach to a message; reply keyboards replace the user’s keyboard. Remove
reply keyboards with
remove_keyboard: truewhen done. - Multi-step flows: grammY conversations plugin or sessions with persistent storage;
python-telegram-bot
ConversationHandlerwith a persistence backend. In-memory state is lost on deploy and breaks with more than one instance.
Rate limits and broadcasts
- Roughly 1 message per second per chat, 20 messages per minute per group, and about 30
messages per second overall for bulk sends. On 429 the response includes
parameters.retry_after; wait that long. - Use grammY’s
auto-retryandtransformer-throttlerplugins, or python-telegram-bot’sAIORateLimiter, rather than hand-written sleeps. - Broadcast: confirm audience and text with the user, send in throttled batches, record per-user results, and stop on users who blocked the bot (403 Forbidden) by marking them inactive.
Payments
Digital goods and services sold inside Telegram must use Telegram Stars: sendInvoice with
currency XTR and an empty provider_token. Answer pre_checkout_query within 10 seconds,
deliver on successful_payment, and store telegram_payment_charge_id for refunds
(refundStarPayment). Physical goods can use a payment provider token from BotFather. Confirm
before any live refund.
Gotchas
- Buttons spin forever: the callback query was never answered.
- Group bots in privacy mode only see commands, replies to them and mentions. Disable privacy in BotFather only when needed, then re-add the bot to the group.
chat_idfor supergroups and channels is negative (-100...); store it as a 64-bit integer.- Editing a message to identical content returns “message is not modified”; ignore that error.
- Files:
getFilegives a path valid for at least one hour; bot downloads are limited to 20 MB on the cloud Bot API.
Verification
getWebhookInfoshows the right URL,pending_update_countnear 0, nolast_error_message.- A request without the secret header gets 401 or 403 from your endpoint.
- Send user text containing
<b>and_*[through every reply path: no parse errors. - Each button press stops spinning and updates the message.
Report template
**Library:** grammY 1.x, webhook at /telegram (secret_token checked), allowed_updates: message, callback_query
**Commands:** /start (deep link payload), /plan; inline keyboard plan:<id>
**State:** sessions in Redis; conversations plugin for onboarding
**Limits:** auto-retry + throttler; broadcast job batches 25/s, marks 403 users inactive
**Tested:** dev bot token only; production webhook switch awaiting approval What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Telegram Bots"
short_description: "Webhooks, keyboards and broadcasts that stay unbanned"
default_prompt: "$telegram-bots Build a Telegram bot with grammY that offers plans through inline keyboard buttons" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.