| 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). |
Add 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
/newbot and follow prompts:
- 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
/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
Before making changes, ask:
-
Mode: Replace WhatsApp or add alongside it?
- If replace: Set
TELEGRAM_ONLY=true
- If alongside: Both will run
-
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)
Architecture
BioClaw 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;
. = opts;
}
(): <> {
. = (.);
..(, {
chatId = ctx..;
chatType = ctx..;
chatName =
chatType ===
? ctx.?. ||
: (ctx. ). || ;
ctx.(
,
{ : },
);
});
..(, {
ctx.();
});
..(, (ctx) => {
(ctx...()) ;
chatJid = ;
content = ctx..;
timestamp = (ctx.. * ).();
senderName =
ctx.?. ||
ctx.?. ||
ctx.?..() ||
;
sender = ctx.?..() || ;
msgId = ctx...();
chatName =
ctx.. ===
? senderName
: (ctx. ). || chatJid;
botUsername = ctx.?.?.();
(botUsername) {
entities = ctx.. || [];
isBotMentioned = entities.( {
(entity. === ) {
mentionText = content
.(entity., entity. + entity.)
.();
mentionText === ;
}
;
});
(isBotMentioned && !.(content)) {
content = ;
}
}
..(chatJid, timestamp, chatName);
group = ..()[chatJid];
(!group) {
logger.(
{ chatJid, chatName },
,
);
;
}
..(chatJid, {
: msgId,
: chatJid,
sender,
: senderName,
content,
timestamp,
: ,
});
logger.(
{ chatJid, chatName, : senderName },
,
);
});
= () => {
chatJid = ;
group = ..()[chatJid];
(!group) ;
timestamp = (ctx.. * ).();
senderName =
ctx.?. || ctx.?. || ctx.?.?.() || ;
caption = ctx.. ? : ;
..(chatJid, timestamp);
..(chatJid, {
: ctx...(),
: chatJid,
: ctx.?.?.() || ,
: senderName,
: ,
timestamp,
: ,
});
};
..(, (ctx, ));
..(, (ctx, ));
..(, (ctx, ));
..(, (ctx, ));
..(, {
name = ctx..?. || ;
(ctx, );
});
..(, {
emoji = ctx..?. || ;
(ctx, );
});
..(, (ctx, ));
..(, (ctx, ));
..( {
logger.({ : err. }, );
});
<>( {
.!.({
: {
logger.(
{ : botInfo., : botInfo. },
,
);
.();
.(
,
);
();
},
});
});
}
(: , : ): <> {
(!.) {
logger.();
;
}
{
numericId = jid.(, );
= ;
(text. <= ) {
...(numericId, text);
} {
( i = ; i < text.; i += ) {
...(numericId, text.(i, i + ));
}
}
logger.({ jid, : text. }, );
} (err) {
logger.({ jid, err }, );
}
}
(): {
. !== ;
}
(: ): {
jid.();
}
(): <> {
(.) {
..();
. = ;
logger.();
}
}
(: , : ): <> {
(!. || !isTyping) ;
{
numericId = jid.(, );
...(numericId, );
} (err) {
logger.({ jid, err }, );
}
}
}
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, timestamp, name),
: registeredGroups,
};
(!) {
whatsapp = (channelOpts);
channels.(whatsapp);
whatsapp.();
}
() {
telegram = (, channelOpts);
channels.(telegram);
telegram.();
}
({
: registeredGroups,
: sessions,
queue,
:
queue.(groupJid, proc, containerName, groupFolder),
: (jid, rawText) => {
channel = (channels, jid);
(!channel) ;
text = (channel, rawText);
(text) channel.(jid, text);
},
});
({
: {
channel = (channels, jid);
(!channel) ();
channel.(jid, text);
},
: registeredGroups,
registerGroup,
: whatsapp?.(force) ?? .(),
getAvailableGroups,
: (gf, im, ag, rj),
});
queue.(processGroupMessages);
();
();
}
- 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
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
/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.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:
@Bio hello or @mention the bot
Check logs: tail -f logs/bioclaw.log
Replace WhatsApp Entirely
If user wants Telegram-only:
- Set
TELEGRAM_ONLY=true in .env
- Run
cp .env data/env/env to sync to container
- 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 @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
/chatid - Get chat ID for registration
/ping - Check if bot is online
Troubleshooting
Bot not responding
Check:
TELEGRAM_BOT_TOKEN is set in .env AND synced to data/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
@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/bioclaw.log
Service conflicts
If running npm run dev while launchd service is active:
launchctl unload ~/Library/LaunchAgents/com.bioclaw.plist
npm run dev
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
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.bioclaw