Remote access
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.
Tailscale (recommended)
Section titled “Tailscale (recommended)”handup config set remote.mode tailscalehandup serve --foreground # or restart the user servicehandup pair # scan the QR code with your phoneThe 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:
tailscale serve --bg --https=8443 http://100.x.y.z:7466 # you run this, not handuphandup config set remote.public_url https://your-host.your-tailnet.ts.net:8443Never use tailscale funnel for handup: it publishes the listener to the internet.
Pairing
Section titled “Pairing”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.
fpis 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 anHttpOnly; SameSite=Strictcookie (Secureover 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.
Scopes
Section titled “Scopes”| 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.
Managing devices
Section titled “Managing devices”handup devices list # id, scope, name, enabled/disabled, push state, timestampshandup devices list --json # includes enabled and push booleanshandup devices disable ID # pause access and push without unpairinghandup devices enable ID # resume with the same device tokenhandup devices mute ID # stop push notifications, keep API/event accesshandup devices unmute ID # resume push notificationshandup devices scope ID view # read-only; use decide to restore decision accesshandup devices revoke ID # immediate, permanent unpairingDisabling 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.
Web UI
Section titled “Web UI”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, andremote.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
POSTrequests must also sendx-handup-csrf. Its value is derived from the token and exposed to page scripts through the non-HttpOnlyhandup_csrfcookie. - 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"withoutallow-same-origin, and network blocked unlesspreviews.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 carriesContent-Security-Policy: sandbox; default-src 'none'andX-Content-Type-Options: nosniff. Anything other than passive images, audio, video, PDF, and plain text (for example HTML, SVG, or XML) is sent withContent-Disposition: attachment, and the web UI’s Open button downloads those types instead of opening a tab.
Read aloud
Section titled “Read aloud”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.
Direct mode (not recommended)
Section titled “Direct mode (not recommended)”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 + keyThe 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
tailscalemode. Never port-forward without TLS; use short token lifetimes and revoke unused devices.
Rate limits
Section titled “Rate limits”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.
Notification backends
Section titled “Notification backends”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.