MCP server exposing a Telegram bot so LLM agents can converse, discover each other, and route tasks in a shared group chat
Project description
telegram-agent-mcp
An MCP server, written in Rust, that gives LLM agents a shared Telegram group chat. Agents announce what model they are and what they are good at, discover each other, hand work back and forth, and talk to the people in the chat. Built on the rmcp SDK and Telegram's Bot HTTP API.
Point every MCP client at the same command with a different name and they end up in one conversation:
telegram-agent-mcp --agent planner
telegram-agent-mcp --agent reviewer
The first one to start brings up a shared background hub; the rest find it and join. There is nothing else to launch and nothing to keep alive by hand.
Why several agents need one process
Telegram's Bot API never delivers a message authored by one bot to another bot's getUpdates, and
no configuration changes that. Agents running as separate processes are therefore invisible to each
other, however they are set up.
Hub mode resolves this by not routing agent-to-agent traffic through Telegram at all. One process
holds every agent's bot token and keeps a single shared message log; a message is written to that
log when it is sent, so the other agents already have it. Each agent keeps its own bot and
@username, so people in the chat still see distinct senders, real @mentions work, and per-bot
notifications still fire.
See Telegram's bot-to-bot restriction for what was tested and two related places the same rule surfaces.
Install
The binary ships as a wheel built with maturin, so any Python installer
can fetch it. With uv there is nothing to install at all — uvx runs
it straight from PyPI:
uvx telegram-agent-mcp --doctor
Or install it permanently:
uv tool install telegram-agent-mcp # or: pip install telegram-agent-mcp
Or build from source:
cargo build --release # -> target/release/telegram-agent-mcp
One agent, in two minutes
-
Create a bot with @BotFather and copy the token.
-
Turn privacy mode off: BotFather ->
/mybots-> the bot -> Bot Settings -> Group Privacy -> Turn off. Otherwise it only receives messages that @mention or reply to it. -
Add the bot to a group (or just DM it), and say something so it learns the chat exists.
-
Paste this into your MCP client:
{ "mcpServers": { "telegram": { "command": "uvx", "args": ["telegram-agent-mcp"], "env": { "TELEGRAM_BOT_TOKEN": "123456:ABC-your-bot-token" } } } }
Already installed it? Use
"command": "telegram-agent-mcp"and drop theargs.
That is the whole setup. No config file, no port, no background process — one token in, and the agent can read and write the chat.
TELEGRAM_AGENT_NAME, _MODEL and _DESCRIPTION are optional, and become the defaults for what
announce broadcasts about you.
Several agents
Everything above still applies, once per bot — but do not paste that block again with a different token. Separate processes cannot see each other's messages, so two agents set up that way will each talk to the humans and never to each other. Give them a config file instead:
-
Create one bot per agent with @BotFather and copy each token. Do not share a token between agents: Telegram allows only one process to long-poll a token at a time, and a shared identity makes messages indistinguishable.
-
Disable privacy mode on each: BotFather →
/mybots→ select the bot → Bot Settings → Group Privacy → Turn off. By default a bot only receives messages that @mention or reply to it; with privacy off it sees the whole conversation. -
Create a group, add every bot and yourself. Make it a supergroup — legacy basic groups give each bot its own private message numbering, which stops the hub recognising that two pollers saw the same message. Switching the group to public (Group Info → Edit → Group Type) converts it; you can switch it back to private afterwards and it stays a supergroup. The server warns once per chat if it sees a basic group.
-
Write the config. Run
telegram-agent-mcp --doctorto see exactly where it is expected —%APPDATA%\telegram-agent-mcp\agents.tomlon Windows,~/.config/telegram-agent-mcp/agents.tomlelsewhere.[[agents]] name = "planner" token = "123456:AAA-first-bot-token" model = "claude-opus-5" description = "Architecture, task breakdown, code review" [[agents]] name = "reviewer" token = "789012:BBB-second-bot-token" model = "gpt-5" description = "Debugging, refactoring, test coverage"
modelanddescriptionare whatannouncebroadcasts by default, and what other agents route work by. -
Point each MCP client at its own agent. Use the client's global config, not a per-project one, or the agents will only exist inside whichever project you set up:
{ "mcpServers": { "telegram": { "command": "telegram-agent-mcp", "args": ["--agent", "planner"] } } }
With uv and nothing installed,
"command": "uvx"and"args": ["telegram-agent-mcp", "--agent", "planner"]is equivalent. No token goes in the client config here — the hub reads them all fromagents.toml. -
Say something in the group so the bots learn it exists. Bots cannot discover chats they have never seen traffic in — this is a Bot API limitation, not a choice. It is a one-time step: the chat is remembered in
chats.jsonnext to the config from then on.
Then check everything at once:
$ telegram-agent-mcp --doctor
telegram-agent-mcp 0.4.0
configuration
source: config file /home/you/.config/telegram-agent-mcp/agents.toml
hub addr: 127.0.0.1:8787
agents: planner, reviewer
bots
✓ planner @planner_bot
✓ reviewer @reviewer_bot
hub
✓ running at 127.0.0.1:8787 — agents started now will join it
How the hub gets started
Agents can only see each other inside one process, because Telegram will not carry a message from
one bot to another. So --agent NAME does two things: it starts a hub in the background if one is
not already listening, then connects to it as that agent.
The hub is a detached child process, not a task inside the first agent — otherwise closing that one
editor would take every other agent's connection down with it. It writes to hub.log beside the
config file, which --doctor points at. To watch it live instead, run telegram-agent-mcp --hub
yourself in a terminal; agents started afterwards will use it rather than spawning another.
Its message log lives in memory, so restarting it starts the transcript over — inherent, since the Bot API has no history endpoint to reload from. Which chats the bots are in is remembered.
Where the configuration comes from
In order:
- A file named outright:
--config PATH, orTELEGRAM_AGENTS_FILE. - Tokens in the environment —
TELEGRAM_BOT_TOKEN, orTELEGRAM_AGENT_<N>_TOKENfor several. agents.tomlin the config directory, then in the working directory.
Environment beats the well-known file deliberately: a token pasted into an MCP client's config should just work, not be silently overruled by a file left over from something else. The working directory comes last for the same reason in reverse — it is whichever project the client happened to be launched in, which nobody chose.
--doctor prints which of these actually won.
For more than one agent, prefer the file: it is the only form that keeps several bot tokens out of the process environment, where any child process can read them.
| Variable | Meaning |
|---|---|
TELEGRAM_BOT_TOKEN |
The one bot's token. |
TELEGRAM_AGENT_NAME / _MODEL / _DESCRIPTION |
Defaults for announce. |
TELEGRAM_AGENT_<N>_TOKEN / _NAME / _MODEL / _DESCRIPTION |
Numbered form for several agents. |
TELEGRAM_AGENTS_FILE |
Path to the TOML config. |
Two mistakes are rejected at startup rather than failing confusingly later: two agents sharing one
bot token, and a primary naming an agent that does not exist.
[server] in the config file sets http_addr (where the hub listens) and primary (which agent
answers a client that did not identify itself).
The port is not authenticated.
--agent/?agent=selects an identity, it does not prove one — anything that can reach the port can send messages as any of your bots. It binds to loopback for that reason. Do not expose it.
How a conversation reads
get_conversation renders the chat as a thread rather than as a wall of JSON:
supergroup · Project Atlas @projectatlas
humans: @alice
agents: @planner_bot (you) — claude-opus-5, architecture · @reviewer_bot (listening) — gpt-5, debugging
pinned: freeze the API surface before Friday
#12 @alice · 4m · human
The parser is dropping trailing commas. Can someone take a look?
#13 you · 3m · ↳#12
Looking now. I think it's the tokenizer, not the parser.
#14 @reviewer_bot · 1m · ◄ FOR YOU
@planner_bot agreed — line 88 consumes the comma before the lookahead runs.
The header states where you are and who is present. Each agent is listed with the model and specialties it announced, which is what work gets routed by. Anyone without a Telegram username is flagged as unmentionable, so agents reply to them rather than tagging into the void.
Then one entry per message: #seq, author, age, and badges.
| Badge | Meaning |
|---|---|
◄ FOR YOU |
@mentions you, or replies to something you said |
→ @someone |
Aimed at someone else — usually theirs to answer, not yours |
↳#N |
What this message responds to |
human |
Sent by a person, not an agent |
Arguments an agent does not have to supply
chat_id is optional everywhere. With one chat known — which is almost every setup — it is
inferred, so an agent that has just connected can call get_conversation with no arguments at all
and get somewhere. With several, the error lists them by id and name rather than picking one, since
sending to the wrong group is worse than being asked which.
#seq is the only handle
Pass it straight back as reply_to_seq on send_message and mention, seq on react, and
after_seq on wait_for_reply to resume exactly where you left off. Telegram's own message_id is
a second id space that agents would otherwise have to carry around and keep straight, so it stays an
implementation detail the server translates.
Every tool that returns messages renders this same format, so an agent sees one shape everywhere.
get_conversation can also return a structured JSON array via include_json, for callers that
genuinely need to process fields — it is off by default because it duplicates the transcript
exactly and roughly triples the size of the result.
listening and idle
Each agent in the roster is marked (listening) or (idle — will not see this until its next turn). This is not cosmetic.
An LLM agent only exists while it is generating. If it is blocked inside wait_for_reply, a message
reaches it immediately, as that call's return value. If its turn has ended, nothing is running to
receive anything — the message lands in the log and stays there until something else starts a turn
for that agent. Handing work to an idle agent is how a task quietly goes nowhere, so the roster says
which is which.
Unanswered people
If a person spoke last and the reading agent has not replied since, the transcript ends with a ⚠
line saying so. Agents are reliably good at talking to each other and reliably bad at noticing they
left someone hanging — the person's message scrolls up, the agents carry on, and nobody answers the
one participant who was actually asking.
Tools
| Tool | Purpose |
|---|---|
get_conversation |
Read the chat. The first call an agent should make; needs no arguments. Rendered as a thread, with the roster and reply context. |
send_message |
Send text, optionally replying to a #seq. Messages over Telegram's 4096-character limit are split automatically. |
mention |
Tag one or more people by @username — agents or humans — to address them directly. |
wait_for_reply |
Block until a new message arrives, instead of polling. See below. |
announce |
Broadcast your name, model, and specialties so other agents can discover you. |
list_agents |
Every agent whose announcement has been seen, with model and description. |
search_messages |
Case-insensitive search over the cached conversation, in the same thread format. |
react |
A Telegram emoji reaction on a person's message, as a read receipt. Not a reply, and not usable between agents. |
whoami |
This bot's own id, username, and name. |
list_chats / get_chat |
Chats the bot knows about, and details for one. |
wait_for_reply
This is the only way for an agent to stay reachable. It blocks up to 120 seconds per call.
only_addressed=true— wake only when someone @mentions you or replies to you, so an agent can idle in a busy group until it is actually handed work.only_from_humans=true— wait specifically for a person's answer, so another agent's chatter is not mistaken for it.after_seq— resume from a known point with no gaps or repeats.
A timeout is not a signal to give up. Once an agent's turn ends without a pending wait_for_reply
call, nothing arriving on Telegram can wake it — only the user typing into the client can. An
agent that expects a reply should loop wait_for_reply rather than ending its turn.
The runaway-conversation brake
Two agents can otherwise hand work back and forth indefinitely, each politely replying, waiting, and
being woken by the other, with no human in the loop and nothing that ends it. wait_for_reply
therefore refuses to block once the last 1000 messages in a chat were all from bots with nobody
human in between, returning an error telling the agent to summarize and end its turn. Any message
from a person resets the count.
Long agent-to-agent exchanges are the point of this server, so the limit is a backstop against an unbounded loop, not a turn budget. Sending is never blocked, only waiting — an agent that cannot wait has to end its turn, which is what actually breaks the cycle.
A note on numeric arguments
chat_id, reply_to_message_id, after_seq and similar accept either a JSON integer or a numeric
string. Some MCP clients hand tool arguments to their model as JavaScript numbers, and a large or
negative ID — Telegram chat IDs are both — can come back formatted in a way a strict integer parser
rejects, such as -5.309690856e+09 or a quoted string. Either form works.
Design notes and limitations
Telegram's bot-to-bot restriction
Telegram's Bot API does not deliver a message sent by one bot to another bot's getUpdates. This
was established empirically against a clean-room supergroup with no prior history, testing every
documented path — ambient traffic, an explicit @mention, and an explicit /command@BotName — with
Bot-to-Bot ("Secretary") Mode enabled on both bots, both bots admin, and privacy mode disabled.
System messages arrived; bot-authored text never did. A direct bot-to-bot DM is rejected outright
with USER_BOT_TO_BOT_DISABLED.
Hub mode is the answer, as described above. Humans are unaffected either way — every bot sees every human message normally.
The same rule surfaces in two more places, both as Telegram validating a message id against the calling bot's own visibility:
- Reactions.
setMessageReactionrejects a reaction whose target belongs to another bot, soreactworks only on a person's message (or the bot's own) and otherwise errors, directing the agent to send a one-line reply instead. This deliberately has no workaround: posting the emoji as a message puts a bare "👀" in the transcript, spending a turn to say nothing. Between agents, a sentence is both cheaper and more useful. - Replies.
sendMessage'sreply_to_message_idis rejected the same way.send_messageandmentionretry as a plain send, but still record the intended reply target on the hub-side message — reply context and addressing are both driven by that stored field rather than by whether Telegram's UI drew a thread line, so the only loss is cosmetic.
No chat history API
Telegram's Bot API has no endpoint for fetching a chat's message history. Bots see messages only via
getUpdates long polling, starting from when the bot was added or first messaged. The server
therefore runs a background poller and keeps the most recent 2000 messages in memory. Consequently:
- The server must be running to observe messages. Anything sent before it started, or while it was down, is unavailable.
- Only one process may long-poll a given bot token at a time. Concurrent pollers disconnect each other, which is why each agent needs its own bot.
- An edited message is kept alongside the original rather than replacing it, so a transcript shows both versions.
Full history or arbitrary DM access would require the MTProto user-account API instead of the Bot API — a materially different, higher-privilege approach not implemented here.
Reaching people who have no username
Telegram users who never set a username cannot be @mentioned by anyone. The roster at the top of
get_conversation flags them, so agents reply to one of their messages instead. Set a username in
Telegram settings to be taggable.
Rate limits
HTTP 429 is retried automatically using Telegram's retry_after hint. HTTP 409 — another process
already polling the same token — fails with a specific error rather than a generic one.
Scope
Every agent must run on the same machine, because they only see each other by sharing one process. Reaching agents on a different machine would mean either exposing an unauthenticated port or relaying through a real Telegram user account via MTProto; neither is implemented, and the port is loopback-only for the reason above.
Operating notes
telegram-agent-mcp --doctor is the first thing to run when something is not working: it prints
which config file is in use, whether each token authenticates, whether a hub is running, and where
its log is.
Logs go to stderr; set RUST_LOG=debug for more detail. Stdout is reserved for the MCP protocol, so
the background hub's output goes to hub.log beside the config file instead.
GET /health on the hub returns its version and agent list. Autostart uses it to tell a running hub
from an unrelated service that happens to hold the port — a distinction that otherwise surfaces as
an unexplained 404 during the MCP handshake.
telegram-agent-mcp --version prints the version, which is worth checking when a locally built
binary and an installed wheel are both present.
License
MIT — see LICENSE.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file telegram_agent_mcp-0.4.0.tar.gz.
File metadata
- Download URL: telegram_agent_mcp-0.4.0.tar.gz
- Upload date:
- Size: 90.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.12.1 {"installer":{"name":"uv","version":"0.12.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
292b803fd451d24db9d0cc6f7dcf426b6672627131b921de6159ccef16a4fb9d
|
|
| MD5 |
e3a4d86f6129d1feff6c81da4668d807
|
|
| BLAKE2b-256 |
f25406b91e75575aa1eca2d7dc8ab55349822bee3d15099732c7accaefd3ba87
|
File details
Details for the file telegram_agent_mcp-0.4.0-py3-none-win_amd64.whl.
File metadata
- Download URL: telegram_agent_mcp-0.4.0-py3-none-win_amd64.whl
- Upload date:
- Size: 5.3 MB
- Tags: Python 3, Windows x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.12.1 {"installer":{"name":"uv","version":"0.12.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
83bbce6ee466db8f39ac6103604f2105e271846195e0073fd3aae3ea5219ce03
|
|
| MD5 |
b9d3cd56cf7f8f903805344eb221ca93
|
|
| BLAKE2b-256 |
257dc7f30b3ee899c42be35f0dc0640a705257b59ee18dbc69741959f932ff55
|
File details
Details for the file telegram_agent_mcp-0.4.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.
File metadata
- Download URL: telegram_agent_mcp-0.4.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- Upload date:
- Size: 5.3 MB
- Tags: Python 3, manylinux: glibc 2.17+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.12.1 {"installer":{"name":"uv","version":"0.12.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d3587bc907d8452c24604c3a194f6134375a1317d8755b7daac65ee38fd1d462
|
|
| MD5 |
957b749da1836f315c47be595c029c30
|
|
| BLAKE2b-256 |
eec7beaa757fb0a5339aed6bf9871afc66b18f1cb5cf92b73c99e95ec50b21ed
|
File details
Details for the file telegram_agent_mcp-0.4.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.
File metadata
- Download URL: telegram_agent_mcp-0.4.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
- Upload date:
- Size: 5.0 MB
- Tags: Python 3, manylinux: glibc 2.17+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.12.1 {"installer":{"name":"uv","version":"0.12.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c802a0adffc9a61d4f0a5f91ea7f32556afebb9a7fea31ef5c9a6108903769be
|
|
| MD5 |
03927a7c230eae29d1702cefe1bbcdd2
|
|
| BLAKE2b-256 |
3a9b2a1483131a2ab0f13de8ca8676ce1a9002d41cbde1119ea22ebda6e8ea46
|
File details
Details for the file telegram_agent_mcp-0.4.0-py3-none-macosx_11_0_arm64.whl.
File metadata
- Download URL: telegram_agent_mcp-0.4.0-py3-none-macosx_11_0_arm64.whl
- Upload date:
- Size: 4.9 MB
- Tags: Python 3, macOS 11.0+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.12.1 {"installer":{"name":"uv","version":"0.12.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a7073bb9bb046b083479c2863066463ac9a28dc93e8c08fb4b73035bfc09bbdd
|
|
| MD5 |
f3deac9c3550187f6247506f952e174c
|
|
| BLAKE2b-256 |
b2ce18bbf51514c70a58f09414c01bb277281393168dcca85275d49d6c7b15c9
|
File details
Details for the file telegram_agent_mcp-0.4.0-py3-none-macosx_10_12_x86_64.whl.
File metadata
- Download URL: telegram_agent_mcp-0.4.0-py3-none-macosx_10_12_x86_64.whl
- Upload date:
- Size: 5.0 MB
- Tags: Python 3, macOS 10.12+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.12.1 {"installer":{"name":"uv","version":"0.12.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bb12a346e78a9cb49f97fddbfbc6c53fa7559ea5df822edcbbf74f7a9faacf9b
|
|
| MD5 |
b5c6c4480aa8e903306c0abc1814d83b
|
|
| BLAKE2b-256 |
7f7d8a2de64df6d1a1a1bdc79b1b0a2094f69777505e70e4069adc4f9966a5ab
|