| name | add-telegram |
| description | Add Telegram as a messaging channel to the Corsair agent. Use when the user wants to chat with their agent via a Telegram bot instead of or alongside WhatsApp. |
Corsair Telegram Setup
Run all steps automatically. Pause only when the user must take a manual action (creating a bot, providing a token). Always run commands directly — never tell the user to run something you can do yourself.
The agent runs in Docker — use docker compose exec agent <command> for commands that need to run inside the container. Exception: docker compose commands themselves run on the host.
1. Verify the agent container is running
docker compose ps
The agent service should be running. If not:
docker compose up -d
2. Create the Telegram bot (BotFather)
grammy ships pre-installed in Corsair's Docker image — no package installation step needed.
If the user doesn't already have a bot token, tell them:
I need you to create a Telegram bot — it only takes 30 seconds:
- Open Telegram and search for
@BotFather
- Send
/newbot
- Choose a display name (e.g. "My Assistant")
- Choose a username — must end in
bot (e.g. my_assistant_bot)
- Copy the token it gives you (looks like
123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11)
Paste the token here when you have it.
Wait for the token before continuing.
4. Configure environment variables
Read .env (in the project root). Add these entries if not present:
# Required: enable the Telegram listener
TELEGRAM_ENABLED=true
# Bot token from BotFather
TELEGRAM_BOT_TOKEN=<their-token>
# Optional: bot username used as trigger in group chats (default: corsair)
BOT_NAME=corsair
Follow the Credentials convention: never ask for the token in chat. Instead, tell the user to run:
echo 'TELEGRAM_BOT_TOKEN=YOUR_TOKEN_HERE' >> /path/to/project/.env
Then update TELEGRAM_ENABLED=true yourself by editing .env.
5. Update the database schema
Open server/db/schema.ts. Make two changes:
5a. Add 'telegram' to the threads source enum
Find the threads table definition. The source column currently lists ['web', 'whatsapp']. Add 'telegram':
source: text('source', { enum: ['web', 'whatsapp', 'telegram'] })
.notNull()
.default('web'),
5b. Add Telegram tables at the end of the file
export const telegramMessages = pgTable('telegram_messages', {
id: uuid('id').primaryKey().defaultRandom(),
chatId: text('chat_id').notNull(),
senderId: text('sender_id').notNull(),
senderName: text('sender_name'),
content: text('content').notNull(),
sentAt: timestamp('sent_at').notNull(),
isGroup: boolean('is_group').notNull().default(false),
processed: boolean('processed').notNull().default(false),
createdAt: ().().(),
});
telegramChats = (, {
: ().(),
: (),
: (, { : [, ] }).(),
: ().().(),
: ().().(),
});
6. Create the Telegram channel files
Create a new directory server/telegram/ with three files, mirroring the WhatsApp pattern.
6a. server/telegram/connection.ts
import { Bot } from 'grammy';
export interface InboundTelegramMessage {
chatId: number;
senderId: number;
senderName: string | null;
content: string;
isGroup: boolean;
sentAt: Date;
}
export class TelegramConnection {
private bot: Bot;
constructor(
token: string,
private onMessage: (msg: InboundTelegramMessage) => Promise<void>,
) {
this.bot = new Bot(token);
this.setupHandlers();
}
private setupHandlers(): void {
this.bot.command('chatid', async (ctx) => {
await ctx.reply(`Chat ID: `);
});
..(, (ctx) => {
msg = ctx.;
= ctx.;
chat = ctx.;
(?.) ;
firstName = ?. ?? ;
lastName = ?. ? : ;
senderName = firstName + lastName || ;
.({
: chat.,
: ?. ?? ,
senderName,
: msg.,
:
chat. === ||
chat. === ||
chat. === ,
: (msg. * ),
});
});
}
(): <> {
..().( {
.(, err);
});
.();
}
(): <> {
..();
.();
}
(: , : ): <> {
...(chatId, text);
}
(: ): <> {
...(chatId, ).( {});
}
}
6b. server/telegram/poller.ts
import type { ModelMessage, ToolModelMessage } from 'ai';
import { asc, desc, eq } from 'drizzle-orm';
import { runAgent } from '../agent';
import { db, telegramMessages, threadMessages, threads } from '../db';
const POLL_INTERVAL_MS = 2000;
function toJid(chatId: number | string): string {
return `tg:${chatId}`;
}
function getBotMentionPattern(): RegExp {
const botName = process.env.BOT_NAME || 'corsair';
return new RegExp(`@${botName}`, 'i');
}
function buildResumeMessages(
storedMessages: ModelMessage[],
toolCallId: string,
toolName: string,
answer: ,
): [] {
[
...storedMessages,
{
: ,
: [
{
: ,
toolCallId,
toolName,
: { : , : answer },
},
],
} ,
];
}
(): <> {
[existing] = db
.({ : threads. })
.(threads)
.((threads., jid))
.();
(existing) existing.;
[created] = db
.(threads)
.({ : , jid })
.({ : threads. });
created!.;
}
(): <> {
unprocessed = db
.()
.(telegramMessages)
.((telegramMessages., ))
.((telegramMessages.));
( msg unprocessed) {
(msg.) {
mentionPattern = ();
(!mentionPattern.(msg.)) {
db
.(telegramMessages)
.({ : })
.((telegramMessages., msg.));
;
}
}
db
.(telegramMessages)
.({ : })
.((telegramMessages., msg.));
jid = (msg.);
chatIdNum = (msg.);
threadId = (jid);
db.(threadMessages).({
threadId,
: ,
: msg.,
});
recent = db
.()
.(threadMessages)
.((threadMessages., threadId))
.((threadMessages.))
.();
pendingAssistant = recent.(
m. === && m.,
);
: [];
(
pendingAssistant?. &&
pendingAssistant. &&
pendingAssistant.
) {
agentMessages = (
pendingAssistant. [],
pendingAssistant.,
pendingAssistant.,
msg.,
);
db
.(threadMessages)
.({
: ,
: ,
: ,
})
.((threadMessages., pendingAssistant.));
} {
history = db
.()
.(threadMessages)
.((threadMessages., threadId))
.((threadMessages.))
.();
agentMessages = history.( ({
: m. | ,
: m. || ,
}));
}
{
(chatIdNum);
output = (agentMessages, { jid });
replyText = ;
(output. === ) {
replyText = output.;
: [] = [
...agentMessages,
...output..(agentMessages.),
];
db.(threadMessages).({
threadId,
: ,
: replyText,
: pendingMsgs,
: output.,
: output.,
});
} (output. === ) {
replyText = output.;
db.(threadMessages).({
threadId,
: ,
: replyText,
});
} (output. === ) {
replyText = output.
?
: output. || output.?.() || ;
db.(threadMessages).({
threadId,
: ,
: replyText,
});
} (output. === ) {
replyText = output.
? output.
: output.
?
: output.
?
: ;
db.(threadMessages).({
threadId,
: ,
: replyText,
});
}
(replyText) {
(chatIdNum, replyText);
}
db
.(threads)
.({ : () })
.((threads., threadId));
} (err) {
.(, msg., , err);
(
chatIdNum,
,
).( {});
}
}
}
(): {
running = ;
(): <> {
(running) {
{
(sendMessage, setTyping);
} (err) {
.(, err);
}
<>(
(resolve, ),
);
}
}
().(.);
.();
{
running = ;
};
}
6c. server/telegram/index.ts
import { db, telegramChats, telegramMessages } from '../db';
import type { InboundTelegramMessage } from './connection';
import { TelegramConnection } from './connection';
import { startPoller } from './poller';
export async function startTelegram(): Promise<() => Promise<void>> {
const token = process.env.TELEGRAM_BOT_TOKEN;
if (!token) {
console.error(
'[telegram] TELEGRAM_BOT_TOKEN is not set. Add it to .env and restart.',
);
return async () => {};
}
const connection = new TelegramConnection(token, handleInbound);
async function handleInbound(msg: InboundTelegramMessage): Promise<void> {
const chatId = (msg.);
db
.(telegramChats)
.({
chatId,
: msg.,
: msg. ? : ,
})
.();
db.(telegramMessages).({
chatId,
: (msg.),
: msg.,
: msg.,
: msg.,
: msg.,
: ,
});
.(
,
);
}
connection.();
stopPoller = (
connection.(chatId, text),
connection.(chatId),
);
() => {
.();
();
connection.();
};
}
7. Update server/db/index.ts (re-export new tables)
Open server/db/index.ts. Add telegramMessages and telegramChats to the exports from ./schema. If the file uses export * from './schema', no change is needed — they'll be exported automatically. If it has named exports, add the two new table names.
8. Wire Telegram into server/index.ts
Open server/index.ts and make three changes:
8a. Add import at the top
import { startTelegram } from './telegram/index';
Also add telegramMessages to the destructured import from './db':
import {
db,
permissions,
telegramMessages,
threadMessages,
threads,
whatsappMessages,
workflows,
} from './db';
8b. Handle permission resume for Telegram threads
Find the block starting at if (thread?.source === 'whatsapp' && thread.jid) in the permission resolve handler. Extend it to also handle Telegram:
if (
(thread?.source === 'whatsapp' || thread?.source === 'telegram') &&
thread.jid
) {
if (thread.source === 'whatsapp') {
await db.insert(whatsappMessages).values({
jid: thread.jid,
senderJid: 'system',
senderName: 'Permission System',
content: answer,
sentAt: new Date(),
isGroup: false,
isBot: false,
processed: false,
});
} else {
const chatId = thread.jid.replace(/^tg:/, '');
await db.insert(telegramMessages).values({
chatId,
senderId: 'system',
senderName: 'Permission System',
content: answer,
sentAt: new Date(),
isGroup: false,
: ,
});
}
} {
}
8c. Start Telegram after the server listens
Find the WhatsApp startup block at the bottom of main():
if (process.env.WHATSAPP_ENABLED === 'true') {
console.log('[server] Starting WhatsApp listener...');
startWhatsApp().catch((err) => {
console.error('[server] WhatsApp startup failed:', err);
});
}
Add immediately after it:
if (process.env.TELEGRAM_ENABLED === 'true') {
console.log('[server] Starting Telegram listener...');
startTelegram().catch((err) => {
console.error('[server] Telegram startup failed:', err);
});
}
9. Push database migrations
The new tables need to exist in Postgres. Migrations run automatically on container restart, but trigger one now:
docker compose up -d agent
Follow the logs to confirm the migration ran:
docker compose logs agent | grep -E 'db:push|telegram|error' | head -20
10. Restart and verify
docker compose up -d agent
docker compose logs -f agent
Check for:
[telegram] Bot started (long polling) — bot connected
[telegram] Poller started (2s interval) — ready to receive messages
If you see TELEGRAM_BOT_TOKEN is not set, the env var didn't make it into the container. Run:
docker compose up -d agent
11. Test the connection
Tell the user:
- Open Telegram and search for your bot's username (e.g.
@my_assistant_bot)
- Tap Start or send any message
- The agent should reply within a few seconds
For groups: add the bot to a group, then send @corsair <your message> (using your BOT_NAME).
To find your chat ID, send /chatid to the bot.
12. Groups: disable privacy mode (optional)
If the user wants the bot to see all group messages without being @mentioned, tell them:
By default Telegram bots only see @mentions and commands in groups. To let the bot see all messages:
- Open @BotFather →
/mybots → select your bot
- Bot Settings → Group Privacy → Turn off
- Remove and re-add the bot to any existing groups (required for the change to take effect)
How it works
Telegram message received
→ grammy long-polling receives it
→ Stored in postgres (telegram_messages, processed=false)
→ Poller queries every 2s
→ DMs: always trigger agent
→ Groups: only if @corsair (BOT_NAME) is in the message
→ runAgent() called → uses Corsair plugins to complete the task
→ Response sent back via Telegram
Troubleshooting
Bot not responding to messages:
- Check
TELEGRAM_ENABLED=true in .env
- Check
TELEGRAM_BOT_TOKEN is set and correct
- Ensure
WHATSAPP_ENABLED=false in .env
- Check
[telegram] Poller started in logs: docker compose logs agent | grep telegram
- For groups: message must include
@corsair (or BOT_NAME)
TELEGRAM_BOT_TOKEN is not set error:
- Run
docker compose up -d agent (not docker compose restart) to pick up new .env values
grammy not found / module resolution error:
ERR_PNPM_UNEXPECTED_STORE or similar pnpm errors:
- Never run
pnpm add inside a running container — the pnpm store paths conflict. Always edit package.json and do the volume-rm + rebuild sequence above.
Verify bot token:
curl "https://api.telegram.org/bot<YOUR_TOKEN>/getMe"
Re-authenticate (new bot): Just update TELEGRAM_BOT_TOKEN in .env and restart.