Editorial

Telegram Rich Messages: Stream AI Bot Replies

sendRichMessageDraft streams a bot reply as a 30-second preview. Call sendRichMessage to keep it, plus blocks, limits and buttons.

JJyoti Ranjan SwainUpdated
Telegram Bot API rich messages: sendRichMessageDraft streams a 30-second preview, sendRichMessage persists the result

If your Telegram bot wraps an LLM, you have almost certainly written the same hack twice: send a placeholder message, then hammer editMessageText every few hundred milliseconds as tokens arrive, then hit a flood wait and slow the loop down until the typing feels wrong. Telegram now has a real method for that. It is sendRichMessageDraft, and it does not behave the way the edit loop did.

The draft is temporary. It expires in about 30 seconds, it does not persist in the chat, and when generation finishes you have to call sendRichMessage separately to save the finished output. Two calls, two objects, one of them disposable. Miss that and your bot streams a beautiful answer that vanishes.

Rich messages themselves landed in Bot API 10.1 on 11 June 2026, grew media handling in 10.2 on 14 July, and picked up buttons plus expandable quotes in 10.3 on 24 August. Three releases in ten weeks, which is why so much of the guidance floating around is already stale.

Table of contents

What a rich message actually is

A normal Telegram message is one text blob plus an array of entities with byte offsets. Anything structural you wanted, a table or a heading, you faked with monospace and newlines.

A rich message is a document. You send an array of blocks and each block has a type: paragraph, heading, pre, list, table, blockquote, footer, divider, map, collage, slideshow, details, plus media blocks and a mathematical-expression block. Telegram clients render them natively, so a code block looks like a code block and a table scrolls like a table.

Two objects matter and they are not the same. RichMessage is what you receive, on the rich_message field of Message. InputRichMessage is what you send. Every block type exists in both flavours, RichBlockParagraph for receiving and InputRichBlockParagraph for sending, so read the direction of the class name before you copy a field list out of the docs.

The streaming flow, end to end

Here is the whole lifecycle, because the disposable middle step is what people get wrong.

Streaming a Telegram rich message: repeated sendRichMessageDraft calls, then one sendRichMessage to persist it

sendRichMessageDraft returns True, not a Message. There is no message ID to hold on to, which reinforces the point: nothing was created. It takes an integer chat_id for a private chat, a non-zero draft_id, and an InputRichMessage. You cannot upload new files inside a draft, and you cannot reference a file by URL either, so any media has to wait for the final send.

draft_id is the animation key

draft_id decides whether the user sees smooth growth or a hard cut. Reuse the same non-zero value across calls and the client animates the diff between updates. Change it and the old draft is replaced with no animation at all.

One draft_id per generation is the rule. If you are running a parallel tool call and want to show its progress separately, that is a second draft_id, not a second message.

Three ways to write the content

InputRichMessage accepts exactly one of blocks, markdown, or html. Pass two and the call fails.

FieldBest forTrade-off
markdownpiping LLM output through nearly untouchedGFM-compatible where possible, so most model output survives
htmlwhen you need tags Markdown has no syntax forverbose, but <sub>, <aside>, <tg-map> and buttons live here
blocksprogrammatic output you assemble yourselfexplicit and typed, no parser surprises

The Markdown flavour is deliberately close to GitHub Flavored Markdown, which is the practical reason to care. Models already emit GFM. **bold**, # Heading, fenced code blocks with a language tag, | tables |, - [ ] task items, > quotes, footnotes with [^id], and $x^2 + y^2$ for inline maths all parse.

Two additions beyond GFM that are easy to miss: ==marked text== and ||spoiler||. And you can mix HTML into the Markdown for the gaps, though Markdown is not parsed inside block-level HTML tags except <details>, <tg-collage> and <tg-slideshow>.

Media is where the two text modes get fiddly. Inside markdown or html you reference an upload with a tg://photo?id=, tg://video?id=, tg://document?id= or tg://audio?id= link, and then declare that ID in the media array with the actual InputMedia* object. The document variant is new in 10.3. IDs are 1-64 characters, A-Z a-z 0-9 _ - only. Media also has to be its own block, never inline with a paragraph, and media blocks accept only HTTP and HTTPS URLs.

If you want automatic entity detection off, pass skip_entity_detection: true. Telegram otherwise finds URLs, emails, @usernames, hashtags, cashtags, bot commands, phone numbers and bank card numbers on its own. For a bot that outputs code or data, that autodetection is usually noise you do not want.

The thinking block

InputRichBlockThinking renders a "Thinking…" placeholder, and it only works in sendRichMessageDraft. You cannot receive one in a message, because it never persists. Its HTML form is <tg-thinking>, its block type is "thinking", and Telegram ships a custom emoji pack at t.me/addemoji/AIActions intended for use inside it.

This is the reasoning-trace slot. If your model has a thinking phase before it produces the answer, put the status line here and the answer in normal paragraph blocks, and the whole thing disappears when the final sendRichMessage lands.

Buttons arrived in 10.3

Rich messages could not carry inline buttons until 24 August 2026. Now they can, via RichMessageButton and RichTextButton, plus RichBlockButtons and its input twin.

Buttons take a style: danger, success, primary, or link, which renders as a plain borderless link and is allowed only on callback buttons. Clients pick theme-appropriate colours per style. Exactly one type field must be set among url, callback_data, web_app, login_url, the three switch_inline_query variants, copy_text, and disabled.

