| name | add-telegram |
| description | Add Telegram as a channel. Can replace WhatsApp entirely or run alongside it. Also configurable as a control-only channel (triggers actions) or passive channel (receives notifications only). |
| allowed-tools | Bash(*), Read, Edit, Write, Glob, Grep, AskUserQuestion |
Add Telegram Channel
UX Rule: Use AskUserQuestion for ALL interactions with the user. Never just output questions as text — always use the tool so the user gets structured prompts with selectable options.
This skill adds Telegram support to BastionClaw. Users can choose to:
- Replace WhatsApp - Use Telegram as the only messaging channel
- Add alongside WhatsApp - Both channels active
- Control channel - Telegram triggers agent but doesn't receive all outputs
- Notification channel - Receives outputs but limited triggering
Prerequisites
1. Install Grammy
npm install grammy
Grammy is a modern, TypeScript-first Telegram bot framework.
2. Create Telegram Bot
Tell the user:
I need you to create a Telegram bot:
- Open Telegram and search for
@BotFather
- Send
/newbot and follow prompts:
- Bot name: Something friendly (e.g., "Andy Assistant")
- Bot username: Must end with "bot" (e.g., "andy_ai_bot")
- Copy the bot token (looks like
123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11)
Wait for user to provide the token.
3. Get Chat ID
Tell the user:
To register a chat, you need its Chat ID. Here's how:
For Private Chat (DM with bot):
- Search for your bot in Telegram
- Start a chat and send any message
- I'll add a
/chatid command to help you get the ID
For Group Chat:
- Add your bot to the group
- Send any message
- Use the
/chatid command in the group
4. Disable Group Privacy (for group chats)
Tell the user:
Important for group chats: By default, Telegram bots in groups only receive messages that @mention the bot or are commands. To let the bot see all messages (needed for requiresTrigger: false or trigger-word detection):
- Open Telegram and search for
@BotFather
- Send
/mybots and select your bot
- Go to Bot Settings > Group Privacy
- Select Turn off
Without this, the bot will only see messages that directly @mention it.
This step is optional if the user only wants trigger-based responses via @mentioning the bot.
Questions to Ask
Use AskUserQuestion to collect configuration:
AskUserQuestion: Should Telegram replace WhatsApp or run alongside it?
- Replace WhatsApp - Telegram will be the only channel (sets TELEGRAM_ONLY=true)
- Alongside - Both Telegram and WhatsApp channels active
AskUserQuestion: Should this chat respond to all messages or only when @mentioned?
- All messages - Responds to everything (sets
requiresTrigger: false)
- Only when @mentioned - Requires trigger (default,
requiresTrigger: true)
Security
Present this warning to the user before proceeding:
SECURITY WARNING: Anyone in a Telegram group with your bot can command it.
The bot runs with your Claude API credentials and has access to your agent's tools, memory, and scheduled tasks. In group chats, anyone who can see the group can trigger the bot.
What this means:
- If you add someone to a group with the bot, they can use your bot
- They can trigger agent containers that run with your API key
- They can potentially read your agent's memory and conversation history
- The main chat has admin privileges (register groups, schedule tasks, manage all groups)
Recommendations:
- Use private DMs with the bot for your main (admin) channel — only you can see those messages
- If you want others to use the bot, create a separate group registered with
requiresTrigger: true — this limits them to @mention interactions only, no admin access
- Don't share group invite links for groups containing the bot
- For group chats, remember that all messages (including bot responses) are visible to all group members
Architecture
BastionClaw uses a Channel abstraction (Channel interface in src/types.ts). Each messaging platform implements this interface. Key files:
| File | Purpose |
|---|
src/types.ts | Channel interface definition |
src/channels/whatsapp.ts | WhatsAppChannel class (reference implementation) |
src/router.ts | findChannel(), routeOutbound(), formatOutbound() |
src/index.ts | Orchestrator: creates channels, wires callbacks, starts subsystems |
src/ipc.ts | IPC watcher (uses sendMessage dep for outbound) |
The Telegram channel follows the same pattern as WhatsApp:
- Implements
Channel interface (connect, sendMessage, ownsJid, disconnect, setTyping)
- Delivers inbound messages via
onMessage / onChatMetadata callbacks
- The existing message loop in
src/index.ts picks up stored messages automatically
Implementation
Step 1: Update Configuration
Read src/config.ts and add Telegram config exports:
export const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || "";
export const TELEGRAM_ONLY = process.env.TELEGRAM_ONLY === "true";
These should be added near the top with other configuration exports.
Step 2: Create Telegram Channel
Create src/channels/telegram.ts implementing the Channel interface. Use src/channels/whatsapp.ts as a reference for the pattern.
import { Bot } from "grammy";
import {
ASSISTANT_NAME,
TRIGGER_PATTERN,
} from "../config.js";
import { logger } from "../logger.js";
import { Channel, OnInboundMessage, OnChatMetadata, RegisteredGroup } from "../types.js";
export interface TelegramChannelOpts {
onMessage: OnInboundMessage;
onChatMetadata: OnChatMetadata;
registeredGroups: () => Record<string, RegisteredGroup>;
}
export class TelegramChannel implements Channel {
name = "telegram";
prefixAssistantName = false;
private bot: Bot | null = null;
private opts: TelegramChannelOpts;
private botToken: string;
constructor(botToken: string, opts: TelegramChannelOpts) {
this.botToken = botToken;
this.opts = opts;
}
async connect(): Promise<void> {
this.bot = new Bot(this.botToken);
this.bot.command("chatid", (ctx) => {
const chatId = ctx.chat.id;
const chatType = ctx.chat.type;
const chatName =
chatType === "private"
? ctx.from?.first_name || "Private"
: (ctx.chat as any).title || "Unknown";
ctx.reply(
`Chat ID: \`tg:${chatId}\`\nName: ${chatName}\nType: ${chatType}`,
{ parse_mode: "Markdown" },
);
});
this.bot.command("ping", (ctx) => {
ctx.reply(`${ASSISTANT_NAME} is online.`);
});
this.bot.on("message:text", async (ctx) => {
if (ctx.message.text.startsWith("/")) return;
const chatJid = `tg:${ctx.chat.id}`;
let content = ctx.message.text;
const timestamp = new Date(ctx.message.date * 1000).toISOString();
const senderName =
ctx.from?.first_name ||
ctx.from?.username ||
ctx.from?.id.toString() ||
"Unknown";
const sender = ctx.from?.id.toString() || "";
const msgId = ctx.message.message_id.toString();
const chatName =
ctx.chat.type === "private"
? senderName
: (ctx.chat as any).title || chatJid;
const botUsername = ctx.me?.username?.toLowerCase();
if (botUsername) {
const entities = ctx.message.entities || [];
const isBotMentioned = entities.some((entity) => {
if (entity.type === "mention") {
const mentionText = content
.substring(entity.offset, entity.offset + entity.length)
.toLowerCase();
return mentionText === `@${botUsername}`;
}
return false;
});
if (isBotMentioned && !TRIGGER_PATTERN.test(content)) {
content = `@${ASSISTANT_NAME} ${content}`;
}
}
this.opts.onChatMetadata(chatJid, timestamp, chatName);
const group = this.opts.registeredGroups()[chatJid];
if (!group) {
logger.debug(
{ chatJid, chatName },
"Message from unregistered Telegram chat",
);
return;
}
this.opts.onMessage(chatJid, {
id: msgId,
chat_jid: chatJid,
sender,
sender_name: senderName,
content,
timestamp,
is_from_me: false,
});
logger.info(
{ chatJid, chatName, sender: senderName },
"Telegram message stored",
);
});
const storeNonText = (ctx: any, placeholder: string) => {
const chatJid = `tg:${ctx.chat.id}`;
const group = this.opts.registeredGroups()[chatJid];
if (!group) return;
const timestamp = new Date(ctx.message.date * 1000).toISOString();
const senderName =
ctx.from?.first_name || ctx.from?.username || ctx.from?.id?.toString() || "Unknown";
const caption = ctx.message.caption ? ` ${ctx.message.caption}` : "";
this.opts.onChatMetadata(chatJid, timestamp);
this.opts.onMessage(chatJid, {
id: ctx.message.message_id.toString(),
chat_jid: chatJid,
sender: ctx.from?.id?.toString() || "",
sender_name: senderName,
content: `${placeholder}${caption}`,
timestamp,
is_from_me: false,
});
};
this.bot.on("message:photo", (ctx) => storeNonText(ctx, "[Photo]"));
this.bot.on("message:video", (ctx) => storeNonText(ctx, "[Video]"));
this.bot.on("message:voice", (ctx) => storeNonText(ctx, "[Voice message]"));
this.bot.on("message:audio", (ctx) => storeNonText(ctx, "[Audio]"));
this.bot.on("message:document", (ctx) => {
const name = ctx.message.document?.file_name || "file";
storeNonText(ctx, `[Document: ${name}]`);
});
this.bot.on("message:sticker", (ctx) => {
const emoji = ctx.message.sticker?.emoji || "";
storeNonText(ctx, `[Sticker ${emoji}]`);
});
this.bot.on("message:location", (ctx) => storeNonText(ctx, "[Location]"));
this.bot.on("message:contact", (ctx) => storeNonText(ctx, "[Contact]"));
this.bot.catch((err) => {
logger.error({ err: err.message }, "Telegram bot error");
});
return new Promise<void>((resolve) => {
this.bot!.start({
onStart: (botInfo) => {
logger.info(
{ username: botInfo.username, id: botInfo.id },
"Telegram bot connected",
);
console.log(`\n Telegram bot: @${botInfo.username}`);
console.log(
` Send /chatid to the bot to get a chat's registration ID\n`,
);
resolve();
},
});
});
}
async sendMessage(jid: string, text: string): Promise<void> {
if (!this.bot) {
logger.warn("Telegram bot not initialized");
return;
}
try {
const numericId = jid.replace(/^tg:/, "");
const MAX_LENGTH = 4096;
if (text.length <= MAX_LENGTH) {
await this.bot.api.sendMessage(numericId, text);
} else {
for (let i = 0; i < text.length; i += MAX_LENGTH) {
await this.bot.api.sendMessage(numericId, text.slice(i, i + MAX_LENGTH));
}
}
logger.info({ jid, length: text.length }, "Telegram message sent");
} catch (err) {
logger.error({ jid, err }, "Failed to send Telegram message");
}
}
isConnected(): boolean {
return this.bot !== null;
}
ownsJid(jid: string): boolean {
return jid.startsWith("tg:");
}
async disconnect(): Promise<void> {
if (this.bot) {
this.bot.stop();
this.bot = null;
logger.info("Telegram bot stopped");
}
}
async setTyping(jid: string, isTyping: boolean): Promise<void> {
if (!this.bot || !isTyping) return;
try {
const numericId = jid.replace(/^tg:/, "");
await this.bot.api.sendChatAction(numericId, "typing");
} catch (err) {
logger.debug({ jid, err }, "Failed to send Telegram typing indicator");
}
}
}
Key differences from the old standalone src/telegram.ts:
- Implements
Channel interface — same pattern as WhatsAppChannel
- Uses
onMessage / onChatMetadata callbacks instead of importing DB functions directly
- Registration check via
registeredGroups() callback, not getAllRegisteredGroups()
prefixAssistantName = false — Telegram bots already show their name, so formatOutbound() skips the prefix
- No
storeMessageDirect needed — storeMessage() in db.ts already accepts NewMessage directly
Step 3: Update Main Application
Modify src/index.ts to support multiple channels. Read the file first to understand the current structure.
- Add imports at the top:
import { TelegramChannel } from "./channels/telegram.js";
import { TELEGRAM_BOT_TOKEN, TELEGRAM_ONLY } from "./config.js";
import { findChannel } from "./router.js";
- Add a channels array alongside the existing
whatsapp variable:
let whatsapp: WhatsAppChannel;
const channels: Channel[] = [];
Import Channel from ./types.js if not already imported.
- Update
processGroupMessages to find the correct channel for the JID instead of using whatsapp directly. Replace the direct whatsapp.setTyping() and whatsapp.sendMessage() calls:
const channel = findChannel(channels, chatJid);
if (!channel) return true;
await channel.setTyping?.(chatJid, true);
await channel.setTyping?.(chatJid, false);
In the onOutput callback inside processGroupMessages, replace:
await whatsapp.sendMessage(chatJid, `${ASSISTANT_NAME}: ${text}`);
with:
const formatted = formatOutbound(channel, text);
if (formatted) await channel.sendMessage(chatJid, formatted);
- Update
main() function to create channels conditionally and use them for deps:
async function main(): Promise<void> {
ensureContainerSystemRunning();
initDatabase();
logger.info('Database initialized');
loadState();
const shutdown = async (signal: string) => {
logger.info({ signal }, 'Shutdown signal received');
await queue.shutdown(10000);
for (const ch of channels) await ch.disconnect();
process.exit(0);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
const channelOpts = {
onMessage: (chatJid: string, msg: NewMessage) => storeMessage(msg),
onChatMetadata: (chatJid: string, timestamp: string, name?: string) =>
storeChatMetadata(chatJid, timestamp, name),
registeredGroups: () => registeredGroups,
};
if (!TELEGRAM_ONLY) {
whatsapp = new WhatsAppChannel(channelOpts);
channels.push(whatsapp);
await whatsapp.connect();
}
if (TELEGRAM_BOT_TOKEN) {
const telegram = new TelegramChannel(TELEGRAM_BOT_TOKEN, channelOpts);
channels.push(telegram);
await telegram.connect();
}
startSchedulerLoop({
registeredGroups: () => registeredGroups,
getSessions: () => sessions,
queue,
onProcess: (groupJid, proc, containerName, groupFolder) =>
queue.registerProcess(groupJid, proc, containerName, groupFolder),
sendMessage: async (jid, rawText) => {
const channel = findChannel(channels, jid);
if (!channel) return;
const text = formatOutbound(channel, rawText);
if (text) await channel.sendMessage(jid, text);
},
});
startIpcWatcher({
sendMessage: (jid, text) => {
const channel = findChannel(channels, jid);
if (!channel) throw new Error(`No channel for JID: ${jid}`);
return channel.sendMessage(jid, text);
},
registeredGroups: () => registeredGroups,
registerGroup,
syncGroupMetadata: (force) => whatsapp?.syncGroupMetadata(force) ?? Promise.resolve(),
getAvailableGroups,
writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj),
});
queue.setProcessMessagesFn(processGroupMessages);
recoverPendingMessages();
startMessageLoop();
}
- Update
getAvailableGroups to include Telegram chats:
export function getAvailableGroups(): AvailableGroup[] {
const chats = getAllChats();
const registeredJids = new Set(Object.keys(registeredGroups));
return chats
.filter((c) => c.jid !== '__group_sync__' && (c.jid.endsWith('@g.us') || c.jid.startsWith('tg:')))
.map((c) => ({
jid: c.jid,
name: c.name,
lastActivity: c.last_message_time,
isRegistered: registeredJids.has(c.jid),
}));
}
Step 4: Update Environment
Add to .env:
TELEGRAM_BOT_TOKEN=YOUR_BOT_TOKEN_HERE
Secrets from .env are automatically passed to containers via stdin (filtered to an allowlist). No manual sync needed.
Step 5: Register a Telegram Chat
After installing and starting the bot, tell the user:
- Send
/chatid to your bot (in private chat or in a group)
- Copy the chat ID (e.g.,
tg:123456789 or tg:-1001234567890)
- I'll register it for you
Registration uses the registerGroup() function in src/index.ts, which writes to SQLite and creates the group folder structure. Call it like this (or add a one-time script):
registerGroup("tg:123456789", {
name: "Personal",
folder: "main",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: false,
});
registerGroup("tg:-1001234567890", {
name: "My Telegram Group",
folder: "telegram-group",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: true,
});
The RegisteredGroup type requires a trigger string field and has an optional requiresTrigger boolean (defaults to true). Set requiresTrigger: false for chats that should respond to all messages.
Alternatively, if the agent is already running in the main group, it can register new groups via IPC using the register_group task type.
Step 6: Build and Restart
npm run build
launchctl kickstart -k gui/$(id -u)/com.bastionclaw
Or for systemd:
npm run build
systemctl --user restart bastionclaw
Step 7: Test
Tell the user:
Send a message to your registered Telegram chat:
- For main chat: Any message works
- For non-main:
@Andy hello or @mention the bot
Check logs: tail -f logs/bastionclaw.log
Replace WhatsApp Entirely
If user wants Telegram-only:
- Set
TELEGRAM_ONLY=true in .env
- The WhatsApp channel is not created — only Telegram
- All services (scheduler, IPC watcher, queue, message loop) start normally
- Optionally remove
@whiskeysockets/baileys dependency (but it's harmless to keep)
Features
Chat ID Formats
- WhatsApp:
120363336345536173@g.us (groups) or 1234567890@s.whatsapp.net (DM)
- Telegram:
tg:123456789 (positive for private) or tg:-1001234567890 (negative for groups)
Trigger Options
The bot responds when:
- Chat has
requiresTrigger: false in its registration (e.g., main group)
- Bot is @mentioned in Telegram (translated to TRIGGER_PATTERN automatically)
- Message matches TRIGGER_PATTERN directly (e.g., starts with @Andy)
Telegram @mentions (e.g., @andy_ai_bot) are automatically translated: if the bot is @mentioned and the message doesn't already match TRIGGER_PATTERN, the trigger prefix is prepended before storing. This ensures @mentioning the bot always triggers a response.
Group Privacy: The bot must have Group Privacy disabled in BotFather to see non-mention messages in groups. See Prerequisites step 4.
Commands
/chatid - Get chat ID for registration
/ping - Check if bot is online
Troubleshooting
Bot not responding
Check:
TELEGRAM_BOT_TOKEN is set in .env
- Chat is registered in SQLite (check with:
sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'tg:%'")
- For non-main chats: message includes trigger pattern
- Service is running:
launchctl list | grep bastionclaw
Bot only responds to @mentions in groups
The bot has Group Privacy enabled (default). It can only see messages that @mention it or are commands. To fix:
- Open
@BotFather in Telegram
/mybots > select bot > Bot Settings > Group Privacy > Turn off
- Remove and re-add the bot to the group (required for the change to take effect)
Getting chat ID
If /chatid doesn't work:
- Verify bot token is valid:
curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe"
- Check bot is started:
tail -f logs/bastionclaw.log
Service conflicts
If running npm run dev while launchd service is active:
launchctl unload ~/Library/LaunchAgents/com.bastionclaw.plist
npm run dev
launchctl load ~/Library/LaunchAgents/com.bastionclaw.plist
Agent Swarms (Teams)
After completing the Telegram setup, use AskUserQuestion:
AskUserQuestion: Would you like to add Agent Swarm support? Without it, Agent Teams still work — they just operate behind the scenes. With Swarm support, each subagent appears as a different bot in the Telegram group so you can see who's saying what and have interactive team sessions.
If they say yes, invoke the /add-telegram-swarm skill.
Removal
To remove Telegram integration:
- Delete
src/channels/telegram.ts
- Remove
TelegramChannel import and creation from src/index.ts
- Remove
channels array and revert to using whatsapp directly in processGroupMessages, scheduler deps, and IPC deps
- Revert
getAvailableGroups() filter to only include @g.us chats
- Remove Telegram config (
TELEGRAM_BOT_TOKEN, TELEGRAM_ONLY) from src/config.ts
- Remove Telegram registrations from SQLite:
sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid LIKE 'tg:%'"
- Uninstall:
npm uninstall grammy
- Rebuild:
npm run build && launchctl kickstart -k gui/$(id -u)/com.bastionclaw