Lirafy블로그Wendo ↗
한국어

블로그

백엔드 없는 모바일 앱: SSH와 파일로 Claude Code 알림과 승인 전달하기

Wendo는 백엔드 없는 모바일 앱입니다. 여러분의 컴퓨터와 폰 사이에 저희 서버는 없습니다. Claude Code가 권한을 요청하거나 작업을 끝내면 여러분의 컴퓨터에 있는 셸 스크립트가 그 이벤트를 파일에 적고, 앱은 터미널을 열 때 쓰는 것과 같은 키로 SSH를 통해 그 파일을 읽습니다. 승인에 대한 답도 같은 길로, 또 하나의 파일이 되어 돌아갑니다.

설정 방법이 궁금하다면 폰에서 Claude Code 알림 받기 가이드에 정리해 두었습니다.

백엔드 없는 모바일 앱이란: 왜 서버를 뺐나

Wendo는 SSH 클라이언트입니다. 동작하려면 폰이 원래 컴퓨터에 닿을 수 있고 거기서 인증할 수 있어야 합니다. 암호화된 그 통로가 이미 열려 있는데, 알림을 위해 중간에 서버를 두는 건 군더더기입니다.

서버를 없애서 저희가 원하던 결과 세 가지를 얻었습니다.

  • 에이전트가 하는 일(실행하려는 명령, 건드리려는 파일, 마지막 메시지)은 저희 쪽 어떤 기계도 거치지 않습니다. 폰이 요청할 때까지 여러분의 컴퓨터에 머뭅니다.
  • 계정이 없습니다. 보관할 이메일도, 유출될 비밀번호도 없습니다. 신원은 SSH 키가 대신합니다.
  • 사용자가 늘 때마다 돈을 내고 관리해야 하는 인프라가 없습니다.

그 대가로 서버가 했을 일(기록 보관, 앱이 닫혀 있을 때의 알림, 기기 페어링, 구매 확인)은 양쪽 끝에 이미 있는 것으로 해결해야 합니다.

계약: 이벤트 하나에 JSON 한 줄

모든 것의 중심은 ~/.wendo/events.jsonl입니다. 설치 프로그램은 wendo-hook.sh를 Claude Code의 이벤트 여섯 개(PermissionRequest, Notification, Stop, UserPromptSubmit, SessionStart, SessionEnd)에 등록합니다. 이벤트가 발생할 때마다 훅은 Claude Code가 표준 입력으로 넘겨주는 JSON을 읽고 파일에 한 줄을 덧붙입니다. 권한 요청 줄은 대략 이런 모양입니다(일부 생략).

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

파일은 끝에서만 자랍니다. 누구도 줄을 고치지 않습니다. 처리된 승인은 resolution 유형의 새 줄로 기록합니다. 덕분에 동시성 문제가 훨씬 단순해집니다. 여러 훅이 동시에 써도 짧은 줄은 원자적으로 덧붙여지기 때문입니다. 파일이 1000줄을 넘으면 훅은 마지막 500줄만 남깁니다(flock이 있으면 이를 써서 한 번에 하나만 회전시킵니다). 폴더는 umask 077로 만들어지고 파일 권한은 600입니다. 여러분의 사용자만 읽을 수 있습니다.

mux 필드는 에이전트가 tmux, zellij, herdr의 어느 패널에서 돌고 있는지 알려 주고, 앱은 이를 보고 여러분을 그 자리로 데려갑니다.

앱이 읽는 방식: SSH 폴링

앱은 아무것도 받지 않고, 직접 물어봅니다. 읽을 때마다 받은편지함이 켜진 호스트마다 SSH 연결을 열고, 터미널 없이 이 정도로 단순한 명령을 실행합니다.

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

빈도는 받은편지함 상태에 따라 달라집니다. 답을 기다리는 항목이 있으면 훅이 금방 만료되므로 5초마다 다시 읽습니다. 에이전트가 작업 중이면 15초마다입니다. 살아 있는 것이 없으면 탭에 들어갈 때만 읽습니다. 답을 보낸 뒤에는 처리 결과를 확인하려고 1.2초 뒤와 3초 뒤에 한 번씩 더 읽습니다.

줄을 손에 쥐면 inbox.dart가 시간순으로 정렬하고 호스트와 세션마다 항목 하나로 접습니다. 다른 앱이라면 서버에 있었을 로직 대부분이 여기 있습니다. 예를 들어, 요청 뒤에 처리 줄 없이 Stop이나 UserPromptSubmit이 오면 터미널에서 답한 것으로 판단하고, 컴팩션 때문에 생긴 SessionStart는 아무것도 바꾸지 않는다고 판단합니다.

파일이 덧붙이기 전용이라, 앱에서 지운다는 건 무엇을 지웠는지 기억한다는 뜻입니다. Wendo는 항목별 또는 호스트별 “묘비”를 폰 자체에 저장합니다. 에이전트가 그 세션에 다시 쓰면 항목이 다시 나타납니다. 호스트가 응답하지 않으면 목록을 비우지 않고 그 호스트에서 마지막으로 읽은 내용을 그대로 둡니다.

