Skip to content

Remote access

View as Markdown

Approve requests from a phone or another computer. Remote access is off by default: the daemon listens only on its unix socket and 127.0.0.1.

remote.mode Use it for How it works
off default Unix socket plus the authenticated loopback API only
tailscale recommended Binds the tailnet IP (tailscale ip -4). Only your tailnet can connect, and each device still needs a paired token
direct not recommended Binds a LAN or public IP over TLS. Needs remote.direct.accept_risk: true

Phones that are not on your tailnet can use a self-hosted end-to-end encrypted relay instead (remote.relay.url, handup pair --relay). The relay works with any remote.mode, including off.

A remote listener accepts paired device tokens and named submit tokens. The loopback bearer token in $XDG_STATE_HOME/handup/token never works on it. Device tokens view or decide but cannot create requests or upload; submit tokens create, upload and read only their own requests. Rules, the audit log, pairing, and device and token management stay local-only.

Terminal window
handup config set remote.mode tailscale
handup serve --foreground # or restart the user service
handup pair # scan the QR code with your phone

The daemon discovers the tailnet IP with the read-only tailscale ip -4 (or set remote.bind to it) and serves the web UI and API on remote.port (default 7466). HTML previews use a second port, remote.preview_port (default 7467), which is a separate browser origin. Traffic between tailnet devices is encrypted by WireGuard.

handup never runs tailscale serve, tailscale funnel, or any other command that changes your Tailscale configuration. If you want a browser-trusted HTTPS name, set it up yourself and tell handup the URL so pairing links and Host/Origin checks use it:

Terminal window
tailscale serve --bg --https=8443 http://100.x.y.z:7466 # you run this, not handup
handup config set remote.public_url https://your-host.your-tailnet.ts.net:8443

Never use tailscale funnel for handup: it publishes the listener to the internet.

handup pair [--scope view|decide] [--name NAME] [--json] prints a QR code in the terminal. The desktop app shows the same code under Pair a phone (see Desktop app); both use the local-only POST /v1/pair, which answers 409 while remote access is off. The QR code encodes:

http://100.x.y.z:7466/pair#code=<32 hex chars>&scope=decide&fp=&name=
  • The one-time code (128 random bits) sits in the URL fragment, so it never reaches server or proxy logs. It expires after 2 minutes, works once, and is compared in constant time.
  • fp is the SHA-256 fingerprint of the TLS certificate when TLS is on. Compare it with your browser’s certificate details before you pair.
  • The pairing page trades the code (POST /v1/pair/exchange) for a device token. Browsers receive it as an HttpOnly; SameSite=Strict cookie (Secure over HTTPS) that page scripts cannot read. API clients can request the token in the JSON response instead.

Tokens are hu_ plus 64 hex characters (256 random bits). The daemon stores only their SHA-256 digest, with the device name, scope, creation time, and last use.

Scope Can
view List and show requests, browse decision history and each request’s audit trail, fetch blobs and previews, see YOLO mode, and receive live events. The web UI is read-only. The global audit log (/v1/log) stays local
decide Everything view can, plus approve, deny, answer, cancel, send server-built test requests/questions (POST /v1/requests/test), and change YOLO mode
submit Create requests and upload blobs; show, wait for, and cancel only requests created by that token. Never decide, list, send synthetic tests, or read/change YOLO mode. See integration tokens

Scoped allow (session, project, or always rules) writes local policy, so it is available only on the machine itself. Remote decisions are recorded in the audit log as device:<id>.

POST /v1/requests/test is available on the remote HTTP listener and encrypted relay tunnel as well as locally. Only paired decide devices can call it remotely; view devices and submit tokens get 403. Unlike arbitrary request submission, it accepts no caller-supplied content. See the test request contract.

Terminal window
handup devices list # id, scope, name, enabled/disabled, push state, timestamps
handup devices list --json # includes enabled and push booleans
handup devices disable ID # pause access and push without unpairing
handup devices enable ID # resume with the same device token
handup devices mute ID # stop push notifications, keep API/event access
handup devices unmute ID # resume push notifications
handup devices scope ID view # read-only; use decide to restore decision access
handup devices revoke ID # immediate, permanent unpairing

Disabling rejects device requests and preview links with HTTP 403 (device disabled) and closes open event WebSockets with code 4403. Reconnects are refused while disabled. Decisions already in flight cannot commit after disable. Push delivery stops, but the stored push token and relay pairing are kept; enabling resumes access and push without registering or pairing again.

