LirafyBlogWendo ↗
Español

Blog

Una app móvil sin backend: avisos y aprobaciones de Claude Code con SSH y archivos

Wendo es una app móvil sin backend: no hay servidor nuestro entre tu ordenador y tu teléfono. Cuando Claude Code pide permiso o termina una tarea, un script de shell en tu máquina apunta el evento en un archivo, y la app lo lee por SSH con la misma clave que usa para abrir el terminal. La respuesta a una aprobación vuelve por el mismo camino, como otro archivo.

Si lo que buscas es configurarlo, eso está en la guía de avisos de Claude Code en el móvil.

Por qué una app móvil sin backend

Wendo es un cliente SSH: para que funcione, tu móvil ya tiene que llegar a tu ordenador y autenticarse en él. Con ese canal abierto y cifrado, un servidor en medio para los avisos sería una pieza de más.

Quitarlo tiene tres consecuencias que buscábamos:

  • Lo que hace tu agente (el comando que quiere ejecutar, el archivo que va a tocar, el último mensaje) no pasa por ninguna máquina nuestra. Se queda en tu ordenador hasta que tu móvil lo pide.
  • No hay cuentas. No hay correo que guardar ni contraseña que filtrar. La identidad es tu clave SSH.
  • No hay infraestructura que pagar ni mantener por cada usuario nuevo.

A cambio, todo lo que haría un servidor (guardar el historial, avisar con la app cerrada, emparejar dispositivos, comprobar compras) hay que resolverlo con lo que ya existe en los dos extremos.

El contrato: una línea JSON por evento

El centro de todo es ~/.wendo/events.jsonl. El instalador registra wendo-hook.sh en seis eventos de Claude Code (PermissionRequest, Notification, Stop, UserPromptSubmit, SessionStart y SessionEnd), y cada vez que salta, el hook lee el JSON que le pasa Claude Code por la entrada estándar y añade una línea al archivo. Una línea de permiso tiene más o menos esta forma (recortada):

{"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"}}

El archivo solo crece por el final. Nadie edita líneas: una aprobación resuelta se apunta como una línea nueva de tipo resolution. Eso simplifica mucho la concurrencia, porque varios hooks pueden escribir a la vez y las líneas cortas se añaden de forma atómica. Cuando el archivo pasa de 1000 líneas, el hook se queda con las 500 últimas (con flock si está disponible, para que rote uno solo a la vez). La carpeta se crea con umask 077 y el archivo queda en 600: solo tu usuario lo lee.

El campo mux dice en qué panel de tmux, zellij o herdr corre el agente, para que la app pueda llevarte a él.

Cómo lee la app: sondeo por SSH

La app no recibe nada: pregunta. En cada lectura abre una conexión SSH a cada host con la bandeja activada y ejecuta, sin terminal, algo tan simple como esto:

tail -n 1000 ~/.wendo/events.jsonl 2>/dev/null; true

La frecuencia depende del estado de la bandeja. Mientras alguna fila espera respuesta, relee cada 5 segundos, porque el hook caduca enseguida. Con agentes trabajando, cada 15. Si no hay nada vivo, solo lee al entrar en la pestaña. Después de que respondas, relee a los 1,2 segundos y otra vez a los 3 para ver la resolución.

Con las líneas en la mano, inbox.dart las ordena por hora y las pliega en filas: una por host y sesión. Ahí vive casi toda la lógica que en otra app estaría en un servidor. Por ejemplo, decide que una petición se contestó en el terminal si después llega un Stop o un UserPromptSubmit sin resolución de por medio, o que un SessionStart por compactación no cambia nada.

Como el archivo es de solo añadir, borrar en la app es recordar qué se borró. Wendo guarda «lápidas» en el propio móvil, por fila o por host. Si el agente vuelve a escribir en esa sesión, la fila reaparece. Si un host no responde, la app conserva lo último que leyó de él en lugar de vaciar la lista.

Con la app cerrada en Android

En Android hay una tarea de WorkManager que despierta como mucho cada 15 minutos (el sistema no deja pedir menos y puede retrasarla para ahorrar batería), lee la bandeja igual que la app abierta y lanza notificaciones locales de lo nuevo. Esta lectura nunca pide la biometría: si tienes la clave SSH protegida y bloqueada, ese host falla y ya. Lo que ya se avisó se guarda en el almacenamiento del móvil para que la app abierta y la tarea de fondo no repitan el mismo aviso. En iOS no existe esta tarea.

Una aprobación de ida y vuelta

PermissionRequest es el único evento en el que el hook se queda esperando. El recorrido completo:

  1. El hook genera un pendingActionId (letras, cifras, _ y -; la app lo valida igual antes de meterlo en una ruta), calcula expiresAt y escribe la línea con options: ["Approve","Deny"].
  2. La app ve una fila con pendingActionId, sin resolución y sin caducar, y muestra los botones.
  3. Al pulsar uno, la app escribe {"decision":"approve"} en ~/.wendo/answers/<id>.tmp y lo renombra a ~/.wendo/answers/<id>. El renombrado evita que el hook lea una respuesta a medias.
  4. El hook mira ese archivo cada medio segundo. Cuando lo encuentra, imprime la decisión en el formato que espera Claude Code y añade su línea de resolución:
{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}

La parte delicada es qué pasa cuando no contestas. La espera dura 90 segundos por defecto (WENDO_APPROVAL_TIMEOUT). Si se agota, el hook no imprime nada, y Claude Code, sin respuesta del hook, hace su pregunta normal en el terminal. El hook apunta una resolución timeout y termina. Si contestas antes en el terminal, Claude Code cancela el hook, y un trap apunta la resolución como local.

