A Telegram and dashboard bridge for OpenAI Codex CLI.
Its primary entrypoint owns a persistent local codex app-server process, so
inbound messages start or steer turns without asking the model to poll.
The original stdio MCP bridge remains available as a compatibility fallback.
Run bun start (or the installed telegram-codex-dispatcher binary). The
dispatcher uses Codex app-server's supported local stdio transport, persists
its thread and delivery ids under ~/.codex/channels/dispatcher/, and launches
the selected channel adapters. Telegram is enabled by default; enable both with:
CODEX_DISPATCHER_CHANNELS=telegram,dashboard bun startInbound behavior is source-safe: a message starts turn/start while idle,
same-conversation messages use turn/steer, and another source waits for the
active turn to finish. Agent-message events are written to the matching
channel outbox, so a Telegram turn cannot reply into dashboard chat.
The state file survives process restarts. A delivery id is handled once; an inbox file is removed only after app-server accepts it. If dispatch fails, the file remains and is retried. If the process restarts during a turn, the source gets an explicit interrupted-turn notice and queued messages continue after the thread resumes. Supervisors should restart the dispatcher when it exits after an app-server or adapter failure. Thread sandbox and approval settings are inherited from Codex configuration; because this headless dispatcher has no approval UI, app-server approval requests are declined rather than escalated.
Dashboard delivery keeps using ~/.claude/channels/dashboard/ as a runtime-
neutral compatibility path because shelld writes durable inbound drops there.
To attach a local file in dispatcher mode, Codex emits a non-empty caption plus
a separate [[5dive-attachment:/absolute/path]] line. The directive is removed
from chat text and up to ten files are sent only on the response's source
channel.
Run bun run start:mcp-fallback and wire server.ts into Codex only when the
dispatcher cannot be used. This legacy path exposes five MCP tools:
wait_for_message— block until the user sends a DM/group message.reply— send a new Telegram message (text, MarkdownV2, file attachments).edit_message— patch a prior bot message in place (silent, no push).react— emoji reaction on an inbound message.download_attachment— fetch a file byfile_idinto the local inbox.
- Bun —
curl -fsSL https://bun.sh/install | bash - Codex CLI —
npm i -g @openai/codex - A Telegram bot token from @BotFather
1. Install the server
git clone https://github.com/5dive-ai/5dive-plugins
cd 5dive-plugins/plugins/telegram-codex
bun install2. Save the bot token
mkdir -m 700 -p ~/.codex/channels/telegram
cat > ~/.codex/channels/telegram/.env <<EOF
TELEGRAM_BOT_TOKEN=123456789:AAH...
EOF
chmod 600 ~/.codex/channels/telegram/.env3. Seed the allowlist
Two options:
3a. Pair via the bot (recommended)
bun pair.tsThe CLI prints DM @<botname> from your Telegram account within 60s to pair.... Send any message to your bot from the Telegram account you
want allowed. The CLI captures your user_id, writes
~/.codex/channels/telegram/access.json, and replies "✅ paired" in
the chat.
Re-run anytime to add another user to the allowlist. Conflicts with a running Codex MCP server (one getUpdates consumer per token) — stop Codex first, pair, then restart.
3b. Hand-write access.json
{
"allowFrom": ["123456789"],
"groups": {
"-1001234567890": { "requireMention": false, "allowFrom": [] }
}
}allowFrom— Telegram user IDs allowed to DM the bot. In a DM thechat_idequals the user ID.groups— group/supergroup chat IDs (negative) and per-group policy.requireMention: trueonly routes messages that@mentionthe bot (or quote-reply to it).allowFrom: []falls back to the top-levelallowFromlist; a non-empty list overrides per group.
Messages from anyone not on the lists are silently dropped before they
reach wait_for_message. Group access can only be configured by
hand-writing access.json — the pair.ts CLI handles DMs only.
4. Optional: wire the polling fallback into Codex
Add to ~/.codex/config.toml:
[mcp_servers.telegram]
command = "bun"
args = ["/absolute/path/to/5dive-plugins/plugins/telegram-codex/server.ts"]5. Add the comms playbook
Drop the contents of AGENTS.md into your
~/.codex/AGENTS.md so the model knows when and how to use the tools.
6a. (Optional) Wire the "approve risky commands from Telegram" bridge
Codex's PermissionRequest hook fires every time it wants to run a command
that exceeds its current approval_policy / sandbox_mode. The
request-permission.ts hook in this plugin routes that prompt to your
Telegram bot — a message with ✅ allow / ❌ deny inline buttons. Tap one
and Codex proceeds (or doesn't).
[features]
hooks = true
[[hooks.PermissionRequest]]
[[hooks.PermissionRequest.hooks]]
type = "command"
command = "bun /absolute/path/to/5dive-plugins/plugins/telegram-codex/hooks/request-permission.ts"
timeout = 180
async = falseBehavior notes:
- Fail-closed. If the MCP server isn't running (no Telegram bridge),
or no one taps a button before the 120s default timeout, the hook
returns
deny. Codex's native UI then takes over — you're never silently auto-approved. - The MCP server must be live for the bridge to work. In practice this means Codex must have called at least one telegram tool earlier in the session (the MCP server lazy-spawns). For one-shot Codex runs where the very first action is privileged, the bridge will fall through to Codex's native UI.
- Hook trust gate. On the first session after wiring this hook,
Codex shows a one-time "Hook needs review" TUI prompt. Press
2(ort) to trust. Codex persists the decision in[hooks.state]of the config. - Override timeout with
CODEX_TG_APPROVAL_TIMEOUT_MSenv (range 5000–600000). - Bypass entirely with
CODEX_TG_APPROVAL_DISABLED=1env — useful for unattended runs where you want Codex's own approval policy to be authoritative without going to Telegram.
This is useless if your approval_policy = "never" / sandbox_mode = "danger-full-access". The bridge only matters when Codex actually
needs to ask.
6b. (Optional) Wire the "turn complete" ping
To get a Telegram ping every time Codex finishes a turn, add the Stop
hook to ~/.codex/config.toml:
[features]
hooks = true
[[hooks.Stop]]
[[hooks.Stop.hooks]]
type = "command"
command = "bun /absolute/path/to/5dive-plugins/plugins/telegram-codex/hooks/notify-stop.ts"
async = falseCodex 0.134 doesn't support async = true — keep it sync. The hook
fires once per Codex turn and runs in under a second.
Override the message text per-session with CODEX_NOTIFY_TEXT=...;
silence pings entirely with CODEX_NOTIFY_DISABLED=1 (useful when
you're already talking to the bot via wait_for_message/reply and
the Stop ping would be duplicate).
7. Run the dispatcher
bun startDM your bot. The channel adapter submits the message directly to app-server and routes streamed agent messages back. No model-owned listen loop is involved.
| Concern | telegram/ (Claude Code) |
telegram-codex/ (this) |
|---|---|---|
| Inbound delivery | claude/channel JSON-RPC notification |
app-server turn/start / turn/steer (MCP polling fallback retained) |
| Permission relay | claude/channel/permission protocol |
PermissionRequest hook + buttons |
| Slash commands | /telegram:configure, :access, … |
bot-side menu (/help /status /stop /restart /agents /tasks /task /org /model /ping /start) |
| Lifecycle hooks | PreToolUse, Stop, etc. | Stop hook ships in hooks/ |
| State dir | ~/.claude/channels/telegram/ |
~/.codex/channels/telegram/ |
| Pairing flow | code via DM → /telegram:access pair |
bun pair.ts standalone CLI |
A fifth runtime, telegram-opencode, is not in this
family: opencode ships a headless HTTP server, so its bridge is a long-running
relay (no wait_for_message, no watchdog, no hooks) rather than an MCP server.
- v0.1.0 — outbound + blocking inbound, preconfigured allowlist
- v0.1.1 —
Stophook for "turn complete" Telegram ping - v0.1.2 — pairing CLI (
bun pair.ts) for one-shot user-id capture - v0.1.3 — approval-mode bridge:
PermissionRequest→ Telegram buttons - v0.1.4 — bot slash commands (
/help,/status,/ping) + setMyCommands menu; wait_for_message capped at 90s to stay inside Codex's MCP-call timeout - v0.1.5 —
replychunks text >4000 chars across multiple Telegram messages (paragraph→line→word→hard cut), so long Codex outputs no longer fail with 400 Bad Request - v0.1.6 — Stop hook suppresses the "turn complete" ping when Codex sent a
replywithin the last 30s (the user already knows). Override viaCODEX_NOTIFY_SUPPRESS_MSenv (0 disables suppression) - v0.1.7 — typing indicator (re-sends
sendChatActionevery 4s betweenwait_for_messageandreply, with a 5min ceiling) so a thinking Codex looks different from a hung one - v0.1.8 — silence watchdog
PreToolUsehook — pings "🟡 still working — N tool calls in, Xs since last reply" when Codex has been silent pastCODEX_SILENCE_WATCHDOG_MS(default 120000). Single ping per silence window — the hook resets its own clock so spam is impossible. - v0.1.9 —
Notificationhook relays error-flavored notifications (rate limit, API failure, timeout) to Telegram with a⚠️ codex: …prefix. Relay-all override viaCODEX_NOTIFY_RELAY_ALL=1; disable viaCODEX_NOTIFY_RELAY_DISABLED=1 - v0.1.10 —
/stopbot command sends Ctrl-C via tmux to interrupt the current Codex turn;/restartinvokessudo 5dive agent restart <name>so the systemd unit respawns the session in ~2s. Both gated on allowFrom - v0.1.11 —
/agentslists sibling 5dive agents on the host (active/inactive, type, channel, marks self). Wrapssudo 5dive agent list --json - v0.2.0 — configurable knobs in
access.json:ackReaction(emoji on every inbound, off by default),textChunkLimit(override the 4000-char chunker cap, range 500–4096),dmPolicy(allowlist/static — reserved for forward parity) - v0.2.1 —
notify-userskill (skills/notify-user/SKILL.md) — Codex-adapted comms playbook covering cadence, the wait_for_message loop, files/images/reactions, the approval bridge, and security. Description trimmed under Codex's 1024-char SKILL.md limit. - v0.2.2 — quieter user-facing pings: silence-watchdog now says
⏳ still working…instead of dumpingN tool calls in, Xs since last reply(telemetry read like debug output).notification-relaydrops transientRetryStatepayloads and, when the upstream hands us a RustDebug-formattedSessionNotification { ... }blob, extracts the innerreason:field instead of forwarding the whole struct. - v0.2.3 — silence-watchdog backoff: first ping in a silence stretch trips at the base threshold (default 2 min); the 2nd ping needs ~20 min of additional silence, the 3rd+ needs ~30 min (cap). A real
replyresets the counter. Stops the "⏳ still working…" message from feeling like a 2-minute heartbeat during long silent runs. - v0.2.4 —
PLUGIN_VERSIONnow reads frompackage.jsonat startup instead of a hardcoded const, so/ping//status/setMyCommandsreport the actual shipped version. Previous PATCH bumps shipped the code but/statuskept showing0.2.1. - v0.2.5 — silence-watchdog base default raised from 2 min → 10 min. With backoff (1× / 10× / 15×), the cadence on a truly silent run is now ~10 min, then +100 min, then +150 min cap — far less surprise during normal back-and-forth where the user just sent a slash command 2 minutes ago. Override via
CODEX_SILENCE_WATCHDOG_MSas before. - v0.2.6 — silence-watchdog is now off by default. With
notify-userauto-seeded, the agent already acks + edits progress updates; the watchdog ping was a redundant heartbeat that just felt like noise. Opt back in withCODEX_SILENCE_WATCHDOG_ENABLED=1. (this)