Android에서 앱이 닫혀 있을 때

Android에는 아무리 잦아도 15분에 한 번 깨어나는 WorkManager 작업이 있습니다(시스템이 그보다 짧은 간격을 허용하지 않고, 배터리를 아끼려고 늦출 수도 있습니다). 이 작업은 열린 앱과 똑같이 받은편지함을 읽고 새 항목을 로컬 알림으로 띄웁니다. 이 읽기는 생체 인증을 요구하지 않습니다. SSH 키를 보호해서 잠가 두었다면 그 호스트는 그냥 실패하고 끝납니다. 이미 알린 내용은 폰 저장소에 기록해서, 열린 앱과 백그라운드 작업이 같은 알림을 두 번 띄우지 않게 합니다. iOS에는 이 작업이 없습니다.

승인 한 번의 왕복

훅이 기다리며 머무는 이벤트는 PermissionRequest 하나뿐입니다. 전체 흐름은 이렇습니다.

  1. 훅이 pendingActionId(영문자, 숫자, _, -. 앱도 경로에 넣기 전에 같은 규칙으로 검사합니다)를 만들고, expiresAt을 계산하고, options: ["Approve","Deny"]가 담긴 줄을 씁니다.
  2. 앱은 pendingActionId가 있고, 처리되지 않았고, 만료되지 않은 항목을 보고 버튼을 보여 줍니다.
  3. 버튼을 누르면 앱은 {"decision":"approve"}를 ~/.wendo/answers/<id>.tmp에 쓰고 ~/.wendo/answers/<id>로 이름을 바꿉니다. 이름 바꾸기 덕분에 훅이 반쯤 쓰인 답을 읽지 않습니다.
  4. 훅은 0.5초마다 그 파일을 확인합니다. 파일을 찾으면 Claude Code가 기대하는 형식으로 결정을 출력하고 처리 줄을 덧붙입니다.
{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}

까다로운 건 답하지 않았을 때입니다. 대기 시간은 기본 90초입니다(WENDO_APPROVAL_TIMEOUT). 시간이 다 되면 훅은 아무것도 출력하지 않고, 훅에게서 답을 받지 못한 Claude Code는 평소처럼 터미널에서 묻습니다. 훅은 timeout 처리를 기록하고 끝납니다. 터미널에서 먼저 답하면 Claude Code가 훅을 취소하고, trap이 처리를 local로 기록합니다.

설계 원칙은 폰 쪽 문제로 에이전트가 절대 막히지 않게 하는 것이었습니다. 배터리가 없든, 신호가 없든, 앱이 닫혀 있든 최악의 경우는 늘 같습니다. 터미널에 뜨는 평소의 질문입니다.

틈이 하나 남습니다. 훅이 포기한 바로 그 순간에 폰에서 답하는 경우입니다. 이를 막으려고, 답을 놓은 뒤 같은 SSH 명령이 그 id의 처리 줄이 나타나는지 최대 3초 동안 기다립니다. 처리 결과가 timeout이거나 아무도 파일을 가져가지 않으면 파일을 지우고, 앱은 보낸 것으로 치지 않고 “만료됨 — 호스트가 로컬로 대체됩니다”라고 알려 줍니다.

Claude Code 승인 대기(my-app의 npm test)와 거부·승인 버튼이 보이는 Wendo 받은편지함 승인의 흐름: Claude Code가 권한을 요청하고, 훅이 events.jsonl에 기록하고, Wendo가 SSH로 읽고, 폰에서 답하면 훅이 Claude Code에 allow나 deny를 돌려줍니다

서버 없이 푸시 알림 보내기: ntfy, 그것도 선택 사항

iOS에서 앱이 닫혀 있을 때, 또는 Android의 15분보다 먼저 알리려면 알림을 밀어 줄 누군가가 필요하고, 그러려면 푸시 서비스가 있어야 합니다. 저희는 ntfy를 골랐습니다. HTTP 요청 한 번으로 게시할 수 있고, 직접 호스팅할 수도 있기 때문입니다.

NTFY_TOPIC을 정의하면 훅은 권한 요청, 알림, 작업 완료를 게시합니다(세션 시작과 종료, 프롬프트마다의 이벤트는 게시하지 않습니다). 알림에는 멀티플렉서 패널이 담긴 wendo://attach?event=…&host=…&sid=… 링크가 붙어 있어서, 누르면 앱이 정확히 그 자리를 엽니다.