La regla de diseño era que un fallo del móvil nunca dejara al agente bloqueado. Sin batería, sin cobertura o con la app cerrada, el peor caso es el de siempre: la pregunta en el terminal.

Queda un hueco: contestar desde el móvil justo cuando el hook ya se ha rendido. Para cubrirlo, después de dejar la respuesta, el mismo comando SSH espera hasta 3 segundos a ver una línea de resolución con ese id. Si la resolución es timeout, o nadie recoge el archivo, lo borra y la app te dice «Expirado — el host recurrirá a la opción local» en vez de darlo por enviado.

Bandeja de Wendo con una aprobación pendiente de Claude Code (npm test en mi-app) y los botones Denegar y Aprobar Recorrido de una aprobación: Claude Code pide permiso, el hook lo apunta en events.jsonl, Wendo lo lee por SSH, respondes en el móvil y el hook devuelve allow o deny a Claude Code

Notificaciones push sin servidor propio: ntfy, y es opcional

Para avisar con la app cerrada en iOS, o antes de los 15 minutos de Android, hace falta alguien que empuje la notificación, y eso exige un servicio de push. Elegimos ntfy porque se publica con una petición HTTP y se puede alojar uno mismo.

Si defines NTFY_TOPIC, el hook publica los avisos de permiso, de notificación y de tarea terminada (no el inicio ni el fin de sesión, ni cada prompt). El aviso lleva un enlace wendo://attach?event=…&host=…&sid=… con el panel del multiplexor, para que al tocarlo la app abra el sitio exacto.

La app también lee el topic (/json?poll=1&since=24h) como una fuente más de la bandeja, pero lo leído por SSH manda. ntfy solo sabe que algo te necesitaba, no si ya se resolvió, así que si la lectura SSH de un host falla, sus avisos de permiso venidos de ntfy no cuentan. Y aquí está el coste en privacidad: en el servidor público, el topic hace de contraseña y el aviso incluye el proyecto y un extracto del comando. Por eso el topic tiene que ser largo y aleatorio, y por eso existe NTFY_URL para usar tu propio servidor.

Emparejar el móvil con el ordenador por QR, sin servidor

Sin cuentas, ¿cómo da de alta el móvil un ordenador? Con un QR que genera wendo-pair.sh y que contiene una URL:

wendo://pair?h=<host>&p=<puerto>&u=<usuario>&n=<nombre>&hk=<huella>

hk es la huella SHA256 de la clave de host ed25519, leída con ssh-keyscan contra el sshd de ese puerto. La app la valida con un patrón estricto (SHA256: y 43 caracteres de base64) y la fija para ese host. La primera conexión se verifica en lugar de aceptarse a ciegas, y si otro día el servidor presenta otra clave, la conexión se rechaza. Un QR sin huella sigue valiendo, con confianza en la primera conexión; uno con la huella mal formada, no. El último paso es instalar la clave pública del móvil en authorized_keys con el comando que enseña la app. Nada de esto sale de tu red.

Pro sin servidor de licencias

Vender una suscripción sin backend es la parte menos cómoda. Wendo Pro se cobra con la facturación de Google Play y de la App Store, y la comprobación vive en el módulo nativo, no en Dart. En Android (ProLicense.kt), cada compra llega con su JSON y una firma RSA hecha con la clave de licencias de la app, y se verifica con la clave pública. También se comprueba que la app no se ha vuelto a firmar, y el estado se guarda sellado con una clave del Keystore, así que editar un archivo no da Pro. Cada sincronización completa con la tienda lo renueva; sin ella, Pro aguanta 7 días con suscripción o 30 con la compra de por vida, y retrasar el reloj no lo alarga. En iOS el nativo verifica con StoreKit 2. Las 5 aprobaciones de prueba del plan gratis también las cuenta el nativo.

El propio código dice hasta dónde llega: con root y Frida, o parcheando la app, se puede saltar todo, porque sin un servidor que pregunte a Google no hay forma de impedirlo. El objetivo es más modesto: que no baste con una herramienta de un clic.

Los límites reales de una app sin backend

  • El móvil tiene que llegar a tu ordenador. En la misma wifi, la IP local basta; desde fuera, necesitas algo como Tailscale. Si el ordenador está apagado o dormido, la app no tiene a quién preguntar.
  • Los avisos tardan lo que tarde el sondeo: unos segundos con la app abierta (5 si algo espera respuesta, 15 si solo hay agentes trabajando), hasta 15 minutos o más en segundo plano en Android, y en iOS con la app cerrada solo llegan con ntfy.
  • Cada lectura es una conexión SSH. Con varios hosts, son varias conexiones cada pocos segundos mientras algo espera.
  • Lo que borras en un móvil se recuerda solo en ese móvil.
  • El hook necesita jq en el ordenador, y por ahora solo se registra en Claude Code.
  • El ntfy público ve lo que publicas en el topic. Si eso te molesta, aloja el tuyo.

Si quieres ver cómo encajan estas piezas en la app, en la página de Wendo está el resto (terminal, espacios de tmux, vista de chat), y la referencia de variables del hook está en la documentación de avisos y aprobaciones. El SSH sobre el que se apoya todo esto lo contamos en SSH, Mosh y Eternal Terminal en Flutter, y otra pieza que funciona sin servidor, el dictado, en Whisper en el móvil, sin internet. Wendo todavía no está en las tiendas.