Skip to content

Desktop app

View as Markdown

The desktop app is a window onto the same local queue that handup ls shows. It talks to the daemon over the unix socket from its Rust process; the bearer token never reaches the web view.

Use the compiled desktop package for your OS and architecture from downloads and releases (AppImage, .deb or .rpm, Linux x86-64). Linux is the primary tested platform; no public release has been published yet. No Rust toolchain or source build is required.

The desktop app is an inbox for the handup daemon; install the CLI too (see downloads) so handup is on your PATH. If no daemon is running, the app starts handup serve. You can also start it from the “Daemon not running” screen.

Run handup ui to open the inbox, or handup ui --next to open the compact quick window on the oldest pending request. Only one instance runs at a time, so a second launch focuses the existing window. Closing the main window hides it to the tray, and the tray icon shows the pending count. If the app binary was replaced since the running instance started (an upgrade), re-opening it from the launcher, handup ui, or the tray menu starts the new version and exits the old one.

Run is offered only for a request with exactly one command preview and one approve option, with no question, nonempty form fields or free-text answer.

For an eligible pending command request, Run executes the command shown on this computer. It uses the command preview’s working directory, then the request’s source cwd, or your home directory if neither is set. Shell commands use a supported $SHELL as a login shell, falling back to /bin/sh; argv commands run directly. Review the command and cwd before clicking: this is execution, not just permission for the agent.

Output streams into the request while it runs. Cancel stops the run; commands still running after 10 minutes are stopped automatically. Ordinary runs stop the process group. The final result includes redacted stdout/stderr tails (up to 64 KiB each), exit code, duration and any error, and appears in History. Output is shown as a terminal would leave it: colour and other escape codes are stripped, \r progress updates keep only their last state, and control characters are dropped. The request view and History show every stored line.

R presses Run (or Run as admin) for the request on screen. When the run finishes, the request leaves the queue and the inbox moves to the next one; the footer notice says how it went and its Output button opens the full output. Settings → Decisions → Show output after Run opens that output by itself: Never (default), On failure (nonzero exit, error, or result not sent to the agent) or Always.

Before starting, the desktop claims the request in the daemon. Its status stays pending with run metadata (started_at, lease_until, elevated); phone and web show Running. Other decision surfaces receive HTTP 409, running in the desktop app, while the claim is active. Agent cancellation remains allowed and tells the desktop to stop the run. Request expiry and timeout decisions are paused while claimed. The claim has a 12-minute lease: if the app crashes or cannot report, the claim lapses and ordinary decisions and expiry resume. This does not extend the command’s 10-minute execution limit.

The local-only API routes are POST /v1/requests/{id}/run with {"content_hash":"…","elevated":false} to claim, and POST /v1/requests/{id}/run/release to release a claim if nothing started. They are available on the Unix socket and authenticated loopback listener, not the remote/relay router; paired devices cannot claim or release runs.

For a command starting with sudo, the button is Run as admin instead. Linux uses pkexec with polkit’s graphical authentication prompt; install pkexec/polkit and ensure a polkit authentication agent is running in your desktop session. macOS uses osascript with the system administrator prompt (macOS is untested). handup does not store or pipe passwords. It removes only the leading sudo; sudo options such as sudo -u root … are unsupported. A later or nested sudo is not rewritten or given this admin path: copy complex commands and run them yourself.

On Linux, pkexec starts a root supervisor that owns the command’s process group. Cancel, timeout or desktop exit closes its stdin, telling it to kill the group; a root-side timer also stops it if the desktop loses control. On macOS, cancelling or timing out can stop the administrator prompt, but handup cannot stop the command once it runs as root; it may continue running.

After a command starts, the desktop sends an approve decision with run_result, even for a nonzero exit, desktop cancellation or timeout. If nothing starts (including launch failure or administrator authentication refusal), it releases the claim and leaves the request pending. Agent cancellation leaves the request cancelled, so a later run result cannot replace that decision. If submission fails, the desktop reports that the result was not sent to the agent. An approved status does not mean the command succeeded: agents must read the result and not run it again; permission hooks block the original tool call with a run summary to prevent duplicate execution. If validation fails before starting, the request stays pending. Run decisions are sent immediately, not held for Undo; Undo cannot reverse command side effects. CLI ask --wait, wait, status and show return exit 5 for a decision carrying run_result, regardless of the command’s exit code. Human feedback is preserved in the decision and appended to the run summary.

The manual path is unchanged: copy, run it yourself, then Approve. Ordinary Approve does not execute the command. Phone, browser/web and relay clients never run commands, and the daemon rejects run_result submitted by paired devices. Execution exists only in the local desktop app.

