Как показать работу AI-агента в Telegram: статусы инструментов, анимации и кнопка Stop

Пользователь просит Telegram-бота сравнить две модели наушников для рабочих звонков. Агент ищет обзоры, читает страницы, проверяет, что пишут о микрофонах. Всё это занимает время, а по надписи «печатает…» трудно понять, на каком этапе он сейчас находится.
В Telegram есть встроенный интерфейс для такой работы: временное сообщение с блоком состояния, анимированными иконками и кнопкой Stop. На примере своего агента swift-claw покажу, как связать его с вызовами инструментов и отменой задачи.

Полный проход: поиск, чтение источников и ответ со ссылками.
На Хабре уже разбирали стриминг через sendMessageDraft и формат Rich Messages. Здесь соберём из возможностей Bot API интерфейс агента, который выполняет несколько действий перед ответом. Примеры используют HTTP и прикладной псевдокод, поэтому их можно перенести в свой бот без привязки к языку или фреймворку.
Показываем состояние во временном черновике
Запрос для демонстрации выглядит так:
Сравни Sony WH-1000XM5 и Bose QuietComfort Ultra Headphones для рабочих звонков. Найди информацию в интернете и прочитай по одному источнику для каждой модели. Ответь по-английски, не больше 150 слов, с кратким сравнением и ссылками.
Пока агент выполняет запрос, будем показывать, что он делает. Для этого у Telegram есть thinking block: отдельная область внутри временного черновика сообщения. Её содержимое задаёт бот. Можно вывести «Ищу тесты микрофона» или «Читаю обзор Sony», опираясь на события инструментов. Для таких статусов не нужны внутренние рассуждения модели.
Создадим первый черновик через sendRichMessageDraft. Этот метод работает в личном чате с ботом. Нам понадобятся токен бота и ID чата; в примере 123456789 нужно заменить на свой. В rich_message.html передадим тег <tg-thinking> с первым статусом:
POST https://api.telegram.org/bot<TOKEN>/sendRichMessageDraft
Content-Type: application/json
{
"chat_id": 123456789,
"draft_id": 42,
"can_stop": true,
"rich_message": {
"html": "<tg-thinking>Ищу тесты микрофонов в наушниках…</tg-thinking>"
}
}
После запроса в чате появится блок с нашим текстом. Параметр can_stop включает встроенную кнопку Stop; обработку нажатия добавим ниже.
Теперь повторим запрос с тем же draft_id, заменив текст на «Читаю обзор Sony WH-1000XM5…». Telegram обновит существующий черновик. Число 42 здесь условное: для нового запроса пользователя выбираем новый ненулевой ID, а для всех обновлений одного запуска сохраняем прежний. Этот ID храним вместе с задачей: он понадобится для обработки Stop.
Без обновлений черновик исчезнет через 30 секунд. Если инструмент выполняется долго, нужно периодически обновлять его, даже когда подпись не меняется. Поэтому отправку обновлений стоит связать и с событиями инструментов, и с таймером на время активного запуска. Иначе блок исчезнет, пока агент ещё работает.
Связываем статусы с вызовами инструментов
Для сравнения наушников агент выполнит несколько вызовов: найдёт источники для каждой модели, затем прочитает выбранные страницы. Одна общая подпись быстро перестанет объяснять, что происходит. Вместо неё покажем отдельные шаги и будем обновлять их по мере выполнения.

Первый поиск уже завершился, второй ещё выполняется.
Нужны события начала и завершения вызова инструмента. При старте добавляем шаг с понятной подписью, при завершении находим этот шаг по ID вызова и записываем результат. Это позволяет различать два одновременных обращения к одному инструменту.
Ниже псевдокод приложения. Названия событий и функций условные, к Telegram SDK они не относятся:
при начале вызова инструмента:
добавить шаг по ID вызова:
подпись, цель, время начала, состояние «выполняется»
запланировать обновление черновика
при завершении вызова инструмента:
найти шаг по ID вызова
записать результат и время выполнения
запланировать обновление черновика
Из текущего списка шагов собираем содержимое <tg-thinking> и отправляем его с прежним draft_id. Названия, длительность и результат каждого шага формирует приложение; Telegram отображает переданное содержимое.
Подпись «Ищу тесты микрофона Bose» объясняет действие без JSON с аргументами инструмента. После завершения можно показать, что поиск выполнен, а если он упал, указать ошибку. Если не обновить неудачный шаг, он так и останется в состоянии «выполняется», даже когда агент уже перешёл к другому источнику. Сами выводы об устройстве и ссылки оставим для ответа: в блоке состояния достаточно информации о ходе работы.
Несколько событий могут прийти почти одновременно. Такие изменения лучше объединять в одно обновление с актуальным состоянием всех шагов. И весь динамический текст нужно экранировать перед вставкой в HTML: поисковые запросы, заголовки страниц и описания инструментов могут содержать символы разметки.
Сохраняем готовый ответ
Черновик подходит для промежуточного состояния. Чтобы готовое сравнение осталось в истории чата, отправим его отдельным сообщением через sendRichMessage. Перед этим остановим таймер и отправку обновлений черновика.
В rich_message.html передаём итоговый текст без <tg-thinking>: этот блок разрешён только в черновиках. В примере ниже вместо короткой заглушки нужно подставить ответ агента, экранировав динамический текст:
POST https://api.telegram.org/bot<TOKEN>/sendRichMessage
Content-Type: application/json
{
"chat_id": 123456789,
"rich_message": {
"html": "<p>Здесь готовое сравнение наушников со ссылками на источники.</p>"
}
}
Текст ответа можно показывать в черновике по мере генерации, рядом с блоком состояния. Финальная отправка всё равно нужна: она сохраняет сообщение. Этот порядок описан в руководстве Telegram по потоковым ответам.

