Bidirectional Telegram bridge for opencode.
Get notified when opencode finishes responding, reply from your phone, route replies to specific conversations, and approve tool requests — all without leaving Telegram.
- Recap notifications: opencode sends a plain-text summary to Telegram after each response (not raw code)
- Quote-reply routing: reply to a specific Telegram message to route your reply to that specific conversation
- Plain-reply fallback: a non-quote reply routes to the most recently idle session
- Conversation titles: each Telegram message is prefixed with the session title so you know which conversation you're looking at
- Approval routing: approve/reject tool permission requests from Telegram (y/yes/n/no)
- Question routing: answer the model's clarification questions from Telegram (number or label)
- Multi-process safe: works across multiple opencode windows/projects via shared file-based state
- Secret filtering: skips messages containing common secret patterns (API keys, tokens, private keys)
- Full response on reply: when you reply via Telegram, the full AI response is sent back (not just the recap)
- Message @BotFather on Telegram
- Send
/newbotand follow the prompts - Save the bot token (looks like
123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11)
- Send any message to your new bot
- Open
https://api.telegram.org/bot<YOUR_TOKEN>/getUpdatesin a browser - Find
"chat":{"id":XXXXXXXXX}in the response — that's your chat ID
Add to your shell profile (~/.zshrc, ~/.bashrc, etc.):
export TELEGRAM_BOT_TOKEN="your_bot_token_here"
export TELEGRAM_CHAT_ID="your_chat_id_here"Reload your shell: source ~/.zshrc (or restart terminal).
Option A — install script:
git clone <repo-url> codegram
cd codegram
node scripts/install.jsOption B — manual copy:
cp telegram-notify.js ~/.config/opencode/plugins/telegram-notify.jsopencode auto-discovers plugins in ~/.config/opencode/plugins/.
Quit all opencode windows and relaunch. The plugin loads on startup — config is not hot-reloaded.
Send any prompt to opencode in the TUI. When the AI finishes, you'll get a recap on Telegram. Reply to it — your reply goes back into that conversation.
All configuration is via environment variables:
| Variable | Required | Default | Description |
|---|---|---|---|
TELEGRAM_BOT_TOKEN |
Yes | — | Bot token from @BotFather |
TELEGRAM_CHAT_ID |
Yes | — | Your Telegram chat ID |
TELEGRAM_DEBUG |
No | unset | Set to 1 to write trace to /tmp/telegram-notify-debug.log |
TELEGRAM_ALLOW_SECRETS |
No | unset | Set to 1 to disable the secret filter (not recommended) |
TELEGRAM_STATE_DIR |
No | OS tmpdir | Override the shared state directory |
Unconfigured (missing env vars) = silent no-op. The plugin does nothing without both TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID.
When opencode finishes responding (session.idle event), the plugin fetches the last assistant message and looks for a [[RECAP]]...[[/RECAP]] block. This block is injected into the system prompt via experimental.chat.system.transform — the model is instructed to end every response with a plain-text recap.
For normal TUI prompts: only the recap is sent to Telegram (safe, no code).
For Telegram-injected prompts (you replied via Telegram): the full AI response is sent (truncated to 4000 chars, secret-filtered), so you can see the answer without opening the TUI.
Each outbound message is prefixed with the conversation title:
Session naming request
Routed the telegram reply to the correct session...
Every opencode process runs a getUpdates long-poll loop (30s timeout). Telegram 409-throttles to one concurrent poll. Each fetched update is claimed by exactly one process — the shared lastUpdateId in a state file is advanced by 1 under a file lock before handling. This prevents duplicate injection across multiple opencode windows.
Routing:
- Quote-reply (Telegram's reply feature — drag a message and quote it): routes to the session that originated the quoted message. The outbound Telegram
message_idis mapped to the session ID in the shared state file. - Plain reply (no quote): routes to the globally last-idle session across all processes.
Injection uses client.session.prompt() — the same API the TUI uses. The message is a real user prompt that triggers a real AI response. It persists in the chat history.
Cross-process state:
Routing state lives in a file ($TMPDIR/telegram-notify/state.json), shared across all opencode processes:
msgIdToSession: maps outbound Telegram message IDs → session IDs (for quote-reply routing)lastSessionId: the most recently idle session (for plain-reply fallback)lastUpdateId: the Telegram getUpdates offset (for claim-once dedup)
All writes are guarded by a file lock (state.lock) with stale-lock recovery. Reads use atomic file rename to avoid torn JSON.
When opencode asks for permission (tool approval) or asks a clarification question:
- The plugin sends a notification to Telegram with the tool name/targets or the question + numbered options
- Your reply is interpreted:
- Permission:
y/yes/approve/accept→ allow once;n/no/disapprove/reject→ reject - Question: a number (
1,2) or the option label → routed as the answer
- Permission:
- Unrecognized replies get a help message listing valid options
"always" is intentionally not wired — persistent approvals from a chat bot are a security risk.
The plugin always writes routing decisions to $TMPDIR/telegram-notify/trace.log — no env var needed. This includes:
- Inbound message routing (quoted ID, mapped session, target)
- Inject success/failure (with retry attempts)
- Toast notifications
For full debug output (including fetch retries), set TELEGRAM_DEBUG=1 and check /tmp/telegram-notify-debug.log.
Due to opencode's architecture, the following limitations apply:
When a Telegram reply is injected into a session, the TUI window showing that session does not re-render if it's not the focused window. The message and AI response are written to the shared SQLite database and persist in chat history, but they won't be visible in the TUI until you navigate to that session.
Workaround: the plugin sends the full AI response back to Telegram for injected prompts, so you see the answer without needing the TUI. A toast notification tells you which session received the reply. Navigate to that session manually when you want the TUI context.
There is no opencode API to determine which session is active in each TUI window. This means:
- We cannot target a specific window for display
- We cannot check if the target session is already open in some window before acting
tui.session.selectbroadcasts to the calling process's TUI, but since we can't identify which process has the target session, using it risks hijacking the wrong window
Decision: the plugin does not call tui.session.select. It relies on Telegram for the response + toast for notification. This avoids the "lost session" problem where a window's active session gets unexpectedly replaced.
The pending approval/question slot is per-process (in-memory). If process A receives a permission.asked event and you reply via Telegram, the reply is handled by whichever process claims the Telegram update — which may not be process A. Cross-process approval routing is not implemented because approval queues are per-server (in-memory request queues).
Workaround: if the Telegram reply doesn't reach the right process, approve/reject in the TUI directly. In practice, the process that asked is usually the one polling Telegram.
All opencode processes use the same TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID. There is no per-window Telegram chat. This is by design — the quote-reply routing distinguishes conversations by the quoted message, not by chat.
The secret filter covers common patterns (OpenAI keys, Anthropic keys, Telegram bot tokens, Slack tokens, GitHub tokens, Google API keys, private keys). It will not catch every possible secret format. Do not send sensitive code or credentials through the bot. The recap instruction explicitly forbids code/secrets, but the model may not always comply.
Telegram bot chats are not E2E encrypted. The recap is a model-authored summary (safer than raw code), but anyone with the bot token can send injectable prompts. This is the same trust level as the token itself — keep it private.
All sessions across all projects send recaps to the same Telegram chat. There is no way to mute specific sessions or projects. The plugin fires in every chat/session globally.
The full AI response for Telegram-injected prompts is truncated to 4000 characters (Telegram's limit is 4096; the margin accounts for the title prefix). Long responses will be cut off. The recap (for normal prompts) is typically short and unaffected.
codegram/
├── telegram-notify.js # The plugin (single file, no dependencies)
├── package.json
├── scripts/
│ └── install.js # Copies the plugin to ~/.config/opencode/plugins/
└── README.md # This file
rm ~/.config/opencode/plugins/telegram-notify.jsRemove the env vars from your shell profile. Restart opencode.
MIT