Un’app mobile senza backend: notifiche e approvazioni di Claude Code con SSH e file

Wendo è un’app mobile senza backend: tra il tuo computer e il tuo telefono non c’è nessun server nostro. Quando Claude Code chiede un permesso o finisce un compito, uno script di shell sulla tua macchina annota l’evento in un file, e l’app lo legge via SSH con la stessa chiave che usa per aprire il terminale. La risposta a un’approvazione torna per la stessa strada, come un altro file.
Se ti interessa configurarlo, trovi tutto nella guida sulle notifiche di Claude Code sul telefono.
Perché fare un’app mobile senza backend
Wendo è un client SSH: per funzionare, il tuo telefono deve già raggiungere il tuo computer e autenticarsi. Con quel canale aperto e cifrato, un server in mezzo per le notifiche sarebbe un pezzo in più.
Toglierlo ha tre conseguenze che cercavamo:
- Quello che fa il tuo agente (il comando che vuole eseguire, il file che sta per toccare, l’ultimo messaggio) non passa da nessuna macchina nostra. Resta sul tuo computer finché il telefono non lo chiede.
- Non ci sono account. Nessuna email da conservare, nessuna password che possa trapelare. L’identità è la tua chiave SSH.
- Non c’è infrastruttura da pagare né da mantenere per ogni nuovo utente.
In cambio, tutto quello che farebbe un server (conservare lo storico, avvisare con l’app chiusa, associare i dispositivi, verificare gli acquisti) va risolto con quello che esiste già ai due capi.
Il contratto: una riga JSON per evento
Il centro di tutto è ~/.wendo/events.jsonl. L’installer registra wendo-hook.sh su sei eventi di Claude Code (PermissionRequest, Notification, Stop, UserPromptSubmit, SessionStart e SessionEnd), e ogni volta che scatta, l’hook legge il JSON che Claude Code gli passa sullo standard input e aggiunge una riga al file. Una riga di permesso ha più o meno questa forma (accorciata):
{"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"}}
Il file cresce solo in coda. Nessuno modifica le righe: un’approvazione risolta viene annotata come una nuova riga di tipo resolution. Questo semplifica parecchio la concorrenza, perché più hook possono scrivere insieme e le righe corte vengono aggiunte in modo atomico. Quando il file supera le 1000 righe, l’hook tiene le ultime 500 (con flock se c’è, perché ruoti uno solo alla volta). La cartella viene creata con umask 077 e il file resta a 600: lo legge solo il tuo utente.
Il campo mux dice in quale pannello di tmux, zellij o herdr gira l’agente, così l’app può portarti lì.
Come legge l’app: polling via SSH
L’app non riceve niente: chiede. A ogni lettura apre una connessione SSH verso ogni host con la posta in arrivo attiva ed esegue, senza terminale, qualcosa di semplice come questo:
tail -n 1000 ~/.wendo/events.jsonl 2>/dev/null; true
La frequenza dipende dallo stato della posta in arrivo. Finché una riga aspetta risposta, rilegge ogni 5 secondi, perché l’hook scade presto. Con agenti al lavoro, ogni 15. Se non c’è niente di vivo, legge solo quando entri nella scheda. Dopo che hai risposto, rilegge a 1,2 secondi e di nuovo a 3 per vedere la risoluzione.
Con le righe in mano, inbox.dart le ordina per ora e le raggruppa in voci: una per host e sessione. Lì vive quasi tutta la logica che in un’altra app starebbe su un server. Per esempio decide che una richiesta ha avuto risposta nel terminale se dopo arriva uno Stop o un UserPromptSubmit senza una risoluzione in mezzo, o che un SessionStart dovuto alla compattazione non cambia niente.
Visto che il file è solo in aggiunta, cancellare nell’app vuol dire ricordarsi cosa è stato cancellato. Wendo salva delle «lapidi» (tombstone) sul telefono stesso, per voce o per host. Se l’agente torna a scrivere in quella sessione, la voce ricompare. Se un host non risponde, l’app tiene l’ultima cosa che ha letto da lì invece di svuotare l’elenco.
Con l’app chiusa su Android
Su Android c’è un task di WorkManager che si sveglia al massimo ogni 15 minuti (il sistema non permette di chiedere meno e può ritardarlo per risparmiare batteria), legge la posta in arrivo come l’app aperta e lancia notifiche locali per le novità. Questa lettura non chiede mai la biometria: se hai la chiave SSH protetta e bloccata, quell’host fallisce e basta. Quello che è già stato notificato viene salvato nella memoria del telefono, così l’app aperta e il task in background non ripetono lo stesso avviso. Su iOS questo task non esiste.
Un’approvazione andata e ritorno
PermissionRequest è l’unico evento in cui l’hook resta in attesa. Il percorso completo:
- L’hook genera un
pendingActionId(lettere, cifre,_e-; l’app lo valida allo stesso modo prima di metterlo in un percorso), calcolaexpiresAte scrive la riga conoptions: ["Approve","Deny"]. - L’app vede una voce con
pendingActionId, senza risoluzione e non scaduta, e mostra i pulsanti. - Quando ne premi uno, l’app scrive
{"decision":"approve"}in~/.wendo/answers/<id>.tmpe lo rinomina in~/.wendo/answers/<id>. La rinomina evita che l’hook legga una risposta a metà. - L’hook controlla quel file ogni mezzo secondo. Quando lo trova, stampa la decisione nel formato che si aspetta Claude Code e aggiunge la sua riga di risoluzione:
{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}
La parte delicata è cosa succede se non rispondi. L’attesa dura 90 secondi di default (WENDO_APPROVAL_TIMEOUT). Se scade, l’hook non stampa niente, e Claude Code, senza risposta dall’hook, fa la sua domanda normale nel terminale. L’hook annota una risoluzione timeout e termina. Se rispondi prima nel terminale, Claude Code cancella l’hook, e un trap annota la risoluzione come local.
La regola di progetto era che un problema del telefono non lasciasse mai l’agente bloccato. Senza batteria, senza copertura o con l’app chiusa, il caso peggiore è quello di sempre: la domanda nel terminale.
Resta un buco: rispondere dal telefono proprio quando l’hook si è già arreso. Per coprirlo, dopo aver lasciato la risposta, lo stesso comando SSH aspetta fino a 3 secondi una riga di risoluzione con quell’id. Se la risoluzione è timeout, o nessuno raccoglie il file, lo cancella e l’app ti dice «Scaduto — l'host ripiegherà sulla gestione locale» invece di darlo per inviato.
Notifiche push senza un server proprio: ntfy, ed è facoltativo
Per avvisarti con l’app chiusa su iOS, o prima dei 15 minuti di Android, serve qualcuno che spinga la notifica, e questo richiede un servizio push. Abbiamo scelto ntfy perché si pubblica con una richiesta HTTP e si può ospitare da sé.
Se definisci NTFY_TOPIC, l’hook pubblica gli avvisi di permesso, di notifica e di compito finito (non l’inizio né la fine della sessione, né ogni prompt). L’avviso porta un link wendo://attach?event=…&host=…&sid=… con il pannello del multiplexer, così toccandolo l’app apre il punto esatto.
L’app legge anche il topic (/json?poll=1&since=24h) come un’altra fonte della posta in arrivo, ma ciò che arriva via SSH ha la precedenza. ntfy sa solo che qualcosa aveva bisogno di te, non se è già stato risolto, quindi se la lettura SSH di un host fallisce, i suoi avvisi di permesso arrivati da ntfy non contano. E qui c’è il costo in privacy: sul server pubblico il topic fa da password e l’avviso include il progetto e un estratto del comando. Per questo il topic deve essere lungo e casuale, e per questo esiste NTFY_URL, per usare il tuo server.
Associare il telefono al computer con un QR, senza un server
Senza account, come fa il telefono a registrare un computer? Con un QR generato da wendo-pair.sh che contiene un URL:
wendo://pair?h=<host>&p=<porta>&u=<utente>&n=<nome>&hk=<impronta>
hk è l’impronta SHA256 della chiave host ed25519, letta con ssh-keyscan sullo sshd di quella porta. L’app la valida con un pattern rigido (SHA256: e 43 caratteri base64) e la fissa per quell’host. La prima connessione viene verificata invece di essere accettata alla cieca, e se un giorno il server presenta un’altra chiave, la connessione viene rifiutata. Un QR senza impronta vale ancora, con fiducia alla prima connessione; uno con l’impronta malformata no. L’ultimo passo è installare la chiave pubblica del telefono in authorized_keys con il comando che mostra l’app. Niente di tutto questo esce dalla tua rete.
Pro senza un server di licenze
Vendere un abbonamento senza backend è la parte meno comoda. Wendo Pro si paga con la fatturazione di Google Play e dell’App Store, e la verifica vive nel modulo nativo, non in Dart. Su Android (ProLicense.kt) ogni acquisto arriva con il suo JSON e una firma RSA fatta con la chiave di licenza dell’app, e viene verificato con la chiave pubblica. Si controlla anche che l’app non sia stata rifirmata, e lo stato viene salvato sigillato con una chiave del Keystore, quindi modificare un file non dà Pro. Ogni sincronizzazione completa con lo store lo rinnova; senza, Pro regge 7 giorni con l’abbonamento o 30 con l’acquisto a vita, e spostare indietro l’orologio non lo allunga. Su iOS il nativo verifica con StoreKit 2. Anche le 5 approvazioni di prova del piano gratuito le conta il nativo.
Il codice stesso dice fin dove arriva: con root e Frida, o patchando l’app, si può aggirare tutto, perché senza un server che chieda a Google non c’è modo di impedirlo. L’obiettivo è più modesto: che non basti uno strumento da un clic.
I limiti reali di un’app senza backend
- Il telefono deve raggiungere il tuo computer. Sulla stessa Wi-Fi basta l’IP locale; da fuori ti serve qualcosa come Tailscale. Se il computer è spento o in sospensione, l’app non ha nessuno a cui chiedere.
- Gli avvisi arrivano con i tempi del polling: pochi secondi con l’app aperta (5 se qualcosa aspetta risposta, 15 se ci sono solo agenti al lavoro), fino a 15 minuti o più in background su Android, e su iOS con l’app chiusa arrivano solo con ntfy.
- Ogni lettura è una connessione SSH. Con più host, sono più connessioni ogni pochi secondi mentre qualcosa aspetta.
- Quello che cancelli su un telefono viene ricordato solo su quel telefono.
- L’hook ha bisogno di
jqsul computer, e per ora si registra solo in Claude Code. - Il ntfy pubblico vede quello che pubblichi nel topic. Se ti dà fastidio, ospita il tuo.
Se vuoi vedere come si incastrano questi pezzi nell’app, sulla pagina di Wendo c’è il resto (terminale, spazi tmux, vista chat), e il riferimento delle variabili dell’hook sta nella documentazione su notifiche e approvazioni. L’SSH su cui poggia tutto questo lo raccontiamo in SSH, Mosh ed Eternal Terminal in Flutter, e un altro pezzo che funziona senza server, la dettatura, in Whisper sul telefono, senza internet. Wendo non è ancora negli store.