В истории остаётся ответ со ссылками на источники.
После первого полного прохода стоит выйти из чата и открыть его снова: сравнение должно остаться на месте.
Добавляем анимированные иконки
Уже на этом этапе текстовые статусы показывают ход работы. К ним можно добавить анимацию: Telegram рекомендует набор custom emoji AIActions для действий вроде поиска и обдумывания. Thinking block поддерживает такие эмодзи.

Иконка меняется вместе с действием. Текстовая подпись остаётся рядом.
Получим набор методом getStickerSet, передав name: "AIActions". У выбранного стикера возьмём custom_emoji_id и поле emoji для запасного отображения. Затем добавим тег <tg-emoji> внутрь блока:
<tg-thinking>
<tg-emoji emoji-id="CUSTOM_EMOJI_ID">🔎</tg-emoji> Ищу обзоры…
</tg-thinking>
CUSTOM_EMOJI_ID и 🔎 нужно заменить значениями из выбранного стикера, а полученную разметку передать в rich_message.html. Если бот не может отправлять custom emoji, оставляем текстовые статусы. Обработка событий инструментов от этого не меняется.
Подпись рядом с иконкой полезна и при включённой анимации: по ней понятно, какое действие выполняется и чего ждать дальше.
Подключаем Stop к отмене задачи
Для проверки остановки возьмём запрос подлиннее: попросим агента изучить пять моделей беспроводных наушников и нажмём Stop во время работы.

После нажатия черновик исчезает, бот отвечает «Stopped». Это подтверждение отправляет приложение.
В первом запросе мы уже передали can_stop: true. По нажатию Telegram присылает update с полем stopped_message_generation. Если бот использует явный список allowed_updates, в него нужно добавить этот тип, сохранив остальные нужные события.
В событии остановки есть объект chat, необязательный message_thread_id и draft_id. По сочетанию chat.id, темы и ID черновика находим запущенную задачу. Затем запрещаем дальнейшие обновления её черновика и запрашиваем отмену агента и выполняющихся инструментов:
при stopped_message_generation:
найти запуск по chat.id, message_thread_id и draft_id
если такого активного запуска нет, выйти
закрыть отправку обновлений его черновика
остановить таймер обновления и отбросить накопленные изменения
запросить отмену агента и его инструментов
Здесь есть две гонки, которые легко пропустить.
Первая связана с поздним событием Stop. Пользователь остановил старую задачу и уже отправил новый запрос, а update пришёл позже. Если искать задачу только по chat_id, можно отменить новый запуск. Привязка к draft_id позволяет отличить их друг от друга.
Вторая возникает, когда результат инструмента приходит после запроса отмены. Например, чтение страницы уже началось, и сетевой ответ пришёл, когда пользователь нажал Stop. Если обработчик этого результата отправит очередное обновление, черновик появится снова. Поэтому сначала закрываем отправку обновлений для конкретного запуска, а уже затем запрашиваем отмену. Закрытое состояние должны учитывать и обработчики событий, и отложенные отправки, и таймер.
Перед отправкой финального ответа тоже нужно проверить состояние запуска. Отменённая задача не должна прислать запоздавший ответ, когда пользователь уже работает над новым запросом.
Telegram предоставляет кнопку и событие, а отмену работы реализует бот. Некоторым инструментам потребуется время, чтобы остановиться. Если инструмент не поддерживает отмену, он может завершить начатую операцию. Уже выполненное действие отмена не откатит. Отдельно этот момент разбирали на Хабре в посте о Stop.
Проверка здесь такая: прерываем работу, сразу отправляем новый запрос и наблюдаем за ним. После обработки Stop старый запуск не должен создавать новые отправки или публиковать финальный ответ.
Проверяем, можно ли отправить уточнение
Во время активного черновика стоит проверить ещё и поле ввода. Если клиент не даёт отправить сообщение, пользователь не сможет ни уточнить запрос, ни написать /stop.
В OpenClaw issue #86195 описан такой случай: Telegram Android 12.7.3 заменял кнопку отправки индикатором загрузки при работе через sendMessageDraft. Автор сообщения не мог отправить уточнение или /stop, пока черновик оставался активным.
Рабочий пример
Все записи сделаны со swift-claw. В изменении с поддержкой Stop можно посмотреть, как устроены привязка события к запуску, закрытие обновлений и отмена задачи. Этот же порядок подходит для агента на другом языке: события инструментов обновляют состояние, Telegram его показывает, а приложение управляет завершением и остановкой работы.
Статья адаптирована из моего английского материала. Там доступны видеозаписи с управлением воспроизведением, если удобнее рассмотреть отдельные состояния интерфейса.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.