Key Action
j / k Next / previous request
a Approve
d Deny
u Undo the newest decision still waiting to be sent; press again for the one before
f Focus the feedback box
r Run / Run as admin (desktop app, eligible command requests)
o / Enter Open the file on screen in its default app (in a file bundle, the file picked in the list)
p Widen the preview (hide the list); p or Esc to go back
Shift+P Allow in project
t Toggle light/dark theme
h Switch between Inbox and History
i Back to the Inbox
m Devices (desktop app)
? Show all shortcuts

While the app stays visible, decisions wait 5 seconds before they reach the daemon. Hiding or leaving the app sends held decisions immediately, ending their undo window, and tries every queued decision, even for computers whose connection looked down. Decisions that cannot reach the daemon stay queued in the outbox. Press u (or click Undo) within that window and the request stays pending; each press takes back the newest decision still waiting. Decide several in a row and each one keeps its own countdown and Undo: the newest sits in the footer, older ones stack just above it (up to four, then “+N more”), so you can take back any of them without the footer moving. Change the wait per device under Settings → Decisions → Undo window (3s to 30s), or for every client with handup config set decisions.undo_window 10s.

Approve, Submit and an agent’s primary option sit on the right of the action row, with Deny and Decline to their left and extras such as Edit & approve, Allow for session and Raw JSON on the far side; Undo appears above the primary action. Left-handed? handup config set decisions.primary_side left mirrors every decision row for all clients, and Settings → Decisions → Approve button side (Default, Right, Left) overrides it on one device.

t switches to an explicit light or dark theme. The Settings button in the header opens a dialog with collapsible Appearance, Decisions, Read aloud, and Test groups. Appearance sets the theme (System, Light, Dark), color palette, density, and layout. Decisions holds the undo window, approve button side, Swipe cards on narrow screens (on by default), and the desktop-only Focus on new requests switch and Show output after Run. Palette picks the colors for the whole app and for code in diffs and file previews: handup (default), Catppuccin, Gruvbox, Solarized, Rosé Pine, GitHub, Everforest, Tokyo Night, Nord, One, Kanagawa, Ayu, Flexoki, or Dracula. Each has a light and a dark variant (for example Catppuccin Latte and Mocha), and the theme picks which one you see. Density opens its own page with a live preview of each choice: Compact (default) shows expiry, agent, and location as small tabs on the preview, Comfortable shows them in a roomy header, and Ultra-compact also trims list rows and headers. The risk badge always sits in the request header. Density applies inside every layout. Theme, palette, density, layout, pane sizes, undo window and Approve button side are saved per device.

Settings → Appearance → Layout opens its own page with a preview of each choice:

  • Split (default): the list sits beside the request; on phones, open a request from the list.
  • Stacked: the list sits above the request, including on phones.
  • Focus: one request at a time, with ‹ ›, N of M, and a Queue button that opens the full list in a sheet. Desktop also shows Next: title.
  • Rail: an icon column with risk dots on desktop, or a horizontal chip strip on phones, plus ‹ › and Queue.

Drag the divider in Split or Stacked to resize with a mouse or touch, on desktop or mobile. Focus the divider and use arrow keys to adjust it; double-click, double-tap, or press Enter to reset. The Layout page also offers Reset sizes after resizing. Split width is 240–640px (default 380px); Stacked height is 15–75% (default 38%).

Settings → Decisions → Focus on new requests (off by default) brings the handup window to the front when a request needs a decision: the quick window if it is open, otherwise the inbox, also when the inbox was closed to the tray. It watches the daemon’s event stream itself, so it works while desktop notifications are silenced. Requests that rules or YOLO mode decide on arrival do not raise it.

While it is on, Ignore input after focus (Off, 0.5s, 1s default, 2s) protects against typing meant for another app, as browsers do for permission prompts: for that long after the window gains focus, keys and clicks are ignored, and a key already held down stays ignored until it is released, so a stray a, d, or Enter cannot approve or deny. A short Input ignored notice appears when it drops input.

The YOLO switch in the header turns on YOLO mode: YOLO auto-approves new low and medium risk requests, Hard YOLO every risk, for 15m, 1h, 4h, or until turned off. While it is on the header shows a warning pill (HARD YOLO in the destructive color) with the time left.

If the daemon stops after the inbox has loaded, the inbox stays up and the header shows Syncing… then Offline · synced hh:mm (click to retry, or Start daemon). Decisions made meanwhile wait in the outbox with Undo and are sent in order once the daemon is back. While connected the pill shows Live, and Syncing… briefly while it catches up. A request answered elsewhere (a phone, the CLI) while open stays a moment with its buttons dimmed and the footer saying how it ended (Approved on another device), then the inbox moves to the next one. A decision that loses such a race is dropped and the footer says Already handled on … · Approved.

