Um app mobile sem backend: avisos e aprovações do Claude Code com SSH e arquivos

O Wendo é um app mobile sem backend: não existe servidor nosso entre o seu computador e o seu celular. Quando o Claude Code pede permissão ou termina uma tarefa, um script de shell na sua máquina registra o evento num arquivo, e o app lê esse arquivo por SSH com a mesma chave que usa para abrir o terminal. A resposta a uma aprovação volta pelo mesmo caminho, como outro arquivo.
Se o que você procura é configurar isso, está no guia de avisos do Claude Code no celular.
Por que um app mobile sem backend
O Wendo é um cliente SSH: para funcionar, o seu celular já precisa alcançar o seu computador e se autenticar nele. Com esse canal aberto e cifrado, um servidor no meio para os avisos seria uma peça a mais.
Tirar o servidor tem três consequências que queríamos:
- O que o seu agente faz (o comando que quer rodar, o arquivo que vai mexer, a última mensagem) não passa por nenhuma máquina nossa. Fica no seu computador até o celular pedir.
- Não há contas. Nenhum e-mail para guardar, nenhuma senha para vazar. A identidade é a sua chave SSH.
- Não há infraestrutura para pagar nem manter a cada usuário novo.
Em troca, tudo o que um servidor faria (guardar o histórico, avisar com o app fechado, parear aparelhos, verificar compras) tem de ser resolvido com o que já existe nas duas pontas.
O contrato: uma linha JSON por evento
O centro de tudo é o ~/.wendo/events.jsonl. O instalador registra o wendo-hook.sh em seis eventos do Claude Code (PermissionRequest, Notification, Stop, UserPromptSubmit, SessionStart e SessionEnd), e cada vez que um dispara, o hook lê o JSON que o Claude Code passa pela entrada padrão e acrescenta uma linha ao arquivo. Uma linha de permissão tem mais ou menos esta cara (cortada):
{"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"}}
O arquivo só cresce no final. Ninguém edita linhas: uma aprovação resolvida vira uma linha nova do tipo resolution. Isso simplifica muito a concorrência, porque vários hooks podem escrever ao mesmo tempo e as linhas curtas são acrescentadas de forma atômica. Quando o arquivo passa de 1000 linhas, o hook fica com as 500 últimas (com flock, se estiver disponível, para que só um faça a rotação por vez). A pasta é criada com umask 077 e o arquivo fica em 600: só o seu usuário lê.
O campo mux diz em qual painel do tmux, zellij ou herdr o agente roda, para que o app possa levar você até ele.
Como o app lê: polling por SSH
O app não recebe nada: ele pergunta. Em cada leitura, abre uma conexão SSH com cada host que tem a caixa de entrada ativada e roda, sem terminal, algo simples assim:
tail -n 1000 ~/.wendo/events.jsonl 2>/dev/null; true
A frequência depende do estado da caixa de entrada. Enquanto alguma linha espera resposta, relê a cada 5 segundos, porque o hook expira rápido. Com agentes trabalhando, a cada 15. Se não há nada vivo, só lê quando você entra na aba. Depois que você responde, relê em 1,2 segundo e de novo em 3 para ver a resolução.
Com as linhas em mãos, o inbox.dart ordena tudo por hora e agrupa em itens: um por host e sessão. É ali que fica quase toda a lógica que, em outro app, estaria num servidor. Por exemplo, ele decide que uma solicitação foi respondida no terminal se depois chega um Stop ou um UserPromptSubmit sem resolução no meio, ou que um SessionStart causado por compactação não muda nada.
Como o arquivo é só de acréscimo, apagar no app significa lembrar o que foi apagado. O Wendo guarda “lápides” (tombstones) no próprio celular, por item ou por host. Se o agente volta a escrever naquela sessão, o item reaparece. Se um host não responde, o app mantém o que leu dele por último em vez de esvaziar a lista.
Com o app fechado no Android
No Android há uma tarefa do WorkManager que acorda no máximo a cada 15 minutos (o sistema não deixa pedir menos e pode atrasá-la para economizar bateria), lê a caixa de entrada igual ao app aberto e dispara notificações locais do que for novo. Essa leitura nunca pede biometria: se a sua chave SSH estiver protegida e bloqueada, aquele host falha e pronto. O que já foi avisado fica salvo no armazenamento do celular, para que o app aberto e a tarefa em segundo plano não repitam o mesmo aviso. No iOS essa tarefa não existe.
Uma aprovação de ida e volta
PermissionRequest é o único evento em que o hook fica esperando. O caminho completo:
- O hook gera um
pendingActionId(letras, números,_e-; o app faz a mesma validação antes de usá-lo num caminho), calcula oexpiresAte escreve a linha comoptions: ["Approve","Deny"]. - O app vê um item com
pendingActionId, sem resolução e sem ter expirado, e mostra os botões. - Quando você toca num deles, o app escreve
{"decision":"approve"}em~/.wendo/answers/<id>.tmpe renomeia para~/.wendo/answers/<id>. A renomeação evita que o hook leia uma resposta pela metade. - O hook olha esse arquivo a cada meio segundo. Quando o encontra, imprime a decisão no formato que o Claude Code espera e acrescenta a sua linha de resolução:
{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}
A parte delicada é o que acontece quando você não responde. A espera dura 90 segundos por padrão (WENDO_APPROVAL_TIMEOUT). Se o tempo acaba, o hook não imprime nada, e o Claude Code, sem resposta do hook, faz a pergunta normal no terminal. O hook registra uma resolução timeout e termina. Se você responde antes no terminal, o Claude Code cancela o hook, e um trap registra a resolução como local.
A regra de design era que uma falha no celular nunca deixasse o agente travado. Sem bateria, sem sinal ou com o app fechado, o pior caso é o de sempre: a pergunta no terminal.
Sobra uma brecha: responder pelo celular bem quando o hook já desistiu. Para cobrir isso, depois de deixar a resposta, o mesmo comando SSH espera até 3 segundos por uma linha de resolução com aquele id. Se a resolução é timeout, ou ninguém pega o arquivo, ele o apaga e o app mostra “Expirado — o host vai decidir localmente” em vez de dar a resposta como enviada.
Notificações push sem servidor próprio: ntfy, e é opcional
Para avisar com o app fechado no iOS, ou antes dos 15 minutos do Android, alguém precisa empurrar a notificação, e isso exige um serviço de push. Escolhemos o ntfy porque publicar nele é uma requisição HTTP e dá para hospedar o seu.
Se você define NTFY_TOPIC, o hook publica os avisos de permissão, de notificação e de tarefa concluída (não o início nem o fim da sessão, nem cada prompt). O aviso leva um link wendo://attach?event=…&host=…&sid=… com o painel do multiplexador, para que, ao tocar, o app abra o lugar exato.
O app também lê o topic (/json?poll=1&since=24h) como mais uma fonte da caixa de entrada, mas o que vem por SSH manda. O ntfy só sabe que algo precisava de você, não se já foi resolvido; por isso, se a leitura SSH de um host falha, os avisos de permissão desse host vindos do ntfy não contam. E aqui está o custo em privacidade: no servidor público, o topic funciona como senha e o aviso inclui o projeto e um trecho do comando. Por isso o topic tem de ser longo e aleatório, e por isso existe o NTFY_URL, para usar o seu próprio servidor.
Parear o celular com o computador por QR, sem servidor
Sem contas, como o celular cadastra um computador? Com um QR que o wendo-pair.sh gera e que contém uma URL:
wendo://pair?h=<host>&p=<porta>&u=<usuario>&n=<nome>&hk=<impressao>
hk é a impressão digital SHA256 da chave de host ed25519, lida com ssh-keyscan no sshd daquela porta. O app a valida com um padrão estrito (SHA256: e 43 caracteres de base64) e a fixa para aquele host. A primeira conexão é verificada em vez de ser aceita às cegas, e se um dia o servidor apresentar outra chave, a conexão é recusada. Um QR sem impressão digital continua valendo, com confiança na primeira conexão; um com a impressão malformada, não. O último passo é instalar a chave pública do celular no authorized_keys com o comando que o app mostra. Nada disso sai da sua rede.
Pro sem servidor de licenças
Vender assinatura sem backend é a parte menos confortável. O Wendo Pro é cobrado pelo faturamento do Google Play e da App Store, e a verificação fica no módulo nativo, não no Dart. No Android (ProLicense.kt), cada compra chega com o seu JSON e uma assinatura RSA feita com a chave de licenciamento do app, e é verificada com a chave pública. Também se confere que o app não foi reassinado, e o estado é salvo selado com uma chave do Keystore, então editar um arquivo não libera o Pro. Cada sincronização completa com a loja renova o estado; sem ela, o Pro aguenta 7 dias com assinatura ou 30 com a compra vitalícia, e atrasar o relógio não estica esse prazo. No iOS o nativo verifica com o StoreKit 2. As 5 aprovações de teste do plano gratuito também são contadas no nativo.
O próprio código diz até onde isso vai: com root e Frida, ou modificando o app, dá para burlar tudo, porque sem um servidor que pergunte ao Google não há como impedir. O objetivo é mais modesto: que uma ferramenta de um clique não baste.
Os limites reais de um app sem backend
- O celular precisa alcançar o seu computador. Na mesma rede Wi-Fi, o IP local basta; de fora, você precisa de algo como o Tailscale. Se o computador está desligado ou em repouso, o app não tem a quem perguntar.
- Os avisos demoram o que o polling demorar: alguns segundos com o app aberto (5 se algo espera resposta, 15 se só há agentes trabalhando), até 15 minutos ou mais em segundo plano no Android, e no iOS com o app fechado só chegam com o ntfy.
- Cada leitura é uma conexão SSH. Com vários hosts, são várias conexões a cada poucos segundos enquanto algo espera.
- O que você apaga num celular fica lembrado só naquele celular.
- O hook precisa do
jqno computador e, por enquanto, só é registrado no Claude Code. - O ntfy público vê o que você publica no topic. Se isso incomoda, hospede o seu.
Se você quer ver como essas peças se encaixam no app, a página do Wendo mostra o resto (terminal, espaços do tmux, visão de chat), e a referência das variáveis do hook está na documentação de avisos e aprovações. O SSH em que tudo isso se apoia contamos em SSH, Mosh e Eternal Terminal no Flutter, e outra peça que funciona sem servidor, o ditado, em Whisper no celular, sem internet. O Wendo ainda não está nas lojas.