Joelclaw telegram
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.
git clone https://github.com/joelhooks/joelclaw
T=$(mktemp -d) && git clone --depth=1 https://github.com/joelhooks/joelclaw "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/telegram" ~/.claude/skills/joelhooks-joelclaw-telegram && rm -rf "$T"
skills/telegram/SKILL.mdTelegram 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:
— channel adapter (inbound + outbound)packages/gateway/src/channels/telegram.ts
— streaming UX (progressive text updates)packages/gateway/src/telegram-stream.ts
— response routingpackages/gateway/src/outbound/router.ts
—packages/gateway/src/channels/types.ts
interfaceChannel
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
; non-owners stay passive/send-only and retry lease acquisition with backoff.getUpdates
Conflict guard still applies for non-cooperative pollers:
telegram.channel.start_failed (with conflict metadata) + telegram.channel.retry_scheduled + telegram.channel.polling_recovered.
Capabilities
Sending Messages
// Via channel adapter await telegramChannel.send("telegram:7718912466", "Hello", { format: "html" }); // Direct grammy API (from telegram-stream or daemon) const bot = getBot(); await bot.api.sendMessage(chatId, text, { parse_mode: "HTML" });
- Max message length: 4096 chars (Telegram API limit)
- Chunking:
for HTML-aware splitting,TelegramConverter.chunk()
for raw textchunkMessage() - Format: markdown→HTML via
, with plain text fallback on validation failureTelegramConverter.convert() - Buttons:
→InlineButton[][]
reply markupinline_keyboard
Reactions (ADR-0162)
// grammy API 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
// grammy API — reply to a specific message 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:
// Send await telegramChannel.sendMedia(chatId, "/path/to/file.jpg", { caption: "Look at this" }); // Receive — handled by bot.on("message:photo") etc. // Downloads via Bot API getFile → local /tmp/joelclaw-media/ // Emits media/received Inngest event for pipeline processing
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"; // On prompt dispatch begin({ chatId, bot, replyTo }); // On each text_delta event pushDelta(delta); // On message_end await finish(fullText);
- Plain text during streaming (no parse_mode) — avoids broken HTML on partial content
- HTML formatting only on
— final edit withfinish()parse_mode: "HTML" - Throttled edits: 800ms minimum between API calls
- Cursor:
appended during streaming, removed on finish▌
awaited ininitialSendPromise
to prevent race conditionsfinish()
Inline Buttons & Callbacks (ADR-0070)
// Send message with buttons await sendTelegramMessage(chatId, "Choose:", { buttons: [ [{ text: "✅ Approve", action: "approve:item123" }], [{ text: "❌ Reject", action: "reject:item123" }], ] }); // Callback handler fires telegram/callback.received Inngest event // Then edits message to show action taken + removes buttons
Callback data max: 64 bytes. Format:
action:context.
Commands
— abort current turn without killing the daemon./stop
— alias for/esc
./stop
— hard stop: disables launchd service + kills process. Emergency use only./kill
Configuration
Currently via environment variables (migrating to
~/.joelclaw/channels.toml per ADR-0162):
| Env Var | Purpose |
|---|---|
| Grammy bot token |
| 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 —
referenceschannels.toml
key names, not raw tokensagent-secrets - Media downloads to
with UUID filenames (no path traversal)/tmp/joelclaw-media/
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 1joelclaw 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
events:text_deltagrep "text_delta" /tmp/joelclaw/gateway.log | tail -5 - Check
lifecycle:telegram-streamgrep "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:
ininitialSendPromise
(commit 175c6ca)finish()
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
adds HTMLfinish()
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