Metadata-Version: 2.4
Name: telegram-agent-mcp
Version: 0.4.0
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Rust
Classifier: Topic :: Communications :: Chat
Classifier: Topic :: Software Development :: Libraries
License-File: LICENSE
Summary: MCP server exposing a Telegram bot so LLM agents can converse, discover each other, and route tasks in a shared group chat
Keywords: mcp,telegram,llm,agents,orchestration
Author: clemente-turrubiates
Requires-Python: >=3.8
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Homepage, https://github.com/clemente-turrubiates/telegram-agent-mcp
Project-URL: Issues, https://github.com/clemente-turrubiates/telegram-agent-mcp/issues
Project-URL: Repository, https://github.com/clemente-turrubiates/telegram-agent-mcp

# telegram-agent-mcp

An [MCP](https://modelcontextprotocol.io) 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](https://crates.io/crates/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:

```sh
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](#telegrams-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](https://www.maturin.rs/), so any Python installer
can fetch it. With [uv](https://docs.astral.sh/uv/) there is nothing to install at all — `uvx` runs
it straight from PyPI:

```sh
uvx telegram-agent-mcp --doctor
```

Or install it permanently:

```sh
uv tool install telegram-agent-mcp     # or: pip install telegram-agent-mcp
```

Or build from source:

```sh
cargo build --release   # -> target/release/telegram-agent-mcp
```

## One agent, in two minutes

1. Create a bot with [@BotFather](https://t.me/BotFather) and copy the token.
2. 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.
3. Add the bot to a group (or just DM it), and say something so it learns the chat exists.
4. Paste this into your MCP client:

   ```json
   {
     "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 the `args`.

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:

1. **Create one bot per agent** with [@BotFather](https://t.me/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.
2. **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.
3. **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.
4. **Write the config.** Run `telegram-agent-mcp --doctor` to see exactly where it is expected —
   `%APPDATA%\telegram-agent-mcp\agents.toml` on Windows, `~/.config/telegram-agent-mcp/agents.toml`
   elsewhere.

   ```toml
   [[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"
   ```

   `model` and `description` are what `announce` broadcasts by default, and what other agents route
   work by.

5. **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:

   ```json
   {
     "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 from `agents.toml`.

6. **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.json` next 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:

1. A file named outright: `--config PATH`, or `TELEGRAM_AGENTS_FILE`.
2. Tokens in the environment — `TELEGRAM_BOT_TOKEN`, or `TELEGRAM_AGENT_<N>_TOKEN` for several.
3. `agents.toml` in 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](#why-several-agents-need-one-process). 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.** `setMessageReaction` rejects a reaction whose target belongs to another bot, so
  `react` works 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`'s `reply_to_message_id` is rejected the same way. `send_message` and
  `mention` retry 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](LICENSE).

