Мобильное приложение без бэкенда: уведомления и подтверждения Claude Code через SSH и файлы

Wendo — мобильное приложение без бэкенда: между твоим компьютером и телефоном нет нашего сервера. Когда Claude Code просит разрешения или заканчивает задачу, shell-скрипт на твоей машине записывает событие в файл, а приложение читает его по SSH тем же ключом, которым открывает терминал. Ответ на запрос подтверждения возвращается тем же путём, ещё одним файлом.
Если тебе нужно это настроить, всё есть в гайде об уведомлениях Claude Code на телефоне.
Зачем мобильное приложение без бэкенда
Wendo — SSH-клиент: чтобы он работал, твой телефон и так должен достучаться до компьютера и пройти на нём аутентификацию. Раз этот канал открыт и зашифрован, сервер посередине ради уведомлений был бы лишней деталью.
Без него получаются три вещи, которых мы и добивались:
- То, что делает твой агент (команда, которую он хочет выполнить, файл, который тронет, последнее сообщение), не проходит ни через одну нашу машину. Оно остаётся на твоём компьютере, пока телефон не запросит.
- Аккаунтов нет. Нет почты, которую надо хранить, и пароля, который может утечь. Личность — это твой SSH-ключ.
- Нет инфраструктуры, за которую надо платить и которую надо поддерживать с каждым новым пользователем.
Взамен всё, что делал бы сервер (хранить историю, уведомлять при закрытом приложении, связывать устройства, проверять покупки), приходится решать тем, что уже есть на обоих концах.
Контракт: одна строка JSON на событие
В центре всего — ~/.wendo/events.jsonl. Установщик регистрирует wendo-hook.sh на шесть событий Claude Code (PermissionRequest, Notification, Stop, UserPromptSubmit, SessionStart и SessionEnd), и при каждом срабатывании хук читает JSON, который Claude Code передаёт ему на стандартный ввод, и дописывает строку в файл. Строка запроса разрешения выглядит примерно так (сокращённо):
{"id":"1793530000-4242-12345","time":1793530000,"host":"portatil","agent":"claude",
"event":"PermissionRequest","category":"approval_required","kind":"permission",
"title":"Claude Code — wendo","message":"Bash: flutter test","sessionId":"…",
"toolName":"Bash","pendingActionId":"p1793530000-4242-6789",
"expiresAt":1793530090000,"options":["Approve","Deny"],
"mux":{"kind":"tmux","session":"dev","window":"1","pane":"%3","socket":"/tmp/tmux-501/default"}}
Файл только растёт в конец. Строки никто не редактирует: решённый запрос записывается новой строкой типа resolution. Это сильно упрощает конкурентность: несколько хуков могут писать одновременно, а короткие строки дописываются атомарно. Когда в файле больше 1000 строк, хук оставляет последние 500 (с flock, если он есть, чтобы ротацию делал только один за раз). Папка создаётся с umask 077, у файла права 600: читать его может только твой пользователь.
Поле mux говорит, в какой панели tmux, zellij или herdr работает агент, чтобы приложение могло перенести тебя туда.
Как читает приложение: опрос по SSH
Приложение ничего не получает, оно спрашивает. При каждом чтении оно открывает SSH-соединение с каждым хостом, у которого включены входящие, и без терминала выполняет что-то настолько простое:
tail -n 1000 ~/.wendo/events.jsonl 2>/dev/null; true
Частота зависит от состояния входящих. Пока какая-то строка ждёт ответа, приложение перечитывает каждые 5 секунд, потому что хук быстро истекает. Когда агенты работают, каждые 15. Если ничего живого нет, читает только при открытии вкладки. После твоего ответа перечитывает через 1,2 секунды и ещё раз через 3, чтобы увидеть результат.
Получив строки, inbox.dart сортирует их по времени и сворачивает в записи: одна на хост и сессию. Здесь живёт почти вся логика, которая в другом приложении была бы на сервере. Например, он решает, что на запрос ответили в терминале, если после него пришёл Stop или UserPromptSubmit без resolution между ними, или что SessionStart после сжатия контекста ничего не меняет.
Раз файл только дописывается, удаление в приложении означает запомнить, что удалено. Wendo хранит «надгробия» на самом телефоне, для записи или для хоста. Если агент снова пишет в эту сессию, запись появляется вновь. Если хост не отвечает, приложение сохраняет то, что прочитало с него в последний раз, а не очищает список.
При закрытом приложении на Android
На Android есть задача WorkManager, которая просыпается не чаще раза в 15 минут (система не даёт просить меньше и может откладывать её ради батареи), читает входящие так же, как открытое приложение, и показывает локальные уведомления о новом. Это чтение никогда не запрашивает биометрию: если SSH-ключ защищён и заблокирован, этот хост просто не читается. То, о чём уже уведомили, сохраняется в хранилище телефона, чтобы открытое приложение и фоновая задача не повторяли одно и то же уведомление. На iOS такой задачи нет.
Подтверждение туда и обратно
PermissionRequest — единственное событие, на котором хук ждёт. Весь путь:
- Хук генерирует
pendingActionId(буквы, цифры,_и-; приложение проверяет его так же, прежде чем подставить в путь), вычисляетexpiresAtи пишет строку сoptions: ["Approve","Deny"]. - Приложение видит запись с
pendingActionId, без resolution и не истёкшую, и показывает кнопки. - При нажатии приложение пишет
{"decision":"approve"}в~/.wendo/answers/<id>.tmpи переименовывает его в~/.wendo/answers/<id>. Переименование не даёт хуку прочитать недописанный ответ. - Хук проверяет этот файл каждые полсекунды. Найдя его, печатает решение в формате, который ждёт Claude Code, и дописывает свою строку resolution:
{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}
Тонкое место — что происходит, если ты не отвечаешь. Ожидание длится 90 секунд по умолчанию (WENDO_APPROVAL_TIMEOUT). Когда время выходит, хук ничего не печатает, и Claude Code, не получив ответа от хука, задаёт обычный вопрос в терминале. Хук записывает resolution timeout и завершается. Если ты ответишь раньше в терминале, Claude Code отменит хук, а trap запишет resolution как local.
Правило было такое: сбой телефона никогда не должен блокировать агента. Сел телефон, нет связи или приложение закрыто — худший случай тот же, что всегда: вопрос в терминале.
Остаётся дыра: ответ с телефона ровно в тот момент, когда хук уже сдался. Чтобы её закрыть, после записи ответа та же SSH-команда до 3 секунд ждёт строку resolution с этим id. Если resolution — timeout или файл никто не забрал, она его удаляет, и приложение пишет «Истекло — хост перейдёт в локальный режим», а не считает ответ отправленным.
Push-уведомления без своего сервера: ntfy, и то по желанию
Чтобы уведомить при закрытом приложении на iOS или раньше, чем через 15 минут на Android, кто-то должен протолкнуть уведомление, а для этого нужен push-сервис. Мы выбрали ntfy: публикация делается одним HTTP-запросом, и его можно поднять у себя.
Если задать NTFY_TOPIC, хук публикует уведомления о запросах разрешения, об оповещениях и о завершённых задачах (не о начале и конце сессии и не о каждом промпте). В уведомлении есть ссылка wendo://attach?event=…&host=…&sid=… с панелью мультиплексора, чтобы по нажатию приложение открыло нужное место.
Приложение читает и топик (/json?poll=1&since=24h) как ещё один источник входящих, но главным остаётся прочитанное по SSH. ntfy знает только, что ты был кому-то нужен, но не знает, решено ли это, поэтому если SSH-чтение хоста не удалось, его запросы разрешения из ntfy не учитываются. И вот цена для приватности: на публичном сервере топик служит паролем, а уведомление содержит проект и фрагмент команды. Поэтому топик должен быть длинным и случайным, и поэтому есть NTFY_URL для собственного сервера.
Как связать телефон с компьютером по QR без сервера
Как без аккаунтов добавить компьютер на телефон? QR-кодом, который генерирует wendo-pair.sh и в котором лежит URL:
wendo://pair?h=<host>&p=<puerto>&u=<usuario>&n=<nombre>&hk=<huella>
hk — SHA256-отпечаток ключа хоста ed25519, полученный через ssh-keyscan от sshd на этом порту. Приложение проверяет его строгим шаблоном (SHA256: и 43 символа base64) и закрепляет за хостом. Первое подключение проверяется, а не принимается вслепую, и если однажды сервер покажет другой ключ, соединение будет отклонено. QR без отпечатка по-прежнему работает, с доверием при первом подключении. С неправильно оформленным отпечатком — нет. Последний шаг — установить публичный ключ телефона в authorized_keys командой, которую показывает приложение. Ничего из этого не выходит за пределы твоей сети.
Pro без сервера лицензий
Продавать подписку без бэкенда — самая неудобная часть. Wendo Pro оплачивается через биллинг Google Play и App Store, а проверка живёт в нативном модуле, не в Dart. На Android (ProLicense.kt) каждая покупка приходит со своим JSON и RSA-подписью, сделанной лицензионным ключом приложения, и проверяется публичным ключом. Ещё проверяется, что приложение не переподписано, а состояние хранится запечатанным ключом из Keystore, так что правка файла Pro не даёт. Каждая полная синхронизация с магазином его продлевает. Без неё Pro держится 7 дней с подпиской или 30 с пожизненной покупкой, и перевод часов назад этот срок не увеличивает. На iOS проверку делает нативный код через StoreKit 2. 5 пробных подтверждений бесплатного плана тоже считает нативная часть.
Сам код честно говорит, где предел: с root и Frida или патчем приложения всё это обходится, потому что без сервера, который спрашивает Google, помешать этому нельзя. Цель скромнее: чтобы не хватало инструмента в один клик.
Реальные ограничения приложения без бэкенда
- Телефон должен достучаться до компьютера. В одной сети Wi-Fi хватает локального IP, снаружи нужно что-то вроде Tailscale. Если компьютер выключен или спит, приложению некого спрашивать.
- Уведомления приходят с задержкой опроса: несколько секунд при открытом приложении (5, если что-то ждёт ответа, 15, если просто работают агенты), до 15 минут и больше в фоне на Android, а на iOS при закрытом приложении только через ntfy.
- Каждое чтение — это SSH-соединение. С несколькими хостами это несколько соединений каждые несколько секунд, пока что-то ждёт.
- То, что ты удалил на одном телефоне, помнит только этот телефон.
- Хуку нужен
jqна компьютере, и пока он регистрируется только в Claude Code. - Публичный ntfy видит то, что ты публикуешь в топик. Если это смущает, подними свой.
Как эти части складываются в приложение, можно посмотреть на странице Wendo (терминал, рабочие пространства tmux, режим чата), а справочник по переменным хука есть в документации об уведомлениях и подтверждениях. SSH, на котором всё это держится, мы разобрали в статье SSH, Mosh и Eternal Terminal во Flutter, а ещё одну часть, которая работает без сервера, диктовку, — в статье Whisper на телефоне без интернета. Wendo пока нет в магазинах приложений.