In HTML mode you write them as <tg-button type="callback_data" style="link" data="callback">, and group them with <tg-button-row align="center">. The disabled field points at a new DisabledButton class: the button renders but does nothing, which is the honest way to show a step the user has not unlocked yet instead of removing it from the keyboard and reflowing the layout.

10.3 also added force_reply to InlineKeyboardMarkup and ReplyKeyboardMarkup, and is_compact to tables.

Limits worth knowing before you design

These are hard caps from the rich message limits section, and a generated document can hit several of them without anyone planning to.

Telegram rich message limits: 32768 chars, 500 blocks, 16 nesting levels, 50 media, 20 table columns

The 500-block cap counts nested blocks, list items, ordered list items, table rows, quotation blocks and details blocks. A model asked for "a detailed breakdown" will produce a 40-row table with a nested list per row and quietly cross it. Count blocks before you send, not after the API returns a 400.

Table cells accept inline formatting only, so no lists or media inside a cell. Formula source is raw LaTeX, passed through as-is.

That 400 is worth a word. Telegram tells you almost nothing about which nested field it disliked, and these payloads go deep. Pasting the body into the JSON Formatter to confirm the structure is valid, and that blocks is an array of objects with type set on each one, is faster than re-reading the docs. Half the failures I have seen were a single block missing its type.

Letting the user press stop

10.3 added can_stop and keep_on_stop to both draft methods. Pass can_stop: true and the client shows a stop button. When the user presses it, your bot receives a stopped_message_generation update carrying a MessageGenerationStopped object with chat, an optional message_thread_id, and the draft_id that was stopped.

Match on draft_id to know which generation to cancel, then actually cancel the upstream request. The button stops the user seeing more tokens, not your provider billing you for them.

keep_on_stop: true keeps the partial draft visible after the press, but it is still a draft: it disappears after a short time or as soon as your bot sends anything. To genuinely preserve what was generated before the stop, call sendRichMessage with the partial content, same as you would on a normal completion.

What this costs you

Streaming changes your token economics in a way that is easy to miss. Every draft frame is a Telegram API call, but the expensive half is upstream: you are paying for output tokens the user may never keep, and the draft that showed them is thrown away by design.

Run the numbers before you ship. The API Cost Calculator takes your model, your average output length and your request volume, so you can see what a streaming bot costs per thousand conversations rather than discovering it on the invoice. For reasoning models this matters more, because thinking tokens usually bill as output.

Message rate limits are unchanged: 30 messages per second by default, or up to 1000 with Paid Broadcasts enabled through @BotFather at 0.1 Telegram Stars per message beyond the free allowance, set via allow_paid_broadcast. Only successful sends are charged.

One prerequisite people trip over. sendRichMessage takes a chat_id as an integer or an @username, and for a group or channel that ID is negative with a -100 prefix that breaks naive integer parsing. getUpdates also returns nothing at all while a webhook is set, which is the usual reason a chat ID lookup comes back empty. Our Telegram Chat ID Finder takes a bot token, validates it, names the bot back so you know you pasted the right one, and lists the chat, group and channel IDs it can see, -100 form handled. It runs in your browser.

Note that sendRichMessageDraft accepts only a private-chat integer chat_id. Group streaming is not available, so a group bot streams nothing and posts the finished rich message once.

FAQ

What is the difference between sendRichMessage and sendRichMessageDraft? sendRichMessage creates a real message and returns it. sendRichMessageDraft streams a temporary preview, returns True, expires in about 30 seconds and never persists. You need both for a streaming reply.

Do I still need editMessageText for streaming? No. editMessageText gained a rich_message parameter in 10.1 for editing rich messages, but drafts are the intended path for streaming partial output.

Can I stream to a group? No. sendRichMessageDraft takes a private-chat chat_id only. In a group, send the completed rich message.

Which Bot API version do I need? 10.1 for rich messages and both send methods, 10.2 for the media array and block classes, 10.3 for buttons, expandable quotations, documents in rich messages and the stop-generation update.

How long does a draft last? About 30 seconds, described in the docs as a temporary 30-second preview. Keep calling with the same draft_id while generation continues.

What happens if I never call sendRichMessage? The draft expires and the chat is left with nothing. From the user's side the answer was never sent.

Can a draft include images? No new uploads and no URL-based files inside a draft. Attach media on the final sendRichMessage.

Is Rich Markdown the same as normal Telegram MarkdownV2? No. Rich Markdown is a separate mode for the markdown field of InputRichMessage, closer to GitHub Flavored Markdown, and it supports headings, tables, footnotes and maths that MarkdownV2 has no syntax for.

Conclusion

The protocol now understands what an AI bot does. Streaming has a method, reasoning traces have a block, and structured output has real headings and tables instead of monospace pretending.

The one thing to get right on day one is the two-call shape: drafts for the stream, sendRichMessage for the result. Everything else you can add later.

If you are still on the edit loop, sendRichMessageDraft is worth the swap this week. Buttons, thinking blocks and the stop update can wait until the stream works.

Sources

  • Telegram Bot API referencesendRichMessage, sendRichMessageDraft, InputRichMessage, block classes, rich message limits and formatting options, read 8 September 2026
  • Bot API changelog — Bot API 10.1 (11 June 2026), 10.2 (14 July 2026) and 10.3 (24 August 2026) entries, read 8 September 2026

Tools In This Article

Browser-based, no sign-up. Try them while the topic is fresh.

More From ToolMintX

Other Blog Posts