Aplikacja mobilna bez backendu: powiadomienia i zgody Claude Code przez SSH i pliki

Wendo to aplikacja mobilna bez backendu: między twoim komputerem a telefonem nie stoi żaden nasz serwer. Gdy Claude Code prosi o zgodę albo kończy zadanie, skrypt powłoki na twojej maszynie zapisuje zdarzenie w pliku, a aplikacja czyta go przez SSH tym samym kluczem, którym otwiera terminal. Odpowiedź na prośbę o zgodę wraca tą samą drogą, jako kolejny plik.
Jeśli chcesz to skonfigurować, opisuje to poradnik o powiadomieniach Claude Code na telefonie.
Po co aplikacja mobilna bez backendu
Wendo jest klientem SSH: żeby działało, twój telefon i tak musi dosięgnąć komputera i się na nim uwierzytelnić. Skoro ten kanał jest otwarty i szyfrowany, serwer pośrodku tylko do powiadomień byłby zbędnym elementem.
Usunięcie go ma trzy skutki, na których nam zależało:
- To, co robi twój agent (polecenie, które chce uruchomić, plik, który zmieni, ostatnia wiadomość), nie przechodzi przez żadną naszą maszynę. Zostaje na twoim komputerze, dopóki telefon o to nie poprosi.
- Nie ma kont. Nie ma adresu e-mail do przechowywania ani hasła, które mogłoby wyciec. Tożsamością jest twój klucz SSH.
- Nie ma infrastruktury, za którą trzeba płacić i którą trzeba utrzymywać przy każdym nowym użytkowniku.
W zamian wszystko, co robiłby serwer (przechowywanie historii, powiadamianie przy zamkniętej aplikacji, parowanie urządzeń, weryfikacja zakupów), trzeba załatwić tym, co już istnieje po obu stronach.
Kontrakt: jedna linia JSON na zdarzenie
Wszystko kręci się wokół ~/.wendo/events.jsonl. Instalator rejestruje wendo-hook.sh na sześć zdarzeń Claude Code (PermissionRequest, Notification, Stop, UserPromptSubmit, SessionStart i SessionEnd). Za każdym razem hook czyta JSON, który Claude Code podaje mu na standardowe wejście, i dopisuje linię do pliku. Linia z prośbą o zgodę wygląda mniej więcej tak (skrócona):
{"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"}}
Plik rośnie tylko na końcu. Nikt nie edytuje linii: rozstrzygnięta prośba zapisuje się jako nowa linia typu resolution. To bardzo upraszcza współbieżność, bo kilka hooków może pisać naraz, a krótkie linie dopisują się atomowo. Gdy plik przekroczy 1000 linii, hook zostawia ostatnie 500 (z flock, jeśli jest dostępny, żeby rotował tylko jeden naraz). Katalog powstaje z umask 077, a plik ma uprawnienia 600: czyta go tylko twój użytkownik.
Pole mux mówi, w którym panelu tmux, zellij lub herdr działa agent, żeby aplikacja mogła cię tam zabrać.
Jak czyta aplikacja: odpytywanie przez SSH
Aplikacja niczego nie odbiera, tylko pyta. Przy każdym odczycie otwiera połączenie SSH z każdym hostem, który ma włączoną skrzynkę, i uruchamia bez terminala coś tak prostego:
tail -n 1000 ~/.wendo/events.jsonl 2>/dev/null; true
Częstotliwość zależy od stanu skrzynki. Gdy jakiś wiersz czeka na odpowiedź, aplikacja czyta co 5 sekund, bo hook szybko wygasa. Gdy agenci pracują, co 15. Jeśli nic się nie dzieje, czyta tylko przy wejściu na kartę. Po twojej odpowiedzi czyta po 1,2 sekundy i jeszcze raz po 3, żeby zobaczyć rozstrzygnięcie.
Mając linie, inbox.dart sortuje je po czasie i składa w wiersze: jeden na host i sesję. Tu mieszka prawie cała logika, która w innej aplikacji byłaby na serwerze. Na przykład uznaje, że prośba została obsłużona w terminalu, jeśli potem przychodzi Stop albo UserPromptSubmit bez rozstrzygnięcia pomiędzy, albo że SessionStart po kompaktowaniu niczego nie zmienia.
Ponieważ plik jest tylko do dopisywania, usuwanie w aplikacji polega na zapamiętaniu, co usunięto. Wendo trzyma „nagrobki” na samym telefonie, per wiersz albo per host. Jeśli agent znowu pisze w tej sesji, wiersz wraca. Jeśli host nie odpowiada, aplikacja zachowuje to, co ostatnio z niego przeczytała, zamiast czyścić listę.
Przy zamkniętej aplikacji na Androidzie
Na Androidzie działa zadanie WorkManager, które budzi się najwyżej co 15 minut (system nie pozwala prosić o krócej i może je opóźnić, żeby oszczędzać baterię), czyta skrzynkę tak samo jak otwarta aplikacja i wysyła lokalne powiadomienia o nowościach. Ten odczyt nigdy nie prosi o biometrię: jeśli twój klucz SSH jest chroniony i zablokowany, ten host po prostu się nie uda. To, o czym już powiadomiono, zapisuje się w pamięci telefonu, żeby otwarta aplikacja i zadanie w tle nie powtarzały tego samego powiadomienia. Na iOS takiego zadania nie ma.
Zgoda w obie strony
PermissionRequest to jedyne zdarzenie, przy którym hook czeka. Cała droga:
- Hook generuje
pendingActionId(litery, cyfry,_i-; aplikacja sprawdza go tak samo, zanim użyje go w ścieżce), wyliczaexpiresAti zapisuje linię zoptions: ["Approve","Deny"]. - Aplikacja widzi wiersz z
pendingActionId, bez rozstrzygnięcia i niewygasły, i pokazuje przyciski. - Po naciśnięciu jednego aplikacja zapisuje
{"decision":"approve"}w~/.wendo/answers/<id>.tmpi zmienia nazwę na~/.wendo/answers/<id>. Zmiana nazwy sprawia, że hook nie przeczyta odpowiedzi w połowie. - Hook sprawdza ten plik co pół sekundy. Gdy go znajdzie, wypisuje decyzję w formacie, którego oczekuje Claude Code, i dopisuje swoją linię rozstrzygnięcia:
{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}
Najtrudniejsze jest to, co się dzieje, gdy nie odpowiesz. Oczekiwanie trwa domyślnie 90 sekund (WENDO_APPROVAL_TIMEOUT). Gdy minie, hook nic nie wypisuje, a Claude Code bez odpowiedzi od hooka zadaje swoje zwykłe pytanie w terminalu. Hook zapisuje rozstrzygnięcie timeout i kończy. Jeśli odpowiesz wcześniej w terminalu, Claude Code anuluje hook, a trap zapisuje rozstrzygnięcie jako local.
Zasada projektowa była taka, że awaria telefonu nigdy nie blokuje agenta. Bez baterii, bez zasięgu albo z zamkniętą aplikacją najgorszy przypadek jest taki jak zawsze: pytanie w terminalu.
Zostaje luka: odpowiedź z telefonu dokładnie wtedy, gdy hook już się poddał. Żeby ją zamknąć, po zostawieniu odpowiedzi to samo polecenie SSH czeka do 3 sekund na linię rozstrzygnięcia z tym id. Jeśli rozstrzygnięciem jest timeout albo nikt nie odbierze pliku, usuwa go, a aplikacja pokazuje „Wygasło — host przejdzie w tryb lokalny”, zamiast uznać odpowiedź za wysłaną.
Powiadomienia push bez własnego serwera: ntfy, i to opcjonalnie
Żeby powiadomić przy zamkniętej aplikacji na iOS albo szybciej niż co 15 minut na Androidzie, ktoś musi wypchnąć powiadomienie, a to wymaga usługi push. Wybraliśmy ntfy, bo publikuje się w nim jednym żądaniem HTTP i można go hostować samemu.
Jeśli ustawisz NTFY_TOPIC, hook publikuje powiadomienia o zgodach, o notyfikacjach i o zakończonych zadaniach (nie o starcie ani końcu sesji ani o każdym prompcie). Powiadomienie zawiera link wendo://attach?event=…&host=…&sid=… z panelem multipleksera, więc po stuknięciu aplikacja otwiera dokładnie to miejsce.
Aplikacja czyta też topic (/json?poll=1&since=24h) jako dodatkowe źródło dla skrzynki, ale odczyt przez SSH ma pierwszeństwo. ntfy wie tylko, że coś cię potrzebowało, a nie, czy to już rozstrzygnięto. Dlatego jeśli odczyt SSH z hosta się nie uda, jego prośby o zgodę z ntfy się nie liczą. I tu jest koszt dla prywatności: na publicznym serwerze topic działa jak hasło, a powiadomienie zawiera projekt i fragment polecenia. Dlatego topic musi być długi i losowy, i dlatego istnieje NTFY_URL, żeby użyć własnego serwera.
Parowanie telefonu z komputerem przez QR, bez serwera
Bez kont jak telefon ma dodać komputer? Kodem QR, który generuje wendo-pair.sh i który zawiera URL:
wendo://pair?h=<host>&p=<puerto>&u=<usuario>&n=<nombre>&hk=<huella>
hk to odcisk SHA256 klucza hosta ed25519, odczytany przez ssh-keyscan z sshd na tym porcie. Aplikacja sprawdza go ścisłym wzorcem (SHA256: i 43 znaki base64) i przypina do tego hosta. Pierwsze połączenie jest weryfikowane, a nie akceptowane w ciemno, a jeśli kiedyś serwer pokaże inny klucz, połączenie zostaje odrzucone. QR bez odcisku nadal działa, z zaufaniem przy pierwszym połączeniu. QR ze źle sformatowanym odciskiem nie działa. Ostatni krok to zainstalowanie klucza publicznego telefonu w authorized_keys poleceniem, które pokazuje aplikacja. Nic z tego nie wychodzi poza twoją sieć.
Pro bez serwera licencji
Sprzedawanie subskrypcji bez backendu to najmniej wygodna część. Wendo Pro jest rozliczane przez płatności Google Play i App Store, a weryfikacja odbywa się w module natywnym, nie w Darcie. Na Androidzie (ProLicense.kt) każdy zakup przychodzi ze swoim JSON-em i podpisem RSA złożonym kluczem licencyjnym aplikacji i jest weryfikowany kluczem publicznym. Sprawdzamy też, czy aplikacja nie została ponownie podpisana, a stan jest zapieczętowany kluczem z Keystore, więc edycja pliku nie daje Pro. Każda pełna synchronizacja ze sklepem go odnawia. Bez niej Pro wytrzymuje 7 dni przy subskrypcji albo 30 przy zakupie dożywotnim, a cofnięcie zegara tego nie wydłuża. Na iOS weryfikuje kod natywny przez StoreKit 2. 5 próbnych zgód w darmowym planie też liczy strona natywna.
Sam kod mówi, dokąd to sięga: z rootem i Fridą albo łatając aplikację da się to wszystko obejść, bo bez serwera, który pyta Google, nie ma jak temu zapobiec. Cel jest skromniejszy: żeby nie wystarczyło narzędzie na jedno kliknięcie.
Prawdziwe ograniczenia aplikacji bez backendu
- Telefon musi dosięgnąć twojego komputera. W tej samej sieci Wi-Fi wystarczy lokalny adres IP, z zewnątrz potrzebujesz czegoś w rodzaju Tailscale. Jeśli komputer jest wyłączony albo uśpiony, aplikacja nie ma kogo zapytać.
- Powiadomienia przychodzą z opóźnieniem odpytywania: kilka sekund przy otwartej aplikacji (5, jeśli coś czeka na odpowiedź, 15, jeśli tylko pracują agenci), do 15 minut lub dłużej w tle na Androidzie, a na iOS przy zamkniętej aplikacji tylko przez ntfy.
- Każdy odczyt to połączenie SSH. Przy kilku hostach to kilka połączeń co parę sekund, dopóki coś czeka.
- To, co usuniesz na jednym telefonie, jest zapamiętane tylko na nim.
- Hook potrzebuje
jqna komputerze i na razie rejestruje się tylko w Claude Code. - Publiczny ntfy widzi to, co publikujesz w topicu. Jeśli ci to przeszkadza, postaw własny.
Jeśli chcesz zobaczyć, jak te elementy pasują do całej aplikacji, reszta (terminal, przestrzenie tmux, widok czatu) jest na stronie Wendo, a opis zmiennych hooka znajdziesz w dokumentacji powiadomień i zgód. SSH, na którym to wszystko stoi, opisujemy w tekście SSH, Mosh i Eternal Terminal we Flutterze, a inny element działający bez serwera, dyktowanie, w Whisper na telefonie bez internetu. Wendo nie jest jeszcze dostępne w sklepach.