Muting stops FCM delivery and relay wake-ups without closing event streams or blocking API access. Push defaults to on, including for existing pairings. Scope changes apply to the next call, including calls on already-open relay connections; a device downgraded to view cannot decide.

Revocation takes effect on the next request (HTTP 401), and open event WebSockets for that device close at once with close code 4401. A decision or cancel commits only if the device still exists with decide scope inside the same database transaction, so a request that is already in flight cannot approve after its device is revoked. Request bodies are read only after the token, CSRF, and scope checks pass, and must arrive within 10 seconds (1 MiB max). Each remote listener serves at most 64 connections at once, and open event WebSockets count toward that limit. Connections that send no request headers within 10 seconds (including the TLS handshake and idle keep-alive connections) are closed. The API equivalents are local-only: GET /v1/devices, PATCH /v1/devices/{id} with any combination of {"enabled": false, "push": false, "scope": "view"}, and DELETE /v1/devices/{id}. PATCH requires at least one field, accepts only view or decide scope, and returns the updated device, 404 for an unknown id, or 400 for an invalid/empty body or a scope change to a submission token. Unknown fields are rejected. None of these management routes are available through the remote listener or relay tunnel.

The remote listener serves installed web assets from remote.web_dir, $HANDUP_WEB_DIR or <prefix>/share/handup/web. Distribution must include these assets; customers should not compile a web UI from application source. A view device sees a read-only inbox.

The browser UI never executes command requests, even on the same computer as the daemon. Run and Run as admin exist only in the local desktop app; phone/web/relay clients only review and decide. The daemon rejects run_result from paired-device credentials, including relay connections.

The header’s Settings button opens collapsible Appearance, Decisions, Read aloud, and Test groups. Decisions contains the undo window, approve button side, and Swipe cards on narrow screens; window focus and Show output after Run are desktop-app-only. Test offers Send test request and Send test question to the connected daemon (requires decide scope). Settings → Appearance → Layout, beside Density, opens a page with previews: Split (default) puts the list beside the request, or shows list then request on phones; Stacked puts the list above the request on any screen; Focus shows one request at a time; Rail uses an icon column with risk dots on desktop or a horizontal chip strip on phones. Focus and Rail offer ‹ ›, N of M, and Queue to open the full list sheet; desktop Focus also shows Next: title. Density applies inside every layout.

Drag the Split or Stacked divider with a mouse or touch to resize, including on phones. Arrow keys adjust a focused divider; double-click, double-tap, or Enter resets it. The Layout page offers Reset sizes after resizing. Split width is 240–640px (default 380px); Stacked height is 15–75% (default 38%). Layout and pane sizes save per device in the browser’s localStorage, not the daemon config; see the desktop guide for the other Settings controls.

Browser protections on the remote listener:

  • A Host allow-list (the listener address, remote.public_url, and remote.web_origin) blocks DNS rebinding.
  • An Origin allow-list applies to every request that sends an Origin. Cookie-authenticated state changes and WebSocket upgrades must send an allowed Origin.
  • A CSRF check: cookie-authenticated POST requests must also send x-handup-csrf. Its value is derived from the token and exposed to page scripts through the non-HttpOnly handup_csrf cookie.
  • A strict CSP on the app (default-src 'self', frame-ancestors 'none').
  • HTML and file previews load from the separate preview port using short-lived capabilities bound to the device and request. They get the same sandbox and CSP as the desktop app: sandbox="allow-scripts" without allow-same-origin, and network blocked unless previews.html.allow_network. A revoked or disabled device’s preview links stop working.
  • Blobs (/v1/blobs/{hash}) are agent-supplied, so they never run as the app origin. Every response carries Content-Security-Policy: sandbox; default-src 'none' and X-Content-Type-Options: nosniff. Anything other than passive images, audio, video, PDF, and plain text (for example HTML, SVG, or XML) is sent with Content-Disposition: attachment, and the web UI’s Open button downloads those types instead of opening a tab.

In a browser with Web Speech synthesis, the speaker button (Read aloud) in a pending request’s header reads the agent, title, shortened summary, risk, questions and, by default, numbered options—not previews, diffs or tool input. Click Stop reading to stop. Changing or deciding the request or leaving its view stops playback; History has no read-aloud button.

