Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ SKILLs for the MetaMask Agent CLI (`@metamask/agentic-cli` v4.0.0). These skills
| --- | --- |
| [`metamask-agent-wallet`](./skills/metamask-agent-wallet/SKILL.md) | Full CLI reference skill that routes the agent to topic-specific reference docs for all MetaMask Agent CLI commands — auth, wallets, transfers, signing, swaps, bridges, perps, prediction markets, market data, and calldata decoding. |
| [`metamask-agent-workflows`](./skills/metamask-agent-workflows/SKILL.md) | Multistep workflow templates for complex operations like onboarding, swaps, bridges, and perps trading. It doesn't include reference docs — relies on `--help` for command details instead. |
| [`metamask-agent-telegram-bridge`](./skills/metamask-agent-telegram-bridge/SKILL.md) | Exposes an mm-backed agent over Telegram via an in-process long-polling bridge — deterministic forwarding (not an LLM-reasoned relay), a fail-closed allow-list, and an explicit pattern for deciding whether Telegram-triggered actions bypass the agent's own action-authority gate. |

## Installation

Expand Down
64 changes: 64 additions & 0 deletions skills/metamask-agent-telegram-bridge/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
---
name: metamask-agent-telegram-bridge
description: Use when the user wants to expose an mm-backed agent over Telegram (or a similar chat-bot messaging API) — "add a Telegram bot", "bridge my agent to Telegram", "let me talk to my wallet agent from my phone". Covers the in-process long-polling pattern, the fail-closed allow-list, and the access-control decision this introduces.
license: MIT
metadata:
author: metamask
version: "1.0.0"
cliVersion: "4.0.0"
---

# MetaMask Agent Telegram Bridge

Exposes an `mm`-backed agent (whatever already drives your wallet actions —
an OpenAI Agents SDK agent, or equivalent) over Telegram, so a user can send
it messages from their phone and get real tool-backed replies back.

Reference implementation and the full flow: `workflows/telegram-bridge.md`.
A runnable skeleton is at `scripts/telegram_poller.py`.

## Before you build this, read the callout below

The natural instinct is to route the bridge through whatever agent framework
is already hosting the bot, and prompt-engineer its own reasoning loop into a
"pure relay" that forwards every message verbatim. **That doesn't hold up.**
If forwarding depends on a model choosing to follow an instruction rather than
a guaranteed code path, there is no hard guarantee every message actually gets
forwarded rather than improvised — an unusual message can get an
LLM-invented reply instead of a real tool-backed one. This skill uses a
deterministic alternative instead: a plain long-polling loop that calls the
agent runner directly, in-process, no LLM reasoning in between the inbound
message and the decision to forward it.

## Access control is the whole security model — decide it deliberately

A Telegram bridge is very often reachable with a WEAKER trust boundary than
whatever primary interface (a web UI, a CLI) the agent already has — because
the bridge is a new, separate entry point, it's easy to wire it up without
thinking about who else can reach it. Two things this skill insists on:

1. **A fail-closed allow-list of Telegram user IDs.** If the allow-list isn't
configured, the bridge should refuse to start — never default to
open-to-anyone.
2. **If your agent has its own action-authority gate** (an on-chain check, a
policy engine, a human-approval step — anything that decides whether a
given action is currently authorized before it executes), **decide
explicitly whether Telegram-triggered actions go through that gate or
bypass it, and document the decision inline in the code, not just in a
PR description.** Bypassing it can be the right call — Telegram's own
allow-list may already be a sufficient trust boundary for your use case —
but it must be a decision someone made on purpose, not an accidental gap
discovered later. See "The gate-bypass decision" in
`workflows/telegram-bridge.md` for the concrete pattern (a context-scoped
flag the gate check inspects, set only for the duration of a
Telegram-triggered call).

## Stack

Nothing exotic: `mm`, an agent runner capable of being called as a plain
function/method (not required to be any specific SDK), any async runtime
that can host a persistent background task alongside whatever's already
serving the agent, and an HTTP client that can hit Telegram's Bot API
directly (`getUpdates` / `sendMessage`) — no Telegram SDK needed. Runs
alongside the agent's existing process; no new service or container
required.
106 changes: 106 additions & 0 deletions skills/metamask-agent-telegram-bridge/scripts/telegram_poller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""
Generic long-polling Telegram bridge for an mm-backed agent.

Adapt `call_agent()` to whatever your agent runner actually is — an OpenAI
Agents SDK `Runner.run(...)`, a plain function call, an in-process queue,
whatever's already used by the agent's primary interface. This file is the
transport; it deliberately has no opinion on what's on the other end of
`call_agent()`.

