> For the complete documentation index, see [llms.txt](https://dotagent.avelino.run/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://dotagent.avelino.run/concepts/telegram.md).

# Telegram

Telegram works in both directions. Outbound is the `telegram` notifier driver, which posts when a run fails — see [Notifications](/concepts/notifications.md). This page is about inbound: messages that cause agents to run.

Off unless you configure it. dotagent never opens an inbound path you did not ask for.

## The shape

```mermaid
sequenceDiagram
    actor me as me
    participant tg as Telegram
    participant p as poller
    participant d as dotagent daemon
    participant disp as dispatcher agent

    p->>tg: getUpdates (held open)
    me->>tg: "how's disk on the laptop?"
    tg-->>p: message
    Note over p: allowlist, then rate limit
    p->>d: TriggerRequest
    d->>disp: run, message in AGENT_TRIGGER_PAYLOAD
    disp-->>d: stdout
    d->>tg: sendMessage to the same chat
    tg-->>me: reply
```

The daemon holds a `getUpdates` connection open rather than polling on a timer, so replies are near-instant without a webhook, a public IP, TLS termination or a tunnel. dotagent runs on laptops behind NAT; a webhook would make the common case the hard one.

## Setup

**1. Bot and user id.** Create a bot with [@BotFather](https://t.me/BotFather). Get your numeric id from [@userinfobot](https://t.me/userinfobot) — the number, not the `@username`.

**2. Token in the secrets file**, `~/.config/dotagent/secrets.env`, mode `600`:

```
TELEGRAM_BOT_TOKEN=123456:AA...
```

**3. Config**, `~/.config/dotagent/config.toml`:

```toml
[telegram]
bot_token        = "${TELEGRAM_BOT_TOKEN}"
allowed_user_ids = [123456789]
dispatcher_agent = "telegram-assistant"
```

**4. Reload.** `dotagent reload`. The log line `telegram ingress started` confirms it.

Every `[telegram]` change takes effect on reload, including the allowlist — the daemon stops the running poller and starts a fresh one against the new config. Revoking a user id and reloading revokes it immediately, rather than leaving them able to trigger runs until the next restart.

Full field list in the [config reference](/guides/config-reference.md#telegram).

## Why the config is daemon-level

Telegram allows exactly one `getUpdates` consumer per bot token. If each manifest declared its own token, N pollers would compete for the same offset and silently drop each other's messages. One bot, one consumer, one place to configure it.

## The allowlist is the whole gate

`allowed_user_ids` is what stands between a bot token and arbitrary local execution. Two consequences:

**Numeric ids only.** A `@username` is changeable and therefore not an authorization input.

**Empty means nobody.** A token with an empty allowlist leaves the ingress off, and the daemon logs why. The other reading — "empty means no restriction" — would turn one forgotten line into an open remote-execution endpoint.

Messages from unlisted senders are refused before anything runs and recorded as `trigger_rejected` at `Critical` severity. That severity is deliberate: it means somebody found your bot.

### Open chats: sharing the bot with a group

`open_chat_ids` lists group chats where **any member** may talk to the dispatcher — useful when the group itself is the team. The list is explicit chat ids on purpose: anyone can add a bot to a group they control, so the trust boundary is membership in a group the *operator* listed, not membership in any group. Two things an open chat deliberately does **not** grant:

* **Direct messages** still require `allowed_user_ids`, always.
* **`!` and `!!`** (typed commands that run binaries) stay owner-only even in an open chat — running a program is a different risk class than asking a question.

The rate limit applies per sender regardless, and every accepted message still lands in the audit log with the sender's numeric id.

## Rate limiting

`rate_limit_per_minute` (default 10) caps accepted messages per sender. Excess is dropped with an audit entry. The bot is reachable from anywhere on the internet, so this is the backstop that keeps one sender from occupying the daemon indefinitely.

The window is in memory. A daemon restart clears it — acceptable, and cheaper than a disk write on every message.

## The dispatcher

`dispatcher_agent` names the agent every accepted message goes to. It is an ordinary agent: env vars in, stdout out, exit code for success. It receives the message through `AGENT_TRIGGER_PAYLOAD` and whatever it prints becomes the reply.

What it does with the message is entirely up to it. [`examples/telegram-assistant`](https://github.com/avelino/dotagent/tree/main/examples/telegram-assistant/README.md) hands it to `claude -p` with the [MCP server](/reference/mcp.md) attached, so a model picks the right agent from a closed catalog. A dispatcher that just matches `/disk` with `case` is equally valid and costs no tokens.

dotagent itself interprets nothing. There is no model, no provider and no prompt in the daemon.

A dispatcher that wants *continuity* declares `[assistant]` in its manifest: the daemon then keeps the conversation's pointers (model session id, toolkit hash, transcript size), reinjects them as `AGENT_ASSISTANT_*` on every trigger, retires sessions whose transcript outgrew the ceiling, and strips `MEMO:` capture lines from replies into the memory workspace. The one trigger after automatic retirement receives `AGENT_ASSISTANT_CONTEXT_RETIRED=true`, so the agent can distinguish a forced fresh context from an ordinary new thread. The chat transcript itself still never lives in the daemon. Schema in the [agent spec](/reference/agent-spec.md#assistant-conversational-harness-opt-in).

## Conversations and threads

Which messages share a conversation depends on the kind of chat:

* **Direct chat** — one chat, one conversation. Every message shares the session keyed by the chat id. This is the original keying and it does not change.
* **Group** — every fresh mention roots a new conversation. Replying to any message of a thread — your own question or the bot's answer — continues that thread's conversation. Two subjects asked in parallel never see each other's context.
* **Group with forum topics** — same as a group, and each topic keeps its own conversations. The bot answers inside the topic it was asked in.

For a group, the opaque session key is `<chat-id>-r<root-message-id>`; a forum topic adds `-t<topic-id>` before the root. For example, `trigger-telegram--1004457436194-r7` identifies Telegram chat `-1004457436194` and reply-chain root message `7`: `r7` is not retry number seven.

```mermaid
flowchart TD
    M[group message arrives] --> R{is a reply?}
    R -->|no| N[new conversation<br/>rooted at this message]
    R -->|yes| L[look up the replied-to message]
    L -->|known| H[continue that conversation]
    L -->|unknown| N
```

The binding lives in `state/notify/telegram/threads.json`, a bounded table of the last thousand messages per chat — the same philosophy as the notification correlation table, and it can be deleted at any time: an unresolvable reply simply starts a new conversation, which is what every message did before threads existed.

`/novo` (or `/new`) resets the current thread's conversation — the next message there starts from zero, keeping memory facts but dropping the model session. It clears any pending automatic-retirement marker rather than emitting one. Like `/help`, it yields to a command file you install yourself. In a direct chat it resets the one conversation there is.

`!!` and parked `!` confirmations are scoped the same way: a confirmation releases what *this thread* parked, so two threads cannot confirm each other's destructive commands.

## Commands

[Commands](/concepts/commands.md) put a `/` menu in the chat. The daemon registers it with `setMyCommands` on start and every reload, scoped to each allowlisted chat rather than globally — the allowlist gates execution already, but a global menu would publish every command name to anyone who finds the bot.

An invoked command arrives beside the text rather than instead of it:

```json
{ "text": "/standup disk-alert", "command": { "name": "standup", "args": "disk-alert" } }
```

`command` is `null` for ordinary prose. The daemon parses `/name args` — Telegram wire syntax, the same class of thing as reading `update_id` — and stops there. Resolving a name to a prompt is `command-get`, over MCP, and belongs to the dispatcher.

Three answers do come straight from the daemon: `/help`, `/novo` and "no command named /typo". The first two are questions about *what exists* and *what to forget* — the catalog the daemon publishes and the registry the daemon owns. Letting `/typo` fall through would mean a model improvising an answer to something meant to be exact.

## Running a command yourself

A message starting with `!` runs an installed binary directly, without the dispatcher seeing it:

```
!rg TODO src
!kubectl get pods
```

No model call, no session, nothing stored. Output comes back as-is, and so do errors — the point of typing a command is that it is exact, and a paraphrased exit code is worse than the exit code.

Destructive commands ask first. `!rm -r /tmp/x` quotes back what it will run and waits for `!!`; anything else in between cancels nothing but does not confirm either. The parked command is keyed by the conversation — reply in the same thread to release it, and a different thread's `!!` cannot. The list covers the usual suspects and every shell, because a guard on `rm` that lets `sh -c 'rm -rf /'` past guards nothing.

It is off unless `[os]` is configured. The prefix obeys the same `allow` list as the assistant's own `os-run`, and it is read *after* the allowlist and rate limit, so `!` is never a way past either. Quotes group an argument (`!rg "hello world"`); nothing else from a shell applies, because no shell is involved.

See [`../guides/config-reference.md`](/guides/config-reference.md#os) for the allowlist and [`../security/threat-model.md`](/security/threat-model.md) V17 for what it does and does not bound.

## Answering a notification

The bot posts when a run fails. Replying to that message is the natural next move, and the reply carries which run it answers:

```jsonc
"reply_to_run": { "agent": "disk-alert", "schedule": "every-15min", "event": "given_up" }
```

Resolved from the replied-to message id and the inbound chat id, through a chat-scoped table of the last few hundred notifications at `state/notify/telegram/sent.json`. Not from the text: one event reads `🚨 disk-alert/every-15min gave up after 3 attempts` and another reads only `preflight aborted by plugin preflight-warp`, so a dispatcher parsing the wording would work for one and be wrong on the other. Legacy records without a chat id fail closed rather than risking a match from another chat.

With it, "por que falhou?" is answerable without guessing which agent — the dispatcher reads the field and calls `dotagent-logs` on the right one. If the manifest declared a [remediation](/reference/mcp.md#remediation-tools), the fix is a tool in the same catalog.

## Replies

The dispatcher's stdout goes back to the chat that asked. Sent as plain text, not MarkdownV2 — the body is agent output, and an unescaped backtick or underscore in a log line would otherwise turn into a Bot API 400. Delivery beats formatting.

Telegram caps a message at 4096 characters. Longer output is trimmed with a `[truncated]` marker; the full text stays in `dotagent logs`.

## Offset

The last acknowledged `update_id` lives in `~/.config/dotagent/state/notify/telegram/offset.json`, written tmp-then-rename.

Telegram redelivers every update until you acknowledge it. Without persistence, a daemon restart would replay the backlog — and for a bot that runs agents, replay means re-running whatever the last messages asked for. Delivery is at-most-once on purpose.

## What is never recorded

Message bodies do not reach the audit log. `trigger_received` records the sender id and the chat id; the text is not attribution, it is content, and a chat can contain anything you pasted into it.

## Failure modes

**Nothing happens.** Usually the allowlist. Check the audit log for `trigger_rejected`, and confirm the id is numeric and yours.

**`telegram bot_token set but allowed_user_ids is empty`.** The ingress stayed off by design. Add your id.

**`telegram poll failed`** repeating with a growing backoff. Transport problem — network, or a revoked token. The backoff doubles to a 60-second ceiling so a dropped connection does not become a tight retry loop.

**The reply never comes but the log shows the run.** The dispatcher printed nothing on stdout, or printed to stderr. stdout is the reply.

## See also

* [Triggers](/concepts/triggers.md) — the general concept
* [MCP server](/reference/mcp.md) — how a model picks an agent
* [Skills](/concepts/skills.md) — teaching the dispatcher a procedure without growing its prompt
* [Threat model](/security/threat-model.md) — what changes when this is on
* [Notifications](/concepts/notifications.md) — the outbound driver


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://dotagent.avelino.run/concepts/telegram.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
