Протокол v1 — справочник¶
Транспорт — WebSocket, каждое сообщение — один JSON-кадр в UTF-8. Точка входа по умолчанию:
ws://<хост>:8787/ws; наружу — wss://<домен>/ws через reverse-proxy (см. Владельцу агента).
Источник истины — adapter/hermes_mobile/protocol.py (константы и валидация) и
adapter/hermes_mobile/adapter.py (поведение). Клиентские реализации:
sdk/typescript/, sdk/python/hermes_client.py.
Общие правила кадра¶
t— тип кадра, обязателен, регистр не важен (приводится к нижнему).v— версия протокола; сервер объявляет её в кадреready. Текущая —1.id— идентификатор, который задаёт клиент; нужен для идемпотентности и ответов (обрезается до 128 символов).- Неизвестный тип кадра клиент обязан игнорировать, а не рвать соединение. Сервер ведёт себя так же: неизвестный кадр от клиента игнорируется (пишется debug-логи).
- Кадр-объект, не JSON или не UTF-8 → ошибка
bad_frame. Сверху по размеру —too_big.
Ограничения и лимиты¶
| Константа | Значение | Что ограничивает |
|---|---|---|
PROTOCOL_VERSION |
1 |
версия протокола (в ready.v) |
MAX_FRAME_BYTES |
16 МиБ (16777216) |
один кадр WebSocket (голос в base64) |
MAX_TEXT_CHARS |
12000 |
текст сообщения (text, caption) |
MAX_AUDIO_BYTES |
8 МиБ (8388608) |
кадр audio (запись голоса) |
MAX_MEDIA_BYTES |
8 МиБ (8388608) |
один медиафайл в обе стороны |
MEDIA_CHUNK_BYTES |
3 МиБ (3145728) |
размер куска при передаче больших файлов |
MIN_MEDIA_CHUNK_BYTES |
4 КиБ (4096) |
минимальный кусок при сборке медиа — от него считается потолок parts |
MAX_MEDIA_PARTS |
2048 |
потолок parts одного файла (ceil(8 МиБ / 4 КиБ)) |
DEFAULT_CHUNK_LIMIT |
3500 |
сколько символов в одном кадре message (нарезка ответа) |
DEFAULT_PROMPT_TTL |
300 с |
время жизни карточки, если владелец не переопределил |
| незавершённых загрузок | 8 на устройство | сколько многочастных media можно держать открытыми одновременно |
| суммарный буфер устройства | 8 МиБ (8388608) |
сумма всех открытых многочастных загрузок одного устройства |
| hello-таймаут | 15 с | столько сервер ждёт первый кадр hello |
| typing idle | 3 с | пауза без сообщений → аватар возвращается в idle |
| код закрытия | 1008 |
отказ в доступе (WebSocket policy violation) |
Рукопожатие¶
- Клиент подключается к
ws(s)://…/ws. Токен передаётся только полемtokenв кадреhello— приём через?token=в URL удалён: query-строка попадает в логи reverse-proxy и утекает. - Первым кадром клиент обязан отправить
hello— иначе сервер пришлётerrorи закроет соединение. - Сервер отвечает
readyсsession,device,avatar_urlиcapabilities.
{"t":"hello","token":"<токен>","device":{"id":"tel-1","name":"Телефон","platform":"android","app_version":"1.0.0"},"user":{"id":"u-1","name":"Евгений"}}
Токен можно привязать к устройству: элемент MOBILE_EXTRA_TOKENS вида device_id=token
разрешает этому токену подключаться только под указанным device.id. Попытка занять чужой id с
другим токеном отклоняется кодом forbidden и закрытием 1008; то же устройство с тем же
токеном — это переподключение (старое соединение закрывается). Подробнее — в
Безопасности.
{"t":"ready","v":1,"session":"mobile:tel-1","device":{"id":"tel-1","name":"Телефон"},"avatar_url":"https://agent.example.com/avatar/","capabilities":{"text":true,"voice":true,"typing":true,"avatar_states":["idle","thinking","speaking"],"media_in":["image","video","document","audio"],"media_out":["image","voice","video","document","animation"],"prompts":["exec_approval","slash_confirm","clarify"],"edit":true,"max_media_bytes":8388608,"max_text_chars":12000}}
Поля capabilities:
| Поле | Тип | Смысл |
|---|---|---|
text / voice / typing |
bool | базовые возможности (всегда true) |
avatar_states |
список | состояния аватара: idle, thinking, speaking |
media_in |
список | что сервер принимает от приложения: image, video, document, audio |
media_out |
список | что сервер может прислать: image, voice, video, document, animation |
prompts |
список | типы карточек: exec_approval, slash_confirm, clarify |
edit |
bool | сервер умеет править message по id (потоковая выдача) |
max_media_bytes |
number | потолок одного медиафайла (8388608) |
max_text_chars |
number | потолок текста (12000) |
session (mobile:<device.id>) — идентификатор сессии в Hermes и адрес доставки для
hermes send --to mobile:<device.id>.
Клиент → сервер: все кадры¶
hello¶
| Поле | Тип | Обязательно | Смысл |
|---|---|---|---|
t |
string | да | "hello" |
token |
string | да | токен приложения — только в кадре hello; query-параметр ?token= не поддерживается |
device |
object | нет | {id,name,platform,app_version} — все строки, обрезаются до 120 символов |
device.id |
string | нет* | постоянный идентификатор устройства → session; если нет — сгенерируется dev-<hex> |
user |
object | нет | {id,name} — под каким пользователем вас видит Hermes |
* device.id не обязателен формально, но без него меняется адрес сессии и усложняется доступ.
Задавайте всегда. Повторный hello в одном соединении — ошибка bad_frame.
text¶
| Поле | Тип | Обязательно | Смысл |
|---|---|---|---|
t |
string | да | "text" |
id |
string | да | идентификатор сообщения (идемпотентность) |
text |
string | да | текст сообщения, ≤ 12 000 символов, не пустой |
audio¶
| Поле | Тип | Обязательно | Смысл |
|---|---|---|---|
t |
string | да | "audio" |
id |
string | да | идентификатор сообщения |
format |
string | да | "wav" или "ogg" (другие → unsupported_frame) |
data |
string | да | запись в base64, декод ≤ 8 МиБ |
text |
string | нет | распознанный текст (если есть своё STT) — тогда агент получит и текст, и файл |
media (приложение → агент)¶
| Поле | Тип | Обязательно | Смысл |
|---|---|---|---|
t |
string | да | "media" |
id |
string | да | идентификатор файла; один и тот же для всех кусков |
kind |
string | да | image / video / document / audio (иначе unsupported_frame) |
mime |
string | нет | MIME; если пусто — определяется по filename |
filename |
string | нет | имя файла (обрезается до 200 символов), важно для документов |
caption |
string | нет | подпись (≤ 12 000); для audio работает как распознанный текст |
data |
string | да | содержимое куска в base64 |
part |
integer | нет | номер куска, с 0 (по умолчанию 0) |
parts |
integer | нет | всего кусков, ≥ 1 и ≤ 2048 (по умолчанию 1) |
Файл ≤ 8 МиБ — одним кадром (parts: 1); крупнее — кусками по ~3 МиБ (part: 0,1,2…).
Адаптер собирает все куски и только потом отдаёт файл агенту: серию нельзя обрывать.
Ограничения памяти при загрузке файлов¶
Приложение не может выесть память шлюза незавершёнными загрузками:
- не больше 8 незавершённых многочастных загрузок на одно устройство одновременно;
- суммарный размер всех открытых буферов одного устройства — не больше 8 МиБ;
partsодного файла — не больше 2048 (ceil(8 МиБ / 4 КиБ));partsсчитается от минимального куска 4 КиБ, а не от 3 МиБ.
При превышении числа загрузок, суммарного объёма (> 8 МиБ) или parts (> 2048) сервер отвечает
too_big / bad_frame и не держит буфер. Незавершённые буферы устройства освобождаются при его
отключении.
prompt (ответ на карточку)¶
| Поле | Тип | Обязательно | Смысл |
|---|---|---|---|
t |
string | да | "prompt" |
prompt_id |
string | да | id карточки, которую прислал сервер |
option_id |
string | условно | выбранный вариант; если нет — нужен непустой text |
text |
string | условно | «свой ответ» (для clarify) либо вместо option_id |
Хотя бы одно из option_id / text должно быть непустым, иначе bad_frame.
Прочие кадры клиента¶
| Кадр | Поля | Смысл |
|---|---|---|
typing |
t |
«пользователь печатает»; сервер учитывает для аватара |
ping |
t, ts? |
проверка связи; сервер отвечает pong с тем же ts |
ack |
t, id |
приложение показало/проиграло сообщение; сервер игнорирует |
deliver |
t, id, target?, text?, media? |
служебная доставка (cron/hermes send); хотя бы text или media |
deliver.media — объект вложения: {kind, mime?, filename?, data(base64)}. target — адрес
устройства; если пусто — используется MOBILE_HOME_DEVICE.
Кадр deliver — служебный: он принимается только соединением с loopback-адреса (127.0.0.0/8,
::1) или соединением с токеном из MOBILE_DELIVER_TOKENS. Остальным устройство отвечает
error{code:"forbidden"} — так обычный клиент не может разослать сообщение от лица cron.
Сервер → клиент: все кадры¶
ready¶
| Поле | Тип | Обязательно | Смысл |
|---|---|---|---|
t |
string | да | "ready" |
v |
integer | да | версия протокола (1) |
session |
string | да | mobile:<device.id> |
device |
object | да | {id,name} — устройство, как его видит сервер |
avatar_url |
string | да | URL HTML-страницы аватара (может быть пустой) |
capabilities |
object | да | объявленные возможности (таблица выше) |
message (ответ агента)¶
| Поле | Тип | Обязательно | Смысл |
|---|---|---|---|
t |
string | да | "message" |
id |
string | да | идентификатор сообщения агента — один и тот же для всех частей и правок |
text |
string | да | очередная часть текста (≤ 3 500 символов на кадр) |
chunk |
object | да | {index, total} — номер и число частей |
final |
bool | да | true — последняя часть/правка |
edit |
bool | нет | true — правка ранее отправленного сообщения с тем же id (стриминг) |
Длинный ответ режется на несколько message с одним id; последний имеет final: true.
Склеивайте по id — тогда ответ выглядит как одно сообщение.
media (агент → приложение)¶
| Поле | Тип | Обязательно | Смысл |
|---|---|---|---|
t |
string | да | "media" |
id |
string | да | идентификатор файла (собирайте куски по нему) |
kind |
string | да | image / voice / video / document / animation |
mime |
string | да | MIME-тип |
filename |
string | да | имя файла |
caption |
string | нет | подпись |
part |
integer | да | номер куска, с 0 |
parts |
integer | да | всего кусков |
final |
bool | да | true — последний кусок файла |
size |
integer | да | полный размер файла в байтах (не куска) |
data |
string | нет | кусок в base64 (может отсутствовать, если файл отдаётся ссылкой) |
url |
string | нет | ссылка на файл (приложение качает само) |
reply_to |
string | нет | id сообщения, на которое отвечает медиа |
prompt (карточка с кнопками)¶
| Поле | Тип | Обязательно | Смысл |
|---|---|---|---|
t |
string | да | "prompt" |
id |
string | да | prompt_id — верните его в ответе |
kind |
string | да | exec_approval / slash_confirm / clarify |
text |
string | да | заголовок/вопрос |
options |
array | да | кнопки: [{id, label, style}] (style — primary/danger/пусто) |
command |
string | нет | для exec_approval: точная команда, которую одобряют (покажите рядом с кнопками) |
description |
string | нет | для exec_approval: пояснение, что команда делает |
session |
string | да | сессия Hermes, которой адресован ответ |
timeout_seconds |
integer | да | время жизни карточки (по умолчанию 300) |
Для карточки exec_approval сервер шлёт отдельные поля command и description: приложение
показывает точную команду рядом с кнопками, чтобы человек видел, что именно одобряет. Карточку
может закрыть только то устройство, которому она показана — ответ с другого соединения
получает error{code:"forbidden"}.
prompt_done (карточка закрыта)¶
| Поле | Тип | Обязательно | Смысл |
|---|---|---|---|
id |
string | да | id карточки |
option_id |
string | нет | фактически применённый вариант (может отличаться от присланного) |
expired |
bool | нет | true — карточка закрыта по таймауту или была неизвестна |
avatar, typing, pong, error¶
| Кадр | Поля | Смысл |
|---|---|---|
avatar |
state (idle/thinking/speaking) |
состояние аватара |
typing |
stop? |
агент работает; stop: true — гасить индикатор немедленно |
pong |
ts |
ответ на ping (эхо ts) |
error |
code, message, fatal |
ошибка; fatal: true — далее соединение закрывается |
Объявлено, но адаптером не отправляется
Кадр delivered ({t,id,device}) объявлен в protocol.py, но текущий адаптер его не шлёт:
служебная доставка подтверждается только на уровне самого соединения. Опирайтесь на факт
отсутствия error/device_offline. Поле avatar.mood также объявлено, но не отправляется —
читайте только state. Код ошибки auth_required в спецификации есть, но при первом кадре,
отличном от hello, адаптер шлёт bad_frame (см. Диагностику).
Карточки-вопросы¶
Ответ на карточку не становится сообщением в чате: адаптер резолвит его штатными механизмами Hermes (одобрение команды, подтверждение слэш-команды, уточнение).
kind |
Допустимые option_id |
Неизвестное значение → |
|---|---|---|
exec_approval |
once, session, always, deny |
deny |
slash_confirm |
once, always, cancel |
cancel |
clarify |
c0, c1, … (по порядку вариантов), other |
запрос текста (other) |
Для clarify вариант cN — «выбран N-й предложенный вариант» (нумерация с нуля), other —
«пользователь введёт свой ответ»: приложение должно передать text.
Ответ валидируется на сервере. Клиент не может «подделать» вариант: любое значение вне
таблицы заменяется на безопасное, а фактический выбор возвращается в prompt_done.option_id.
Полагаться на клиент нельзя.
Карточка живёт timeout_seconds; по истечении приходит prompt_done с expired: true. Ответ на
неизвестный/просроченный prompt_id не рвёт соединение — приходит prompt_done{expired:true}.
Медиа в обе стороны¶
Один кадр media — один кусок одного файла. Крупные файлы идут несколькими кадрами и собираются
на принимающей стороне.
- вверх:
kind∈image/video/document/audio; ≤ 8 МиБ — одним кадром, крупнее — кусками по ~3 МиБ с постояннымid; серию нельзя обрывать. - вниз:
kind∈image/voice/video/document/animation; собирайтеdataпоidи сохраняйте приfinal: true; ориентируйтесь наmime/filename; если естьurl— качайте по ссылке.
Если входящий файл больше 8 МиБ или превышен размер кадра — сервер отвечает error с кодом
too_big; разбивайте на куски (part/parts) или сжимайте. Помните про лимиты памяти: ≤ 8
незавершённых загрузок на устройство, суммарный буфер ≤ 8 МиБ, parts ≤ 2048 (см. выше).
Жизненный цикл соединения и состояния аватара¶
connect → hello → ready ──► обмен: text/audio/media/prompt ⇄ message/media/prompt/avatar/typing
▲ │
│ ▼
обрыв/сон ── переподключение с паузой ─┘ typing{stop:true} → avatar:idle
Порядок кадров при типичном ходе: avatar:thinking (и typing) → message (1..N частей) →
avatar:speaking → через паузу (typing idle, ~3 с) avatar:idle. Если в конце приходит
typing{stop:true} — индикатор гасится немедленно, не дожидаясь таймаута.
Состояния аватара: thinking (агент обрабатывает), speaking (агент выдаёт ответ/медиа),
idle (покой). Любое неизвестное состояние сервер заменяет на idle.
Идемпотентность по id¶
idв кадрахtext/audio/media— ключ дедупликации. Повтор с тем жеid(например, после обрыва сети) игнорируется: агент не получит сообщение дважды.- Ключ дедупликации — пара
<chat_id>:<id>; окно хранения — около 600 секунд (буфер ~4000 записей). - Дубликат
text/audioотбрасывается молча (безerror); медиа проверяется на дубль по целому файлу, а не по куску. - Держите
idпостоянным для каждого сообщения и повторяйте его при переотправке.
Коды ошибок¶
| Код | Когда приходит | Что делать приложению |
|---|---|---|
bad_frame |
не JSON/не UTF-8/не объект; нет обязательного поля (text, data, prompt_id); пустое сообщение; повторный hello |
исправить кадр; фатальные — переподключиться |
auth_failed |
неверный токен (или устройство вне MOBILE_ALLOWED_DEVICES); шлётся затем закрытие 1008 |
показать «нет доступа», не повторять |
forbidden |
действие запрещено этому соединению: занят чужой device.id (токен привязан к другому устройству), ответ на карточку чужого устройства, либо deliver не от cron (не loopback и нет токена из MOBILE_DELIVER_TOKENS) |
не повторять как есть: проверить привязку токена/своего устройства; deliver шлёт только cron |
unsupported_frame |
неизвестный тип кадра; неподдерживаемый format аудио или kind медиа |
обновить приложение |
too_big |
превышен MAX_FRAME_BYTES / MAX_TEXT_CHARS / MAX_AUDIO_BYTES / MAX_MEDIA_BYTES |
разбить сообщение, слать файл кусками или сжать |
device_offline |
доставка (deliver) на устройство, которого нет в сети |
повторить позже |
auth_required |
объявлен в спецификации; текущий адаптер вместо него шлёт bad_frame при первом кадре, отличном от hello |
отправить hello первым |
internal_error |
внутренняя ошибка адаптера (объявлен; используется редко) | показать общий текст, переподключиться |
Фатальные ошибки (неверный токен, занятый чужой device.id, превышение лимита кадра, hello-таймаут)
сопровождаются закрытием соединения кодом 1008 (в reason — код ошибки).
Версионирование и обратная совместимость¶
- Новые необязательные поля можно добавлять в любой момент — старые клиенты их игнорируют.
- Новые типы кадров добавляются так же: клиент обязан игнорировать неизвестный
t. - Несовместимые изменения — только сменой
vвreadyи в документации. - Все описанные здесь возможности (карточки-вопросы, медиа, правки
message,typing.stop) — обратно совместимые дополнения, поэтому версия остаётся1.