Settings → Read aloud saves voice, Speed, Pitch, Read options and scope per device; Test voice plays a sample. Search the voice picker by name, language name or code, or Natural/Online labels. This request is the default; Whole queue reads 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 not the requests already waiting at first load. Browsers may require a user gesture before speech can start.

The web UI uses the browser’s Web Speech backend and available voices; the native desktop and Android apps use tauri-plugin-tts instead. Unsupported browsers hide the request button and explain the limitation in Read aloud settings. On Linux, install speech-dispatcher and a voice such as espeak-ng if the browser offers no voices. Online voices send the spoken text to the voice provider.

remote:
mode: direct
bind: 192.168.1.20 # LAN address; plaintext non-loopback binds are refused
direct: { accept_risk: true }
tls: { enabled: true } # self-signed cert generated and persisted; or set cert + key

The daemon refuses to start in direct mode without accept_risk: true and TLS, and it refuses plaintext listeners outside loopback and the tailnet. When tls.cert and tls.key are empty, handup generates a self-signed certificate under the state directory, reuses it across restarts, and pins its fingerprint in the pairing QR code. At every start the daemon prints this warning, handup doctor reports it, and every UI shows it as a persistent red banner (remote_warning in GET /v1/ui-settings):

Anyone who can reach this port and obtain a device token can approve arbitrary actions your agents take, including running commands on this machine. Prefer tailscale mode. Never port-forward without TLS; use short token lifetimes and revoke unused devices.

In every remote mode, and per peer IP:

  • 5 pairing exchanges per minute. Every attempt counts, even before its body arrives.
  • 10 failed authentications per minute. After that, requests get HTTP 429 until the window passes.

notifications.backends accepts desktop, ntfy, and fcm. Every backend follows on_new_request, on_high_risk (high risk bypasses quiet_hours), on_expiring, remind_every, and quiet_hours. Delivery runs in the background with bounded timeouts (10 seconds; FCM 15 seconds per HTTP call), and failures are logged without blocking the queue. Requests without a deadline do not emit on_expiring notifications. remind_every (off by default) re-notifies while they wait for the human.

notifications:
backends: [desktop, ntfy]
ntfy: { server: https://ntfy.sh, topic: "", token_env: HANDUP_NTFY_TOKEN, include_content: false }

fcm delivers Android native notifications to registered paired devices. Configure notifications.fcm.service_account with a Firebase service-account JSON key path and notifications.fcm.payload with title (default) or wake (generic title). Payloads contain only request id, risk, and redacted title, never previews. Decide/cancel/expire also send cancellation messages, independent of quiet hours. Invalid/unregistered provider tokens are removed automatically. The app registers or removes its own token at PUT / DELETE /v1/devices/self/push with device-token auth (view or decide scope). Device revocation removes registration. See Android notifications for Firebase project and APK setup.

ntfy publishes JSON to <server>/ with the topic, the redacted title, and the deep link handup://r/<id> as the message. When remote access is on, the click URL opens <web UI>/#/r/<id>. Priorities: 5 (urgent) for high risk, 4 for medium, 3 for low. Preview content is never sent unless include_content: true, which adds the summary and inline preview text. If the environment variable named by token_env is set, its value is sent as a bearer token. handup refuses to send that token over plaintext http:// to anything other than loopback: the notification is skipped and a warning is logged. Use a hard-to-guess topic, or better, a self-hosted server. Anyone who knows a public topic can read it.

Lifecycle webhooks and local commands are configured separately under top-level hooks:. They run independently of notification policies and also receive decisions, cancellations, and expirations. See event hooks for signing, templates, migration, and automation recipes. The old notifications.webhook configuration and webhook notification backend are removed; loading them reports a migration hint.

Key Default Meaning
remote.mode off off, tailscale, or direct; restart the daemon to apply
remote.bind empty Listener IP; tailscale discovers it when empty
remote.port 7466 API and web UI
remote.preview_port 7467 Separate preview origin
remote.public_url empty Externally visible origin for pairing links and Host checks
remote.web_dir empty Built UI directory
remote.web_origin empty Extra trusted browser origin
remote.tls.enabled false TLS on the remote listener (required for direct)
remote.tls.cert, remote.tls.key empty Your PEM cert and key; empty generates a self-signed pair
remote.direct.accept_risk false Required for direct mode

The Android app is a native client of the same remote listener; see mobile.md. Native FCM push and the end-to-end encrypted relay (FCM/APNs wake-ups) are available; native APNs delivery from the daemon is not.