Eine mobile App ohne Backend: Benachrichtigungen und Freigaben für Claude Code über SSH und Dateien

Wendo ist eine mobile App ohne Backend: Zwischen deinem Rechner und deinem Handy steht kein Server von uns. Wenn Claude Code um Erlaubnis fragt oder eine Aufgabe beendet, schreibt ein Shell-Skript auf deinem Rechner das Ereignis in eine Datei, und die App liest sie per SSH mit demselben Schlüssel, mit dem sie das Terminal öffnet. Die Antwort auf eine Freigabe nimmt denselben Weg zurück, als weitere Datei.
Wenn du es einrichten willst, steht das in der Anleitung zu Claude-Code-Benachrichtigungen auf dem Handy.
Warum eine mobile App ohne Backend
Wendo ist ein SSH-Client: Damit er überhaupt funktioniert, muss dein Handy deinen Rechner schon erreichen und sich dort anmelden. Wenn dieser verschlüsselte Kanal ohnehin offen ist, wäre ein Server in der Mitte für die Benachrichtigungen ein Teil zu viel.
Ihn wegzulassen, hat drei Folgen, die wir wollten:
- Was dein Agent tut (der Befehl, den er ausführen will, die Datei, die er gleich anfasst, seine letzte Nachricht), läuft über keine Maschine von uns. Es bleibt auf deinem Rechner, bis dein Handy danach fragt.
- Es gibt keine Konten. Keine E-Mail-Adresse zu speichern, kein Passwort, das durchsickern kann. Deine Identität ist dein SSH-Schlüssel.
- Es gibt keine Infrastruktur, die wir für jeden neuen Nutzer bezahlen und warten müssten.
Im Gegenzug muss alles, was ein Server erledigen würde (den Verlauf speichern, bei geschlossener App benachrichtigen, Geräte koppeln, Käufe prüfen), mit dem gelöst werden, was an beiden Enden schon vorhanden ist.
Der Vertrag: eine JSON-Zeile pro Ereignis
Im Zentrum steht ~/.wendo/events.jsonl. Der Installer registriert wendo-hook.sh für sechs Ereignisse von Claude Code (PermissionRequest, Notification, Stop, UserPromptSubmit, SessionStart und SessionEnd), und jedes Mal, wenn eines auslöst, liest der Hook das JSON, das Claude Code ihm über die Standardeingabe gibt, und hängt eine Zeile an die Datei an. Eine Berechtigungszeile sieht ungefähr so aus (gekürzt):
{"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"}}
Die Datei wächst nur am Ende. Niemand bearbeitet Zeilen: Eine erledigte Freigabe wird als neue Zeile vom Typ resolution notiert. Das vereinfacht die Nebenläufigkeit enorm, denn mehrere Hooks können gleichzeitig schreiben, und kurze Zeilen werden atomar angehängt. Hat die Datei mehr als 1000 Zeilen, behält der Hook die letzten 500 (mit flock, falls verfügbar, damit immer nur einer rotiert). Der Ordner wird mit umask 077 angelegt, und die Datei hat am Ende 600: Nur dein Benutzer kann sie lesen.
Das Feld mux sagt, in welchem Pane von tmux, zellij oder herdr der Agent läuft, damit die App dich dorthin bringen kann.
Wie die App liest: Polling per SSH
Die App empfängt nichts, sie fragt nach. Bei jedem Lesen öffnet sie eine SSH-Verbindung zu jedem Host, bei dem der Posteingang aktiviert ist, und führt ohne Terminal etwas so Einfaches aus:
tail -n 1000 ~/.wendo/events.jsonl 2>/dev/null; true
Wie oft, hängt vom Zustand des Posteingangs ab. Solange eine Zeile auf Antwort wartet, liest sie alle 5 Sekunden neu, weil der Hook schnell abläuft. Wenn Agenten arbeiten, alle 15. Ist nichts aktiv, liest sie nur beim Öffnen des Tabs. Nachdem du geantwortet hast, liest sie nach 1,2 Sekunden und noch einmal nach 3, um die Auflösung zu sehen.
Mit den Zeilen in der Hand sortiert inbox.dart sie nach Zeit und faltet sie zu Einträgen zusammen: einer pro Host und Sitzung. Dort sitzt fast die ganze Logik, die in einer anderen App auf einem Server läge. Sie entscheidet zum Beispiel, dass eine Anfrage im Terminal beantwortet wurde, wenn danach ein Stop oder ein UserPromptSubmit ohne Auflösung dazwischen kommt, oder dass ein SessionStart wegen Komprimierung nichts ändert.
Weil an die Datei nur angehängt wird, heißt Löschen in der App, sich zu merken, was gelöscht wurde. Wendo speichert „Grabsteine“ auf dem Handy selbst, pro Eintrag oder pro Host. Schreibt der Agent wieder in diese Sitzung, taucht der Eintrag wieder auf. Antwortet ein Host nicht, behält die App das Letzte, was sie von ihm gelesen hat, statt die Liste zu leeren.
Bei geschlossener App auf Android
Auf Android gibt es eine WorkManager-Aufgabe, die höchstens alle 15 Minuten aufwacht (kürzer lässt das System es nicht zu, und es kann sie verschieben, um Akku zu sparen). Sie liest den Posteingang genau wie die offene App und zeigt lokale Benachrichtigungen für alles Neue. Dieses Lesen fragt nie nach Biometrie: Ist dein SSH-Schlüssel geschützt und gesperrt, schlägt dieser Host eben fehl. Was schon gemeldet wurde, wird im Speicher des Handys abgelegt, damit die offene App und die Hintergrundaufgabe nicht dieselbe Benachrichtigung doppelt zeigen. Auf iOS gibt es diese Aufgabe nicht.
Eine Freigabe hin und zurück
PermissionRequest ist das einzige Ereignis, bei dem der Hook wartet. Der ganze Ablauf:
- Der Hook erzeugt eine
pendingActionId(Buchstaben, Ziffern,_und-; die App prüft sie genauso, bevor sie sie in einen Pfad einsetzt), berechnetexpiresAtund schreibt die Zeile mitoptions: ["Approve","Deny"]. - Die App sieht einen Eintrag mit
pendingActionId, ohne Auflösung und nicht abgelaufen, und zeigt die Knöpfe an. - Tippst du auf einen, schreibt die App
{"decision":"approve"}nach~/.wendo/answers/<id>.tmpund benennt die Datei in~/.wendo/answers/<id>um. Das Umbenennen verhindert, dass der Hook eine halb geschriebene Antwort liest. - Der Hook schaut jede halbe Sekunde nach dieser Datei. Findet er sie, gibt er die Entscheidung in dem Format aus, das Claude Code erwartet, und hängt seine Auflösungszeile an:
{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}
Heikel ist, was passiert, wenn du nicht antwortest. Das Warten dauert standardmäßig 90 Sekunden (WENDO_APPROVAL_TIMEOUT). Läuft die Zeit ab, gibt der Hook nichts aus, und Claude Code stellt ohne Antwort vom Hook seine normale Frage im Terminal. Der Hook notiert eine Auflösung timeout und beendet sich. Antwortest du vorher im Terminal, bricht Claude Code den Hook ab, und ein trap notiert die Auflösung als local.
Die Designregel war, dass ein Ausfall des Handys den Agenten nie blockieren darf. Leerer Akku, kein Empfang oder geschlossene App: Im schlimmsten Fall passiert das Übliche, die Frage im Terminal.
Eine Lücke bleibt: vom Handy aus zu antworten, genau wenn der Hook schon aufgegeben hat. Um sie zu schließen, wartet derselbe SSH-Befehl nach dem Ablegen der Antwort bis zu 3 Sekunden auf eine Auflösungszeile mit dieser ID. Ist die Auflösung timeout oder holt niemand die Datei ab, löscht er sie, und die App meldet „Abgelaufen – Host fällt lokal zurück“, statt die Antwort als gesendet zu zählen.
Push-Benachrichtigungen ohne eigenen Server: ntfy, und optional
Um bei geschlossener App auf iOS zu benachrichtigen, oder früher als nach den 15 Minuten von Android, muss jemand die Benachrichtigung anstoßen, und dafür braucht es einen Push-Dienst. Wir haben ntfy gewählt, weil man dort mit einer einzigen HTTP-Anfrage veröffentlicht und es selbst hosten kann.
Wenn du NTFY_TOPIC setzt, veröffentlicht der Hook die Benachrichtigungen zu Berechtigungen, zu Notification und zu beendeten Aufgaben (nicht Start und Ende einer Sitzung, auch nicht jeden Prompt). Die Benachrichtigung enthält einen Link wendo://attach?event=…&host=…&sid=… mit dem Pane des Multiplexers, damit die App beim Antippen genau an der richtigen Stelle öffnet.
Die App liest das Topic auch (/json?poll=1&since=24h) als weitere Quelle für den Posteingang, aber was per SSH gelesen wird, hat Vorrang. ntfy weiß nur, dass etwas dich gebraucht hat, nicht, ob es schon erledigt ist. Schlägt also das SSH-Lesen eines Hosts fehl, zählen dessen Berechtigungsanfragen aus ntfy nicht. Und hier liegt der Preis für die Privatsphäre: Auf dem öffentlichen Server ist das Topic das Passwort, und die Benachrichtigung enthält das Projekt und einen Auszug des Befehls. Deshalb muss das Topic lang und zufällig sein, und deshalb gibt es NTFY_URL für deinen eigenen Server.
Handy und Rechner per QR-Code koppeln, ohne Server
Ohne Konten: Wie meldet das Handy einen Rechner an? Mit einem QR-Code, den wendo-pair.sh erzeugt und der eine URL enthält:
wendo://pair?h=<host>&p=<port>&u=<benutzer>&n=<name>&hk=<fingerprint>
hk ist der SHA256-Fingerprint des ed25519-Host-Schlüssels, gelesen mit ssh-keyscan gegen den sshd auf diesem Port. Die App prüft ihn mit einem strengen Muster (SHA256: und 43 Base64-Zeichen) und pinnt ihn für diesen Host. Die erste Verbindung wird also geprüft statt blind akzeptiert, und zeigt der Server eines Tages einen anderen Schlüssel, wird die Verbindung abgelehnt. Ein QR-Code ohne Fingerprint gilt weiterhin, mit Vertrauen bei der ersten Verbindung; einer mit fehlerhaftem Fingerprint nicht. Der letzte Schritt ist, den öffentlichen Schlüssel des Handys mit dem Befehl, den die App anzeigt, in authorized_keys einzutragen. Nichts davon verlässt dein Netz.
Pro ohne Lizenzserver
Ein Abo ohne Backend zu verkaufen, ist der unbequemste Teil. Wendo Pro wird über die Abrechnung von Google Play und App Store bezahlt, und die Prüfung sitzt im nativen Modul, nicht in Dart. Auf Android (ProLicense.kt) kommt jeder Kauf mit seinem JSON und einer RSA-Signatur, erstellt mit dem Lizenzschlüssel der App, und wird mit dem öffentlichen Schlüssel geprüft. Außerdem wird kontrolliert, dass die App nicht neu signiert wurde, und der Zustand wird mit einem Schlüssel aus dem Keystore versiegelt gespeichert, sodass das Bearbeiten einer Datei kein Pro freischaltet. Jede vollständige Synchronisierung mit dem Store erneuert ihn; ohne sie hält Pro 7 Tage mit Abo oder 30 mit dem lebenslangen Kauf, und die Uhr zurückzustellen, verlängert das nicht. Auf iOS prüft die native Seite mit StoreKit 2. Auch die 5 Probefreigaben des kostenlosen Plans zählt die native Seite.
Der Code selbst sagt, wie weit das reicht: Mit Root und Frida oder durch Patchen der App lässt sich alles umgehen, denn ohne einen Server, der bei Google nachfragt, gibt es keinen Weg, das zu verhindern. Das Ziel ist bescheidener: dass ein Ein-Klick-Werkzeug nicht reicht.
Die echten Grenzen einer App ohne Backend
- Das Handy muss deinen Rechner erreichen. Im selben WLAN reicht die lokale IP; von außen brauchst du etwas wie Tailscale. Ist der Rechner aus oder im Ruhezustand, hat die App niemanden, den sie fragen kann.
- Benachrichtigungen brauchen so lange wie das Polling: ein paar Sekunden bei offener App (5, wenn etwas auf Antwort wartet, 15, wenn nur Agenten arbeiten), bis zu 15 Minuten oder mehr im Hintergrund auf Android, und auf iOS kommen sie bei geschlossener App nur über ntfy.
- Jedes Lesen ist eine SSH-Verbindung. Mit mehreren Hosts sind das mehrere Verbindungen alle paar Sekunden, solange etwas wartet.
- Was du auf einem Handy löschst, merkt sich nur dieses Handy.
- Der Hook braucht
jqauf dem Rechner und wird vorerst nur in Claude Code registriert. - Der öffentliche ntfy-Server sieht, was du im Topic veröffentlichst. Wenn dich das stört, hoste deinen eigenen.
Wenn du sehen willst, wie diese Teile in der App zusammenpassen, findest du den Rest (Terminal, tmux-Arbeitsbereiche, Chat-Ansicht) auf der Seite von Wendo, und die Referenz der Hook-Variablen steht in der Dokumentation zu Benachrichtigungen und Freigaben. Das SSH, auf dem all das aufbaut, beschreiben wir in SSH, Mosh und Eternal Terminal in Flutter, und ein weiteres Teil, das ohne Server auskommt, das Diktieren, in Whisper auf dem Handy, ohne Internet. Wendo ist noch nicht in den Stores.