Settings → Test offers Send test request and Send test question. The desktop app sends to its local daemon; the web UI sends to the daemon it is connected to. The daemon builds a harmless low-risk sample titled Test request or Test question, with agent handup, session handup-test, and a 15-minute timeout. Approving or denying it performs no action. Use it to try previews, questions, notifications, and the decision flow. Remote clients need decide scope; view-only devices cannot send tests.

Click the speaker button (Read aloud) in a pending request’s header; click 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. Moving to another request, deciding it, or leaving the request view stops playback; History has no read-aloud button.

Settings → Read aloud saves settings on this device: voice, Speed, Pitch, Read options, and Test voice. The voice picker has a search field that filters by voice name, language name or code, and Natural or Online labels. Read → 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 desktop app uses native TTS through tauri-plugin-tts, not browser speech synthesis. On Linux it needs speech-dispatcher and an installed voice such as espeak-ng; install them and restart the app if no voices appear. Debian bundles declare the libspeechd2 runtime library dependency, but you still need the Speech Dispatcher service and a voice. Other desktop platforms use their OS speech backend (macOS remains untested). Voices come from the device; online voices send the spoken text to the voice provider.

The Inbox | History switch at the top of the list (or h) shows resolved requests, newest first and grouped by day. Each row shows the outcome (approved, denied, expired, cancelled, or auto for rule and YOLO decisions), the agent, and when it was resolved. Search matches the title, summary, and folder; the chips filter by outcome, Files (file, bundle, image, PDF, audio, or video previews), and request kind (Command, Edit, Review, Question, or Custom). Filters combine; tap the selected Files/kind chip again to clear it. Changing filters discards any older page still loading for the previous filters. j/k move and / focuses search. Inbox remains the default view.

The detail pane is the request as it was asked, read-only, with a summary of who decided it (this computer, a paired device by name, a rule, or the timeout), the chosen option, feedback, and answers, plus any desktop Run’s exit status and full stored output. Audit trail expands every event. Attachments retain their Open and Download actions in read-only history.

The API is GET /v1/history with optional outcome, agent, type (one preview type), has=attachments, kind, and q (title, summary, folder). All filters combine. limit defaults to 50 (maximum 500); pass next_cursor as cursor with the same filters to fetch older results without duplicates. Paired view/decide devices can also read GET /v1/requests/{id}/audit; submission tokens cannot access history or request audit. History keeps the last history.keep_days days and history.max_requests decisions (90 and 2000 by default); older ones are deleted.

The phone button in the header opens Pair a phone, the desktop version of handup pair. It shows a QR code for the handup app or the phone’s camera, the link with a Copy button, and the TLS certificate fingerprint when the remote listener uses TLS. Pick Can decide or View only. When remote.relay.url is set, Through the relay pairs through the end-to-end encrypted relay instead; it starts on when remote.mode is off.

Each code works once and expires after 2 minutes; the dialog counts down and New code issues a fresh one. When a phone uses the code, the dialog shows “Paired: ”. If remote access is off, the dialog explains how to turn it on (handup config set remote.mode tailscale, then restart the daemon) and links to Remote access. The button exists only in the desktop app: pairing codes are local-only, so the web UI and the mobile app never offer it.

The devices button in the header (or m) opens Devices: the phones and browsers paired with this computer, newest first, with when each was paired and last seen. Integration credentials are not listed. Per device:

  • Can decide / View only changes its access. It applies at once, also to an open app or browser tab.
  • Enabled off blocks the device (403) without unpairing it; turn it back on to restore access.
  • Push off stops push notifications and relay wake-ups to it; it keeps access to the queue.
  • Revoke (after a confirmation) unpairs it; it has to pair again.

Errors show under the device; the list refreshes after each change. With no devices, Pair a phone opens the pairing view in the same dialog. Like pairing, device management is local-only (PATCH/DELETE /v1/devices/{id}), so only the desktop app offers it.

bind = SUPER, A, exec, handup ui --next
windowrule = float, title:^(handup: next request)$

HTML previews load from a separate handup-preview: origin inside a sandboxed iframe with no allow-same-origin. The origin’s CSP blocks network access, and Tauri IPC is not exposed to it.

Web and mailto: links always open in your system browser or mail app, never inside the handup window.

On Linux, audio and video play through GStreamer. If a clip says it could not be decoded, install gst-plugins-good, gst-plugins-bad, and gst-libav (package names vary by distro). The Linux and Android apps load the whole clip before it plays, so they download it only when you tap Play; the request itself opens at once. The clip starts once its metadata loads, and the preview shows Loading… or Buffering… until it can play.

No tested macOS customer release is available. Desktop configuration and Intel/Apple Silicon CLI targets exist, but they are not a support guarantee. See platform availability; do not disable platform security checks to install an unverified build.