Start this as a background task alongside your agent's normal process
startup (an async framework's lifespan/startup hook, a supervised thread,
etc.) — it is not meant to run as its own separate service.
"""

import asyncio
import logging
import os

import httpx

logger = logging.getLogger("telegram_poller")

_API_BASE = "https://api.telegram.org/bot{token}"
_MAX_MESSAGE_CHARS = 4000 # Telegram's hard cap is 4096; leave headroom.


def _allowed_user_ids() -> set[str]:
raw = os.environ.get("TELEGRAM_ALLOWED_USER_IDS", "")
return {s.strip() for s in raw.split(",") if s.strip()}


async def call_agent(chat_id: str, text: str) -> str:
"""Replace with a real call into your agent runner. `chat_id` is a stable
per-conversation key — use it to key your agent's thread/session state so
follow-up messages have continuity. Set your gate-bypass context flag
(see workflows/telegram-bridge.md) for the duration of this call, if your
agent has one."""
raise NotImplementedError("wire this up to your agent runner")


async def _send_message(client: httpx.AsyncClient, api: str, chat_id: int, text: str) -> None:
url = f"{api}/sendMessage"
for i in range(0, len(text), _MAX_MESSAGE_CHARS):
chunk = text[i : i + _MAX_MESSAGE_CHARS]
resp = await client.post(url, json={"chat_id": chat_id, "text": chunk})
if resp.status_code != 200:
logger.error("sendMessage failed: %s %s", resp.status_code, resp.text[:300])


async def run_telegram_poller() -> None:
token = os.environ.get("TELEGRAM_BOT_TOKEN")
if not token:
logger.warning("TELEGRAM_BOT_TOKEN not set — Telegram poller disabled")
return
allowed = _allowed_user_ids()
if not allowed:
logger.warning(
"TELEGRAM_ALLOWED_USER_IDS not set — poller disabled "
"(fail-closed: this bridge never defaults to open-to-anyone)"
)
return

api = _API_BASE.format(token=token)
offset = 0
logger.info("Telegram poller started (allowed users: %s)", ", ".join(sorted(allowed)))

async with httpx.AsyncClient(timeout=35.0) as client:
while True:
try:
resp = await client.get(f"{api}/getUpdates", params={"offset": offset, "timeout": 30})
resp.raise_for_status()
updates = resp.json().get("result", [])
except asyncio.CancelledError:
raise
except Exception:
logger.exception("getUpdates failed, retrying in 5s")
await asyncio.sleep(5)
continue

for update in updates:
offset = update["update_id"] + 1
message = update.get("message")
if not message or "text" not in message:
continue

chat_id = message["chat"]["id"]
sender_id = str(message["from"]["id"])
text = message["text"]

if sender_id not in allowed:
logger.warning("refused message from non-allow-listed user %s", sender_id)
await _send_message(client, api, chat_id, "Not authorized.")
continue

try:
reply = await call_agent(str(chat_id), text)
except Exception:
logger.exception("call_agent failed")
reply = "Something went wrong on my end — try again in a moment."

await _send_message(client, api, chat_id, reply)


if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
asyncio.run(run_telegram_poller())
88 changes: 88 additions & 0 deletions skills/metamask-agent-telegram-bridge/workflows/telegram-bridge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Telegram bridge workflow

Use this workflow when wiring an existing `mm`-backed agent up to Telegram.
Assumes the agent already runs continuously somewhere (a server, a
long-running process) — this bridge is a background task added to that
same process, not a new service.

## Flow

1. Register a bot with `@BotFather` on Telegram; get the bot token.
2. Get the numeric Telegram user ID(s) that should be allowed to use the bot
(message `@userinfobot` to find your own).
3. Add a long-polling background task to the agent's process (see
`scripts/telegram_poller.py`) that:
- Polls Telegram's `getUpdates` endpoint in a loop.
- Checks the sender's user ID against the allow-list. Refuses (and does
NOT call the agent) if the sender isn't on it.
- Calls the agent runner directly — in-process, not over HTTP to a
separate service — with the message text.
- Sends the reply back via `sendMessage`.
4. Start the poller as a background task alongside the agent's normal
startup (a `lifespan`/startup hook, a supervised subprocess, whatever
your runtime already uses for background work).
5. Set the bot token and allow-list as environment variables / secrets on
wherever the agent already runs. No new hosting target.

Don't skip step 2's fail-closed check — see "Edge cases" below.

## Per-chat conversation continuity

Key your agent's conversation/thread state by the Telegram chat ID, the same
way you'd key it by a session ID for any other transport. Each distinct
Telegram chat gets its own stable thread, so follow-up messages ("what did I
just ask?", a multi-turn preview→confirm→execute flow) have the context they
need.

## The gate-bypass decision

If the agent has an action-authority gate — some check that runs before a
fund-moving or otherwise consequential action executes — decide explicitly
whether Telegram-triggered calls go through it. The pattern that keeps this
decision visible in the code (not just in a commit message):

```python
# gate.py
from contextvars import ContextVar

telegram_mode: ContextVar[bool] = ContextVar("telegram_mode", default=False)

async def gate_or_refusal() -> str | None:
if telegram_mode.get():
return None # deliberately bypassed for Telegram — see SKILL.md
# ... the real gate check ...
```

```python
# telegram_poller.py, around the call into the agent
token = telegram_mode.set(True)
try:
result = await run_agent(...)
finally:
telegram_mode.reset(token)
```

A `ContextVar` (not a module-level bool) keeps this isolated per
asyncio task — it can't leak into a concurrent request on the agent's
primary interface. Whichever way you decide, the flag makes the decision a
single, greppable line instead of a scattered assumption.

## Edge cases

- **Allow-list not configured**: refuse to start the poller entirely, and
log why. Never fall back to "allow everyone" — that's the single access
control this whole bridge has.
- **Message from a non-allow-listed user**: reply with a plain refusal (or
silently ignore, if you'd rather not confirm the bot exists to strangers)
and do not call the agent runner at all — the refusal must happen before
the agent sees the message, not after.
- **Long replies**: Telegram caps messages at 4096 characters; chunk longer
replies rather than silently truncating a wallet-relevant answer.
- **`getUpdates` failures** (network blip, Telegram-side issue): catch,
log, back off briefly, and keep polling — don't let a transient failure
kill the background task permanently.
- **Silent logging**: if you're not seeing expected log lines in production,
confirm your logging is actually configured with a level/handler — a
default Python logger config drops `INFO` (and even some `WARNING`)
messages with no error, which looks identical to "the code path never
ran."