.agents/skills/add-telegram/SKILL.md
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).
npx skillsauth add omniaura/omniclaw add-telegramInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
This skill adds Telegram support to OmniClaw. Users can choose to:
npm install grammy
Grammy is a modern, TypeScript-first Telegram bot framework.
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.
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
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.
Before making changes, ask:
Mode: Replace WhatsApp or add alongside it?
TELEGRAM_ONLY=trueChat behavior: Should this chat respond to all messages or only when @mentioned?
requiresTrigger: false)requiresTrigger: true)OmniClaw 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:
Channel interface (connect, sendMessage, ownsJid, disconnect, setTyping)onMessage / onChatMetadata callbackssrc/index.ts picks up stored messages automaticallyRead 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.
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., ^@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
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:
Channel interface — same pattern as WhatsAppChannelonMessage / onChatMetadata callbacks instead of importing DB functions directlyregisteredGroups() callback, not getAllRegisteredGroups()prefixAssistantName = false — Telegram bots already show their name, so formatOutbound() skips the prefixstoreMessageDirect needed — storeMessage() in db.ts already accepts NewMessage directlyModify src/index.ts to support multiple channels. Read the file first to understand the current structure.
import { TelegramChannel } from './channels/telegram.js';
import { TELEGRAM_BOT_TOKEN, TELEGRAM_ONLY } from './config.js';
import { findChannel } from './router.js';
whatsapp variable:let whatsapp: WhatsAppChannel;
const channels: Channel[] = [];
Import Channel from ./types.js if not already imported.
processGroupMessages to find the correct channel for the JID instead of using whatsapp directly. Replace the direct whatsapp.setTyping() and whatsapp.sendMessage() calls:// 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);
main() function to create channels conditionally and use them for deps: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();
}
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),
}));
}
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.
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.
npm run build
launchctl kickstart -k gui/$(id -u)/com.omniclaw
Or for systemd:
npm run build
systemctl --user restart omniclaw
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/omniclaw.log
If user wants Telegram-only:
TELEGRAM_ONLY=true in .envcp .env data/env/env to sync to container@whiskeysockets/baileys dependency (but it's harmless to keep)[email protected] (groups) or [email protected] (DM)tg:123456789 (positive for private) or tg:-1001234567890 (negative for groups)The bot responds when:
requiresTrigger: false in its registration (e.g., main group)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.
/chatid - Get chat ID for registration/ping - Check if bot is onlineCheck:
TELEGRAM_BOT_TOKEN is set in .env AND synced to data/env/envsqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'tg:%'")launchctl list | grep omniclawThe bot has Group Privacy enabled (default). It can only see messages that @mention it or are commands. To fix:
@BotFather in Telegram/mybots > select bot > Bot Settings > Group Privacy > Turn offIf /chatid doesn't work:
curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe"tail -f logs/omniclaw.logIf running npm run dev while launchd service is active:
launchctl unload ~/Library/LaunchAgents/com.omniclaw.plist
npm run dev
# When done testing:
launchctl load ~/Library/LaunchAgents/com.omniclaw.plist
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.
To remove Telegram integration:
src/channels/telegram.tsTelegramChannel import and creation from src/index.tschannels array and revert to using whatsapp directly in processGroupMessages, scheduler deps, and IPC depsgetAvailableGroups() filter to only include @g.us chatsTELEGRAM_BOT_TOKEN, TELEGRAM_ONLY) from src/config.tssqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid LIKE 'tg:%'"npm uninstall grammynpm run build && launchctl kickstart -k gui/$(id -u)/com.omniclawtools
Manage stacked pull requests using Graphite CLI. Create, submit, and restack PR chains.
tools
Full GitHub operations via `gh` CLI — pull requests, issues, code review, CI/CD, search, and GraphQL API. Use for any GitHub interaction beyond basic git.
development
Browse the web for any task — research topics, read articles, interact with web apps, fill forms, take screenshots, extract data, and test web pages. Use whenever a browser would be useful, not just when the user explicitly asks.
testing
X (Twitter) integration for OmniClaw. Post tweets, like, reply, retweet, and quote. Use for setup, testing, or troubleshooting X functionality. Triggers on "setup x", "x integration", "twitter", "post tweet", "tweet".