Skip to content

Mobile app

View as Markdown

The handup mobile app is a companion to the daemon on your computer, not a daemon of its own. It connects through the remote listener, so turn on remote access first (remote.mode: tailscale is recommended).

Android is a pre-alpha, sideloaded APK published with each handup release (handup-android_<version>_arm64.apk); there is no Play Store listing or native iOS app, and no public release has been published yet. See downloads and releases for availability.

Phone clients never execute command requests and have no Run or Run as admin button. Approval grants permission to the agent; it does not start a desktop run. Paired devices cannot submit run_result. A command run locally in the desktop app can still be reviewed in History with its returned result. During a desktop run, the phone shows Running and decisions are blocked with 409 running in the desktop app. Agent cancellation can still stop the run; expiry is paused while its 12-minute claim lease is active.

Download the APK from the release on your phone, open it, and allow your browser to install unknown apps when Android asks, only if you trust the release. Verify it against the release checksums first if you can (see downloads). No Android SDK, Rust toolchain or application source is needed. The phone app then pairs with your computer.

  1. On the computer, run handup pair (add --scope view for a read-only device), or open Pair a phone in the desktop app.
  2. In the app, tap Scan QR code and point the camera at the QR code. Or paste the printed link (http://100.x.y.z:7466/pair#code=…) into Pairing link and tap Pair with link. Away from your tailnet, use handup pair --relay instead: the app then talks to the daemon through your end-to-end encrypted relay (…/pair#ch=…).
  3. The code works once and expires after two minutes.

The app can pair with several computers (say a laptop, a desktop and a build server). Their pending requests share one inbox; once two or more are paired, each row and request shows a chip with its computer’s name. Approve, deny, previews, attachments, history and audit always go to the request’s own computer, and each computer stays the only authority for its requests (they never sync with each other). Pairing the same computer again replaces just that pairing.

Settings (the gear icon) lists the paired computers with the device name and scope and the pinned certificate. Rename a computer there (the name defaults to its hostname), tap Pair another computer to add one, or Unpair one. Unpairing only forgets that computer’s token on the phone. To cut the device off, also run handup devices revoke <id> on the computer. When every paired computer has revoked the device, the app shows “This device was revoked” and offers pairing again; a single revoked computer shows as offline in the header while the others keep working.

If a computer is asleep or off the tailnet, its name shows as offline in the header (with one computer: “Can’t reach handup”) and the app keeps retrying; the other computers’ requests stay usable. History shows one computer at a time, picked above the search box.

For ordinary Approve/Deny requests, Deny is on the left and Approve is on the right in the bottom action row, above the phone’s safe area. Questions follow the same rule (Decline left, Submit right), as do custom choices: the agent’s primary option takes the right edge and deny options the left. Extras such as Raw JSON or Edit & approve sit in a row above; the allow scopes (Allow for session, Allow in project, Always allow) fold behind an Allow… toggle there so the footer stays short. To mirror every decision row (primary on the left, Undo above it), set decisions.primary_side: left in the laptop’s config, or choose Settings → Decisions → Approve button side → Left for this phone only. The 48px feedback slot is always reserved above the controls: showing Undo, replacing the feedback, or clearing it never moves the decision buttons. Undo has a 44px touch target and is separated from the decision row.

Swipe the approval card—including its title and preview—right to approve or left to deny, like dealing a card. The card follows your finger, tilts, and shows an Approve or Deny stamp. A short drag (about a fifth of the card) or a quick flick sends it off; let go earlier and it springs back. There is no separate swipe strip. Cancelled, edge-started, and multi-finger gestures do nothing. Turn Swipe cards off under Settings → Decisions to use only the buttons. Swipe right always approves, also when the buttons are mirrored to the left. Vertical scrolling and pinch zoom remain native. A drag locks to an axis after a few pixels: mostly-vertical drags scroll, mostly-horizontal drags move the card from anywhere on it, including file lists, diffs and code previews. A preview that is already scrolled sideways (a long code or diff line) scrolls back to its edge first; the next drag moves the card. Taps on buttons, file rows and checkboxes still work. Swipes starting on input fields, sliders, media controls or an existing text selection do not decide. Interactive HTML frames keep their own input. Buttons and tabs have 44px touch targets on narrow screens. The buttons remain available; swipes are not offered for custom choices, questions, multiple selected requests, or view-only devices. Both methods use the same validation, biometric gate, and held-send/Undo behavior.

After the last decision, All clear replaces the card, but Undo remains in the same bottom slot above the former Approve button—not in the center of the screen. The last action row’s space remains reserved during the Undo window. Long titles truncate so the countdown and Undo stay visible. Swipe through several requests quickly and every decision still waiting keeps its own countdown and 44px Undo: the newest stays in the footer slot, older ones stack above it over the card (up to four, then “+N more”), so you can take back any one without the buttons moving. Desktop keeps its compact sizing and shows the U shortcut.

While the app stays visible, decisions wait for the undo window (5 seconds by default). Switching apps or otherwise hiding handup ends that window and hands held decisions to delivery immediately. On Android, a native outbox keeps retrying offline decisions in the background; see Offline for limits.

The History tab above the request list shows resolved requests grouped by day, with search and outcome chips. Tap one for the read-only detail with the decision summary and audit trail; History (or the back gesture) returns to the list. It uses the same API as the desktop app, so a view-only device sees it too.

  • The token stays out of the webview. The React UI calls Rust commands. Rust attaches Authorization: Bearer <device token> and talks to the daemon over its own HTTP/WebSocket client. No command returns the token.
  • Secure storage. On Android the pairing (base URL, fingerprint, token) and settings are sealed with AES-256-GCM under a non-exportable Android Keystore key (SecureStorePlugin, alias handup-secure-store). Only the ciphertext is written to the app-private shared_prefs/handup.secure.xml. App backup is disabled (allowBackup="false"). On iOS the Keychain is used through the keyring crate (untested).
  • TLS pinning. When the pairing link carries a certificate fingerprint (fp=, e.g. direct mode with its self-signed certificate), the app accepts only a server certificate whose SHA-256 matches it. Without a fingerprint, HTTPS URLs are checked against the public web roots. In tailscale mode the daemon listens on plain HTTP inside the tailnet, and WireGuard provides the encryption.
  • Relay pairing. A relay link carries the daemon’s Noise static public key (pk=) and the device’s relay bearer credential (dk=). The app pins the key and runs Noise_IK_25519_ChaChaPoly_SHA256 with the daemon, so the relay sees only ciphertext. The app’s per-device Noise key and the channel credential live in the same Keystore-sealed pairing.

Approving a high-risk request asks for your fingerprint or face, or falls back to the screen lock PIN or pattern (“Use PIN”). Denying never asks, and neither do low- or medium-risk requests. The setting Confirm high-risk approvals is on by default. Turning it off requires the same confirmation when the device has a lock screen. The gate fails closed. If the device has no screen lock, high-risk approvals fail with an error until you set one up or turn the setting off.

Turning on Hard YOLO from the header switch (YOLO mode) asks for the same confirmation, since it auto-approves high-risk requests.

The prompt appears when you tap Approve or swipe to approve, before the few-second undo window and before an offline approval is queued. The confirmation covers that request at that content only: if the request changes before the approval is sent, the daemon refuses it. On Android, a queued high-risk approval with its tap-time confirmation can send in the background. Without that confirmation, it waits for the open app and prompts there; background delivery never bypasses the gate.

The header pill shows whether the phone is in sync with your computer: Live (changes arrive as they happen), Syncing… (catching up or reconnecting; you can keep browsing and deciding), or Offline · synced 14:03 (the time of the last sync). Tap it to resync now. The app also resyncs the moment it returns to the foreground, the network comes back, its event stream reconnects, or a push notification arrives. The daemon pings the event stream every 25 seconds (through a relay, the relay’s own pings every 30 seconds count); a stream that stays silent for 75 seconds (a network change, or Android dozing) is dropped and reconnected rather than left looking live.

A request answered somewhere else (the laptop, the terminal, another phone) leaves the phone’s inbox as soon as the event arrives. If it is the one open on screen, it stays for a moment with its controls dimmed and the footer saying how it ended, e.g. Approved on laptop or Denied on another device, then moves to the next pending request, or All clear. It is in History as usual.

Once the inbox has loaded, losing the connection to your computer never hides it. The last queue is kept on the phone, so opening the app while offline still shows it, marked with when it last synced.

Approve, deny, and answers made while offline are queued in Decisions waiting to send, each marked Sending when connected with an Undo button. When the connection returns they are sent in the order you made them, bound to the content you saw. The first decision to reach your computer wins. If one from the phone loses (the request was already answered elsewhere, expired, or changed since), it is dropped, never retried and never counted as a second decision, and its row says what happened, e.g. Already handled on laptop · Approved. The same line appears in the footer when a decision sent while online loses that race. Nothing is ever sent that you did not tap. Each computer keeps its own cached queue and outbox: one being offline never holds back decisions for another. On Android, queued decisions are also stored in a durable native outbox (outbox.db), separate from the disposable History/file cache. Rust delivers them without relying on the webview to keep running. Hiding the app ends any remaining undo windows and tries every queued decision, even for a computer whose connection looked down. Network failures stay queued and retry in the background.

When you leave the app with pending work, Android starts a foreground service. Its silent Sending 1 decision… or Sending N decisions… notification shows how many decisions are waiting and disappears when none are waiting. If Android refuses to start the service, delivery waits for the next open. Swiping handup away or Force stop stops background decision delivery until you reopen it; queued decisions survive. Android 15 limits dataSync foreground services to about six hours per day. When that allowance runs out, unsent decisions remain queued for the next open. This native background delivery is Android-only; desktop and web behavior is unchanged. Unpairing a computer forgets its cached queue and any unsent decisions for it.

The app keeps a copy of the History pages, request details, audit trails and files you read or that auto-download fetched, per paired computer, in an app-private SQLite database. When that computer is unreachable, History serves the copy and shows Offline copy from … with its age; online, every read goes to the computer and refreshes the copy. Only these reads are kept: never the pending queue (it has its own store above), decisions or other writes, settings, pairing, devices, or any token. Files are checked against their SHA-256 before they are kept.

Each computer’s card in Settings has its own controls:

  • History kept on this phone: Last 30 days (default) of what you read, Everything read, or Off (nothing is kept, files included).
  • Space limit: 256 MB by default (16–4096). Past it, the least recently used entries go first; lowering it trims at once.
  • Also save files to Downloads (off by default, Android 10 and later): new requests’ named files, and files you open, are copied to Download/handup once each, up to a per-file limit (25 MB by default). It uses Android’s MediaStore, so no storage permission is asked. Saved files stay after you unpair; delete them in a file manager.
  • Clear cache deletes that computer’s copy only. Unpairing a computer also deletes its copy and settings; other computers keep theirs.

The copy holds request content (titles, previews, files), so anyone who can unlock the phone and open the app can read it offline. Set History to Off on a computer whose requests should not stay on the phone.

Settings → Previews decides whether the files of new pending requests (images, video, audio, PDFs and other files) download in the background, so their previews open at once instead of loading while you wait:

  • Auto-download media: Always, Wi-Fi only (default; any network Android reports as unmetered counts), or Never (files load when you open them).
  • Per request up to (MB): 50 MB by default (1–1024). A request’s files download in order while they fit; a file that does not fit is skipped and loads when you open it.

Downloads start when a request arrives, and again whenever the app reconnects to a computer (opening the app, network back, or a push notification waking it), so requests that arrived meanwhile are caught up. The setting applies to every paired computer and uses that computer’s offline copy: with History kept on this phone set to Off, nothing downloads. Opening a preview while its file is still downloading waits for that download instead of starting another.

Once a request is decided, expires or is cancelled, its downloaded files are deleted unless you opened them (opened files stay like any file you read) or another pending request still uses them. The computer’s Space limit caps everything kept, oldest-used first.

A download gives up only after 30 seconds without receiving anything, not after a fixed total time, so large files finish on a slow or relayed connection.

Settings opens a full-screen page on Android, with a persistent Back button and independently scrolling sections. Android Back also returns to the inbox. Changes save automatically on this device; there is no separate Save step and no confirmation when you leave. The header always shows the save state: Saving…, Saved with a check, or Not saved with Retry. Back during a pending save waits for it to finish, then returns. If a save failed, Back keeps Settings open and offers Retry or Discard and go back; discarding keeps the settings that were actually stored.

Settings has collapsible Appearance, Decisions, Previews, Read aloud, and Test groups alongside the paired-computer controls. Decisions contains the undo window, approve button side, and Swipe cards; Previews holds auto-download media. Settings → Appearance sets the theme (System follows the phone’s light/dark setting live, or Light, or Dark), color palette, density, and layout. The mobile header has no separate theme shortcut. Density opens its own page with a live preview of each choice; Back returns to Settings. Compact (the default) puts expiry, agent, and location in small tabs on top of the preview; tap a tab to show that detail. Comfortable shows them in a roomy header; Ultra-compact also trims list rows and headers. In every density the risk badge sits in the request header, next to the title.

Settings → Appearance → Layout opens a page with a preview of each choice: Split (default) shows the list, then the request you open; Stacked keeps the list above the request; Focus shows one request at a time; Rail adds a horizontal chip strip. Focus and Rail have ‹ › controls, N of M, and a Queue button that opens the full list in a sheet. Density applies inside every layout. Layout and pane sizes save on this device.

Drag the Split or Stacked divider to resize with touch or a mouse. With the divider focused, arrow keys adjust it; double-tap, double-click, or Enter resets it. Reset sizes also appears on the Layout page after resizing. Split width is 240–640px (default 380px); Stacked height is 15–75% (default 38%).

Android Back closes an open dialog or settings, returns from a request (including one opened from a notification) to the list, and leaves the app only from the list. Command and text previews have a Copy button.

Settings → Test has one row per paired computer, each with Send test request and Send test question. Choose the row for the computer whose inbox and notifications you want to try. It creates a harmless server-built sample on that computer, not on the phone: Test request or Test question, low risk, agent handup, session handup-test, with a 15-minute timeout. Approving, denying, or answering performs no action. The pairing needs decide scope; a view-only computer returns an explanatory error. If the computer runs an older daemon without this route, update it.

Tap the speaker button (Read aloud) in an open pending request’s header; tap Stop reading to stop. It reads the agent, title, a shortened summary, risk, questions and, by default, numbered options—not previews, diffs or tool input. Changing or deciding the request, returning to the list, or opening History stops playback. History has no read-aloud button.

Settings → Read aloud saves the voice, Speed, Pitch, Read options and scope on this phone; Test voice plays a sample. This request is the default; Whole queue continues through the visible pending requests after the open one, selecting each in turn. Read new requests is off by default; when enabled, it can read a newly arrived request while handup is open and idle, but does not read the requests already waiting at first load.

The searchable voice picker filters by name, language name or code, and Natural or Online labels.

Android uses native TTS through tauri-plugin-tts, not the System WebView’s speech APIs. It always reads aloud with the system default text-to-speech engine. To change the engine or install voices (for example if none appear), use Android’s own text-to-speech settings, then reopen Read aloud in handup. Online voices send the spoken text to the voice provider. iOS remains untested. For a phone browser rather than the native app, see Web UI read aloud.

File attachments offer Download and Open on Android. Download fetches the attachment through your authenticated pairing, then opens Android’s save picker. Choose a location on the phone; the suggested name preserves the original basename, including Unicode. You may rename it, and the selected storage provider may adjust duplicate names. Cancelling saves nothing and shows Download cancelled. Wait for Saved followed by the final file name before assuming a download finished. A write failure reports an error and attempts to remove the newly created partial document; providers that refuse deletion may retain that partial file.

Open passes a temporary, read-only content URI and the file’s MIME type to an installed viewer. Another app receives the attachment, not your pairing token or the computer’s file URL. If no suitable viewer is installed, use Download and choose a compatible app. The private Open copy remains available until Android evicts the cache. Open hands over the file’s own type first, then plain text for text-like files (code, JSON, YAML…), then any app that accepts the file.

Files previews list every file with its size and type. Tap a row to view it (code is highlighted, Markdown renders, images and media play), or use its open icon to hand it to another app. Each viewed file has Open and Download. Check rows (or the select-all box) to Download several at once; more than one file downloads as a single .zip that keeps the folder layout. On Android the .zip goes through the same save picker; on desktop, downloads land in your Downloads folder without overwriting existing files. HTML files only open in the sandboxed preview; download them instead.

JSON previews are pretty-printed and syntax-highlighted with the active palette; switch to Tree to fold objects and arrays.

.md and .markdown attachments (case-insensitive), or attachments marked text/markdown, render in the app with headings, lists, GFM tables and syntax-highlighted fenced code. Unknown code languages remain readable plain text. Tables and long code lines scroll inside the reader rather than widening the page. Raw HTML is not rendered and remote images are blocked. Tapping a web or mailto: link in Markdown, a summary or a plain-text preview opens it in your browser or mail app, never inside handup; other links do nothing. Open and Download remain available if reading fails.

These native file actions support Android; iOS attachment delivery is not implemented. Browser downloads continue to use the browser’s own file handling.

Android can receive native Firebase Cloud Messaging (FCM) notifications directly in handup; the ntfy app is not required. Push is optional at build time.

Queued decision delivery uses a separate silent Sending decisions channel (outbox, low importance), not FCM or ntfy. It has no sound, vibration, or badge and is shown only while the foreground service has work waiting. This does not require a Firebase-enabled build. Tapping it opens handup.

The app declares FOREGROUND_SERVICE and FOREGROUND_SERVICE_DATA_SYNC to keep queued decision delivery running after you leave it. These are manifest permissions, not extra runtime prompts. Android 13+’s notification permission controls notification visibility; it is requested after pairing as described below. The service is still subject to Android’s start restrictions and Android 15’s daily dataSync limit; reopening the app resumes queued work.

  1. In the Firebase console, create a project and add an Android app with package name dev.handup.app.
  2. Download its google-services.json and place it at apps/handup-app/gen/android/app/google-services.json before building the APK. This file is gitignored. The Google Services Gradle plugin is applied only when the file exists. Without it the release APK still builds, and Settings → Notifications says Not available in this build.
  3. Enable the Firebase Cloud Messaging HTTP v1 API for the project. In Project settings → Service accounts, generate a service-account private key (or create a dedicated account with the Firebase Cloud Messaging API Admin role). Save the JSON key on the daemon host, outside the repository, readable only by its owner (chmod 600). Never put this private key in the APK or commit it.

Steps 1–3 can be scripted with gcloud (logged in as the project owner). The one manual step is opening the Firebase console once to add Firebase to the new project, which accepts the Firebase terms; the API returns 403 until then.

Terminal window
P=handup-push-$(head -c3 /dev/urandom | od -An -tx1 | tr -d ' \n')
gcloud projects create "$P" --name="handup push"
gcloud services enable firebase.googleapis.com fcm.googleapis.com iam.googleapis.com --project "$P"
# Console: Add project → choose the existing Google Cloud project "$P".
T=$(gcloud auth print-access-token)
H=(-H "Authorization: Bearer $T" -H "x-goog-user-project: $P" -H 'Content-Type: application/json')
F=https://firebase.googleapis.com/v1beta1/projects/$P
curl -s -X POST "${H[@]}" "$F/androidApps" -d '{"packageName":"dev.handup.app","displayName":"handup"}'
APP=$(curl -s "${H[@]}" "$F/androidApps" | jq -r '.apps[] | select(.packageName=="dev.handup.app") | .appId')
curl -s "${H[@]}" "$F/androidApps/$APP/config" | jq -r .configFileContents | base64 -d \
> apps/handup-app/gen/android/app/google-services.json
SA=handup-fcm@$P.iam.gserviceaccount.com
gcloud iam service-accounts create handup-fcm --project "$P"
gcloud projects add-iam-policy-binding "$P" --member "serviceAccount:$SA" \
--role roles/firebasecloudmessaging.admin --condition None
(umask 077; gcloud iam service-accounts keys create ~/.config/handup/firebase-service-account.json --iam-account "$SA")

For CI-built APKs, store google-services.json as a protected GitLab CI file variable named GOOGLE_SERVICES_JSON; the android job copies it into place.

  1. Configure the daemon and restart it:

    notifications:
    backends: [desktop, fcm]
    fcm:
    service_account: /absolute/path/to/firebase-service-account.json
    payload: title

    handup doctor checks the account and reports the project id without printing credentials. The APK and service account must use the same Firebase project.

  2. Install the configured APK and pair it with the daemon. Android 13+ asks for notification permission after pairing succeeds, not on initial launch. Settings → Notifications shows On or Blocked and opens the system’s app-notification settings. If the status cannot be read within a few seconds, it says Could not check with the reason and a Retry button. Android 12 and earlier use the system setting without a runtime permission prompt.

The app registers its FCM token using device authentication, over either the tailnet listener or the encrypted relay tunnel. Token refresh is synchronized while the app is running, and on the next launch after an Android background refresh. Registration retries when the daemon is offline. Unpairing attempts to remove push registration; revoking the device always deletes it on the daemon.

FCM uses two system channels: Approval requests (requests, default importance) and High-risk approvals (high_risk, high importance), separate from the outbox’s Sending decisions channel. Android controls sound, lock-screen display, and heads-up eligibility per channel. Push messages are high-priority, data-only; the app constructs the notification even when its activity is not running. Swiping the app away is supported; Android’s explicit Force stop prevents FCM delivery until the app is opened again.

payload: title (default) sends only the redacted title, request id, and risk; preview content is never sent. payload: wake substitutes the generic “Approval requested” title. Google can see this push metadata; the relay’s end-to-end encryption does not cover the daemon-to-FCM notification. The usual notification rules and quiet hours apply. Decide, cancel, and expiry send a resolved message to cancel the matching notification, even during quiet hours. Android can delay that message while the app is closed, so the app also clears a request’s notification as soon as you tap its decision (not after the undo window or delivery; Undo keeps the request in the inbox but does not bring the notification back) and, whenever its queue syncs (including when it is opened or brought back), clears those that are no longer pending. Tapping a notification opens the request via handup://r/<id>. Push contains no Approve/Deny actions.

The app handles handup://r/<request id> and opens that request:

Terminal window
adb shell am start -a android.intent.action.VIEW -d handup://r/01M3S8C6H0CP6R88FFXM4NPFSF

The ntfy backend adds an Open action with this link to every notification, so tapping Open in the ntfy Android app opens the request in handup. When remote access is on, tapping the notification itself (click) opens the web UI (<web UI>/#/r/<id>) in the browser instead. ntfy remains an optional alternative; native FCM is Android-only. Native APNs delivery from the daemon is not implemented.

No native iOS release, TestFlight or App Store download is available. The iOS configuration exists but has never been built or run; there is no customer installation procedure to offer yet.

The existing alternative is the daemon’s web UI over Tailscale: pair from Safari using the handup pair QR code and add it to the Home Screen. It lacks native biometric confirmation and deep links, but has the same inbox.