Une application mobile sans backend : les alertes et autorisations de Claude Code par SSH et par fichiers

Wendo est une application mobile sans backend : aucun serveur à nous ne se trouve entre ton ordinateur et ton téléphone. Quand Claude Code demande une autorisation ou termine une tâche, un script shell sur ta machine note l’événement dans un fichier, et l’app le lit par SSH avec la même clé que celle qui ouvre le terminal. La réponse à une demande d’autorisation revient par le même chemin, sous forme d’un autre fichier.
Si tu cherches à le configurer, c’est dans le guide des notifications de Claude Code sur téléphone.
Pourquoi une application mobile sans backend
Wendo est un client SSH : pour qu’il fonctionne, ton téléphone doit déjà pouvoir joindre ton ordinateur et s’y authentifier. Avec ce canal ouvert et chiffré, un serveur au milieu pour les notifications serait une pièce de trop.
Le supprimer a trois conséquences que nous cherchions :
- Ce que fait ton agent (la commande qu’il veut lancer, le fichier qu’il va toucher, son dernier message) ne passe par aucune machine à nous. Ça reste sur ton ordinateur jusqu’à ce que ton téléphone le demande.
- Il n’y a pas de comptes. Pas d’e-mail à stocker ni de mot de passe à faire fuiter. Ton identité, c’est ta clé SSH.
- Il n’y a pas d’infrastructure à payer ni à maintenir pour chaque nouvel utilisateur.
En échange, tout ce que ferait un serveur (garder l’historique, prévenir quand l’app est fermée, appairer des appareils, vérifier les achats) doit se résoudre avec ce qui existe déjà aux deux bouts.
Le contrat : une ligne JSON par événement
Tout tourne autour de ~/.wendo/events.jsonl. L’installateur enregistre wendo-hook.sh sur six événements de Claude Code (PermissionRequest, Notification, Stop, UserPromptSubmit, SessionStart et SessionEnd), et chaque fois que l’un d’eux se déclenche, le hook lit le JSON que Claude Code lui passe sur l’entrée standard et ajoute une ligne au fichier. Une ligne d’autorisation ressemble à peu près à ceci (raccourcie) :
{"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"}}
Le fichier ne grandit que par la fin. Personne ne modifie de ligne : une autorisation résolue est notée comme une nouvelle ligne de type resolution. Ça simplifie beaucoup la concurrence, parce que plusieurs hooks peuvent écrire en même temps et que les lignes courtes s’ajoutent de façon atomique. Quand le fichier dépasse 1000 lignes, le hook garde les 500 dernières (avec flock s’il est disponible, pour qu’un seul fasse la rotation à la fois). Le dossier est créé avec umask 077 et le fichier finit en 600 : seul ton utilisateur peut le lire.
Le champ mux indique dans quel panneau de tmux, zellij ou herdr tourne l’agent, pour que l’app puisse t’y emmener.
Comment l’app lit : interrogation par SSH
L’app ne reçoit rien, elle demande. À chaque lecture, elle ouvre une connexion SSH vers chaque hôte dont la boîte de réception est activée et exécute, sans terminal, quelque chose d’aussi simple que ceci :
tail -n 1000 ~/.wendo/events.jsonl 2>/dev/null; true
La fréquence dépend de l’état de la boîte de réception. Tant qu’une ligne attend une réponse, elle relit toutes les 5 secondes, parce que le hook expire vite. Avec des agents au travail, toutes les 15. Si rien n’est actif, elle ne lit qu’en ouvrant l’onglet. Après ta réponse, elle relit au bout de 1,2 seconde puis à 3 secondes pour voir la résolution.
Une fois les lignes récupérées, inbox.dart les trie par heure et les replie en entrées : une par hôte et par session. C’est là que vit presque toute la logique qu’une autre app mettrait sur un serveur. Par exemple, il décide qu’une demande a reçu sa réponse dans le terminal si un Stop ou un UserPromptSubmit arrive ensuite sans résolution entre les deux, ou qu’un SessionStart dû à une compaction ne change rien.
Comme le fichier est en ajout seul, supprimer dans l’app revient à se souvenir de ce qui a été supprimé. Wendo garde des « pierres tombales » sur le téléphone lui-même, par entrée ou par hôte. Si l’agent écrit de nouveau dans cette session, l’entrée réapparaît. Si un hôte ne répond pas, l’app conserve la dernière chose qu’elle a lue de lui au lieu de vider la liste.
Avec l’app fermée sur Android
Sur Android, une tâche WorkManager se réveille au plus toutes les 15 minutes (le système ne permet pas de demander moins, et il peut la retarder pour économiser la batterie). Elle lit la boîte de réception comme l’app ouverte et affiche des notifications locales pour ce qui est nouveau. Cette lecture ne demande jamais la biométrie : si ta clé SSH est protégée et verrouillée, cet hôte échoue, point. Ce qui a déjà été notifié est enregistré sur le téléphone pour que l’app ouverte et la tâche de fond ne répètent pas la même alerte. Sur iOS, cette tâche n’existe pas.
Une autorisation, aller et retour
PermissionRequest est le seul événement où le hook reste en attente. Le parcours complet :
- Le hook génère un
pendingActionId(lettres, chiffres,_et-; l’app le valide de la même façon avant de le mettre dans un chemin), calculeexpiresAtet écrit la ligne avecoptions: ["Approve","Deny"]. - L’app voit une entrée avec
pendingActionId, sans résolution et non expirée, et affiche les boutons. - Quand tu en touches un, l’app écrit
{"decision":"approve"}dans~/.wendo/answers/<id>.tmpet le renomme en~/.wendo/answers/<id>. Le renommage évite que le hook lise une réponse à moitié écrite. - Le hook cherche ce fichier toutes les demi-secondes. Quand il le trouve, il affiche la décision au format qu’attend Claude Code et ajoute sa ligne de résolution :
{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}
La partie délicate, c’est ce qui se passe quand tu ne réponds pas. L’attente dure 90 secondes par défaut (WENDO_APPROVAL_TIMEOUT). Si elle s’épuise, le hook n’affiche rien, et Claude Code, sans réponse du hook, pose sa question habituelle dans le terminal. Le hook note une résolution timeout et se termine. Si tu réponds d’abord dans le terminal, Claude Code annule le hook, et un trap note la résolution comme local.
La règle de conception était qu’une défaillance du téléphone ne bloque jamais l’agent. Sans batterie, sans couverture ou avec l’app fermée, le pire cas reste celui de toujours : la question dans le terminal.
Il reste un trou : répondre depuis le téléphone pile au moment où le hook a déjà abandonné. Pour le couvrir, après avoir déposé la réponse, la même commande SSH attend jusqu’à 3 secondes de voir une ligne de résolution avec cet identifiant. Si la résolution est timeout, ou si personne ne ramasse le fichier, elle le supprime et l’app t’affiche « Expiré — l’hôte basculera en local » au lieu de considérer la réponse comme envoyée.
Notifications push sans serveur à nous : ntfy, et il est facultatif
Pour prévenir avec l’app fermée sur iOS, ou plus tôt que les 15 minutes d’Android, il faut que quelqu’un pousse la notification, et ça exige un service de push. Nous avons choisi ntfy parce qu’on y publie avec une simple requête HTTP et qu’on peut l’héberger soi-même.
Si tu définis NTFY_TOPIC, le hook publie les alertes d’autorisation, de notification et de tâche terminée (pas le début ni la fin de session, ni chaque prompt). L’alerte porte un lien wendo://attach?event=…&host=…&sid=… avec le panneau du multiplexeur, pour qu’en la touchant l’app s’ouvre à l’endroit exact.
L’app lit aussi le topic (/json?poll=1&since=24h) comme une source de plus pour la boîte de réception, mais ce qui est lu par SSH prime. ntfy sait seulement que quelque chose avait besoin de toi, pas si c’est déjà résolu ; donc si la lecture SSH d’un hôte échoue, ses alertes d’autorisation venues de ntfy ne comptent pas. Et voici le coût pour la vie privée : sur le serveur public, le topic fait office de mot de passe, et l’alerte inclut le projet et un extrait de la commande. C’est pour ça que le topic doit être long et aléatoire, et que NTFY_URL existe pour utiliser ton propre serveur.
Appairer le téléphone et l’ordinateur par QR code, sans serveur
Sans comptes, comment le téléphone ajoute-t-il un ordinateur ? Avec un QR code généré par wendo-pair.sh, qui contient une URL :
wendo://pair?h=<hôte>&p=<port>&u=<utilisateur>&n=<nom>&hk=<empreinte>
hk est l’empreinte SHA256 de la clé d’hôte ed25519, lue avec ssh-keyscan sur le sshd de ce port. L’app la valide avec un motif strict (SHA256: et 43 caractères base64) et l’épingle pour cet hôte. La première connexion est vérifiée au lieu d’être acceptée à l’aveugle, et si un autre jour le serveur présente une autre clé, la connexion est refusée. Un QR code sans empreinte reste valable, avec confiance à la première connexion ; un QR code avec une empreinte mal formée, non. La dernière étape consiste à installer la clé publique du téléphone dans authorized_keys avec la commande qu’affiche l’app. Rien de tout ça ne quitte ton réseau.
Pro sans serveur de licences
Vendre un abonnement sans backend, c’est la partie la moins confortable. Wendo Pro est facturé via Google Play et l’App Store, et la vérification vit dans le module natif, pas dans Dart. Sur Android (ProLicense.kt), chaque achat arrive avec son JSON et une signature RSA faite avec la clé de licence de l’app, et il est vérifié avec la clé publique. On vérifie aussi que l’app n’a pas été re-signée, et l’état est enregistré scellé avec une clé du Keystore, donc modifier un fichier ne donne pas Pro. Chaque synchronisation complète avec le store le renouvelle ; sans elle, Pro tient 7 jours avec un abonnement ou 30 avec l’achat à vie, et reculer l’horloge ne prolonge rien. Sur iOS, le natif vérifie avec StoreKit 2. Les 5 autorisations d’essai du plan gratuit sont elles aussi comptées par le natif.
Le code dit lui-même jusqu’où ça va : avec root et Frida, ou en patchant l’app, on peut tout contourner, parce que sans serveur qui interroge Google, il n’y a aucun moyen de l’empêcher. L’objectif est plus modeste : qu’un outil en un clic ne suffise pas.
Les vraies limites d’une application sans backend
- Le téléphone doit joindre ton ordinateur. Sur le même Wi-Fi, l’IP locale suffit ; de l’extérieur, il te faut quelque chose comme Tailscale. Si l’ordinateur est éteint ou en veille, l’app n’a personne à qui demander.
- Les alertes mettent le temps de l’interrogation : quelques secondes avec l’app ouverte (5 si quelque chose attend une réponse, 15 s’il n’y a que des agents au travail), jusqu’à 15 minutes ou plus en arrière-plan sur Android, et sur iOS avec l’app fermée elles n’arrivent que par ntfy.
- Chaque lecture est une connexion SSH. Avec plusieurs hôtes, ça fait plusieurs connexions toutes les quelques secondes tant que quelque chose attend.
- Ce que tu supprimes sur un téléphone n’est mémorisé que sur ce téléphone.
- Le hook a besoin de
jqsur l’ordinateur, et pour l’instant il ne s’enregistre que dans Claude Code. - Le ntfy public voit ce que tu publies dans le topic. Si ça te dérange, héberge le tien.
Si tu veux voir comment ces pièces s’assemblent dans l’app, le reste (terminal, espaces tmux, vue chat) est sur la page de Wendo, et la référence des variables du hook se trouve dans la documentation des alertes et autorisations. Le SSH sur lequel tout ça repose, on le raconte dans SSH, Mosh et Eternal Terminal en Flutter, et une autre pièce qui fonctionne sans serveur, la dictée, dans Whisper hors ligne sur le téléphone. Wendo n’est pas encore sur les stores.