Telegram notifications for OpenCode — v1 and v2 — with tap-only inline buttons for permission requests. Never auto-approves anything.
🔐 "OpenCode needs you" — permission requests, questions, completions and failures land in your Telegram chat while OpenCode keeps running on your server or workstation.
🔐 Permission request
Project: my-app · Session: Fix login
shell
> npm test
[ Allow once ] [ Reject ]
[ Always allow ]
→ tap ✅ → OpenCode continues. Nothing runs without you.
Since OpenCode v2 ({id, setup} plugin contract) landed, the Telegram extensions
people know — opencode-telegram-bot-plugin, opencode-channels-*,
Tommertom's list entries — are still written against the v1 SDK and either
stop loading, lack permission handling, or pull a bot framework + extra shared
runtime for a task that needs none of it.
This plugin is different in four ways:
| opencode-telegram-notify | typical alternatives | |
|---|---|---|
| OpenCode versions | v1 + v2 (two entries, one shared core) | v1 SDK only |
| Runtime dependencies | zero (plain fetch) |
bot frameworks / channels stacks |
| Permissions | tap-only inline buttons → supported reply surface | missing or reply-as-prompt (hallucination risk) |
| Safety model | one bot = one chat, single-poller lock, ownership gate, event dedupe | assumptions |
Scope is deliberate: notifications + tap-only permission buttons. It is not a chat-with-your-agent relay, it never injects text into your sessions.
- 🔌 Works with OpenCode v1 and v2 — the v2 default export (
{id, setup}) and a v1legacyentry share the same core; only events transport and permission reply surface differ per version. - 🔐 Permission requests as messages with three buttons: Allow once,
Always allow, Reject — mapped to
client.permission.reply(once/always/reject). Tap-only: no free text, no prompt injection, no auto-approval. - ✍️ Live feedback — buttons are replaced with ✅/🚫 confirmations and a toast appears on tap; on next OpenCode restart older buttons answer politely with "outdated" instead of dying silently.
- ❓ Questions (v2
form.*) answered in chat: questions with one choice field (2–5 options) arrive as tap buttons (answer viasession.form.replywhen available, otherwisequestion.reply; skip viasession.form.cancelwhen available, otherwisequestion.reject). String fields send the selected value and multiselect fields send a one-item array. Other question types stay notify-only with the terminal pointer, and pending questions survive restarts (question.listrecovery). When the host exposes no reply surface (the current OpenCode v2 plugin context through 2.2.5 exposes none for forms — tracked upstream in anomalyco/opencode#51108), the message is notify-only with the terminal pointer instead of showing dead buttons. - ✅ / ❌ / ⏹ Lifecycle events — completion (debounced so mid-run pauses
don't spam), failure with error type, cancellation (silent). Completion
messages include the session's cumulative token and cost usage when the
host reports it (OpenCode v2;
⚡ 13.2k in · 3.2k out · $0.042). Failures caused by a usage limit (OpenCode free tier, provider quota, rate limits; Console budgets on hosts that emit them) get a friendly headline instead of the raw error code, e.g.⛔ Free usage limit reached. - 🤖 Bot command menu —
/start,/status,/setup,/stop,/helpregistered viasetMyCommands, so the ☰ menu works out of the box./setupprints a copy-paste-readyopencode.jsoncblock. - 🏠 Multi-instance safe — several OpenCode instances on one host: each
notifies only for its own project root; a single-poller file lock guarantees
only one process consumes
getUpdates; process-level event-id dedupe means reloads don't double-send. - 🌐 en / it / zh notification language (BCP-47 tags accepted).
- 🔒 Private by default —
hideCommands: truereplaces shell/tool args with a count; every log line and error message is token-redacted.
- OpenCode v2 (tested on 2.2.7; the 2.0.x line is also supported) or v1.18+
(the hooks-based
@opencode-ai/plugincontract, e.g. the 1.18/1.19 line). v1 has no per-plugin options, so its configuration is environment-driven only. - A Telegram bot token (via @BotFather — takes one minute) and the chat id of your user or group.
- Bun only if you run the dev tooling (typecheck/tests). The plugin itself runs inside OpenCode with zero extra installs.
- In Telegram, chat with
@BotFather, send/newbot, follow prompts, copy the token (looks like123456789:AA…). - Send
/startto your bot once, then find your numeric chat id — the/startreply echoes your chat id in a copy block, or message@userinfobot.
OpenCode v2 — add it to ~/.config/opencode/opencode.jsonc. Use a source
checkout path (works on every v2 build) or the npm name where supported:
(the npm form "package": "@hamzaabili/opencode-telegram-notify" is verified:
the v2 loader resolves the name from the registry and caches it under
~/.cache/opencode/npm/; other builds may only accept absolute paths.)
OpenCode v1 — register the legacy entry as a plugin name in
opencode.json:
{
"plugin": [
"@hamzaabili/opencode-telegram-notify/legacy"
]
}If your v1 build does not resolve npm package specifiers, clone this repo and point the entry at the local file:
{ "plugin": ["/absolute/path/to/opencode-telegram-notify/src/legacy.ts"] }The plugin expands ${VAR} in option values and falls back to the flat env
names, so secrets never sit in opencode.jsonc:
export TELEGRAM_BOT_TOKEN="123456789:AA…"
export TELEGRAM_CHAT_ID="123456789" # negative number for group chats
export TELEGRAM_LANGUAGE="en" # optional: en, it, zh, or a tag like zh-CN(For a systemd unit or docker compose install, pass these environment
variables into the OpenCode process environment.)
- if
TELEGRAM_CHAT_IDwas unset, run/startin the bot chat — the reply already contains your chat id inside a<code>block; - every future permission request arrives as a message you can answer by tap.
All options live under options. of the plugin entry. Only botToken and
chatId are required.
| Option | Type | Default | What it does |
|---|---|---|---|
telegram.botToken |
string | — | Bot token; "${VAR}" expands from process env |
telegram.chatId |
string | — | Destination chat (user id or negative group id) |
telegram.allowedUserIds |
string[] | [] |
Extra guard: only these Telegram user ids may tap buttons or run commands (empty = everyone reaching this bot token) |
language |
string | "en" |
Notification language: en, it, zh. Tags such as zh-CN, zh-Hans or it-IT match by base language; unknown tags fall back to en; cn is accepted as a zh alias |
events.permission |
boolean | true |
Permission request notifications + buttons |
events.question |
boolean | true |
Question (form.created) notifications |
events.idle |
boolean | true |
Completion notifications (debounced) |
events.error |
boolean | true |
Failure and cancellation notifications (limit refusals included, with a friendly label) |
events.usage |
boolean | true |
Token and cost line on completion notifications (v2 only; v1 hosts report no usage and the line is omitted) |
interactions |
boolean | true |
When false, permissions go out as simple messages (no buttons) |
idleDebounceMs |
number | 60000 | Wait after execution.succeeded before pinging you; any new activity cancels it |
idleCooldownMs |
number | 600000 | Min gap between completion notifications for the same session — conversational pauses never spam (0 disables) |
maxMessageChars |
number | 3800 | Max body length (Telegram hard limit is 4096) |
hideCommands |
boolean | false |
Replace shell/tool arguments with [n] in bodies |
logFile |
string | off | Append-only debug log path (token-redacted, one line per event) |
language is process-wide (one chat per plugin instance). zh currently
contains Simplified Chinese; zh-TW/zh-Hant resolve to it until a
Traditional Chinese catalog is added.
Registered on the bot menu (☰); unknown commands and plain messages are ignored — this is a notifier, not a chat relay.
| Command | Effect |
|---|---|
/start |
Re-enables a silenced chat; echoes your chat id in a copy-block |
/status |
Running state, project root, which event groups are on |
/setup |
Copy-paste-ready opencode.jsonc snippet (with placeholders, never your token) |
/stop |
Silence notifications in this chat until /start |
/help |
Short command list |
- v2 plugin entry is the
{id, setup}contract —setupsubscribes to the event stream, starts the poller, and returns a dispose. - v1 entry (
./legacy) is a hooks factory: bus events arrive through theeventhook and permission answers go over the v1 SDK surfacePOST /session/{id}/permissions/{permissionID}with body{ response }(or the modernpermission.replyclient namespace when present). Both v1 permission generations are accepted on the event side:permission.updated {id, title, metadata}(pre-rework hosts) andpermission.asked {id, permission, patterns, metadata, always, tool?}(post-rework v1.x). On v1,MessageAbortedErrormaps to the silent interruption notification (a user abort is not a failure); v1 has noform.*flow, so questions are simply not available there. Note: the bot's/setupreply always prints the v2plugins[]config shape; on v1 copy theplugin: [...]entry from this README instead. - Events used:
permission.asked/replied,form.created/replied/cancelled,session.execution.succeeded/failed/interrupted, plussession.created,session.renamedand activity events for the debounce/ownership logic. - Permission answers go through the supported surface
client.permission.reply({ sessionID, requestID, decision }); a legacyreply-key shape is attempted (and logged) if the server rejectsdecision. - Only events whose
location.directoryresolves to this OpenCode root are handled — a conversation in another project never triggers your bot.
One host can run several OpenCode instances (different roots, same bot token):
- the first instance that wins the cache-dir file lock (
~/.cache/ opencode-telegram-notify/poller.lock, with liveness heartbeat) consumes Telegram updates and serves the chat; - every instance is notified only about its own root, so taps are routed by whichever instance is polling;
- losing activation (crash, machine restart), staleness after ~30 s lets the next instance take over.
- 409 Conflict in the log — another process (old activation or another app)
is polling the same token; the poller backs off automatically. Check you did
not reuse a token in
opencode-telegram-monitoror another bot. - Nothing arrives — verify the two env vars are visible to the OpenCode
process, and that
options.telegram.botTokenis not a literal placeholder. SetlogFileto a path and watch it while you drive OpenCode. - Buttons say "outdated" — the message predates the current activation (buttons are routed through this instance's pending map). Answer in the terminal as usual.
- Config error — misconfiguration never crashes OpenCode: you get a clear
configuration error: …line (stderr/logFile) and the plugin disables itself for that session.
Deliberately not scheduled; revisit when they earn their complexity:
- menu-style settings keyboard (runtime config writes are outside the current plugin surface);
- reply-to-message → in-session notes (extra control channel);
- multi-field question wizards (open, keep notify-only for now).
Same constraints: MIT, zero runtime dependencies, OpenCode V1 + V2.
- opencode-agent-plugins — Agent Plugins 1.0 client: discovery from
.agents/pluginsand npm packages, native skills, trust-first MCP registration. - opencode-sandbox — native command sandbox: bubblewrap on Linux, Seatbelt on macOS, fail-closed, network denied by default.
- Bugs: open an issue with the matching template and redact secrets or private paths.
- Setup and design questions: use Discussions.
- Security: private advisory, never a public issue.
bun install # dev only: typescript + @types/bun
bun run ci # lint + typecheck + tests
bun run typecheck
bun test
bun run smoke # live OpenCode host check (OPENCODE_BIN to override)
bun run compat # ci + smoke; used by upstream-watch before it moves the anchor- Add the base code to
LANGUAGESinsrc/config.ts. - Copy
src/i18n/it.tstosrc/i18n/<code>.ts, replace the values, and keep the HTML tags,{placeholders}andsatisfies Catalogintact. - Register the catalog in
src/i18n/index.ts. - Run
bun run ci: the compiler andtests/i18n.test.tsenforce key and placeholder parity.
- Downloads: always-visible badge on top (npm total); live numbers via the npm downloads API and the package page on npmjs.com. npm's downloads index starts reporting a few days after the first publish; the badge fills in once indexed.
- GitHub: the repo Insights → Traffic tab shows clones and stargazers; the stars badge is in the header.
MIT — see LICENSE.
{ "plugins": [ { "package": "/absolute/path/to/opencode-telegram-notify", "options": { "telegram": { "botToken": "${TELEGRAM_BOT_TOKEN}", "chatId": "${TELEGRAM_CHAT_ID}" }, "language": "en" } } ] }