Agent Teams work without swarm support — they just operate behind the scenes. Swarm support makes subagents visible as separate bots. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
20 KiB
name, description
| name | description |
|---|---|
| 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). |
Add Telegram Channel
This skill adds Telegram support to NanoClaw. 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
/newbotand 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
/chatidcommand to help you get the IDFor Group Chat:
- Add your bot to the group
- Send any message
- Use the
/chatidcommand 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: falseor trigger-word detection):
- Open Telegram and search for
@BotFather- Send
/mybotsand 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
- 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
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: Add storeMessageDirect to Database
Read src/db.ts and add this function (place it near the storeMessage function):
/**
* Store a message directly (for non-WhatsApp channels that don't use Baileys proto).
*/
export function storeMessageDirect(msg: {
id: string;
chat_jid: string;
sender: string;
sender_name: string;
content: string;
timestamp: string;
is_from_me: boolean;
}): void {
db.prepare(
`INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me) VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run(
msg.id,
msg.chat_jid,
msg.sender,
msg.sender_name,
msg.content,
msg.timestamp,
msg.is_from_me ? 1 : 0,
);
}
This uses the existing db instance from db.ts. No additional imports needed.
Step 3: Create Telegram Module
Create src/telegram.ts. The Telegram module is a thin layer that stores incoming messages to the database. It does NOT call the agent directly — the existing startMessageLoop() in src/index.ts polls all registered group JIDs and picks up Telegram messages automatically.
import { Bot } from "grammy";
import {
ASSISTANT_NAME,
TRIGGER_PATTERN,
} from "./config.js";
import {
getAllRegisteredGroups,
storeChatMetadata,
storeMessageDirect,
} from "./db.js";
import { logger } from "./logger.js";
let bot: Bot | null = null;
/** Store a placeholder message for non-text content (photos, voice, etc.) */
function storeNonTextMessage(ctx: any, placeholder: string): void {
const chatId = `tg:${ctx.chat.id}`;
const registeredGroups = getAllRegisteredGroups();
if (!registeredGroups[chatId]) 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}` : "";
storeChatMetadata(chatId, timestamp);
storeMessageDirect({
id: ctx.message.message_id.toString(),
chat_jid: chatId,
sender: ctx.from?.id?.toString() || "",
sender_name: senderName,
content: `${placeholder}${caption}`,
timestamp,
is_from_me: false,
});
}
export async function connectTelegram(botToken: string): Promise<void> {
bot = new Bot(botToken);
// Command to get chat ID (useful for registration)
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
bot.command("ping", (ctx) => {
ctx.reply(`${ASSISTANT_NAME} is online.`);
});
bot.on("message:text", async (ctx) => {
// Skip commands
if (ctx.message.text.startsWith("/")) return;
const chatId = `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 || chatId;
// Translate Telegram @bot_username mentions into TRIGGER_PATTERN format.
// Telegram @mentions (e.g., @andy_ai_bot) won't match TRIGGER_PATTERN
// (e.g., ^@Andy\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
storeChatMetadata(chatId, timestamp, chatName);
// Check if this chat is registered
const registeredGroups = getAllRegisteredGroups();
const group = registeredGroups[chatId];
if (!group) {
logger.debug(
{ chatId, chatName },
"Message from unregistered Telegram chat",
);
return;
}
// Store message — startMessageLoop() will pick it up
storeMessageDirect({
id: msgId,
chat_jid: chatId,
sender,
sender_name: senderName,
content,
timestamp,
is_from_me: false,
});
logger.info(
{ chatId, chatName, sender: senderName },
"Telegram message stored",
);
});
// Handle non-text messages with placeholders so the agent knows something was sent
bot.on("message:photo", (ctx) => storeNonTextMessage(ctx, "[Photo]"));
bot.on("message:video", (ctx) => storeNonTextMessage(ctx, "[Video]"));
bot.on("message:voice", (ctx) => storeNonTextMessage(ctx, "[Voice message]"));
bot.on("message:audio", (ctx) => storeNonTextMessage(ctx, "[Audio]"));
bot.on("message:document", (ctx) => {
const name = ctx.message.document?.file_name || "file";
storeNonTextMessage(ctx, `[Document: ${name}]`);
});
bot.on("message:sticker", (ctx) => {
const emoji = ctx.message.sticker?.emoji || "";
storeNonTextMessage(ctx, `[Sticker ${emoji}]`);
});
bot.on("message:location", (ctx) => storeNonTextMessage(ctx, "[Location]"));
bot.on("message:contact", (ctx) => storeNonTextMessage(ctx, "[Contact]"));
// Handle errors gracefully
bot.catch((err) => {
logger.error({ err: err.message }, "Telegram bot error");
});
// Start polling
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`,
);
},
});
}
export async function sendTelegramMessage(
chatId: string,
text: string,
): Promise<void> {
if (!bot) {
logger.warn("Telegram bot not initialized");
return;
}
try {
const numericId = chatId.replace(/^tg:/, "");
// Telegram has a 4096 character limit per message — split if needed
const MAX_LENGTH = 4096;
if (text.length <= MAX_LENGTH) {
await bot.api.sendMessage(numericId, text);
} else {
for (let i = 0; i < text.length; i += MAX_LENGTH) {
await bot.api.sendMessage(numericId, text.slice(i, i + MAX_LENGTH));
}
}
logger.info({ chatId, length: text.length }, "Telegram message sent");
} catch (err) {
logger.error({ chatId, err }, "Failed to send Telegram message");
}
}
export async function setTelegramTyping(chatId: string): Promise<void> {
if (!bot) return;
try {
const numericId = chatId.replace(/^tg:/, "");
await bot.api.sendChatAction(numericId, "typing");
} catch (err) {
logger.debug({ chatId, err }, "Failed to send Telegram typing indicator");
}
}
export function isTelegramConnected(): boolean {
return bot !== null;
}
export function stopTelegram(): void {
if (bot) {
bot.stop();
bot = null;
logger.info("Telegram bot stopped");
}
}
Key differences from WhatsApp message handling:
- No
onMessagecallback — messages are stored to DB and the existing message loop picks them up - Registration check uses
getAllRegisteredGroups()fromdb.tsdirectly - Trigger matching is handled by
startMessageLoop()/processGroupMessages(), not the Telegram module
Step 4: Update Main Application
Modify src/index.ts:
- Add imports at the top:
import {
connectTelegram,
sendTelegramMessage,
setTelegramTyping,
stopTelegram,
} from "./telegram.js";
import { TELEGRAM_BOT_TOKEN, TELEGRAM_ONLY } from "./config.js";
- Update
sendMessagefunction to route Telegram messages. Find thesendMessagefunction and add atg:prefix check before the WhatsApp path:
async function sendMessage(jid: string, text: string): Promise<void> {
// Route Telegram messages directly (no outgoing queue needed)
if (jid.startsWith("tg:")) {
await sendTelegramMessage(jid, text);
return;
}
// WhatsApp path (with outgoing queue for reconnection)
if (!waConnected) {
outgoingQueue.push({ jid, text });
logger.info({ jid, length: text.length, queueSize: outgoingQueue.length }, 'WA disconnected, message queued');
return;
}
try {
await sock.sendMessage(jid, { text });
logger.info({ jid, length: text.length }, 'Message sent');
} catch (err) {
outgoingQueue.push({ jid, text });
logger.warn({ jid, err, queueSize: outgoingQueue.length }, 'Failed to send, message queued');
}
}
- Update
setTypingfunction to route Telegram typing indicators:
async function setTyping(jid: string, isTyping: boolean): Promise<void> {
if (jid.startsWith("tg:")) {
if (isTyping) await setTelegramTyping(jid);
return;
}
try {
await sock.sendPresenceUpdate(isTyping ? 'composing' : 'paused', jid);
} catch (err) {
logger.debug({ jid, err }, 'Failed to update typing status');
}
}
- Update
main()function. Add Telegram startup beforeconnectWhatsApp()and wrap WhatsApp in aTELEGRAM_ONLYcheck:
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');
stopTelegram();
await queue.shutdown(10000);
process.exit(0);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// Start Telegram bot if configured (independent of WhatsApp)
const hasTelegram = !!TELEGRAM_BOT_TOKEN;
if (hasTelegram) {
await connectTelegram(TELEGRAM_BOT_TOKEN);
}
if (!TELEGRAM_ONLY) {
await connectWhatsApp();
} else {
// Telegram-only mode: start all services that WhatsApp's connection.open normally starts
startSchedulerLoop({
registeredGroups: () => registeredGroups,
getSessions: () => sessions,
queue,
onProcess: (groupJid, proc, containerName, groupFolder) =>
queue.registerProcess(groupJid, proc, containerName, groupFolder),
sendMessage,
assistantName: ASSISTANT_NAME,
});
startIpcWatcher();
queue.setProcessMessagesFn(processGroupMessages);
recoverPendingMessages();
startMessageLoop();
logger.info(
`NanoClaw running (Telegram-only, trigger: @${ASSISTANT_NAME})`,
);
}
}
Note: When running alongside WhatsApp, the connection.open handler in connectWhatsApp() already starts the scheduler, IPC watcher, queue, and message loop — no duplication needed.
- Update
getAvailableGroupsfunction to include Telegram chats. The current filter only shows WhatsApp groups (@g.us). Update it to also includetg:chats so the agent can discover and register Telegram chats via IPC:
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 5: 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 6: Register a Telegram Chat
After installing and starting the bot, tell the user:
- Send
/chatidto your bot (in private chat or in a group)- Copy the chat ID (e.g.,
tg:123456789ortg:-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 7: Build and Restart
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
Or for systemd:
npm run build
systemctl --user restart nanoclaw
Step 8: Test
Tell the user:
Send a message to your registered Telegram chat:
- For main chat: Any message works
- For non-main:
@Andy helloor @mention the botCheck logs:
tail -f logs/nanoclaw.log
Replace WhatsApp Entirely
If user wants Telegram-only:
- Set
TELEGRAM_ONLY=truein.env - Run
cp .env data/env/envto sync to container - The WhatsApp connection code is automatically skipped
- All services (scheduler, IPC watcher, queue, message loop) start independently
- Optionally remove
@whiskeysockets/baileysdependency (but it's harmless to keep)
Features
Chat ID Formats
- WhatsApp:
120363336345536173@g.us(groups) or1234567890@s.whatsapp.net(DM) - Telegram:
tg:123456789(positive for private) ortg:-1001234567890(negative for groups)
Trigger Options
The bot responds when:
- Chat has
requiresTrigger: falsein 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_TOKENis set in.envAND synced todata/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 nanoclaw
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
@BotFatherin 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/nanoclaw.log
Service conflicts
If running npm run dev while launchd service is active:
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
npm run dev
# When done testing:
launchctl load ~/Library/LaunchAgents/com.nanoclaw.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/telegram.ts - Remove Telegram imports from
src/index.ts - Remove
sendTelegramMessage/setTelegramTypingrouting fromsendMessage()andsetTyping()functions - Remove
connectTelegram()/stopTelegram()calls frommain() - Remove
TELEGRAM_ONLYconditional inmain() - Revert
getAvailableGroups()filter to only include@g.uschats - Remove
storeMessageDirectfromsrc/db.ts - Remove Telegram config (
TELEGRAM_BOT_TOKEN,TELEGRAM_ONLY) fromsrc/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.nanoclaw