> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dairo.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Telegram interactivity

> Add inline buttons, handle taps, edit and react to messages, send media and voice notes, and message other bots over Telegram.

Turn a one-way Telegram bot into an interactive one. Your agent can put buttons
under a message, receive the tap back as a normal inbound message, edit what it
already sent, react with an emoji, send media and voice notes, and message another
bot. Every one of these rides the same send call and MCP tools you already use for
plain text.

This builds on [connecting a Telegram bot](/channels/telegram). If your inbox is
`active`, you're ready.

## Sending media (photos, PDFs, files)

Attach files to a Telegram send exactly as you would on email — the same `attachments`
field. Each attachment is an object: inline `{filename, contentBase64}` **or** a storage
reference `{objectId}` of a Dairo object you own. Dairo routes each to its **native** Telegram
type by content type:

* an **image** (`image/*`) → **sendPhoto** (rendered inline),
* a **video** (`video/*`) → **sendVideo** (native inline player),
* **OGG/Opus audio** (`audio/ogg`, `audio/opus`) → **sendVoice** (a real voice-note bubble —
  perfect for an agent's spoken reply),
* **other audio** (`audio/*`) → **sendAudio** (the music player),
* anything else (PDF, doc, …) → **sendDocument** (a file card),
* **2+ files** → a **sendMediaGroup** album (mixed sets fall back to separate native sends),
* the `text`/`html` body becomes the **caption** (≤1024 chars); a send with an attachment
  needs **no** body, so a caption-less voice note or photo is valid.

<CodeGroup>
  ```json title="MCP — send_message (a PDF from storage)" theme={null}
  {
    "name": "send_message",
    "arguments": {
      "inboxId": "inbox_9f2b...",
      "to": ["telegram:5031234567"],
      "text": "Here's the July invoice 📄",
      "attachments": [ { "objectId": "obj_a1b2..." } ]
    }
  }
  ```

  ```bash title="cURL (an inline photo)" theme={null}
  curl -X POST https://api.dairo.app/v1/messages \
    -H "Authorization: Bearer $DAIRO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "inboxId": "inbox_9f2b...",
      "to": ["telegram:5031234567"],
      "text": "Latest chart 🖼️",
      "attachments": [ { "filename": "chart.png", "contentType": "image/png", "contentBase64": "iVBORw0K..." } ]
    }'
  ```
</CodeGroup>

**Native up to 50 MB, link over that.** Dairo fetches a storage `{objectId}` and uploads it
to Telegram natively — up to Telegram's **50 MB** bot-upload cap — so it bypasses the
inline-JSON size limit entirely (upload a 20 MB video to a bucket, reference it by `objectId`,
and it lands in the chat). Over 50 MB, a send returns a **413**: create a
Dairo share link for the file and include the link in your message text instead (Dairo won't
auto-add it) — the same pattern as email.

A single photo/document can also carry `buttons`; an album (2+ files) cannot (Telegram albums
have no keyboard), so buttons + multiple attachments is a 400.

## Receiving media

When someone sends the bot a **photo, document, video, audio, or voice** message, Dairo
downloads the file and lands it as a **first-class attachment** on the inbound mailbox
message — the same shape as an inbound email attachment (its own storage object, with a
`messageId` join key). The message's caption becomes the text body. A media-only message
(no caption) still arrives, carrying only the attachment. Your normal read loop sees it;
no special casing.

## Native message types (location, poll, dice, …)

Beyond text + media, a Telegram send can carry a `telegram` object for the native message
types — **exactly one** per send:

| `telegram.<field>`                                                                           | Sends                                                                           |
| -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `location: {latitude, longitude, livePeriod?}`                                               | a map point (`sendLocation`)                                                    |
| `venue: {latitude, longitude, title, address}`                                               | a venue (`sendVenue`)                                                           |
| `contact: {phoneNumber, firstName, lastName?}`                                               | a phone contact (`sendContact`)                                                 |
| `poll: {question, options[], isAnonymous?, type?, allowsMultipleAnswers?, correctOptionId?}` | a poll or quiz — `type:"quiz"` + `correctOptionId` makes it a quiz (`sendPoll`) |
| `dice: {emoji?}`                                                                             | an animated 🎲 🎯 🏀 ⚽ 🎳 🎰 (`sendDice`)                                       |
| `sticker: "<file_id or https URL>"`                                                          | a sticker (`sendSticker`)                                                       |
| `videoNote: "<file_id or https URL>"`                                                        | a round video note (`sendVideoNote`)                                            |

Files route by content type too: an `image/gif` attachment becomes an **animation**
(`sendAnimation`), an `image/webp` becomes a **sticker**.

```json title="MCP — send_message (a poll)" theme={null}
{
  "name": "send_message",
  "arguments": {
    "inboxId": "inbox_9f2b...",
    "to": ["telegram:5031234567"],
    "telegram": { "poll": { "question": "Ship it?", "options": ["Yes", "Absolutely"] } }
  }
}
```

## Voice messages from text

Your agent can send a real Telegram **voice note from pure text** — no audio file. Dairo
synthesizes the speech (**Dairo TTS**) and delivers it as a native voice bubble:

```json title="MCP — send_message (a voice note)" theme={null}
{
  "name": "send_message",
  "arguments": {
    "inboxId": "inbox_9f2b...",
    "to": ["telegram:5031234567"],
    "telegram": { "voice": { "text": "Deploy #42 is live.", "voiceId": "adrian" } }
  }
}
```

* `text` — required, up to 4000 characters (\~4 minutes of speech).
* `voiceId` — optional: a catalog slug (browse them with `GET /v1/telegram/voices`), a raw
  Dairo TTS voice id, or omit it for the default voice (`adrian`, a clean narrator).

Voice notes are delivered in the background: the send returns `status: "queued"` immediately,
then Dairo synthesizes and delivers the note, so long text never times out the request. Read
the final `sent`/`failed` outcome with `GET /v1/messages/{id}`.

### Browsing voices

Dairo ships a catalog of thousands of voices — celebrity and character voices alongside
neutral professionals. Each carries a name, description, best language, tags, and a `featured`
flag. Browse it:

```bash title="cURL — browse voices" theme={null}
curl "https://api.dairo.app/v1/telegram/voices?featured=true&limit=20" \
  -H "Authorization: Bearer $DAIRO_API_KEY"
# also: ?q=<search> for free-text search, ?language=de to filter by best language
# limit defaults to 50 (max 100); offset drives paging
```

Each row returns `{ slug, name, description, bestLanguage, languages, gender, tags, featured, popularity }`,
ordered by popularity. Pass a row's `slug` as the `voiceId` on a voice send; celebrity and
character voices carry `featured: true`.

## Built-in slash commands

Every connected Telegram bot registers a small set of **built-in commands** that Dairo
answers itself — no agent code needed. They autocomplete in the Telegram `/` menu:

| Command   | What it does                                                        |
| --------- | ------------------------------------------------------------------- |
| `/start`  | A warm, personal welcome, written fresh by Dairo for each new chat. |
| `/status` | A link to the Dairo system status page.                             |
| `/usage`  | The account's real send volume over the last 24h.                   |
| `/whoami` | This inbox's identity (bot handle + inbox id).                      |
| `/help`   | The command list.                                                   |

These are **read-only** and handled entirely by Dairo. **Every other command** (your own
`/deploy`, `/summary`, …) **and every normal message flows to your agent** as a normal
inbound — so you can implement your own commands by branching on the inbound text. New bots
get the built-ins registered automatically on connect.

## Inline buttons on a send

Add a `buttons` field to any Telegram send. It is a **2-D array** — an array of
**rows**, each row an array of buttons. Each button is **exactly one of**:

* **`url`** — a link button. Tapping it opens the URL (`http`/`https`/`tg` only).
* **`callback`** — an action button. Tapping it surfaces the tap back to your agent
  (see [Handling a tap](#handling-a-button-tap)). The `callback` string is your own
  opaque value — Dairo stores it and hands it back on the tap.

<CodeGroup>
  ```json title="MCP — send_message" theme={null}
  {
    "name": "send_message",
    "arguments": {
      "inboxId": "inbox_9f2b...",
      "to": ["telegram:5031234567"],
      "text": "Deploy #42 is ready to ship to production.",
      "buttons": [
        [
          { "text": "Approve", "callback": "approve:deploy-42" },
          { "text": "Reject",  "callback": "reject:deploy-42" }
        ],
        [
          { "text": "View diff", "url": "https://dairo.app/deploys/42" }
        ]
      ]
    }
  }
  ```

  ```bash title="cURL" theme={null}
  curl -X POST https://api.dairo.app/v1/messages \
    -H "Authorization: Bearer $DAIRO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "inboxId": "inbox_9f2b...",
      "to": ["telegram:5031234567"],
      "text": "Deploy #42 is ready to ship to production.",
      "buttons": [
        [ { "text": "Approve", "callback": "approve:deploy-42" },
          { "text": "Reject",  "callback": "reject:deploy-42" } ],
        [ { "text": "View diff", "url": "https://dairo.app/deploys/42" } ]
      ]
    }'
  ```
</CodeGroup>

The row layout you send is the layout Telegram renders — two buttons side by side, then a
full-width link button below.

Buttons work on a `text` **or** an `html` (rich) body. The grid is bounded:

| Limit            | Value                                                         |
| ---------------- | ------------------------------------------------------------- |
| Rows             | up to **8**                                                   |
| Buttons per row  | up to **8**                                                   |
| Button `text`    | 1–**64** characters                                           |
| `callback` value | up to **200** characters (stored by Dairo, never on the wire) |

Your `callback` string never travels on the Telegram wire — Telegram hard-caps
`callback_data` at 64 bytes, so Dairo sends a compact reference and keeps your full
`callback` value (and the button label) on the outbound message. The tap round-trips
your real payload.

### Tap feedback

By default, tapping a `callback` button flashes a small confirmation toast — a check
mark plus the button's label (e.g. `✓ Approve`) — so a tap is never silent. Override it
per button:

* **`feedback`** — the toast text to show on tap (up to **200** characters).
* **`alert`** — set `true` to show a **modal** the user must dismiss, instead of a toast.

```json title="Buttons with feedback" theme={null}
{
  "buttons": [[
    { "text": "Approve", "callback": "approve:42", "feedback": "✅ Deploy approved — rolling out" },
    { "text": "Reject",  "callback": "reject:42",  "feedback": "Rejected. Nothing shipped.", "alert": true }
  ]]
}
```

The toast/alert is answered inside Telegram's brief callback window, so it is decided at
**send time**, per button. For a richer, stateful response — checking off the choice,
swapping the keyboard, rewriting the text — [edit the message](#editing-a-message) when
you receive the tap.

## Handling a button tap

When someone taps a **callback** button, Dairo does three things:

1. **Answers the tap** immediately, so the tapper's client stops its loading spinner.
2. **Persists an inbound message** into your mailbox — your agent's normal read loop
   sees it, no special casing. Its `subject` is `Button tapped: <label>` and its
   `textBody` is your original `callback` value.
3. **Emits a `message.button.tapped` webhook** on the durable event ledger.

The inbound message carries the tap details in `channelMetadata`:

```json theme={null}
{
  "object": "message",
  "channel": "telegram",
  "direction": "inbound",
  "subject": "Button tapped: Approve",
  "textBody": "approve:deploy-42",
  "channelMetadata": {
    "chatId": 123456789,
    "kind": "callbackQuery",
    "buttonCallback": "approve:deploy-42",
    "buttonLabel": "Approve",
    "sourceMessageId": "msg_the_send_that_carried_the_button",
    "sourceTelegramMessageId": 555,
    "callbackQueryId": "cbq_...",
    "fromUsername": "alice",
    "fromIsBot": false,
    "updateId": 77
  }
}
```

The `message.button.tapped` webhook event carries the same facts in its `data`:

```json theme={null}
{
  "type": "message.button.tapped",
  "data": {
    "messageId": "msg_the_inbound_tap",
    "sourceMessageId": "msg_the_send_that_carried_the_button",
    "inboxId": "inbox_9f2b...",
    "channel": "telegram",
    "direction": "inbound",
    "kind": "button_tap",
    "buttonCallback": "approve:deploy-42",
    "buttonLabel": "Approve",
    "callbackQueryId": "cbq_...",
    "chatId": 123456789,
    "sourceTelegramMessageId": 555,
    "fromIsBot": false
  }
}
```

Use `sourceMessageId` to tie the tap back to the exact send it belongs to, and
`buttonCallback` to branch on which action the user chose. A source message can be
tapped many times — **each tap is its own inbound row** (keyed on the callback query
id), so a count of taps is a count of rows.

<Note>
  `fromIsBot` flags a tap that came from another bot — a loop guard for bot-to-bot
  flows. A `url` button never produces a tap event — it only opens its link.
</Note>

## Editing a message

Edit a message you already **sent** with `manage_sent_messages { action: "edit" }` (REST:
`POST /v1/messages/{id}/edit`). Pass `text` or `html` to replace the body and/or a `buttons`
grid to change the keyboard; supplying both `text` and `html` is a **422**. This is the
canonical "acknowledge, then finish" pattern — send with buttons, then edit the text to a
result.

<CodeGroup>
  ```json title="MCP — manage_sent_messages" theme={null}
  {
    "name": "manage_sent_messages",
    "arguments": {
      "action": "edit",
      "messageId": "msg_9f2b...",
      "text": "Deploy #42 shipped ✅ — approved by @alice."
    }
  }
  ```

  ```bash title="cURL" theme={null}
  curl -X POST https://api.dairo.app/v1/messages/msg_9f2b.../edit \
    -H "Authorization: Bearer $DAIRO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{ "text": "Deploy #42 shipped ✅ — approved by @alice." }'
  ```
</CodeGroup>

How `buttons` behaves on an edit:

| You pass           | Result                                                              |
| ------------------ | ------------------------------------------------------------------- |
| **omit `buttons`** | The existing keyboard is **preserved** (Dairo re-sends it for you). |
| `buttons: [[…]]`   | The keyboard is **replaced** with the new grid.                     |
| `buttons: []`      | The keyboard is **cleared**.                                        |

<Note>
  Omitting `buttons` on a body edit preserves the keyboard even on Telegram Desktop,
  where a raw `editMessageText` without a keyboard would otherwise drop the buttons.
  To retire the buttons once an action is taken, pass `buttons: []` explicitly.
</Note>

Only Telegram messages you **sent** are editable. A non-Telegram id, an inbound id,
or another tenant's id returns **404** (existence is never leaked).

## Reacting to a message

Set the bot's emoji reaction on a message you **sent or received** with
`manage_sent_messages { action: "react" }` (REST: `POST /v1/messages/{id}/react`). Clear it
with `action: "unreact"`.

<CodeGroup>
  ```json title="MCP — react" theme={null}
  {
    "name": "manage_sent_messages",
    "arguments": { "action": "react", "messageId": "msg_9f2b...", "emoji": "👍", "big": true }
  }
  ```

  ```json title="MCP — unreact" theme={null}
  {
    "name": "manage_sent_messages",
    "arguments": { "action": "unreact", "messageId": "msg_9f2b..." }
  }
  ```

  ```bash title="cURL — react" theme={null}
  curl -X POST https://api.dairo.app/v1/messages/msg_9f2b.../react \
    -H "Authorization: Bearer $DAIRO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{ "emoji": "👍", "big": true }'
  ```
</CodeGroup>

`emoji` must be one of **Telegram's allowed reaction emoji** (👍 👎 ❤ 🔥 🥰 👏 😁 🤔
🎉 🙏 …) — a normal bot cannot use a custom or premium emoji, and one that isn't on
the list returns **400**. `big` (optional) plays the big-reaction animation. Reacting
to an inbound message is the lightweight way to acknowledge it without sending a reply.

## Bot-to-bot & by-username messaging

A send normally goes to the chat that connected the bot. To message a **specific bot,
channel, or user by username** instead, put an `@username` (or `telegram:@username`)
in `to`:

<CodeGroup>
  ```json title="MCP — send_message" theme={null}
  {
    "name": "send_message",
    "arguments": {
      "inboxId": "inbox_9f2b...",
      "to": ["@partner_service_bot"],
      "text": "handoff: ticket-8842 needs your triage.",
      "buttons": [[ { "text": "Ack", "callback": "ack:ticket-8842" } ]]
    }
  }
  ```

  ```bash title="cURL" theme={null}
  curl -X POST https://api.dairo.app/v1/messages \
    -H "Authorization: Bearer $DAIRO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "inboxId": "inbox_9f2b...",
      "to": ["@partner_service_bot"],
      "text": "handoff: ticket-8842 needs your triage."
    }'
  ```
</CodeGroup>

Because a by-username send needs no bound chat, it works **even before the inbox is
verified** — handy for a bot that only ever talks to other bots or posts to a channel.

<Note>
  For **bot-to-bot** messaging, **both** bots must enable *Bot-to-Bot Communication
  Mode* in [@BotFather](https://t.me/BotFather); for a channel or user the bot must be
  permitted to post there. If Telegram rejects the send, Dairo surfaces a **422** with
  that exact guidance rather than a generic error.
</Note>

## Put it together: an approval loop

1. **Send** a message with `Approve` / `Reject` callback buttons.
2. **Wait** for the `message.button.tapped` webhook (or poll inbound messages).
3. **Branch** on `buttonCallback`, matching `sourceMessageId` to your send.
4. **Edit** the original message to the outcome and pass `buttons: []` to retire the
   buttons — so the same message can't be actioned twice.
5. Optionally **react** 👍 on the tap to acknowledge it.

Every step is one call on the surface you already use — no Telegram SDK, no webhook
plumbing of your own.

## Next steps

<CardGroup cols={2}>
  <Card title="Rich messages" icon="wand-magic-sparkles" href="/agent-first/telegram-rich-messages">
    Headings, tables, media, and code blocks in an html body.
  </Card>

  <Card title="MCP recipes" icon="plug" href="/agent-first/mcp-recipes">
    Copy-pasteable tool-call JSON for the canonical flows.
  </Card>

  <Card title="Webhooks" icon="bell" href="/webhooks/webhooks">
    Receive message.button.tapped and other events in real time.
  </Card>
</CardGroup>
