| name | telegram |
| description | Operate the joelclaw Telegram channel — primary mobile interface between Joel and the gateway. Covers grammy Bot API, text/media/reactions, inline buttons, callbacks, and streaming. |
Telegram Channel Skill
Operate the joelclaw Telegram channel — the primary mobile interface between Joel and the gateway agent. Built on grammy (Bot API wrapper), supports text, media, reactions, replies, inline buttons, callbacks, and streaming.
Architecture
Joel (Telegram app)
→ Bot API (long polling via grammy)
→ telegram.ts channel adapter
→ enrichPromptWithVaultContext()
→ command-queue → pi session
→ outbound router → telegram.ts send → Bot API → Joel
Key files:
packages/gateway/src/channels/telegram.ts — channel adapter (inbound + outbound)
packages/gateway/src/telegram-stream.ts — streaming UX (progressive text updates)
packages/gateway/src/outbound/router.ts — response routing
packages/gateway/src/channels/types.ts — Channel interface
SDK: grammy@1.40.0 — Bot instance at module scope, exposed via getBot().
Multi-instance poll ownership (2026-03-05): Telegram long polling now uses a Redis lease per bot token hash.
- Owner key:
joelclaw:gateway:telegram:poll-owner:<tokenHash>
- Status key:
joelclaw:gateway:telegram:poll-status:<tokenHash>
- Only owner polls
getUpdates; non-owners stay passive/send-only and retry lease acquisition with backoff.
Conflict guard still applies for non-cooperative pollers: telegram.channel.start_failed (with conflict metadata) + telegram.channel.retry_scheduled + telegram.channel.polling_recovered.
Delivery audit
Telegram has three distinct delivery lanes:
- Human conversation: Telegram update → command queue → Pi session → stream/router → Bot API.
- Canonical CLI notification:
joelclaw notify send → gateway Redis event queue → immediate send or Pi session → Bot API.
- Direct system notification: producer →
gateway/send.message → Redis joelclaw:outbound:messages → gateway drain → Bot API.
Both lanes emit privacy-safe OTEL receipts under one flowId. Message bodies and previews are not stored. Receipts keep only SHA-256, character count, byte count, producer, origin system, route, queue timing, reply/message IDs, and terminal status.
Canonical actions:
telegram.inbound.accepted
channel.delivery.queued
channel.delivery.dequeued
telegram.delivery.attempted
telegram.delivery.confirmed
telegram.delivery.failed
Query the whole lane, then narrow to one flow:
joelclaw otel search "telegram.delivery" --hours 24
joelclaw otel search "channel.delivery" --hours 24
joelclaw otel search "<flowId>" --hours 24
If the default ClickHouse adapter is unavailable, use the working Typesense mirror explicitly:
joelclaw otel search "telegram.delivery" --hours 24 --adapter typesense-otel
joelclaw otel search "<flowId>" --hours 24 --adapter typesense-otel
telegram.delivery.confirmed means Telegram's Bot API accepted the send and returned message ID(s). It does not mean Joel read the message.
The queue still uses LRANGE then DEL before delivery. A queued flow with no dequeue/terminal receipt exposes that gap, but does not recover the lost item. Reliable claim/ack plus a dead-letter queue is separate follow-up work.
Capabilities
Sending Messages
await telegramChannel.send("telegram:7718912466", "Hello", { format: "html" });
const bot = getBot();
await bot.api.sendMessage(chatId, text, { parse_mode: "HTML" });
- Max message length: 4096 chars (Telegram API limit)
- Chunking:
TelegramConverter.chunk() for HTML-aware splitting, chunkMessage() for raw text
- Format: markdown→HTML via
TelegramConverter.convert(), with plain text fallback on validation failure
- Buttons:
InlineButton[][] → inline_keyboard reply markup
Reactions (ADR-0162)
await bot.api.setMessageReaction(chatId, messageId, [
{ type: "emoji", emoji: "👍" }
]);
Telegram supports a fixed set of emoji reactions. Common ones: 👍 👎 ❤️ 🔥 🎉 🤔 👀 ✅ ❌ 🤯 💯
Agent convention: Include <<react:EMOJI>> at the start of a response. The outbound router strips it and calls setMessageReaction before sending text.
Replies
await bot.api.sendMessage(chatId, text, {
reply_parameters: { message_id: targetMessageId }
});
Already wired in the adapter via RichSendOptions.replyTo. The agent uses <<reply:MSG_ID>> directive.
Media
Supports photo, video, audio, voice, and document sending/receiving:
await telegramChannel.sendMedia(chatId, "/path/to/file.jpg", { caption: "Look at this" });
File size limit: 20MB download via Bot API (larger files need direct Telegram API).
Streaming (ADR-0160)
Progressive text updates with cursor:
import { begin, pushDelta, finish, abort } from "./telegram-stream";
begin({ chatId, bot, replyTo });
pushDelta(delta);
await finish(fullText);
- Plain text during streaming (no parse_mode) — avoids broken HTML on partial content
- HTML formatting only on
finish() — final edit with parse_mode: "HTML"
- Throttled edits: 800ms minimum between API calls
- Cursor:
▌ appended during streaming, removed on finish
initialSendPromise awaited in finish() to prevent race conditions
Inline Buttons & Callbacks (ADR-0070)
await sendTelegramMessage(chatId, "Choose:", {
buttons: [
[{ text: "✅ Approve", action: "approve:item123" }],
[{ text: "❌ Reject", action: "reject:item123" }],
]
});
Callback data max: 64 bytes. Format: action:context.
Commands
/stop — abort current turn without killing the daemon.
/esc — alias for /stop.
/kill — hard stop: disables launchd service + kills process. Emergency use only.
Configuration
Currently via environment variables (migrating to ~/.joelclaw/channels.toml per ADR-0162):
| Env Var | Purpose |
|---|
TELEGRAM_BOT_TOKEN | Grammy bot token |
TELEGRAM_USER_ID | Joel's Telegram user ID (only authorized user) |
Security
- Single-user lockdown — middleware drops all messages from users other than
TELEGRAM_USER_ID
- No token in config —
channels.toml references agent-secrets key names, not raw tokens
- Media downloads to
/tmp/joelclaw-media/ with UUID filenames (no path traversal)
Troubleshooting
Bot not receiving messages
- Check gateway is running:
cat /tmp/joelclaw/gateway.pid && ps aux | grep daemon.ts
- Check Telegram polling started:
grep "telegram.*started" /tmp/joelclaw/gateway.log
- Verify token:
curl https://api.telegram.org/bot<TOKEN>/getMe
- Check polling errors in stderr:
rg "telegram.channel.start_failed|failed to start polling|getUpdates" /tmp/joelclaw/gateway.err
- Check ownership lifecycle telemetry:
joelclaw otel search "telegram.channel.poll_owner" --hours 1
joelclaw otel search "telegram.channel.retry_scheduled" --hours 1
If you see repeated 409 conflicts, another bot process is polling the same token. Telegram phone/desktop apps are not Bot API pollers and do not cause getUpdates contention.
Messages arriving but no response
- Check command queue:
grep "command-queue\|enqueue" /tmp/joelclaw/gateway.log | tail -10
- Check pi session health:
grep "session\|prompt" /tmp/joelclaw/gateway.log | tail -10
- Check outbound routing:
grep "outbound\|response ready" /tmp/joelclaw/gateway.log | tail -10
Streaming not working
- Verify
text_delta events: grep "text_delta" /tmp/joelclaw/gateway.log | tail -5
- Check
telegram-stream lifecycle: grep "telegram-stream" /tmp/joelclaw/gateway.log | tail -10
- Common issue: model does tool calls before text → no deltas until after tools complete
- Race condition fix:
initialSendPromise in finish() (commit 175c6ca)
HTML formatting broken
- Check converter output:
TelegramConverter.convert(text) + .validate(result)
- Fallback: adapter auto-strips HTML and sends plain text if validation fails
- Streaming path sends plain text (no parse_mode), only
finish() adds HTML
Related ADRs
- ADR-0042 — Media download pipeline
- ADR-0070 — Inline buttons and callbacks
- ADR-0160 — Telegram streaming UX
- ADR-0162 — Reactions, replies, and channel configuration