앱은 토픽(/json?poll=1&since=24h)도 받은편지함의 출처 중 하나로 읽지만, SSH로 읽은 내용이 우선합니다. ntfy는 무언가가 여러분을 필요로 했다는 것만 알 뿐, 그것이 이미 처리됐는지는 모릅니다. 그래서 어떤 호스트의 SSH 읽기가 실패하면, ntfy로 온 그 호스트의 권한 요청은 셈에 넣지 않습니다. 그리고 여기에 사생활 비용이 있습니다. 공개 서버에서는 토픽이 비밀번호 역할을 하고, 알림에는 프로젝트 이름과 명령 일부가 들어갑니다. 그래서 토픽은 길고 무작위여야 하고, 직접 운영하는 서버를 쓰기 위한 NTFY_URL이 있습니다.

서버 없이 폰과 컴퓨터를 QR로 페어링하기

계정이 없는데 폰은 어떻게 컴퓨터를 등록할까요? wendo-pair.sh가 만드는 QR 코드로 하고, 그 안에는 URL이 들어 있습니다.

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

hk는 ed25519 호스트 키의 SHA256 지문으로, 그 포트의 sshd에 ssh-keyscan을 실행해 읽은 값입니다. 앱은 엄격한 패턴(SHA256:와 base64 43자)으로 검사한 뒤 그 호스트에 고정합니다. 첫 연결은 무조건 받아들이지 않고 검증하며, 나중에 서버가 다른 키를 내밀면 연결을 거부합니다. 지문이 없는 QR도 쓸 수 있고, 이때는 첫 연결을 신뢰하는 방식이 됩니다. 지문 형식이 잘못된 QR은 쓸 수 없습니다. 마지막 단계는 앱이 보여 주는 명령으로 폰의 공개 키를 authorized_keys에 넣는 것입니다. 이 중 어느 것도 여러분의 네트워크 밖으로 나가지 않습니다.

라이선스 서버 없는 Pro

백엔드 없이 구독을 파는 건 가장 불편한 부분입니다. Wendo Pro는 Google Play와 App Store 결제로 청구되고, 확인은 Dart가 아니라 네이티브 모듈에서 합니다. Android(ProLicense.kt)에서는 구매마다 JSON과 앱 라이선스 키로 만든 RSA 서명이 함께 오고, 이를 공개 키로 검증합니다. 앱이 다시 서명되지 않았는지도 확인하고, 상태는 Keystore 키로 봉인해 저장하므로 파일을 고쳐도 Pro가 되지 않습니다. 스토어와 완전히 동기화될 때마다 상태가 갱신됩니다. 동기화가 없으면 Pro는 구독이면 7일, 평생 구매면 30일 동안 유지되고, 시계를 되돌려도 늘어나지 않습니다. iOS에서는 네이티브 쪽이 StoreKit 2로 검증합니다. 무료 플랜의 체험 승인 5회도 네이티브 쪽이 셉니다.

코드 스스로 어디까지인지 밝힙니다. root와 Frida를 쓰거나 앱을 패치하면 전부 우회할 수 있습니다. Google에 물어볼 서버가 없으면 막을 방법이 없기 때문입니다. 목표는 더 소박합니다. 클릭 한 번짜리 도구로는 안 되게 하는 것입니다.

백엔드 없는 앱의 한계: 서버가 없어서 생기는 진짜 문제

  • 폰이 컴퓨터에 닿아야 합니다. 같은 Wi-Fi라면 로컬 IP로 충분하고, 밖에서는 Tailscale 같은 것이 필요합니다. 컴퓨터가 꺼져 있거나 잠들어 있으면 앱은 물어볼 상대가 없습니다.
  • 알림은 폴링 간격만큼 늦게 옵니다. 앱이 열려 있으면 몇 초(답을 기다리는 게 있으면 5초, 에이전트가 작업만 하고 있으면 15초), Android 백그라운드에서는 15분 이상, iOS에서 앱이 닫혀 있으면 ntfy로만 옵니다.
  • 읽을 때마다 SSH 연결이 하나 열립니다. 호스트가 여럿이면, 무언가 기다리는 동안 몇 초마다 연결이 여러 개 열립니다.
  • 한 폰에서 지운 내용은 그 폰만 기억합니다.
  • 훅에는 컴퓨터에 jq가 있어야 하고, 지금은 Claude Code에만 등록됩니다.
  • 공개 ntfy는 토픽에 게시한 내용을 볼 수 있습니다. 그게 신경 쓰이면 직접 호스팅하세요.

이 부품들이 앱 안에서 어떻게 맞물리는지는 Wendo 페이지에서 나머지(터미널, tmux 공간, 채팅 뷰)와 함께 볼 수 있고, 훅 변수 레퍼런스는 알림과 승인 문서에 있습니다. 이 모든 것을 받치는 SSH는 Flutter 앱에 SSH, Mosh, Eternal Terminal을 넣은 방법에서, 서버 없이 동작하는 또 하나의 부품인 받아쓰기는 인터넷 없이 폰에서 Whisper 쓰기에서 다뤘습니다. Wendo는 아직 스토어에 나오지 않았습니다.