A Telegram bot is a convenient remote control for a script or an AI agent on your own machine. It works on every phone, needs no app of your own, and needs no public server. The catch is that a bot is public. Anyone can find its username and message it, and the token only proves that your script is the bot; it says nothing about who is writing. The osFoundry desktop app has a bridge like this for its agents, written in Rust (telegram_bridge.rs). Below is the same design in about 70 lines of standard-library Python, tested against a local stand-in for the Bot API, along with the one-line mistake that turned a quiet bot into 183,389 requests a minute.
Telegram delivers messages in two ways. A webhook needs a public HTTPS URL that Telegram can call. getUpdates needs nothing exposed: the script asks for new messages, and with timeout=25 Telegram holds the request open for up to 25 seconds and answers as soon as one arrives. On a laptop that is the right choice. There is no port to open, no tunnel, and no certificate.
import json, os, secrets, time, urllib.parse, urllib.request
API = os.environ.get("TG_API", "https://api.telegram.org")
TOKEN = os.environ["TG_TOKEN"]
LONG_POLL = 25
PAIR_TTL = 600
def call(method, params=None, body=None, timeout=40):
url = f"{API}/bot{TOKEN}/{method}"
if params:
url += "?" + urllib.parse.urlencode(params)
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data, {"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.load(r)
def send(chat_id, text):
call("sendMessage", body={"chat_id": chat_id, "text": text[:3900]})
The HTTP timeout (40 s) has to be longer than the long-poll hold (25 s). If it is shorter, every quiet cycle ends in a client-side timeout that looks like an outage. The app keeps the token in the OS keychain, not in a file. From Python, keyring.get_password does the same, and an environment variable is fine for a test.
Over 60 idle seconds, the long-poll loop made 3 requests and a once-a-second short poll made 60. A message that arrived while a request was being held got its reply 1.5 to 6.3 ms later in the local test. Over a real network, the round trip to Telegram is added on top.
The bot must know which person it serves before it does anything. The app does this with a one-time code. You click "Pair a phone" on the machine, it shows eight random characters, and you send /pair CODE to the bot from your phone. Whoever sends a valid code within 10 minutes is the owner.
pending = {} # code -> created_at
paired = {} # sender id -> chat id
def begin_pairing():
code = secrets.token_hex(4).upper()
pending[code] = time.time()
return code
The code comes from secrets, not from anything guessable like the time or the bot name, because the code is the only thing that authorises a sender. It is also removed when used, so it works once.
What gets stored is the numeric from.id, not the @username or the display name. Both of those can be changed, and a freed username can be registered by someone else. The chat id is stored next to it, for a reason that comes up below.
def handle(msg, run_agent):
text = (msg.get("text") or "").strip()
sender = msg.get("from", {}).get("id")
chat = msg["chat"]
if not text or sender is None:
return
words = text.split()
if words[0] == "/pair" or words[0].startswith("/pair@"):
if chat.get("type") != "private":
return send(chat["id"], "Pair with me in a direct message, not in a group.")
code = words[1].upper() if len(words) > 1 else ""
created = pending.pop(code, None)
if created is None or time.time() - created > PAIR_TTL:
return send(chat["id"], "That code is not valid or has expired.")
paired[sender] = chat["id"]
return send(chat["id"], "Paired. Message me and the agent will answer.")
if text.lower() == "/unpair":
gone = paired.pop(sender, None) is not None
return send(chat["id"], "Unpaired." if gone else "You were not paired.")
if paired.get(sender) != chat["id"] or chat.get("type") != "private":
return # silence
send(chat["id"], run_agent(text))
Three rules carry the security, and each one comes from a specific failure noted in the app's source.
Unknown senders get silence. A reply like "you are not paired" tells a stranger that the bot is connected to a real person's machine and invites them to keep trying. No reply looks the same as a bot nobody runs.
Pairing only works in a private chat. A bot added to a group receives the group's messages, so pairing there would let every member give the agent instructions.
A paired person is answered only in the chat they paired from. The app's comments describe an earlier version that checked the sender but replied to whatever chat the message came from. A paired user who added the bot to a group then had an agent answering in that group, where everyone could drive tools on the owner's machine. Authorisation is by sender and the reply goes to a chat, so the two have to be checked together.
/unpair is handled before that chat check, so access can be removed from the phone, which is where you are when you need it.
def poll(run_agent):
offset, failures = 0, 0
while True:
try:
res = call("getUpdates", {"timeout": LONG_POLL, "offset": offset,
"allowed_updates": '["message"]'})
failures = 0
except Exception:
failures += 1
time.sleep({1: 5, 2: 15, 3: 60}.get(failures, 300))
continue
for u in res["result"]:
offset = max(offset, u["update_id"] + 1) # even for messages we ignore
if "message" in u:
handle(u["message"], run_agent)
offset is how the script tells Telegram which updates it has seen. Anything below it is confirmed and dropped, and anything at or above it is sent again. The comment on that line is the important part. It is tempting to move the offset only after doing something with a message, since that feels like "processed". But a message from a stranger is deliberately ignored, so it is never confirmed, so it comes back on the next call, immediately, because there is a pending update and Telegram has no reason to hold the request.
The backoff is for the other failure: a revoked or mistyped token fails on every request, and retrying at full speed is a 401 every few seconds forever. The ladder of 5, 15, 60 and then 300 seconds is the one the app uses.
The local stand-in implements getUpdates (offset confirmation and the timeout hold) and sendMessage, and records every call. Each scenario injected one message and then read what the bot sent back.
| Message | Reply |
|---|---|
| stranger: "hi" | none |
stranger: /pair 12345678 |
That code is not valid or has expired. |
me, in a group: /pair CODE |
Pair with me in a direct message, not in a group. |
me, in a DM: /pair code (lower case) |
Paired. |
| stranger, reusing my code | That code is not valid or has expired. |
| me, in the DM: a question | the agent's answer |
| me, in the group: a question | none |
me: /unpair , then a question |
Unpaired., then none |
Then four loops ran for 60 seconds, each against its own stand-in:
| Loop | getUpdates calls in 60 s |
|---|---|
| long poll, no messages | 3 |
| short poll every second, no messages | 60 |
| one stranger message, offset advanced for every update | 4 |
| one stranger message, offset advanced only for handled messages | 183,389 |
On the stand-in, which answers from the same machine, the bug ran at about 3,000 requests a second. Against the real API, the loop would run as fast as the network round trip allows, with the same stranger's "hi" redelivered on every call and nothing newer ever arriving. One message from someone you will never answer is enough to start it.
A real bridge also needs to cap replies under Telegram's 4,096-character limit (the app cuts at 3,900, on a character boundary, because a byte slice can land in the middle of a Japanese character), and to send replies as plain text. With Markdown, Telegram rejects the whole message if an asterisk or underscore is unbalanced, and an agent writing about snake_case code produces exactly that. Most important, the text that arrives is written by whoever holds the phone, so it should reach the agent as untrusted input, the same as an email. Pairing decides who may talk to the agent; it doesn't make what they say safe.
The osFoundry desktop app ships this bridge for its AI agents: Device → Background for the token, and "Pair a phone" in a channel's Agents panel. It is free from the osFoundry download page.