BioClaw add-telegram
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).
git clone https://github.com/Runchuan-BU/BioClaw
T=$(mktemp -d) && git clone --depth=1 https://github.com/Runchuan-BU/BioClaw "$T" && mkdir -p ~/.claude/skills && cp -r "$T/.claude/skills/add-telegram" ~/.claude/skills/runchuan-bu-bioclaw-add-telegram && rm -rf "$T"
.claude/skills/add-telegram/SKILL.mdAdd Telegram Channel
This skill adds Telegram support to BioClaw. 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
and follow prompts:/newbot
- Bot name: Something friendly (e.g., "Bio 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
command to help you get the ID/chatidFor Group Chat:
- Add your bot to the group
- Send any message
- Use the
command in the group/chatid
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
or trigger-word detection):requiresTrigger: false
- Open Telegram and search for
@BotFather- Send
and select your bot/mybots- 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
Before making changes, ask:
-
Mode: Replace WhatsApp or add alongside it?
- If replace: Set
TELEGRAM_ONLY=true - If alongside: Both will run
- If replace: Set
-
Chat behavior: Should this chat respond to all messages or only when @mentioned?
- Main chat: Responds to all (set
)requiresTrigger: false - Other chats: Default requires trigger (
)requiresTrigger: true
- Main chat: Responds to all (set
Architecture
BioClaw uses a Channel abstraction (
Channel interface in src/types.ts). Each messaging platform implements this interface. Key files:
| File | Purpose |
|---|---|
| interface definition |
| class (reference implementation) |
| , , |
| Orchestrator: creates channels, wires callbacks, starts subsystems |
| IPC watcher (uses dep for outbound) |
The Telegram channel follows the same pattern as WhatsApp:
- Implements
interface (Channel
,connect
,sendMessage
,ownsJid
,disconnect
)setTyping - Delivers inbound messages via
/onMessage
callbacksonChatMetadata - The existing message loop in
picks up stored messages automaticallysrc/index.ts
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; // Telegram bots already display their name 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); // Command to get chat ID (useful for registration) 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" }, ); }); // Command to check bot status this.bot.command("ping", (ctx) => { ctx.reply(`${ASSISTANT_NAME} is online.`); }); this.bot.on("message:text", async (ctx) => { // Skip commands 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(); // Determine chat name const chatName = ctx.chat.type === "private" ? senderName : (ctx.chat as any).title || chatJid; // Translate Telegram @bot_username mentions into TRIGGER_PATTERN format. // Telegram @mentions (e.g., @andy_ai_bot) won't match TRIGGER_PATTERN // (e.g., ^@Bio\b), so we prepend the trigger when the bot is @mentioned. 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}`; } } // Store chat metadata for discovery this.opts.onChatMetadata(chatJid, timestamp, chatName); // Only deliver full message for registered groups const group = this.opts.registeredGroups()[chatJid]; if (!group) { logger.debug( { chatJid, chatName }, "Message from unregistered Telegram chat", ); return; } // Deliver message — startMessageLoop() will pick it up 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", ); }); // Handle non-text messages with placeholders so the agent knows something was sent 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]")); // Handle errors gracefully this.bot.catch((err) => { logger.error({ err: err.message }, "Telegram bot error"); }); // Start polling — returns a Promise that resolves when started 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:/, ""); // Telegram has a 4096 character limit per message — split if needed 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
interface — same pattern asChannelWhatsAppChannel - Uses
/onMessage
callbacks instead of importing DB functions directlyonChatMetadata - Registration check via
callback, notregisteredGroups()getAllRegisteredGroups()
— Telegram bots already show their name, soprefixAssistantName = false
skips the prefixformatOutbound()- No
needed —storeMessageDirect
in db.ts already acceptsstoreMessage()
directlyNewMessage
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
variable:whatsapp
let whatsapp: WhatsAppChannel; const channels: Channel[] = [];
Import
Channel from ./types.js if not already imported.
- Update
to find the correct channel for the JID instead of usingprocessGroupMessages
directly. Replace the directwhatsapp
andwhatsapp.setTyping()
calls:whatsapp.sendMessage()
// Find the channel that owns this JID const channel = findChannel(channels, chatJid); if (!channel) return true; // No channel for this JID // ... (existing code for message fetching, trigger check, formatting) await channel.setTyping?.(chatJid, true); // ... (existing agent invocation, replacing whatsapp.sendMessage with channel.sendMessage) 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
function to create channels conditionally and use them for deps:main()
async function main(): Promise<void> { ensureContainerSystemRunning(); initDatabase(); logger.info('Database initialized'); loadState(); // Graceful shutdown handlers 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')); // Channel callbacks (shared by all channels) const channelOpts = { onMessage: (chatJid: string, msg: NewMessage) => storeMessage(msg), onChatMetadata: (chatJid: string, timestamp: string, name?: string) => storeChatMetadata(chatJid, timestamp, name), registeredGroups: () => registeredGroups, }; // Create and connect channels 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(); } // Start subsystems 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
to include Telegram chats:getAvailableGroups
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 # Optional: Set to "true" to disable WhatsApp entirely # TELEGRAM_ONLY=true
Important: After modifying
.env, sync to the container environment:
cp .env data/env/env
The container reads environment from
data/env/env, not .env directly.
Step 5: Register a Telegram Chat
After installing and starting the bot, tell the user:
- Send
to your bot (in private chat or in a group)/chatid- Copy the chat ID (e.g.,
ortg:123456789)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):
// For private chat (main group): registerGroup("tg:123456789", { name: "Personal", folder: "main", trigger: `@${ASSISTANT_NAME}`, added_at: new Date().toISOString(), requiresTrigger: false, // main group responds to all messages }); // For group chat (note negative ID for Telegram groups): registerGroup("tg:-1001234567890", { name: "My Telegram Group", folder: "telegram-group", trigger: `@${ASSISTANT_NAME}`, added_at: new Date().toISOString(), requiresTrigger: true, // only respond when triggered });
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.bioclaw
Or for systemd:
npm run build systemctl --user restart bioclaw
Step 7: Test
Tell the user:
Send a message to your registered Telegram chat:
- For main chat: Any message works
- For non-main:
or @mention the bot@Bio helloCheck logs:
tail -f logs/bioclaw.log
Replace WhatsApp Entirely
If user wants Telegram-only:
- Set
inTELEGRAM_ONLY=true.env - Run
to sync to containercp .env data/env/env - The WhatsApp channel is not created — only Telegram
- All services (scheduler, IPC watcher, queue, message loop) start normally
- Optionally remove
dependency (but it's harmless to keep)@whiskeysockets/baileys
Features
Chat ID Formats
- WhatsApp:
(groups) or120363336345536173@g.us
(DM)1234567890@s.whatsapp.net - Telegram:
(positive for private) ortg:123456789
(negative for groups)tg:-1001234567890
Trigger Options
The bot responds when:
- Chat has
in its registration (e.g., main group)requiresTrigger: false - Bot is @mentioned in Telegram (translated to TRIGGER_PATTERN automatically)
- Message matches TRIGGER_PATTERN directly (e.g., starts with @Bio)
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
- Get chat ID for registration/chatid
- Check if bot is online/ping
Troubleshooting
Bot not responding
Check:
is set inTELEGRAM_BOT_TOKEN
AND synced to.envdata/env/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 bioclaw
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
in Telegram@BotFather
> select bot > Bot Settings > Group Privacy > Turn off/mybots- 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/bioclaw.log
Service conflicts
If running
npm run dev while launchd service is active:
launchctl unload ~/Library/LaunchAgents/com.bioclaw.plist npm run dev # When done testing: launchctl load ~/Library/LaunchAgents/com.bioclaw.plist
Agent Swarms (Teams)
After completing the Telegram setup, ask the user:
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
import and creation fromTelegramChannelsrc/index.ts - Remove
array and revert to usingchannels
directly inwhatsapp
, scheduler deps, and IPC depsprocessGroupMessages - Revert
filter to only includegetAvailableGroups()
chats@g.us - Remove Telegram config (
,TELEGRAM_BOT_TOKEN
) fromTELEGRAM_ONLYsrc/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.bioclaw