Een mobiele app zonder backend: meldingen en goedkeuringen van Claude Code via SSH en bestanden

Wendo is een mobiele app zonder backend: tussen je computer en je telefoon zit geen server van ons. Als Claude Code om toestemming vraagt of een taak afrondt, schrijft een shellscript op je machine het event weg in een bestand, en de app leest dat via SSH met dezelfde sleutel waarmee hij de terminal opent. Het antwoord op een goedkeuring gaat langs dezelfde weg terug, als een ander bestand.
Wil je het vooral instellen, dan staat dat in de handleiding over Claude Code-meldingen op je telefoon.
Waarom een mobiele app zonder backend
Wendo is een SSH-client: om te werken moet je telefoon je computer al kunnen bereiken en zich daar aanmelden. Met dat open, versleutelde kanaal zou een server ertussen voor de meldingen een overbodig onderdeel zijn.
Hem weglaten heeft drie gevolgen waar we op uit waren:
- Wat je agent doet (het commando dat hij wil uitvoeren, het bestand dat hij gaat aanpassen, het laatste bericht) komt niet langs een machine van ons. Het blijft op je computer tot je telefoon erom vraagt.
- Er zijn geen accounts. Geen e-mailadres om te bewaren, geen wachtwoord dat kan uitlekken. Je identiteit is je SSH-sleutel.
- Er is geen infrastructuur te betalen of te onderhouden per nieuwe gebruiker.
In ruil daarvoor moet alles wat een server zou doen (geschiedenis bewaren, melden als de app dicht is, apparaten koppelen, aankopen controleren) worden opgelost met wat er al aan beide kanten is.
Het contract: één JSON-regel per event
Het middelpunt is ~/.wendo/events.jsonl. De installer registreert wendo-hook.sh bij zes events van Claude Code (PermissionRequest, Notification, Stop, UserPromptSubmit, SessionStart en SessionEnd). Elke keer dat er een afgaat, leest de hook de JSON die Claude Code op standaardinvoer meegeeft en voegt hij een regel aan het bestand toe. Een toestemmingsregel ziet er ongeveer zo uit (ingekort):
{"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"}}
Het bestand groeit alleen aan het eind. Niemand bewerkt regels: een afgehandelde goedkeuring wordt als nieuwe regel van het type resolution toegevoegd. Dat maakt gelijktijdigheid een stuk eenvoudiger, want meerdere hooks kunnen tegelijk schrijven en korte regels worden atomair toegevoegd. Wordt het bestand langer dan 1000 regels, dan houdt de hook de laatste 500 over (met flock als dat er is, zodat er maar één tegelijk roteert). De map wordt aangemaakt met umask 077 en het bestand staat op 600: alleen jouw gebruiker kan het lezen.
Het veld mux zegt in welk paneel van tmux, zellij of herdr de agent draait, zodat de app je daarheen kan brengen.
Hoe de app leest: pollen via SSH
De app ontvangt niets: hij vraagt. Bij elke leesronde opent hij een SSH-verbinding naar elke host waarvoor de inbox aan staat en voert hij, zonder terminal, iets eenvoudigs als dit uit:
tail -n 1000 ~/.wendo/events.jsonl 2>/dev/null; true
Hoe vaak hangt af van de toestand van de inbox. Zolang er een regel op antwoord wacht, leest hij elke 5 seconden opnieuw, omdat de hook snel verloopt. Met werkende agents elke 15. Is er niets actief, dan leest hij alleen als je het tabblad opent. Nadat je hebt geantwoord, leest hij na 1,2 seconde en nog eens na 3 om de afhandeling te zien.
Met de regels in handen sorteert inbox.dart ze op tijd en vouwt ze samen tot rijen: één per host en sessie. Daar zit bijna alle logica die in een andere app op een server zou draaien. Het beslist bijvoorbeeld dat een verzoek in de terminal is beantwoord als er daarna een Stop of UserPromptSubmit komt zonder afhandeling ertussen, of dat een SessionStart door compactie niets verandert.
Omdat het bestand alleen aangevuld wordt, betekent verwijderen in de app onthouden wat er verwijderd is. Wendo bewaart “grafstenen” (tombstones) op de telefoon zelf, per rij of per host. Schrijft de agent weer in die sessie, dan komt de rij terug. Reageert een host niet, dan houdt de app vast wat hij er het laatst van las in plaats van de lijst leeg te maken.
Met de app dicht op Android
Op Android is er een WorkManager-taak die hooguit elke 15 minuten wakker wordt (vaker mag je het systeem niet vragen, en het kan de taak uitstellen om batterij te sparen), de inbox leest zoals de geopende app en lokale meldingen geeft voor wat nieuw is. Deze leesronde vraagt nooit om biometrie: is je SSH-sleutel beveiligd en vergrendeld, dan faalt die host gewoon. Wat al gemeld is, wordt in de opslag van de telefoon bijgehouden, zodat de geopende app en de achtergrondtaak niet twee keer dezelfde melding geven. Op iOS bestaat deze taak niet.
Een goedkeuring heen en terug
PermissionRequest is het enige event waarbij de hook blijft wachten. Het hele traject:
- De hook maakt een
pendingActionId(letters, cijfers,_en-; de app valideert hem op dezelfde manier voordat hij hem in een pad zet), berekentexpiresAten schrijft de regel metoptions: ["Approve","Deny"]. - De app ziet een rij met
pendingActionId, zonder afhandeling en nog niet verlopen, en toont de knoppen. - Tik je er een aan, dan schrijft de app
{"decision":"approve"}naar~/.wendo/answers/<id>.tmpen hernoemt het naar~/.wendo/answers/<id>. Door het hernoemen leest de hook nooit een half antwoord. - De hook kijkt elke halve seconde of dat bestand er is. Zodra hij het vindt, print hij de beslissing in het formaat dat Claude Code verwacht en voegt hij zijn afhandelingsregel toe:
{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}
Het lastige deel is wat er gebeurt als je niet antwoordt. Het wachten duurt standaard 90 seconden (WENDO_APPROVAL_TIMEOUT). Is die tijd om, dan print de hook niets, en Claude Code stelt zonder antwoord van de hook gewoon zijn vraag in de terminal. De hook noteert een afhandeling timeout en stopt. Antwoord je eerder in de terminal, dan breekt Claude Code de hook af en noteert een trap de afhandeling als local.
De ontwerpregel was dat een probleem aan de telefoonkant de agent nooit mocht laten vastlopen. Lege batterij, geen bereik of de app dicht: in het slechtste geval krijg je wat je altijd al had, de vraag in de terminal.
Er blijft één gat over: antwoorden vanaf de telefoon op het moment dat de hook het net heeft opgegeven. Om dat af te dekken wacht hetzelfde SSH-commando na het achterlaten van het antwoord tot 3 seconden op een afhandelingsregel met dat id. Is de afhandeling timeout, of pakt niemand het bestand op, dan verwijdert hij het en zegt de app “Verlopen — host valt lokaal terug” in plaats van te doen alsof het verzonden is.
Pushmeldingen zonder eigen server: ntfy, en optioneel
Om je te waarschuwen als de app dicht is op iOS, of eerder dan de 15 minuten van Android, moet iemand de melding pushen, en daar heb je een pushdienst voor nodig. We kozen ntfy, omdat je erop publiceert met een HTTP-verzoek en je het zelf kunt hosten.
Stel je NTFY_TOPIC in, dan publiceert de hook de meldingen voor toestemming, notificatie en afgeronde taak (niet het begin of einde van een sessie, en niet elke prompt). De melding bevat een link wendo://attach?event=…&host=…&sid=… met het paneel van de multiplexer, zodat de app bij het aantikken precies de juiste plek opent.
De app leest het topic ook (/json?poll=1&since=24h) als extra bron voor de inbox, maar wat via SSH binnenkomt gaat voor. ntfy weet alleen dat iets je nodig had, niet of het al is afgehandeld. Mislukt de SSH-leesronde van een host, dan tellen de toestemmingsmeldingen van die host via ntfy dus niet mee. En hier zit de prijs voor je privacy: op de publieke server werkt het topic als wachtwoord en bevat de melding het project en een stukje van het commando. Daarom moet het topic lang en willekeurig zijn, en daarom bestaat NTFY_URL, om je eigen server te gebruiken.
Je telefoon koppelen aan je computer via QR, zonder server
Zonder accounts, hoe voegt je telefoon dan een computer toe? Met een QR-code die wendo-pair.sh maakt en die een URL bevat:
wendo://pair?h=<host>&p=<poort>&u=<gebruiker>&n=<naam>&hk=<fingerprint>
hk is de SHA256-fingerprint van de ed25519-hostsleutel, gelezen met ssh-keyscan tegen de sshd op die poort. De app valideert hem met een strikt patroon (SHA256: en 43 base64-tekens) en pint hem voor die host. De eerste verbinding wordt dus gecontroleerd in plaats van blind geaccepteerd, en biedt de server op een dag een andere sleutel aan, dan wordt de verbinding geweigerd. Een QR-code zonder fingerprint blijft bruikbaar, met vertrouwen bij de eerste verbinding; een met een misvormde fingerprint niet. De laatste stap is de publieke sleutel van de telefoon in authorized_keys zetten met het commando dat de app toont. Niets hiervan verlaat je netwerk.
Pro zonder licentieserver
Een abonnement verkopen zonder backend is het minst comfortabele deel. Wendo Pro wordt afgerekend via de facturering van Google Play en de App Store, en de controle zit in de native module, niet in Dart. Op Android (ProLicense.kt) komt elke aankoop binnen met zijn JSON en een RSA-handtekening, gezet met de licentiesleutel van de app, en wordt die gecontroleerd met de publieke sleutel. Er wordt ook gecontroleerd dat de app niet opnieuw is ondertekend, en de toestand wordt verzegeld opgeslagen met een sleutel uit de Keystore, dus een bestand aanpassen levert geen Pro op. Elke volledige synchronisatie met de store vernieuwt hem; zonder die synchronisatie houdt Pro het 7 dagen vol met een abonnement of 30 met de levenslange aankoop, en de klok terugzetten rekt dat niet op. Op iOS controleert de native kant met StoreKit 2. Ook de 5 proefgoedkeuringen van het gratis plan worden native geteld.
De code zegt zelf hoe ver dit reikt: met root en Frida, of door de app te patchen, kun je alles omzeilen, want zonder een server die het aan Google vraagt valt dat niet te voorkomen. Het doel is bescheidener: dat een tool met één klik niet genoeg is.
De echte beperkingen van een app zonder backend
- Je telefoon moet je computer kunnen bereiken. Op hetzelfde wifi-netwerk volstaat het lokale IP; van buitenaf heb je iets als Tailscale nodig. Staat de computer uit of slaapt hij, dan heeft de app niemand om het aan te vragen.
- Meldingen komen zo snel als het pollen: een paar seconden met de app open (5 als er iets op antwoord wacht, 15 als er alleen agents werken), tot 15 minuten of langer op de achtergrond op Android, en op iOS met de app dicht alleen via ntfy.
- Elke leesronde is een SSH-verbinding. Met meerdere hosts zijn dat meerdere verbindingen om de paar seconden zolang er iets wacht.
- Wat je op een telefoon verwijdert, wordt alleen op die telefoon onthouden.
- De hook heeft
jqop de computer nodig, en wordt voorlopig alleen in Claude Code geregistreerd. - De publieke ntfy ziet wat je op het topic publiceert. Vind je dat vervelend, host dan je eigen.
Wil je zien hoe deze onderdelen in de app samenkomen, dan staat de rest (terminal, tmux-werkruimtes, chatweergave) op de pagina van Wendo, en de referentie van de hookvariabelen vind je in de documentatie over meldingen en goedkeuringen. Het SSH waar dit allemaal op leunt, beschrijven we in SSH, Mosh en Eternal Terminal in Flutter, en een ander onderdeel dat zonder server werkt, het dicteren, in Whisper op je telefoon, zonder internet. Wendo staat nog niet in de app-stores.