Интеграция в своё приложение¶
Три шага: получить адрес и токен от владельца агента → подключиться по WebSocket → отправлять и принимать кадры. Никакого SDK-обязательства нет: протокол — это JSON-кадры, поэтому подойдёт любая платформа с WebSocket.
1. Что нужно от владельца агента¶
| Что | Откуда | Пример |
|---|---|---|
| Адрес адаптера | MOBILE_HOST/MOBILE_PORT/MOBILE_PATH на машине агента, либо домен через reverse-proxy |
wss://agent.example.com/ws |
| Токен приложения | MOBILE_TOKEN в ~/.hermes/.env |
64 hex-символа |
| Разрешение устройству | hermes pairing approve mobile <id> после первого контакта либо заранее MOBILE_ALLOWED_USERS=<id пользователя>; отдельный фильтр устройств — MOBILE_ALLOWED_DEVICES |
hermes pairing approve mobile 84b9042da9357a03 |
Идентификатор устройства (device.id) выбирает приложение и держит его постоянным: от него
зависит идентификатор сессии в Hermes (mobile:<device.id>), адрес для cron-доставки и
записи в allowlist. Один пользователь может иметь несколько устройств — это разные сессии.
2. Подключение и жизненный цикл¶
connect() → отправляем hello{token, device} → ждём ready{session, avatar_url, capabilities}
↓
текст/голос/медиа вверх (text/audio/media) ⟷ ответы вниз (message, prompt, media, typing, avatar, error)
↓
обрыв сети → переподключение с паузой → снова hello (история и сессия сохраняются на стороне Hermes)
Правила, которые важно соблюсти:
- Первый кадр — только
hello. Иначе адаптер пришлётerrorи закроет соединение. - Код закрытия 1008 — отказ в доступе (неверный токен или устройство вне списка). Повторять бессмысленно: покажите пользователю «нет доступа» и не долбите сервер.
idсообщения ставьте постоянный для каждого сообщения и повторяйте его при переотправке после обрыва: адаптер игнорирует повторы (защита от двойных сообщений).- Неизвестные кадры игнорируйте — так протокол расширяется без поломок.
- Разрывы нормальны (мобильная сеть, сон устройства). Переподключайтесь с экспоненциальной паузой (в TS-SDK это встроено).
3. Ответы агента и «печатает»¶
Длинный ответ приходит несколькими кадрами message с одним и тем же id и полем
chunk: {index, total}; последний кадр имеет final: true. Склеивайте по id — так
ответ выглядит как одно сообщение. Пока final не пришёл, показывайте индикатор.
Кадры avatar (thinking / speaking / idle) и typing — сигналы для интерфейса:
анимация аватара, «агент печатает», отключение кнопки отправки. Кадр typing с stop: true
означает «выключить индикатор немедленно» (агент закончил) — обработайте его так же, как
финал ответа.
4. Кнопки вместо текста (карточки-вопросы)¶
Иногда агент не отвечает текстом, а просит выбор: одобрить опасную команду, подтвердить
слэш-команду или уточнить запрос. Приходит кадр prompt с готовыми кнопками:
{"t":"prompt","id":"5f6a…","kind":"clarify","text":"Какой формат нужен?","options":[{"id":"c0","label":"CSV","style":""},{"id":"c1","label":"JSON","style":""},{"id":"other","label":"✏️ Свой ответ","style":""}],"session":"mobile:tel-1","timeout_seconds":300}
Что делает приложение:
- Показывает
textи кнопки изoptions(label— подпись,style— подсказка оформления:primary/danger/пусто). Дляexec_approvalсервер шлёт ещё поляcommand(точная команда) иdescription— покажите команду рядом с кнопками. - На нажатие отправляет один кадр
promptсidкарточки иoption_id:
- Ждёт
prompt_done. Если он пришёл сexpired: true— карточка истекла (по умолчанию через 300 с), просто закройте её.
Особенности:
- Ответ на карточку не становится сообщением в чате — адаптер резолвит его штатными
механизмами Hermes (одобрение / подтверждение / уточнение). Поэтому не дублируйте выбор
отдельным
text. - Для
clarifyвариантotherозначает «пользователь введёт свой ответ»: отправьте кадр сoption_id: "other"и полемtext, либо сразу{"t":"prompt","prompt_id":"…","text":"…"}безoption_id. - Не подставляйте свой
option_idнаугад: сервер сам заменит недопустимое значение на безопасное (denyдля одобрения,cancelдля подтверждения) и вернёт фактический выбор вprompt_done.option_id. - Если карточка уже истекла/неизвестна, ответ не сломает соединение: придёт
prompt_done{expired:true}. - Карточку может закрыть только то устройство, которому она показана: ответ с другого
соединения получает
error{code:"forbidden"}.
5. Медиа¶
Отправка медиа агенту¶
Один файл — один или несколько кадров media. Для файла ≤ 8 МБ хватит одного кадра
(parts: 1); крупнее — шлите кусками по ~3 МБ (part: 0,1,2…), адаптер соберёт их и
отдаст агенту целый файл.
{"t":"media","id":"m1","kind":"image","mime":"image/jpeg","filename":"photo.jpg","caption":"Смотри","data":"<base64>","part":0,"parts":1}
// Kotlin: отправка фото одним кадром
val bytes = File(path).readBytes()
ws.send(JSONObject(mapOf(
"t" to "media", "id" to mid, "kind" to "image",
"mime" to "image/jpeg", "filename" to "photo.jpg",
"caption" to "Смотри", "data" to Base64.encodeToString(bytes, Base64.NO_WRAP),
"part" to 0, "parts" to 1
)).toString())
// Dart: отправка кусками
final bytes = await File(path).readAsBytes();
const chunk = 3 * 1024 * 1024;
final parts = (bytes.length / chunk).ceil();
for (var i = 0; i < parts; i++) {
final slice = bytes.sublist(i * chunk, min((i + 1) * chunk, bytes.length));
channel.sink.add(jsonEncode({'t': 'media', 'id': mid, 'kind': 'document',
'mime': 'application/pdf', 'filename': 'report.pdf',
'data': base64Encode(slice), 'part': i, 'parts': parts}));
}
Правила: kind — image / video / document / audio; досылайте все part до
parts-1 (серия без последнего куска не собирается и агенту не уходит); id держите
постоянным для всех кусков одного файла. Если в приведённом выше примере parts для
документа больше 1, финальный кусок имеет part == parts - 1.
Приём медиа от агента¶
Агент может прислать картинку, голос, видео, файл или анимацию — всё это кадры media:
{"t":"media","id":"a1","kind":"image","mime":"image/png","filename":"chart.png","caption":"График","part":0,"parts":1,"final":true,"size":20480,"data":"<base64>"}
// Swift: приём и сохранение файла на диск
func onMedia(_ f: [String: Any]) {
guard let id = f["id"] as? String, let data = f["data"] as? String,
let chunk = Data(base64Encoded: data) else { return }
var buffer = self.mediaBuffers[id] ?? Data()
buffer.append(chunk); self.mediaBuffers[id] = buffer
if f["final"] as? Bool == true {
let name = (f["filename"] as? String) ?? "file"
try? buffer.write(to: docDir.appendingPathComponent(name))
self.mediaBuffers[id] = nil; self.showMedia(name, f["caption"] as? String)
}
}
Правила: собирайте куски по id; файл готов, когда пришёл кадр с final: true; если у кадра
есть url, файла в data может не быть — скачайте по ссылке; ориентируйтесь на mime и
filename при сохранении. Лимит одного файла — 8 МБ (max_media_bytes из capabilities).
6. Потоковая выдача ответа¶
Если на стороне агента включён стриминг, ответ сначала приходит целиком, а потом правится
по мере генерации. Признак правки — поле edit: true в кадре message с тем же id:
{"t":"message","id":"r1","text":"Считаю","edit":true,"chunk":{"index":0,"total":1},"final":false}
{"t":"message","id":"r1","text":"Считаю ответ…","edit":true,"chunk":{"index":0,"total":1},"final":true}
Приложение хранит сообщения по id: при edit: true оно перерисовывает существующее
сообщение, а не добавляет новое. final: true в правке — конец. Обрабатывать edit
необязательно: если его игнорировать, кадры покажутся как новые сообщения (будут дубли) —
поэтому для аккуратного вида лучше перерисовывать по id.
Стриминг и индикатор «печатает» независимы: пока генерация идёт, аватар в thinking;
после последней правки приходит typing{stop:true} и через паузу avatar:idle.
7. Как не сломаться на неизвестных кадрах¶
Протокол расширяется только за счёт необязательных полей и новых типов кадров, поэтому:
- Разбирайте кадр по полю
t; неизвестныйt— молча игнорируйте (не рвите соединение). - Не считайте поля обязательными, если они помечены как необязательные (
edit,stop,url,reply_to,captionи т.п.) — проверяйте их наличие. - Не падайте на незнакомом
kindкарточки или медиа: покажите общий вид и ждитеprompt_done/следующий кадр. capabilitiesвready— источник истины: если поля нет или оноfalse, соответствующую возможность просто не используйте.- Ошибку
unsupported_frameпоказывает сервер, когда он не понял ваш кадр; для своих новых кадров это сигнал обновиться.
8. Голос¶
- Запишите WAV: 16 кГц, моно, PCM 16 бит (так делает
react-native-live-audio-stream; на iOS/Android то же самое даётAVAudioRecorder/MediaRecorder). - Отправьте
audioсformat: "wav"иdata: base64. - Если у вас есть собственное распознавание — положите текст в
text: агент получит и текст, и аудиофайл. Если текста нет, Hermes распознает речь сам (при настроенном STT на машине агента).
Ограничения: аудио ≤ 8 МБ (примерно несколько минут речи), кадр целиком ≤ 16 МБ. Для длинных
записей отправляйте несколько сообщений. Голосовое от агента приходит кадром media с
kind: "voice" — сохраните и проиграйте его.
Озвучка ответа — на стороне приложения (TTS устройства) или аватара: SDK отдаёт текст, дальше решаете вы.
9. Аватар¶
Аватар — обычная HTML-страница в WebView, которая реализует контракт:
window.hermesAvatar = {
setState: (state) => {}, // 'idle' | 'thinking' | 'speaking'
speak: (text) => {}, // просьба озвучить/показать реплику агента
setVolume:(level) => {}, // уровень микрофона при записи, 0..1
};
URL страницы приходит в кадре ready (avatar_url) — задаётся переменной
MOBILE_AVATAR_URL на машине агента. Если URL не задан, используйте свою страницу
(в examples/react-native/avatarHtml.ts есть минимальная с тем же контрактом).
Вызовы из приложения:
10. Скетчи для разных платформ¶
Общая часть везде одна: WebSocket + JSON. Ниже — минимальный «скелет» подключения, а затем
обработка новых кадров (prompt, media, edit, typing.stop).
Kotlin (Android, OkHttp)
val ws = OkHttpClient().newWebSocket(
Request.Builder().url(wsUrl).build(),
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket?, response: Response?) {
webSocket?.send("""{"t":"hello","token":"$token","device":{"id":"android-1","name":"Телефон","platform":"android"}}""")
}
override fun onMessage(webSocket: WebSocket?, text: String) {
val f = JSONObject(text)
when (f.getString("t")) {
"ready" -> session = f.getString("session")
"message" -> if (f.optBoolean("edit")) editMessage(f.getString("id"), f.getString("text"))
else append(f.getString("id"), f.getString("text"), f.getBoolean("final"))
"avatar" -> avatarState = f.getString("state")
}
}
override fun onClosing(code: Int, reason: String?) { if (code == 1008) showNoAccess() }
}
)
Новые кадры в том же onMessage (Kotlin):
"prompt" -> showButtons(f.getString("id"), f.getJSONArray("options"))
"media" -> saveMedia(f.getString("id"), f.getString("kind"), f.optString("filename"),
f.optString("data"), f.getBoolean("final"))
"typing" -> if (f.optBoolean("stop")) hideTyping() else showTyping()
// ответ на карточку: ws.send("""{"t":"prompt","prompt_id":"$pid","option_id":"$opt"}""")
Swift (iOS, URLSessionWebSocketTask)
let task = URLSession.shared.webSocketTask(with: URL(string: wsUrl)!)
task.resume()
task.send(.string(#"{"t":"hello","token":"\#(token)","device":{"id":"ios-1","name":"iPhone","platform":"ios"}}"#))
// далее task.receive { ... } в цикле, разбор JSON по полю "t"
Обработка новых кадров (Swift):
func onFrame(_ raw: String) {
guard let f = try? JSONSerialization.jsonObject(with: Data(raw.utf8)) as? [String: Any],
let t = f["t"] as? String else { return }
switch t {
case "message": (f["edit"] as? Bool) == true
? editMessage(f["id"] as? String, text: f["text"] as? String)
: append(f["id"] as? String, text: f["text"] as? String, final: f["final"] as? Bool ?? false)
case "prompt": showButtons(id: f["id"] as? String, options: f["options"] as? [[String: Any]] ?? [])
case "media": onMedia(f) // собрать по id, сохранить на final (см. раздел «Медиа»)
case "typing": (f["stop"] as? Bool) == true ? hideTyping() : showTyping()
default: break
}
}
// ответ на карточку:
// task.send(.string(#"{"t":"prompt","prompt_id":"\#(pid)","option_id":"\#(opt)"}"#))
Dart / Flutter (web_socket_channel)
final channel = WebSocketChannel.connect(Uri.parse(wsUrl));
channel.sink.add(jsonEncode({'t': 'hello', 'token': token, 'device': {'id': 'flutter-1', 'name': 'Телефон'}}));
channel.stream.listen((raw) {
final f = jsonDecode(raw as String) as Map<String, dynamic>;
switch (f['t']) {
case 'message':
f['edit'] == true ? editMessage(f['id'], f['text']) : addMessage(f['id'], f['text'], f['final']);
case 'prompt':
showButtons(f['id'], (f['options'] as List).cast<Map>());
case 'typing':
f['stop'] == true ? hideTyping() : showTyping();
}
});
// ответ на карточку: channel.sink.add(jsonEncode({'t': 'prompt', 'prompt_id': pid, 'option_id': opt}));
// медиа от агента: собрать data по id, сохранить при final == true (см. раздел «Медиа»)
11. Диагностика¶
| Симптом | Причина |
|---|---|
auth_failed / закрытие 1008 |
токен не совпадает с MOBILE_TOKEN, либо device.id отсутствует в MOBILE_ALLOWED_DEVICES, либо пользователь ещё не одобрен Hermes (MOBILE_ALLOWED_USERS / pairing / MOBILE_ALLOW_ALL_USERS=1) |
ready приходит, ответов нет |
устройство ещё не одобрено: hermes pairing list → approve; на первом контакте агент отвечает pairing-кодом |
| Ответ «No home channel is set» | штатное напоминание Hermes: отправьте /sethome, чтобы этот чат стал домашним (туда уходят cron-сообщения) |
device_offline в cron-доставке |
приложение не подключено: мобильная доставка требует живого соединения |
too_big при медиа |
файл больше 8 МБ или кадр больше 16 МБ — шлите кусками (part/parts) или сожмите |
too_big / bad_frame при серии кусков |
больше 8 незавершённых загрузок на устройство, суммарный буфер устройства > 8 МБ или parts > 2048: дождитесь завершения загрузок, не режьте мельче 4 КиБ на кусок (буферы освобождаются при отключении) |
forbidden |
чужой device.id (токен привязан к другому устройству), ответ на карточку чужого устройства или deliver не от cron: подключайтесь под своим device.id, deliver шлёт cron (loopback) или токен из MOBILE_DELIVER_TOKENS |
| ответ на карточку «проглочен» | карточка истекла (prompt_done{expired:true}) или prompt_id не тот |
| «кадр не пришёл» при подключении извне | порт адаптера не открыт наружу или не проксируется через Caddy — см. ../../host/install.md |
Проверить руками: wscat -c "ws://127.0.0.1:8787/ws", затем первым кадром отправить
{"t":"hello","token":"<токен>","device":{"id":"cli"}} (токен в URL ?token= не принимается).
12. Безопасность¶
- Токен — единственный секрет приложения: храните его в Keychain/Keystore, не в открытом коде и не в логи.
- Наружу — только
wss://(TLS на reverse-proxy).ws://допустим в локальной сети или черезadb reverse tcp:8787 tcp:8787при разработке. - Одно приложение — один токен (
MOBILE_EXTRA_TOKENSпозволяет их различить). Отзыв токена = убрать его из.envи перезапустить шлюз. - Ответ на карточку-вопрос валидируется на сервере: недопустимый
option_idпревращается в безопасное значение (deny/cancel), «подделать» одобрение командой клиента нельзя. - Полезная нагрузка (текст, медиа) — часть диалога с агентом: не отправляйте через протокол данные, которые агент не должен видеть.