Skip to the content.

Haven’s MCP tools — what an agent can do, and when you are asked

Haven exposes an MCP endpoint so an AI agent (Claude Code, or anything speaking MCP over HTTP) can drive the phone side of your sessions. This page is the complete tool surface, generated from the same registry the server dispatches — there are no undocumented tools.

The security model, in order

  1. Off by default. Nothing listens until you enable Settings → Agent endpoint.
  2. Loopback only by default. The endpoint binds 127.0.0.1 (device ports 8730–8739, SSH-tunneled clients 8740–8749). LAN/WireGuard exposure is a separate, explicit opt-in.
  3. Pairing. A new client’s first request raises an Allow/Deny prompt on the phone. Allowing mints a per-client 256-bit bearer token, shown once; Haven stores only its SHA-256. Unpairing (Settings → Agent endpoint → Paired clients) revokes it.
  4. Capability switches. File reading and terminal-input queuing have their own Settings toggles on top of everything else; tools behind them are marked below.
  5. Per-call consent. Every tool carries one of three consent levels — asks every call, asks once per session, or no per-call prompt — shown as a tag on each tool below. Consent sheets are rendered by Haven itself, and agent-injected taps are refused while one is showing — an agent cannot approve its own prompt.
  6. Standing policies. An agent may request a time-boxed, rate-limited pre-approval for named tools (create_standing_policy) — it is itself consent-gated on every call, shows exactly what would be allowed, and has a kill-switch on the Agent activity screen.
  7. Audit. Every call crossing the agent transport is recorded on-device with arguments redacted (Settings → Agent activity — the same screen that lists and revokes standing policies). Haven is the dashboard, not a black box.

How to read this page

Tools are grouped into sections by what they touch, and each tool is collapsed — expand one for its description and arguments. The tag after each name is its consent level:

Sections

Connections & profiles (9)

The saved SSH/SFTP/RDP/VNC/… connection profiles and their live connect/disconnect state.

connect_profile · asks once per session

Initiate a connection for a saved profile via the same code path a UI tap uses (route-through, stored password, key auth all apply). Posts an AgentUiCommand.ConnectProfile that ConnectionsViewModel observes — the actual connect happens asynchronously. Use list_sessions afterwards to confirm the session reached CONNECTED. If the profile needs a password that isn’t stored and isn’t a key, the UI password prompt will surface to the user. For an SSH profile that uses a session manager (tmux/zellij/screen), pass sessionName to attach or create a named session non-interactively; without it, a connect to a profile that has existing sessions surfaces the interactive picker — poll get_pending_session_picker and resolve it with answer_session_picker (no longer stalls unanswerable).

create_connection · asks every call
Create a saved connection profile. Supports connectionType=SSH, SMB, VNC, RDP, SPICE, EMAIL. SSH-family fields: username (required), password (optional, stored), keyId (optional — references list_ssh_keys), ignoreSavedKeys (force password-only auth, never offer saved keys), useMosh (turn an SSH profile into a Mosh profile), sessionManager (optional: TMUX ZELLIJ SCREEN BYOBU — attach through that multiplexer; omit for a plain shell). SMB: smbShare (required), username + password, smbDomain. VNC: vncUsername, vncPassword, vncPort, and vncSshForward + vncSshProfileId to tunnel VNC through a saved SSH profile. RDP: rdpUsername (required), rdpPassword, rdpDomain, rdpPort. SPICE: spicePassword (optional ticket — no username/domain), spicePort (default 5900), and spiceSshForward + spiceSshProfileId to tunnel SPICE through a saved SSH profile. EMAIL: emailProvider (“imap” default, or “proton”); username = the email address; password = the account/app-password; for IMAP set emailServer (required) + emailPort (993) + emailSmtpPort (465) + emailTls (true), plus emailSmtpServer when the SMTP host differs (e.g. smtp.gmail.com); for Proton add emailMailboxPassword if two-password mode. EMAIL host is optional (the tunnel-ingress/bastion SPA/knock guards), not the mail server. BTSERIAL (Bluetooth-serial console, #406): host = the paired device’s Bluetooth MAC (from list_bluetooth_devices); no other fields. The device must already be paired in Android Settings. BLESERIAL (Bluetooth-LE-serial console — Nordic UART Service / HM-10): host = the BLE peripheral’s MAC; no other fields. It needn’t be paired — scan-and-pick in the editor; the GATT service/characteristics are auto-detected (NUS 6E400001…, then HM-10 FFE0/FFE1). USBSERIAL (USB-serial console, #408 — Arduino / Duet3D G-code / ESP32 / USB-TTL): host = the device’s vendorId:productId hex, e.g. 1a86:7523, from list_usb_devices; usbBaudRate = baud (default 115200); usbDataBits/usbParity/usbStopBits/usbFlowControl set the rest of the line format (default 8N1, no flow control). Plug the adapter in first; connect_profile pops the Android USB-permission prompt. Chipsets: CDC-ACM, CH34x, FTDI, CP21xx, Prolific. The new profile id is returned for follow-up calls (set_profile_routing, connect_profile). For Reticulum / rclone / local create the profile in the UI — those paths need OAuth / destination-hash flows the agent can’t drive.
delete_connection · asks every call

Delete a saved connection profile by id. Disconnects any live session for the profile first. Use this after integration tests to clean up agent-created profiles.

disconnect_profile · asks once per session

Disconnect every live session for a profile across all transports (SSH, Mosh, Eternal Terminal, RDP, VNC, SMB, Reticulum, local, Bluetooth/BLE/USB serial). Use list_connections to find profileIds.

get_connection_log · no per-call prompt

Read the most recent ConnectionLog entries for a profile. Use this to verify post-hoc what happened during a connect — including knock results (look for ‘[knock]’ lines in verboseLog), TLS handshakes, and authentication. limit defaults to 10.

list_bluetooth_devices · no per-call prompt

List Bluetooth Classic devices already paired (bonded) with the phone — { address, name } each. Use the address as host when creating a BTSERIAL (Bluetooth-serial console) profile with create_connection (#406). Only paired devices appear; pair a new BT-to-serial adapter in Android Settings → Bluetooth first. Returns an error hint if BLUETOOTH_CONNECT hasn’t been granted (open the app’s Bluetooth-serial connection editor once to grant it).

list_connections · no per-call prompt

List saved connection profiles (SSH, Mosh, VNC, RDP, SMB, rclone, local, Reticulum). Secrets like passwords and keys are redacted.

run_command · asks every call

Run one command on a saved SSH connection over an exec channel (no terminal session, no PTY) and return { exitCode, stdout, stderr } — the automation-shaped verb (#367): a MacroDroid/Tasker HTTP Request macro or cron-style agent POSTs a single tools/call and reads the output straight from the response. Reuses the live connection when the profile is connected (also the only path for FIDO2/encrypted-key profiles); otherwise makes a one-shot headless connect, which needs a stored password or an unencrypted non-FIDO2 key AND a host key already trusted from a previous interactive connect (fail-closed TOFU — unknown/changed keys are refused, never silently accepted). Blocks until the command exits or timeoutMs elapses (then returns partial output with timedOut:true, exitCode:null). For unattended macros, create_standing_policy scoped to this tool + {“profileId”: …} removes the per-call consent prompt. Headless-path limits: port-knock/SPA-guarded and tunnel-routed profiles aren’t knocked/routed — connect those interactively first and let the reuse path serve them.

update_connection · asks every call

Edit fields on an existing connection profile (load → change → save). Pass profileId (required) plus only the fields you want to change — anything omitted is left as-is. Common SSH-family fields: label, host, port, username, password (stored, mapped to the profile’s transport), keyId, ignoreSavedKeys (force password-only auth), useMosh, forwardAgent. Desktop tunnels: vncSshForward + vncSshProfileId, rdpSshForward + rdpSshProfileId, spiceSshForward + spiceSshProfileId, smbSshForward + smbSshProfileId. Passwords are stored encrypted and never echoed back. For routing/proxy use set_profile_routing; for port-knock/SPA use set_port_knock/set_spa. Returns the updated profile (secrets redacted).

Terminal, selection & sessions (26)

Reading and driving terminal sessions: input, scrollback, text selection, snippets, and workspace layouts.

answer_auth_prompt · asks once per session

Answer the prompt reported by get_pending_auth_prompt — supply the secret a user would type into Haven’s fallback dialog and Haven re-drives the connect through the same path a UI tap uses. Pass password (the host password or the encrypted key’s passphrase). Optional username overrides the login user; rememberPassword stores it on the profile. Returns { answered:false } when no prompt is pending. A wrong value just re-surfaces the prompt (poll get_pending_auth_prompt; it clears on success) — confirm the result with list_sessions.

answer_session_picker · asks once per session

Answer the picker reported by get_pending_session_picker — attach to an existing remote session by name, or create a new one. Pass sessionName (must be one of the picker’s sessionNames) to attach to it, or createNew: true to start a fresh session. Haven re-drives the attach through the same path a human tap uses (onSessionSelected). Returns { answered:false } when no picker is pending. Confirm the result with list_sessions / read_terminal_snapshot.

compose_workspace · asks once per session

Launch every item of a saved workspace through the same WorkspaceLauncher the user’s tap goes through — terminal sessions, file-browser tabs, remote desktops, and the Wayland tab open in dependency-friendly order (TERMINAL first so tunneled DESKTOPs attach to live SSH sessions). Returns the workspace id and item count; progress is surfaced live in the Connections screen banner.

copy_selection · asks every call

Copy the current selection to the system clipboard and return the copied text. Goes through Haven’s smart-copy interceptor (TUI border-strip + soft-wrap rejoin). Clears the selection after copying. Returns { text }.

drag_selection_to · asks once per session
Drag the selection’s end anchor toward (toRow, toCol), auto-scrolling the viewport into scrollback (or back toward live) when toRow lies outside [0, rows-1]. Mirrors the Compose drag gesture that runs when the finger crosses the top or bottom edge zone: each row of out-of-viewport target is one ScrollController.scrollBy(±1) paired with one shiftSelectionStartByRows(±1) in lockstep. toRow < 0 scrolls back toRow rows (end anchor lands at viewport row 0). toRow >= rows scrolls forward toRow-(rows-1) rows (end anchor lands at viewport row rows-1). toRow in [0, rows-1] is equivalent to extend_selection. Clamps at the live screen and at scrollback.size; the response’s clamped field indicates whether the full requested distance was achieved. Returns the resolved selection range, the new scrollbackPosition, the number of scroll steps actually taken (signed), the clamp flag, and the selection text via SelectionController.getSelectedText (libvterm softWrapped-aware, scrollback-aware).
drag_terminal · asks every call

Drive a synthetic touch-drag through the terminal’s REAL pointer pipeline — the same awaitEachGesture handler a physical finger feeds. Unlike start_selection / extend_selection / drag_selection_to, which mutate the selection model directly, this exercises the actual gesture code: long-press classification, drag-extend, edge-zone detection, and the held-still auto-repeat edge-scroll ticker. The gesture is: touch-down at path[0]; hold still for pressMs (long enough for the long-press selection timeout, ~500ms, to fire); move through path[1..] one stepMs apart; hold still at path.last() for holdMs (the window the edge-scroll ticker runs in); lift. Rows are viewport-relative; out-of-viewport rows (e.g. -3, or rows beyond the bottom) still map to valid pixels so a path can target the top/bottom edge zone. Blocks until the gesture completes (~pressMs + path*stepMs + holdMs). Requires the session’s terminal tab to be the foreground tab (the gesture injector mounts with the Composable). Returns { sessionId, cells, approxDurationMs }. Verify the effect with get_selection + read_terminal_snapshot afterwards.

extend_selection · asks once per session

Move the selection’s end anchor to (row, col) in an active terminal session. Equivalent to dragging the selection handle to that cell. Pairs with start_selection — call start_selection first to set the anchor, then extend_selection to move the far end. Does NOT scroll the viewport — use drag_selection_to to extend a selection past the top or bottom of the viewport into scrollback.

feed_terminal_output · asks every call

Inject raw bytes into a terminal session’s OUTPUT stream — as if they had arrived from the remote — running the exact pipeline the live data callback uses (OSC scan → mouse-mode scan → emulator). Distinct from send_terminal_input, which sends to the PTY input as if typed. Use this to deterministically exercise output-side parsing without a cooperating remote: e.g. feed an OSC 52 sequence to test the clipboard round-trip, a DECSET 1000/1002/1003 to flip mouseMode, an OSC 8 hyperlink, or a partial escape split across two calls (the OSC scanner keeps state between calls). Provide exactly one of text (UTF-8) or bytesBase64 (for control bytes / ESC). Hard cap 65536 bytes per call. On agent-opened local shells the bytes run the DECSET mode scan (mouse / bracketed paste) then go into the agent emulator — same as their live pipeline, which has no OSC scan. Errors when the session has no output pipeline. Returns { sessionId, bytesFed }.

focus_terminal_session · no per-call prompt

Switch to the Terminal tab and bring the session with this sessionId to the front. Tap-equivalent — same effect as the user tapping the Terminal tab and tapping the session header. Use list_sessions to discover live sessionIds; stale IDs drop silently without error.

get_pending_auth_prompt · no per-call prompt

Return the password / key-passphrase prompt Haven is currently waiting on for a stalled connect (its in-app fallback dialog), or { pending: false } if none. A connect started via connect_profile that needs a secret which isn’t stored (a wrong/missing host password, or an assigned encrypted SSH key whose passphrase failed, #292) surfaces here instead of failing silently: { pending: true, profileId, label, requiresPassphrase } — requiresPassphrase=true means it wants the encrypted key’s passphrase, false means a host/account password. Answer it with answer_auth_prompt. Read-only.

get_pending_session_picker · no per-call prompt

Return the session-manager picker Haven is currently waiting on, or { pending: false } if none. A connect_profile to a tmux/zellij/screen profile that has existing remote sessions (and no sessionName preselected) surfaces this picker instead of attaching: { pending: true, profileId, sessionId, sessionManager, sessionNames: […], previousSessionNames: […], suggestedNewName }. Answer it with answer_session_picker. Read-only. (Previously this picker was human-only and stalled the agent.)

get_selection · no per-call prompt
Return the current text-selection state for an active terminal session: { active, mode (NONE/CHARACTER/WORD/LINE), range: { startRow, startCol, endRow, endCol } null }. Reads termlib’s SelectionController; valid only while the session has an attached terminal tab.
list_sessions · no per-call prompt

List currently registered sessions across all transports (ssh, mosh, et, reticulum, rdp, smb, local, mail, and Bluetooth/BLE/USB serial) with sessionId, profileId, label, status (connecting, connected, reconnecting, disconnected, error), transport, and isAgentRepl — a screen heuristic (Claude Code TUI chrome in the bottom lines) marking which terminal session is an agent REPL, so a conversation peer can be picked without guessing; null when the session has no attached terminal tab. SSH sessions additionally include sessionManager, chosenSessionName (the stable tmux/zellij identity that survives reconnects), channel state, jump-session linkage, and active port forwards.

list_snippets · no per-call prompt

List terminal toolbar snippets (custom send-key macros reachable from the scissors button). Returns each snippet’s label, send (the literal text/escape sequence it types), and placement: “row1”/”row2” (has a dedicated toolbar button on that row) or “library” (in the scissors sheet only, no button). Manage them with set_snippet.

list_workspaces · no per-call prompt

List the user’s saved workspace profiles — named bundles of terminal sessions, file-browser tabs, remote desktops, and Wayland that compose_workspace can reopen in one shot. Each entry has id, name, and itemCount. The kinds of items inside come from the same Kind enum the UI uses (TERMINAL / FILE_BROWSER / DESKTOP / WAYLAND).

open_local_shell · no per-call prompt

Open a fresh local Alpine PRoot shell session and return its sessionId. Equivalent to tapping the Terminal icon in the Connections top bar — creates the local shell profile if missing, registers a session, and connects it. The returned sessionId is immediately usable with send_terminal_input and read_terminal_scrollback. Use this when you need a clean bash REPL (e.g. when an existing session has Claude Code, vim, or another stdin-capturing process in front of it). Pass plain: true to bypass the user’s session-manager preference (tmux / zellij / screen / byobu) and exec a bare login shell — required when the agent needs Haven’s own scrollback ring to capture output, which doesn’t happen when a multiplexer’s status bar uses DECSTBM to reserve the bottom row. NOTE: if a live local shell already exists, this returns that sessionId regardless of plain (response reused: true); call disconnect_profile on the existing profile first if you need a fresh plain-shell respawn.

queue_terminal_input · asks every call

Extra capability switch — off by default: queue_terminal_input is disabled — enable in Settings → Agent endpoint → Allow agents to queue terminal input.

Power-user: queue text to be typed into any connected SSH session at the next matching prompt. Haven polls the session’s stdout, matches promptPattern against the tail (ANSI escapes stripped, regex MULTILINE), then types text + submitKey via the session’s tty when the pattern appears. Use cases include responding to interactive prompts in install scripts (Continue? [y/N]y, or Path:/usr/local), driving REPLs (Python, psql, Claude Code, etc.), and chaining steps where the agent waits for one prompt then types into the next. Defaults are tuned for the common “drive an interactive shell or REPL” case; supply explicit promptPattern / submitKey / sessionId when targeting something specific. Returns immediately with a queueId; delivery happens out-of-turn whenever the prompt appears (or the queue times out). Caveat: within one SSH session, only the foreground tmux pane receives the typed text — for multi-pane setups, pass sessionId to the right session and ensure the target pane is foreground. Gated by Settings → Agent endpoint → “Allow agents to queue terminal input” and per-call consent (with an “Allow for N min” option for collaborative windows).

read_terminal_scrollback · no per-call prompt

Return the most recent bytes of raw SSH stdout for an active terminal session, exactly as the user sees them (ANSI escapes, OSC markers, control bytes preserved). Use list_sessions to discover sessionIds. The buffer is capped at 256 KiB per session and rolls older bytes off; the human terminal still keeps its own visual scrollback separately.

read_terminal_snapshot · no per-call prompt
Return a structured snapshot of an active terminal session: dimensions, cursor row/col, terminal title, scrollback line count + current scrollbackPosition, the remote-driven terminal modes (mouseMode / activeMouseMode 1000 1002 1003 / bracketPasteMode), an oscEvents object with the last-seen OSC 52 clipboard-set / OSC 7 cwd / OSC 8 hyperlink / OSC 9 777 notification, and the visible-screen lines as plain text (with softWrapped flag per line, and optional OSC 133 semantic segments). Use list_sessions to discover sessionIds. Distinct from read_terminal_scrollback, which returns raw bytes; this is the parsed view useful for cursor-aware tooling, prompt detection, and asserting OSC/mouse-mode round-trips.
scroll_terminal · asks once per session

Scroll an active terminal session’s viewport by N lines. Positive lines = back into scrollback (older content); negative lines = toward the live screen. Clamps at 0 (live) and scrollback.size. Returns { scrollbackPosition } — the new position after clamping.

send_terminal_input · asks once per session

Send input to an active terminal session as if the user typed it. Provide text (UTF-8) and/or named keys — real control bytes (Enter/Esc/Ctrl-C/arrows) that text can’t express (a “\r” in text arrives as literal chars; a raw-mode REPL reads “\n” as newline-insert, not submit). text is sent first, then keys, so a submit key lands after the body. Set bracketedPaste to wrap text in bracketed-paste markers so a raw-mode REPL (Claude Code, readline, vim) treats multi-line input as one paste instead of interleaved keystrokes that fight submit. Set returnSnapshot to get the resulting screen back without a follow-up read_terminal_snapshot. Hard cap 4096 bytes total.

set_compose_mode · asks once per session

Toggle or set termlib’s local compose mode for a terminal session — the on-screen buffer used for CJK / accented / voice-friendly text entry. While compose mode is on, typed text (including IME-composed CJK candidates) buffers in an overlay at the cursor and the terminal hands the IME a composition-friendly InputConnection; the buffer commits to the shell on Enter, after which compose mode clears. Pass enabled=true/false to set explicitly, or omit enabled to toggle. Requires an attached terminal tab (errors for headless agent shells). Returns { sessionId, composeModeActive, composedText }.

set_snippet · asks once per session

Add, move, update or delete a terminal toolbar snippet (#244). label is required. To add/update: pass send (the text to type; JSON escapes like \n for Enter and \u001b for Esc work) and optional placement — “row1”/”row2” gives it a toolbar button on that row, “library” (the default) keeps it in the scissors sheet only. Re-passing an existing label moves/updates it. To remove it from both the toolbar and the library, pass delete:true. Returns the affected snippet’s resulting placement and the full snippet list.

set_terminal_font_from_url · asks every call

Download a font from a URL, validate it, install it as Haven’s terminal font (replacing any prior custom font), and return the saved path. The URL may point at a .ttf/.otf, or a .zip containing them (a Regular face is auto-extracted) — useful for repos like Maple/Nerd Fonts that ship only zips (#123, #177). WOFF/WOFF2 web fonts are rejected (Android can’t render them). Requires the URL to be reachable from the device — use a tunneled URL (via add_port_forward LOCAL) to expose a workstation HTTP server back through the existing SSH session.

start_selection · asks once per session

Anchor a new text selection at (row, col) in an active terminal session. Equivalent to a long-press at that cell. Modes: CHARACTER (default), WORD (snaps to word boundaries on creation), LINE (whole-row selection). Subsequent extend_selection / copy_selection / clear_selection calls on the same session operate on this anchor. Replaces any existing selection on this session.

tap_terminal · asks every call

Simulate a tap inside an active terminal session at (row, col). When the user has Settings → Terminal → Tap to move cursor on supported prompts enabled, and the tap lands on a row carrying an OSC 133 COMMAND_INPUT segment with no matching COMMAND_FINISHED, Haven dispatches arrow keys so the readline cursor lands at the tapped column. Returns { handled, deltaCols, dispatched } describing what happened. handled=false means no OSC 133 prompt at the tap row — falls through silently.

Files, media & clipboard (19)

The unified file browser (local and SFTP), format conversion, media playback/streaming, encryption, and the clipboard.

convert_file · asks every call

Run ffmpeg to transcode a source URL (typically the playerUrl/playlistUrl from stream_sftp_file, or any URL ffmpeg can read) into a new file in Haven’s app cache. Returns the cache path on success — use upload_file_to_sftp to put the result on a remote.

decrypt_file · asks every call

Decrypt the .age file at path on profileId in place (strips .age) using any stored age identity (VISION §2). Drives the file browser’s Decrypt (age) action via the UI command bus — the user sees it run. Fails to produce output if no stored identity matches the file’s recipients.

delete_file · asks every call

Delete a file (not a directory) on any connected backend (local, SSH, SMB, rclone). Replaces delete_sftp_file; that still works as a deprecated alias.

delete_sftp_file · asks every call

DEPRECATED: prefer delete_file(profileId=…, path=…). Delete a file (not directory) from a connected SFTP profile. Requires a connected SSH/SFTP session.

encrypt_file · asks every call

Encrypt the file at path on profileId to age recipients, producing <name>.age in the same folder (VISION §2 — works on every backend: local, SFTP, SMB, rclone). recipients (optional) is a list of age1… strings; omit it to encrypt to ALL of your stored age identities (so you can decrypt it back). Drives the file browser’s Encrypt (age) action via the UI command bus — the user sees it run and the output appear. Non-destructive (the original is kept). Use list_age_identities for recipients and list_directory to find paths.

list_directory · no per-call prompt

List entries at a path on any connected backend (local, SSH/SFTP, SMB, rclone). Resolves the right driver from profileId — pass the literal string ‘local’ for the device filesystem, otherwise a profile ID from list_connections. Returns name, path, isDir, size, modTime, permissions, and mimeType for each entry. Replaces list_sftp_directory and list_rclone_directory; those still work as deprecated aliases.

list_sftp_directory · no per-call prompt

DEPRECATED: prefer list_directory(profileId=…, path=…). List files at a path on a connected SFTP profile. Requires an already-connected SSH/SFTP session for the profile.

navigate_sftp_browser · no per-call prompt

Switch to the Files tab and open the file browser at the given path on the given profile. Tap-equivalent — same effect as the user tapping into the SFTP screen and entering the path. The path is interpreted by whichever backend the profile resolves to (POSIX absolute for SSH/Local, share-relative for SMB, remote-relative for rclone).

open_convert_dialog_with_args · no per-call prompt

Stage a conversion in the SFTP screen’s convert dialog with the given container / codec defaults. Switches to the SFTP tab and opens the dialog; the user reviews and taps Convert to actually run ffmpeg. Tap-equivalent — the agent suggests, the user confirms. Use convert_file (EVERY_CALL consent) to skip the dialog and run the conversion directly.

open_file_in_editor · no per-call prompt

Open a text file in Haven’s built-in editor (TextMate-syntax-highlighted, with Save). Routes to the SFTP/Files tab and loads the file from the given profile’s backend (SSH, SMB, rclone, or the literal “local” for the device filesystem). The file is read on demand by the active backend; binary files render as garbled UTF-8 — use this for source code, config, logs, etc.

play_file · no per-call prompt

Open a media URL in the system player (VLC, MX Player, Chrome, etc.) via Android’s ACTION_VIEW intent. Typically the playerUrl/playlistUrl returned by stream_sftp_file, or any http/https/content URL the agent already knows. The user’s preferred app picker (or default app) decides what handles it — Haven only kicks off the intent.

read_clipboard · no per-call prompt

Return the system clipboard’s primary plain-text content. Returns { text } where text is null when the clipboard is empty or non-text (image, intent, etc.). On Android 10+ the system enforces foreground/IME restrictions on clipboard reads; this call may return null even when the clipboard has content if Haven isn’t currently focused.

serve_file · asks every call

Extra capability switch — off by default: agent file read is disabled — enable in Settings → Agent endpoint.

Publish a single file from any connected backend (local, SFTP, SMB, rclone) as a short-lived loopback HTTP URL the caller can curl to its own filesystem. Returns { url, size, mimeType }. Bytes are streamed over HTTP rather than returned inline through JSON-RPC. Gated by Settings → Agent endpoint → “Allow agents to read file contents” and confirmed per-call by a consent prompt.

stop_stream · no per-call prompt

Stop any currently running HLS stream started by stream_sftp_file or the UI.

stream_sftp_file · no per-call prompt

Start an HLS stream for an SFTP file and return the playlist URL. Reads via a loopback HTTP bridge so no bulk download is needed. Requires a connected SSH/SFTP session. Stops any prior HLS stream.

upload_file · asks every call

Write a local file to a path on any connected backend (local, SSH, SMB, rclone). Source must live under Haven’s app cache (context.cacheDir) — the agent has no other writable surface, so this constraint blocks reads of arbitrary device files via the upload destination. Currently uses small-file semantics (loads the source into memory); streaming variants ship in a later #126 stage. Replaces upload_file_to_sftp; that still works as a deprecated alias for the SSH streaming path.

upload_file_to_sftp · asks every call

DEPRECATED: prefer upload_file(profileId=…, localPath=…, remotePath=…). Upload a local file to a path on a connected SFTP profile. Source must be a path under Haven’s app cache (context.cacheDir) — the agent has no other writable surface, so this constraint blocks reads of arbitrary files via the upload destination. Requires a connected SSH/SFTP session. Uses streaming SFTP put (no in-memory buffer); use this for files larger than ~50 MiB until upload_file gains streaming support.

view_file · no per-call prompt

Render a file from the ACTIVE proot guest to an INLINE image the agent can see directly — no desktop, X server, VNC client, or GPU needed (fully headless / GL-free). Handles .kicad_sch and .kicad_pcb (via kicad-cli), .pdf (first page, or page), .svg, and raster images (png/jpg/jpeg/webp/bmp/gif). The result is downscaled to maxWidth and returned as an image content block, so it works even when no VNC desktop is running. Prefer this over capture_desktop for looking at design output (schematics, PCBs, PDFs, plots).

write_clipboard · asks every call

Set the system clipboard’s primary plain-text content. Replaces whatever’s currently on the clipboard. Useful for priming the clipboard before triggering a terminal paste.

Cloud storage (rclone) (15)

rclone remotes and the saved sync jobs that run between them.

cancel_rclone_sync · asks once per session

Cancel a running rclone job started by start_rclone_sync. If jobId is omitted, cancels the active job. No-op if the job is already finished or never existed.

configure_rclone_remote · asks every call

Create (or replace) a credentials-based rclone remote and verify it by listing the root. Pass remoteName, provider (ftp/sftp/webdav/s3/b2/mega/filen/…), and parameters — an option→value map (see list_rclone_provider_options; rclone obscures password fields server-side). For OAuth providers (drive/dropbox/…) use the in-app browser sign-in instead. Returns { created, verified, entryCount } or an error. Makes the remote usable by the rclone list/sync tools. (#181)

delete_rclone_remote · asks every call

Delete an rclone remote from rclone.conf by name, and any RCLONE connection profile that references it. The inverse of configure_rclone_remote / the rclone-config import — use it to clean up a remote (e.g. a test or a failed/ghost import that left a remote with no usable profile). Returns { deleted, remoteName, removedProfiles }. deleted is false if no such remote existed (matching profiles are still removed).

delete_sync_profile · asks every call

Delete a saved rclone sync configuration by id. The dialog’s dropdown updates immediately. EVERY_CALL consent because it’s destructive (the user’s saved config is gone after).

get_rclone_stats · no per-call prompt

Return rclone’s global transfer counters (bytes, totalBytes, speed, transfers, totalTransfers, errors, deletes, deletedDirs). These reset only when reset_rclone_stats is called or a new sync resets them at start.

get_rclone_sync_status · no per-call prompt

Poll the status of an async rclone job started by start_rclone_sync. Returns finished/success/error plus live transfer stats: bytes, totalBytes, speed, transfers, totalTransfers, errors, deletes, deletedDirs. If jobId is omitted, reports on the most recently started job (or returns active=false if none).

import_rclone_config · asks every call

Import remotes from a Linux rclone.conf (the headless equivalent of the in-app Import rclone config dialog, #269). Pass configText (the file contents). Each chosen remote becomes an rclone remote (token/creds copied verbatim, non-interactively — OAuth remotes don’t block on the browser flow) plus a matching RCLONE connection profile; a half-created remote is rolled back on failure so a failed import leaves no ghost. Skips typeless sections and names already configured. Optional names limits the import to those remote names. Returns { created, skipped, failed }. Returns an error if the config is password-encrypted.

list_rclone_directory · no per-call prompt

DEPRECATED: prefer list_directory(profileId=…, path=…). List files and subdirectories at a given path on an rclone remote. Returns name, isDir, size, mimeType, and modTime for each entry.

list_rclone_provider_options · no per-call prompt

List a credentials-based rclone provider’s basic config fields — the non-advanced options needed to configure a non-OAuth remote (ftp, sftp, webdav, s3, b2, mega, filen, …). Each entry has name, help, required, isPassword, default, type. Feed the collected values into configure_rclone_remote’s parameters. OAuth providers (drive, dropbox, onedrive, box, pcloud) are configured via the in-app browser sign-in, not this. (#181)

list_rclone_remotes · no per-call prompt

List rclone cloud storage remotes configured in Haven.

list_saved_sync_profiles · no per-call prompt

List the user’s saved rclone sync configurations (#159) — the named src/dst/mode/filters bundles surfaced in the SFTP folder-sync dialog’s dropdown. Returns id, name, srcFs, dstFs, mode (copy/sync/move), include/exclude patterns, optional minSize/maxSize/bandwidthLimit, createdAt, lastRunAt. Sorted most-recently-run first.

reset_rclone_stats · no per-call prompt

Reset rclone’s global transfer counters to zero. Useful when running ad-hoc operations outside start_rclone_sync (which already resets on start).

save_sync_profile · asks every call

Create or update a named rclone sync configuration (#159). Pass an id to overwrite an existing one; omit it to create. mode accepts copy/sync/move (or “mirror” as a sync alias). includePatterns/excludePatterns are arrays of glob strings. Returns the saved profile’s id and the full resolved fields. Mutates Haven state, gated by EVERY_CALL consent.

start_rclone_sync · asks every call

Start an async rclone transfer between two remote paths. mode=copy adds new/updated files (no deletes); mode=sync (a.k.a. “Mirror” in the UI) makes destination identical to source and deletes extras; mode=move copies then removes source files. srcFs/dstFs use rclone’s remote-prefixed notation, e.g. “gdrive:Backup/Photos” or “gdrive:” for the remote root. Returns { jobId, mode } — poll get_rclone_sync_status to read finished/success and the transfer/delete counters. Honours the same optional filter and dryRun fields the SFTP sync dialog exposes.

update_rclone_remote · asks every call

Update an existing rclone remote’s config in place (rclone config/update) — unlike configure_rclone_remote, which replaces the whole remote. Pass remoteName and a parameters option→value map of just the fields to change (rclone obscures password fields). Returns { updated, remoteName }.

Email (15)

Mailboxes, messages, drafts, and inbound Mail Rules automation.

create_mail_folder · asks every call

Create a new folder/label on a connected EMAIL profile (IMAP CREATE; on Gmail this is a new label). Pass profileId + name (use the server’s hierarchy separator for nesting, e.g. “Work/2026”). Returns { created, folderId } — use folderId as a destination for modify_mail_message move/copy. Fails if it already exists. IMAP/Gmail only — Proton returns 501. Changes the mailbox — prompts for consent on every call.

create_mail_rule · asks every call
Create an inbound-email automation rule: when a message in folderId (default INBOX) of accountProfileId (omit = any connected email account) matches criteria, run the ordered actions. criteria = {combinator:”ALL” “ANY”, conditions:[{type, op, value}]} where type is from to subject is_unread body has_attachment attachment_name attachment_mime header and op is CONTAINS EQUALS REGEX GLOB. actions = an ordered array of {type, …}: save_attachments{destProfileId,destDir,nameGlob?,mimeGlob?} run_command{template,background?} send_to_agent{messageTemplate,targetSessionId?} notify{titleTemplate,bodyTemplate} imap_filter{op: MARK_READ MARK_UNREAD SET_FLAGGED UNSET_FLAGGED MOVE DELETE, destFolderId?} forward{to[],template?} invoke_mcp_tool{toolName,argsTemplateJson}. Templates may use {from} {fromName} {subject} {to} {uid}. Creating + enabling a rule is your standing authorization for its actions (they fire without a per-call prompt); destructive actions (move/delete/forward/run-command, or a non-NEVER MCP tool) are queued for foreground approval when Haven is backgrounded. Turn the master switch on with set_preference mail_automation_enabled=true.
delete_mail_folder · asks every call

Delete a folder/label on a connected EMAIL profile (IMAP DELETE). Pass profileId + folderId (from list_mail_folders). On Gmail this removes the LABEL — messages survive in All Mail; on a plain IMAP server it deletes the mailbox AND its messages (destructive). System folders (Inbox/Sent/Drafts/Trash/Spam/All Mail/…) are refused. Returns { deleted, folderId }. IMAP/Gmail only — Proton returns 501. Destructive — prompts for consent on every call.

delete_mail_rule · asks once per session

Delete a Mail Rule by id (see list_mail_rules).

get_mail_automation_status · no per-call prompt

Mail-Rules automation status: master switch, rule counts, recent firings (the audit log), and destructive actions queued for foreground approval. Read-only.

list_mail_folders · no per-call prompt

List folders/labels for a connected EMAIL profile (IMAP/Gmail or Proton). Pass profileId (from list_connections). The profile must already be connected (connect_profile first). Returns each folder’s id, name, type, and role (inbox/sent/trash/…). Read-only.

list_mail_messages · no per-call prompt

List message envelopes in a folder of a connected EMAIL profile. Pass profileId and folderId (default ‘0’/INBOX; see list_mail_folders). Returns id, subject, from, unread, time, numAttachments per message, newest first. Page with limit (default 100) + offset (skip from the newest end; offset = page*limit walks older). Read-only.

list_mail_rules · no per-call prompt

List inbound-email automation rules (Mail Rules). Returns each rule’s id, name, enabled, orderIndex, accountProfileId (null=any), folderId, criteria, actions, lastFiredAt. Read-only.

modify_mail_message · asks every call
Mutate one message on a connected EMAIL profile: mark read/unread, flag/unflag (star), move to another folder, copy/apply-a-label, or delete. Pass profileId + messageId (from list_mail_messages) + op (mark_read mark_unread flag unflag move copy delete). op=move and op=copy also require destFolderId (a folder id from list_mail_folders). IMAP/Gmail only — the Proton engine returns 501. On Gmail: move relabels (removes the source label, adds dest); copy is additive — it applies the dest label and KEEPS the message in its current folders (use copy to label without archiving from Inbox); delete moves to Trash. Returns { ok, op, messageId }. Side-effectful — prompts for consent on every call.
poke_mail_watch · no per-call prompt

Force a Mail-Rules poll cycle now instead of waiting for the periodic timer (for testing/immediacy). No-op when the master switch is off. Returns { poked }.

read_mail_message · no per-call prompt

Fetch one message from a connected EMAIL profile (IMAP/Gmail or Proton; Proton messages are decrypted), returning parsed headers (from, to[], cc[] — cc enables reply-all) and plain-text body (HTML is stripped; remote content is never loaded). Pass profileId and messageId (from list_mail_messages). Each attachment carries an { index, filename, mimeType, sizeBytes, isInline } — pass the index to save_mail_attachment to write its bytes to any connected filesystem. Read-only.

save_mail_attachment · asks every call

Save one attachment from a message on a connected EMAIL profile to any connected filesystem (local, SFTP, SMB, rclone, Reticulum). Pass profileId + messageId + attachmentIndex (the index from read_mail_message), and the destination as destProfileId (“local” or any connected profile id) + destPath (a directory). Optional destFilename overrides the saved name. The file is named after the attachment (sanitised); a collision gets “ (1)”, “ (2)”, … Returns { saved, destProfileId, backend, destPath, filename, bytes }. Works for both IMAP and Proton. Writes a file — prompts for consent on every call.

save_mail_draft · asks every call

Save a draft (NOT sent) to the account’s Drafts folder on a connected EMAIL profile — use to compose a message for the user to review/send later. Same fields as send_mail (to/cc/bcc/subject/body, optional attachments, optional inReplyToMessageId to thread) but all are optional — a draft may be incomplete. Returns { saved, draftFolderId }. IMAP/Gmail only — Proton returns 501. Writes to the mailbox — prompts for consent on every call.

search_mail · no per-call prompt

Server-side search of a folder on a connected EMAIL profile. Pass profileId, optional folderId (default INBOX; see list_mail_folders), and one or more criteria: from, to, subject, body (substring matches), unreadOnly (bool), sinceEpochSec / beforeEpochSec (Unix seconds, day granularity). Criteria are ANDed; at least one is required. Optional limit (default 100, 1..500). Returns the same envelope shape as list_mail_messages (newest first) — feed ids into read_mail_message / modify_mail_message. IMAP/Gmail only — Proton returns 501. Read-only.

send_mail · asks every call

Send a plain-text email from a connected EMAIL profile. Pass profileId (from list_connections; connect_profile first), to (array of recipient addresses, at least one), subject, and body (plain text). Optional cc/bcc arrays. Optional attachments: an array of { profileId, path } files on any connected backend (“local” or a connected profile id) to attach. To reply in-thread, pass inReplyToMessageId (a messageId from list_mail_messages) — the engine sets In-Reply-To/References from that message so the reply threads (set your own “Re: …” subject). Returns { sent, messageId, appendedToSent }. IMAP/SMTP only — Proton send is not yet implemented and returns an error. Side-effectful: prompts for consent on every call and is recorded in the connection log.

Linux guest (proot) & desktops (44)

The on-device Linux distros, their desktop environments and windows, guest services, the audio bridge, and guest-file access.

capture_desktop · asks once per session

Capture a screenshot of a running desktop (deId) and return it INLINE as an image the agent can see directly — no second port or file download. Works for both X11/VNC desktops (via ImageMagick import) and nested-Wayland desktops — Sway / Hyprland / niri / cage (via grim, the wlroots screenshooter; auto-installed on first use). Whole screen by default; a single window via windowId (from list_desktop_windows) is X11/VNC only — nested-Wayland captures the whole output. The image is downscaled to maxWidth and JPEG-encoded by default to stay cheap over the MCP tunnel. Captures inside the guest, so it works even when the user isn’t on the VNC tab. Returns the image plus { deId, width, height, format, source, windowId?, windowTitle? }.

capture_desktop_tab · asks once per session

Capture what a remote-desktop VIEWER tab (RDP, VNC, or SPICE) is actually rendering, INLINE as an image — the framebuffer the user sees, with the server cursor composited on top at the tracked pointer position. This is distinct from capture_desktop, which screenshots an in-guest X11/VNC desktop; this one captures the RDP/VNC/SPICE client viewer (e.g. to verify colours and the cursor against a remote Windows/Linux server). Pass profileId to pick a tab (from list_desktop_sessions); omit it when exactly one desktop tab is open. Returns the image plus { profileId, protocol, width, height, hasCursor, cursorWidth?, cursorHeight?, hotspotX?, hotspotY?, pointerX?, pointerY?, format }.

delete_distro · asks every call

Wipe a distro’s rootfs and remove all installed DEs on it. Stops any running DEs first. Destructive — frees the disk space and is also the recovery path when an install lands in a broken state.

delete_system_vm_image · asks every call

Delete a stored system-VM image (#326) — removes its qcow2 and label. Stops the VM first if it is the one currently running.

detach_from_guest · no per-call prompt

Stop the haven-usb guest proxy started by usb_attach_to_guest and release the brokered USB device handle (the guest’s /dev/pts serial bridge or LD_PRELOAD HID routing stops working immediately). Pass keepOpen:true to leave the device handle open. The teardown counterpart to usb_attach_to_guest.

get_audio_bridge_status · no per-call prompt

Proot audio bridge status: state (STOPPED/STARTING/RUNNING/ERROR), loopback PCM port, bytesStreamed so far, and any error. Read-only — a quick way to confirm guest audio is actually flowing.

get_custom_binds · no per-call prompt

List the user-defined extra proot bind mounts (#301) for a distro — the per-distro custom Android→guest mounts added on top of the fixed system binds. Pass distroId; omit to use the active distro. Returns each bind as {host, guest} plus its proot spec.

get_proot_install_log · no per-call prompt

Return install-log events from the Room-backed ProotInstallLog table. Survives logcat rotation and app restarts. Filter by distroId and/or sinceMs (millis since epoch) to poll incrementally. Each event: id, timestamp, distroId, phase, deId?, exit?, ok, message?, logTail?.

gl_smoke_test · asks once per session

Launch a GL app into a RUNNING desktop on the GPU PATH (venus/virpipe — NOT the llvmpipe software fallback that launch_app_in_desktop forces), screenshot it, and heuristically report whether the frame is non-blank. A regression check for the windowed-GL-present pipeline (a blank/white frame = GL didn’t present). The verdict is reliable only for a FULL-FRAME GL app (a fullscreen / cage-kiosk GL test app like ‘glxgears’ or ‘es2gears’); for a windowed app the 2D chrome masks a blank 3D pane, so rely on the returned image. Optionally writes the gpu_use_venus pref first. Returns the screenshot plus { passed, distinctColors, topColorFraction, gpuPath, windowId? }. Detects non-blank, not correctness.

import_distro · asks every call

Import a custom rootfs tarball as a new distro (#284) — “bring your own rootfs”. The tarball (http(s) URL or an on-device file path) is extracted to its own rootfs and registered as a first-class distro, so it then appears in list_distros / set_active_distro / install_desktop exactly like a built-in. Raw mode: no baseline packages and no distro hooks run — the rootfs is used as shipped; family only routes later package installs (apk/apt/pacman/xbps). Use this for proot-distro / Docker-export tarballs and for a SECOND instance of a distro you already have (give it a new id — that is how #302 multiple-instances is done). Returns immediately; poll inspect_proot.osSetupState (Downloading → Extracting → Ready/Error). Supported compression: .tar.gz and .tar.xz (zstd not yet supported — recompress first).

import_system_vm_image · asks every call

Import a bootable disk image as a system VM (#326). source is an http(s) URL or an on-device file path; it is downloaded/copied then normalised to qcow2 via qemu-img convert (raw/qcow2/vdi/vmdk in, qcow2 out) under the app cache. Provide a label; id defaults to a slug of the label. Optional sha256 is verified against the SOURCE bytes. Installs qemu in the active distro on first use (needs a VNC-capable qemu — Debian’s has it, Alpine’s does not). Synchronous: returns { id, label, sizeBytes } when the converted image is ready. Then boot it with start_system_vm.

inspect_proot · no per-call prompt

Single rich read of the proot subsystem: active distro id, every Distro (id, label, family, installed, sizeMb, bytesOnDisk, postExtractHookIds, and installedDesktops — the desktop ids installed on THAT distro’s rootfs, so the cross-distro picture is visible without switching active distro), every DesktopEnvironment with per-family Stable/Experimental/Broken compatibility and Experimental notes, current osSetupState (phase / step / progress / errorPhase / errorMessage / errorTail), current desktopSetupState (phase / errorMessage / errorTail), and the last 50 install-log events. The single endpoint to drive issue #162 verification.

install_desktop · asks every call

Install a desktop environment on the active distro. Calls ProotManager.setupDesktop which downloads packages, configures VNC, and writes the launcher. Poll inspect_proot.desktopSetupState for progress. Failures are attributed to a DePhase (Packages / VncConfig / Marker) in both the state and the install log.

install_distro · asks every call

Set the given distro as active and trigger installRootfs(). Returns immediately; poll inspect_proot.osSetupState for progress (Downloading → Extracting → BootstrapHook → Baseline → Ready, or Error with attribution). Idempotent: if the distro is already installed, just switches active.

launch_app_in_desktop · asks once per session

Launch a GUI application into a RUNNING desktop (deId). X11/VNC desktops get DISPLAY/XAUTHORITY; nested-Wayland desktops (Sway/Hyprland/niri/cage) get XDG_RUNTIME_DIR/WAYLAND_DISPLAY. The software-GL fallback (LIBGL_ALWAYS_SOFTWARE=1, GALLIUM_DRIVER=llvmpipe) is exported either way, so GPU-less GL apps like KiCad/eeschema don’t crash their canvas. Optionally waits for the app’s window to appear and returns its windowId — pass that to capture_desktop to screenshot just that window (window-wait/windowId need enumeration: X11 and Sway; on other nested-Wayland compositors the app still launches but no windowId is returned). The app keeps running after this returns. For looking at saved design FILES prefer view_file (headless, no desktop needed); use this when you need the live interactive app.

list_desktop_environments · no per-call prompt

Slim DE-only read of inspect_proot. Filters to DEs that have a package list for the active distro’s family (matches the UI filter). Each entry includes per-family compatibility (Stable/Experimental/Broken), an Experimental note when relevant, installed?, and running? state.

list_desktop_sessions · no per-call prompt

List open remote-desktop tabs (VNC/RDP/SPICE) by connection profile, with their live status (connecting, connected, error). These are Desktop-screen tabs, not transport sessions — a VNC/RDP/SPICE-over-SSH desktop has its SSH tunnel in list_sessions and its own connect state here. Use after connect_profile to confirm a desktop reached ‘connected’, and after disconnect_profile to confirm the tab is gone (profile absent from the list).

list_desktop_windows · asks once per session

Enumerate the visible top-level windows on a running desktop (deId), so an agent can target a specific application window (e.g. KiCad’s schematic editor vs. PCB editor) before capturing it. Returns { deId, count, windows:[{id,title,x,y,width,height}] }. Works on X11/VNC desktops (via xdotool) and Sway nested-Wayland desktops (via swaymsg get_tree); other nested-Wayland compositors (Hyprland/niri/cage) aren’t enumerable yet — use capture_desktop for a whole-output screenshot there. Installs the X11 capture toolset (xdotool + ImageMagick) on first use.

list_distros · no per-call prompt

Slim distro-only read of inspect_proot. Returns each Distro with installed/active/sizeMb/family. Use this when you only need the catalog and not the live state or log events.

list_guest_apps · no per-call prompt

List the GUI applications installed in the active proot guest, discovered from its .desktop files (the same source an xfce4 application menu reads). Use this to find an app to launch with present_app without knowing its exact command. Returns { count, iconsResolved, apps:[{ name, exec, hasIcon, categories }] } sorted by name; exec is the runnable guest command (field codes stripped) you pass straight to present_app’s command. hasIcon indicates whether a decodable icon was resolved (icons themselves stay on-device for the launcher UI). Skips NoDisplay/Terminal/non-application entries.

list_guest_services · no per-call prompt

List guest services registered on the active distro with their live state (STOPPED/STARTING/RUNNING/ERROR), command, port, autostart flag, and last error/output tail. Read-only.

list_system_vm_images · no per-call prompt

List the stored system-VM disk images (#326) and the current VM’s state. A system VM is a full QEMU x86_64 Linux VM booted inside the active proot and viewed over VNC on loopback — distinct from a desktop environment (list_desktop_environments) and the USB-drive appliance (#287). Returns { vm: { status, vncPort }, count, images:[{ id, label, sizeBytes }] }. status is one of stopped/starting/running/error; when running, vncPort is the loopback port a VNC connection points at (create_connection type=VNC host=127.0.0.1 vncPort=). Images are qcow2, normalised on import.

open_desktop_terminal · no per-call prompt

Open an interactive local PRoot shell whose environment JOINS a RUNNING desktop (deId) — exports DISPLAY (X11/VNC) or WAYLAND_DISPLAY + XDG_RUNTIME_DIR (nested-Wayland / native labwc) — so you can drive the desktop’s apps from the command line (e.g. launch/inspect GUI programs in the same session a user is viewing over VNC). Returns a sessionId usable with send_terminal_input / read_terminal_scrollback, plus the resolved display/waylandDisplay/xdgRuntimeDir. Always a fresh session (a reused plain shell would lack the display env). The desktop must already be RUNNING (start_desktop). Unlike launch_app_in_desktop (fire-and-forget single app), this gives you an interactive shell.

read_app_window_log · no per-call prompt

Read the captured output log of a present_app cage window. The cage redirects BOTH the sway compositor AND the GUI app it runs (stdout+stderr merged) into one log, so this is how the agent SEES a present_app app’s own output — startup errors, GL/Mesa diagnostics, a crash trace — without wrapping the command in a logging script. Pass the sessionId returned by present_app for a live window; OMIT it to read the most-recent app-window log, which still works after the app crashed or exited (the session is gone but the log survives on disk). Returns { sessionId?, display, bytes, truncated, log }. For a GUI app that came up then died (a grey/blank or vanished window), this is the first thing to read.

read_desktop_log · no per-call prompt

Read a running (or just-failed) desktop’s RUNTIME logs — distinct from inspect_proot, which only covers install state. For nested-Wayland DEs (Sway / Hyprland / niri) returns the compositor’s own stdout/stderr (compositor.log: the wlr/[ERROR] lines, output-enable, buffer-allocation failures) plus Haven’s captured launch-process output (the [haven] progress markers + wayvnc lines). This is the diagnostic for grey-screen / no-frames / compositor-refuses-to-start issues — the data that otherwise requires opening a proot shell. Pass deId to target one DE; omit for all running desktops.

read_guest_file · no per-call prompt

Read a small file from the ACTIVE proot guest and return its contents to the agent (UTF-8 text; set asBase64 only for small binary). The reliable agent⇄guest text channel. Reads are capped to maxBytes. For large or binary files prefer serve_file (streams over a loopback URL — no base64 through the agent); for images/PDFs/schematics use view_file (renders to an inline picture); for anything the USER should see use present_media.

register_guest_service · asks once per session

Register a long-lived helper process to run inside the ACTIVE distro’s proot guest — typically an app-native MCP server (KiCad/FreeCAD/OpenSCAD) the agent drives for structured control. Haven supervises it: starts it (if autostart) when the MCP endpoint comes up, re-launches it after an app restart, and — when an MCP reverse-tunnel endpoint is configured — multiplexes its loopback port back to the remote MCP client alongside Haven’s own endpoint (no adb forward needed). The registry is persisted per-distro. Returns the generated service id. Use start_guest_service to launch it now.

run_in_proot · asks once per session

Run a shell command inside the ACTIVE distro’s proot guest (the same rootfs the running desktop uses) and return its combined stdout+stderr. Distro-agnostic: invokes /bin/sh -lc in whatever distro is active (check inspect_proot.activeDistroId), so it works on Debian/Arch/Void, not just Alpine like open_local_shell. For long jobs (apt-get install, pip install) you can pass background:true to get a jobId immediately, then poll by calling again with that jobId — the response carries the accumulated output and, once finished, the exitCode. Even without background, a synchronous call that runs longer than ~30s is auto-backgrounded: it returns {jobId, status:”running”, note, output:} instead of blocking past the MCP request timeout, and you poll it the same way. A quick command returns inline ({exitCode, output}). This is the agent's headless way to provision the guest (install packages, run kicad-cli ERC/DRC, etc.).

scroll_desktop_tab · asks once per session

Scroll a remote-desktop VIEWER tab (RDP/VNC/SPICE) by injecting mouse-wheel notches into the remote server. deltaY > 0 scrolls down, < 0 scrolls up; magnitude is the number of notches. Pass profileId to pick a tab (from list_desktop_sessions); omit when exactly one is open. Returns { profileId, protocol, deltaY }.

send_desktop_clipboard · asks once per session

Set the clipboard on a remote-desktop VIEWER tab (RDP/VNC) to the given text, so it can be pasted inside the remote server (Ctrl+V / right-click paste). This is the closest substitute for typing while keyboard injection is unsupported. Pass profileId to pick a tab (from list_desktop_sessions); omit when exactly one is open. Returns { profileId, protocol, chars }.

set_active_distro · no per-call prompt

Switch the active proot distro WITHOUT installing anything — the lightweight counterpart to install_distro (which downloads). The active distro is the rootfs that run_in_proot, install_desktop, start_desktop and the desktop/USB tools all operate on, so this is how you drive cross-distro work over MCP (e.g. run_in_proot inside Void instead of the current active distro). The distro must already be installed — call list_distros for installed ids, or install_distro to add one. Returns the new active distro id, its family, and the desktops installed on it.

set_custom_binds · asks every call

Replace the user-defined extra proot bind mounts (#301) for a distro — exposes arbitrary Android paths inside that distro’s guest (interactive shell, desktop, and run_in_proot all pick them up). proot binds are read-write. Pass distroId (omit for active) and binds, an array of {host, guest?} objects (guest blank = same path as host). This REPLACES the whole list; pass [] to clear. Takes effect on the NEXT session/command, not already-running ones.

start_audio_bridge · asks once per session

Start the proot audio bridge (#257): launches a PulseAudio daemon in the active distro and plays its output through the Android speaker — output only, no mic. Guest apps reach it via PULSE_SERVER (written to /etc/profile.d/pulse.sh and exported into desktop sessions), so apps launched from a login shell / desktop get sound. Installs pulseaudio on first use. Idempotent. Returns { state, port }.

start_desktop · asks every call

Start an installed desktop environment on the active distro. Calls DesktopManager.startDesktop; the launch is asynchronous. Returns the allocated display + vncPort so callers can connect a VNC client. Poll inspect_proot.desktopEnvironments[].running (or list_desktop_environments) to confirm RUNNING state. NestedWayland DEs (Sway, Hyprland, niri) bring up a wlroots/smithay compositor on the headless backend inside the rootfs and expose it via wayvnc on the returned port; X11Vnc DEs spawn Xvnc + the desktop; NativeCompositor runs the JNI labwc bridge.

start_guest_service · asks once per session

Start a registered guest service by id (no-op if already running). Returns its state.

start_system_vm · asks every call

Boot a stored system-VM image (#326) as a QEMU x86_64 VM with a VNC display on a free loopback port, and wait for the VNC server to bind (up to ~20s; a timeout means this distro’s qemu has no VNC — try a Debian image/distro). One VM at a time — call stop_system_vm first to replace a running one. Does NOT open a viewer (MCP has no UI) — returns { imageId, status, vncHost, vncPort, hint } so you connect it yourself: create_connection type=VNC host=127.0.0.1 vncPort=, then connect_profile. Under TCG (no KVM) the guest boots slowly (~2 min) but is usable.

stop_audio_bridge · no per-call prompt

Stop the proot audio bridge: kills the in-guest PulseAudio daemon and releases the Android AudioTrack.

stop_desktop · asks every call

Stop a running desktop environment. Tears down the compositor / Xvnc process tree and releases the display number.

stop_guest_service · no per-call prompt

Stop a running guest service by id (leaves it registered).

stop_system_vm · asks every call

Power off / kill the running system VM (#326) and release its loopback VNC port. Idempotent — a no-op if none is running. Kills the whole qemu process tree inside the proot.

tap_desktop_tab · asks every call

Click a point on a remote-desktop VIEWER tab (RDP/VNC/SPICE) — inject a mouse click into the remote server. Coordinates are in the REMOTE framebuffer’s pixel space (the same space capture_desktop_tab reports: 0..width, 0..height), NOT Haven’s own UI (that’s tap_haven_ui). Pass profileId to pick a tab (from list_desktop_sessions); omit when exactly one desktop tab is open. Buttons follow X11: 1=left (default), 2=middle, 3=right. Keyboard typing is not yet supported (the session abstraction has no key verb). Returns { profileId, protocol, x, y, button }.

uninstall_desktop · asks every call

Remove a desktop environment from the active distro. Stops it first if running. Calls ProotManager.uninstallDesktop.

unregister_guest_service · no per-call prompt

Stop (if running) and remove a guest service from the registry by id.

write_guest_file · asks once per session

Write a file into the ACTIVE proot guest. Supply content (UTF-8 text); for binary prefer upload_file (stages a device-cache file into the guest) over contentBase64. Parent directories are created by default. The reliable way to push agent-authored text files (scripts, generators, configs) into the guest without a terminal heredoc. For large files, send in ordered chunks: first chunk {append:false, final:false}, middle chunks {append:true, final:false}, last chunk {append:true, final:true} — the file lands in the guest only on the final chunk.

Networking — tunnels & port forwarding (14)

SSH tunnels, port forwards, the serial↔TCP bridge, and the port-knock / single-packet-auth gates.

add_port_forward · asks once per session

Save a port-forward rule on an SSH profile. If the profile is currently connected the rule is also activated immediately. Type LOCAL=-L (local→remote), REMOTE=-R (remote→local), DYNAMIC=-D (SOCKS5 proxy server). Returns the saved rule’s id and (when activated) the actually-bound port; activated:false with an error means the bind failed on the live session (e.g. port held in TIME_WAIT by a just-closed connection) — the rule is still saved and applies on the next connect.

bridge_serial_to_tcp · asks once per session

Expose a live serial terminal session (BTSERIAL / BLESERIAL / USBSERIAL — sessionId from list_sessions) as a raw-byte TCP server on 127.0.0.1, and return the bound port. Feed that port to add_port_forward (a remote-forward) or a tunnel to reach the device off-phone — this is how a serial device joins Haven’s routing fabric. The terminal tab keeps working; the bridge taps the same stream. Bytes are raw with no framing: every connected client sees the device output and any client can write back to it. Pass an explicit port, or omit for an OS-assigned free port. Idempotent — a second call for the same session returns the existing port. Open the device’s terminal first (connect_profile / a serial tab); a session with no open terminal has nothing to bridge.

create_tunnel · asks every call

Add a new WireGuard, Tailscale, or Cloudflare Tunnel config. WIREGUARD: pass configText (wg-quick INI body). TAILSCALE: pass tailscaleAuthKey (and optional tailscaleControlUrl for Headscale). CLOUDFLARE_ACCESS: pass accessHostname; for Access-protected routes also pass accessJwt (from cloudflared access token --app https://<host>); optional accessJumpDestination for bastion-mode multi-target tunnels. Returns the new tunnel id, which can then be passed to set_profile_routing.

delete_tunnel · asks once per session

Delete a saved tunnel config by id. Profiles that referenced it via tunnelConfigId will fall through to direct dialling on next connect.

list_live_tunnels · no per-call prompt

Return the live-tunnel snapshot from TunnelManager — every tunnel currently up, paired with the set of profile ids holding it. Useful for verifying refcount semantics in #149 integration tests: confirm the tunnel stays open while a sibling transport keeps it acquired, and that it tears down on the last release.

list_serial_bridges · no per-call prompt

List active serial↔TCP bridges: sessionId, bound loopback host/port, and connected-client count. Read-only. Bridges whose serial session has died (device unplugged / out of range) are pruned here.

list_tunnels · no per-call prompt

List saved WireGuard / Tailscale tunnel configs available for Route-through on connection profiles. Returns id, label, type (WIREGUARD or TAILSCALE), and createdAt for each. The encrypted configText (wg-quick payload or Tailscale authkey blob) is NOT returned.

remove_port_forward · asks once per session

Delete a port-forward rule by id, and deactivate it on the live session if the owning profile is currently connected.

set_port_knock · asks every call

Update the port-knock fields on an existing profile. Pass portKnockSequence=’’ (empty) to disable knocking. Format: ‘port[/proto]’ tokens — e.g. ‘7000 8000 9000’ or ‘7000/tcp 8000/udp’. Returns the updated profile summary.

set_profile_routing · asks once per session

Set or clear the Route-through configuration on a connection profile. Pass either tunnelConfigId (WireGuard / Tailscale tunnel from list_tunnels) OR proxyType+proxyHost+proxyPort (legacy SOCKS5 / SOCKS4 / HTTP), and the other field set is cleared — mutually exclusive at the data layer too. Pass clear=true to drop both and route direct.

set_spa · asks every call

Configure fwknop Single Packet Authorization (SPA) on an existing profile — the cryptographic alternative to port knocking. Pass spaKey=’’ to disable. spaAccessSpec is the port(s) to open, e.g. ‘tcp/22’ or ‘tcp/22,udp/53’. allowMode is SOURCE (default; fwknopd opens for the packet’s source IP), RESOLVE (resolve public IP), or EXPLICIT (use explicitIp). Returns the updated profile summary; key material is never echoed back.

stop_serial_bridge · asks once per session

Tear down a serial↔TCP bridge started by bridge_serial_to_tcp, closing its listen port and every connected client. The underlying serial session and its terminal tab are unaffected.

test_port_knock · asks once per session

Send a port-knock sequence to a host without committing or connecting anything. Bypasses the saved profile state — pass host + sequence directly. Returns ok/sent/durationMs/error so an agent can verify a knockd config end-to-end without opening a real session.

test_spa · asks once per session

Build and send one fwknop SPA packet to a host without committing a profile or connecting. Pass the key/access directly. Returns ok/bytesSent/spaPort/error so an agent can verify a fwknopd config end-to-end. Key material is never echoed back.

USB & host-device brokers (17)

USB devices and drives, USB/IP export, and the adb-over-VPN bridge.

close_usb_drive · no per-call prompt

Close a USB-drive VM opened by open_usb_drive: power off the VM, stop its USB/IP export, and remove the transient SSH profile + ephemeral key. Idempotent.

delete_usb_appliance · asks once per session

Delete the persistent USB-helper Linux appliance — the small installed Alpine VM (with usbip+ssh baked in) that open_usb_drive boots to mount drives. It’s provisioned once and kept so repeat opens are fast; deleting it frees the disk (~280 MB) and forces a one-time re-provision (re-download + install) on the next open_usb_drive. Closes any live USB-drive VM first. Idempotent.

enable_wireless_adb · asks every call

Turn on Android’s Wireless debugging (adb connect over WiFi) by setting adb_wifi_enabled=1 via Shizuku. Requires Shizuku to be running and Haven to have its permission granted. NOTE: on Android 11+, a host that has never paired with this device must still complete the pairing-code flow manually — this tool cannot bypass that. For an already-paired host (the common case after a phone reboot) flipping the flag is enough.

expose_adb · asks every call

Make the device’s adb reachable from the workstation over 4G even with a system VPN active. Enables classic adb-over-TCP on a loopback port (default 5555) via Shizuku — no per-host pairing — then reverse-forwards 127.0.0.1: over the existing MCP tunnel. Because the only phone-side hop is loopback (which Android never routes through a VpnService), adb stays reachable through any VPN. On the workstation: `adb connect localhost:`. Requires Shizuku running + granted. Use install_apk_from_url/_from_backend for installs that don't need a full adb connection. Tear down with unexpose_adb.

list_bridges · no per-call prompt

Unified view of every phone capability Haven is currently brokering to a sink — the ‘Bridges’ registry (see docs/design/bridges.md). A bridge is one Android-held capability (a USB device, audio, etc.) re-exposed to a consumer that can’t reach it directly: the AI agent, the local Linux guest, a local VM, a remote host, or the workstation. Generalises list_usb_exports across all bridge types. Each entry: source, sourceKind, sink, transport, state, plus type-specific detail (busid/port/profileId/mounts). Read-only.

list_usb_devices · no per-call prompt

List USB devices attached to the phone (host/OTG). Each entry has deviceName (the stable /dev/bus/usb path used as the key for the other usb_* tools), vidPid, deviceClass, hasPermission, isOpen, and the interface/endpoint descriptors (id, class, endpoint address + direction + type). Manufacturer/product/serial strings are only filled once permission is held (call request_usb_permission). Read-only; never prompts.

list_usb_drives · no per-call prompt

List phone-attached USB mass-storage drives (the candidates for open_usb_drive) and every currently-open USB-drive VM in vms (up to a phone-resource concurrency limit): busid, phase (idle/opening/ready/error), the loopback SSH profileId, whether it’s mounted read-only, any locked (LUKS) partitions awaiting unlock_usb_drive_partition, and the mounted paths once ready. Read-only — poll this after open_usb_drive until the matching vms[] entry has phase=ready.

list_usb_exports · no per-call prompt

List active USB exports of phone-attached devices: the USB/IP server (start_usbip_export — to remote hosts) and the guest proxy (usb_attach_to_guest — to the local proot guest). Reports the exported device, busid/bound port, and whether a remote usbip client is currently attached. Read-only.

open_usb_drive · asks once per session

Open a phone-attached USB drive (mass storage — flash drive, SSD, SD reader) inside an on-device QEMU Linux VM and surface its files as an ordinary connection (#287). Unlike usb_attach_to_guest (which gives the proot guest a char device), this gives the drive a REAL kernel, so ext4 / GPT / block partitions mount and their files are browseable. Flow: exports the drive over USB/IP, boots (or reuses, if another drive is already open) a small Alpine VM that imports it, mounts every partition (read-only unless writable), and runs sshd — then returns a loopback SSH/SFTP profileId you browse with list_directory / serve_file (and a terminal tab into the VM). A LUKS-encrypted partition mounts locked (reported in list_usb_drives’ vm.locked) — call unlock_usb_drive_partition with its passphrase to mount it. The VM boot is slow (TCG, no KVM unrooted) + the first run installs packages, so this returns {status:”starting”} immediately — poll list_usb_drives until phase=ready (profileId set) or error. Consent-gated per session (mounting the user’s disk is sensitive). Up to a phone-resource limit of concurrent drives (they share one VM, so this is a vhci-port/practical cap, not RAM); isochronous (webcam/audio) still can’t pass.

request_usb_permission · asks once per session

Request the Android runtime USB permission for a device (pops the system grant dialog) and open it, caching the connection for usb_control_transfer / usb_bulk_transfer. Idempotent: a no-op if permission is already held and the device is open. Returns the device info with hasPermission/isOpen reflecting the result.

start_usbip_export · asks once per session

Start a userspace USB/IP server exporting a phone-attached USB device over TCP (default port 3240) so a remote Linux host can usbip attach it as a real local device node — every app there (ssh, libfido2, browsers) sees it, with the touch happening on the phone. Opens the device (requesting permission if needed) and returns the busid, bound port, and the client-side attach command. deviceName is optional when exactly one device is attached. Pass loopbackOnly:true to bind 127.0.0.1 only (for use behind an SSH/WireGuard tunnel); the default binds all interfaces for direct LAN attach. This is the remote-host counterpart to usb_attach_to_guest (which targets the local proot guest, where usbip can’t run — the Android kernel has no vhci-hcd).

stop_usbip_export · no per-call prompt

Stop the USB/IP server started by start_usbip_export (closes the listening socket and any active client connection) and release the brokered USB device handle. Pass keepOpen:true to leave the handle open for a fast re-export.

unexpose_adb · asks once per session

Tear down expose_adb: remove the adb reverse forward from the MCP tunnel and disable adb-over-TCP on the device (returns adbd to USB-only). Safe to call even if adb wasn’t exposed.

unlock_usb_drive_partition · asks once per session

Unlock a LUKS-encrypted partition on an open USB-drive VM (see list_usb_drives’ vms[].locked for candidates, e.g. “sdb2” → devicePath “/dev/sdb2”) and mount it. Runs against the already-booted VM — no reboot. Returns the updated mount/locked lists; throws on a wrong passphrase.

usb_attach_to_guest · asks once per session

Expose a USB device to the proot Linux guest: opens it (requesting permission if needed) and binds the haven-usb proxy on an abstract LocalSocket the guest can reach, then stages the haven-usb-probe binary into the guest. Returns the socketName, the in-guest probePath, and a probeCommand you can run via run_in_proot to verify reachability. For a CDC-ACM serial device it also returns serialBridgeCommand (the haven-usb-serial PTY bridge) so unmodified serial apps (e.g. LIRC’s lircd/mode2) can open it as /dev/pts/N. deviceName is optional when exactly one device is attached. This is the entry point for the guest-side USB shim (LD_PRELOAD/DllMap for HID, a PTY bridge for serial).

usb_bulk_transfer · asks every call

Perform a USB bulk or interrupt transfer on an opened device. Direction is taken from the endpoint descriptor. Args: deviceName, endpoint (bEndpointAddress, int), dataBase64 (OUT payload, omit for IN), length (IN read length), timeoutMs (default 1000). The owning interface is claimed automatically. Returns bytesTransferred and, for IN endpoints, dataBase64.

usb_control_transfer · asks every call

Perform a USB endpoint-0 control transfer on an opened device. Args: deviceName, requestType (bmRequestType, int — bit 7 set = device-to-host/IN), request (bRequest), value (wValue), index (wIndex), dataBase64 (OUT payload, omit for IN), length (IN read length), timeoutMs (default 1000). Returns bytesTransferred and, for IN transfers, dataBase64. The device must already be opened via request_usb_permission.

Security — SSH keys, host keys, TOTP & age (14)

The SSH key store, pinned host keys (TOFU), trusted host CAs, TOTP secrets, and age encryption identities.

add_trusted_host_ca · asks every call

Trust an SSH host CA (#133): register a CA public key so any server presenting an OpenSSH host certificate signed by it connects without a TOFU prompt. caPublicKey is an OpenSSH public-key line (“ssh-ed25519 AAAA… [comment]”) or a bare base64 blob; name is a label. Ed25519 and ECDSA host CAs are verified natively; RSA host CAs are stored but not yet validated by the SSH library. Establishing trust is a security boundary — gated by consent.

create_age_identity · asks every call

Generate and store a new age X25519 encryption identity (VISION §2). Optional label. Returns the new id and its public age1… recipient. Tap-equivalent to Keys → + → Generate age identity.

create_totp_secret · asks every call

Store an OATH-TOTP secret so it can auto-fill an SSH keyboard-interactive OTP prompt (#178). Pass otpauth (an otpauth://totp/... URI) OR secret (a raw base32 string) plus an optional label. Returns the new secret id; reference it via a TOTP:<id> token in create_connection’s authMethods.

delete_ssh_key · asks every call

Delete a saved SSH key by id. Profiles that referenced it via sshKeyId will fall through to password auth (or fail) on next connect — no cascade rewrite. Irreversible: the encrypted private key bytes are removed.

delete_totp_secret · asks every call

Delete a saved TOTP secret by id. Profiles referencing it via a TOTP auth element fall through to a manual OTP prompt on next connect. Irreversible.

delete_trusted_host_ca · asks every call

Remove a trusted SSH host CA by id (from list_trusted_host_cas). Servers signed by it fall back to the usual per-host TOFU prompt on the next connect. Deletes the whole step-ca config row for that id. No-op if none matches; returns removed=true/false. Gated by consent.

forget_known_host · asks every call

Forget a pinned SSH host key by hostname + port, so the next connect re-pins on first-use trust (TOFU). Use when a server has legitimately rotated its host key, or to clear a stale pin. hostname and port are required (from list_known_hosts). No-op if none matches; returns removed=true/false.

import_ssh_key · asks every call

Import an OpenSSH / PEM / PKCS#8 / PuTTY PPK private key into the Haven key store. Pass privateKey (the text body, e.g. starting with -----BEGIN OPENSSH PRIVATE KEY-----), label (user-facing name), and optional passphrase (only if the key is encrypted). Returns the new key id, keyType, publicKeyOpenSsh (suitable for an authorized_keys line), and fingerprintSha256.

list_age_identities · no per-call prompt

List saved age file-encryption identities (VISION §2). Returns id, label, the public age1… recipient (encrypt to this with encrypt_file or the file browser’s Encrypt action), and createdAt. The private key (AGE-SECRET-KEY-1…) is NEVER returned — it stays encrypted at rest.

list_known_hosts · no per-call prompt

List pinned SSH host keys (the TOFU known_hosts store). Returns hostname, port, keyType, fingerprint (SHA-256), and firstSeen (epoch ms). Use forget_known_host to remove one.

list_ssh_keys · no per-call prompt

List saved SSH keys available for SSH / Mosh / SFTP profiles. Returns id, label, keyType (e.g. ed25519, rsa, sk-ssh-ed25519@openssh.com), publicKeyOpenSsh, fingerprintSha256, isEncrypted (passphrase-protected), biometricProtected, enabledForAuth (whether it’s offered in ‘any saved key’ auto-auth), verifyRequired (FIDO2/SK keys only: requires its PIN at sign-in), and createdAt. Set enabledForAuth / verifyRequired via set_ssh_key_option. Private key bytes are NEVER returned — they stay encrypted at rest.

list_totp_secrets · no per-call prompt

List saved OATH-TOTP authenticator secrets (#178). Returns id, label, issuer, accountName, algorithm, digits, periodSeconds, and createdAt. The base32 secret itself is NEVER returned — it stays encrypted at rest. Reference an id as a TOTP:<id> token in create_connection’s authMethods to auto-fill the SSH ‘Verification code:’ prompt.

list_trusted_host_cas · no per-call prompt

List the trusted SSH host-CA entries — the step-ca configs whose SSH host-CA public key is set (#133). A server presenting an OpenSSH host certificate signed by one of these CAs connects with no TOFU fingerprint prompt. Returns, per entry: id, name, keyType, and fingerprint (SHA-256, OpenSSH format). Configs with no host-CA key are omitted. Use add_trusted_host_ca / delete_trusted_host_ca to change the store.

set_ssh_key_option · asks once per session

Set per-key options on a saved SSH key (the toggles on the Keys screen). keyId (from list_ssh_keys) is required; pass either or both of: enabledForAuth (bool) — whether the key takes part in ‘any saved key’ auto-auth (off = only used when a profile pins it); verifyRequired (bool) — FIDO2/SK keys only — whether the key requires its PIN at every sign-in (true) or is touch-only (false); flips the SK flag in place without re-registering; storedPassphrase (string) — encrypted keys only — store the key’s passphrase on-device (encrypted at rest) so connects don’t prompt and the key can be exposed over SSH agent forwarding (#377); empty string clears a stored passphrase. Returns the key’s resulting enabledForAuth and verifyRequired. Biometric-protected SK keys can’t have verifyRequired changed over MCP (no prompt available).

Agent ↔ you (attention & self-drive) (12)

How an agent reaches your attention (present_*, notifications, the agent-to-agent turn tools) and drives Haven’s own UI.

await_turn · no per-call prompt
Block until a terminal session is idle-at-prompt — the natural “wait for the other agent / command to finish” primitive for turn-based conversation (#226). Detection: OSC 133 shell-integration segments when present (idle = cursor on the newest prompt row); otherwise screen heuristics tuned for Claude Code’s TUI (no busy spinner / “esc to interrupt”, a prompt-looking line near the bottom, and the screen stable across polls). Idle must hold for settleMs before returning. Returns { sessionId, idle, method: “osc133” “heuristic”, waitedMs, timedOut }. idle=false + timedOut=true means the timeout elapsed first — the session may still be mid-turn. Full-screen TUIs that are neither shells nor agent REPLs (vim, htop) can read as idle once their screen stops changing; this is a turn heuristic, not a process-state probe.
capture_haven_ui · asks once per session

Capture HAVEN’S OWN rendered screen — the app UI the user is looking at right now (Connections list, terminal tab, a dialog, the file browser, an agent overlay), NOT a remote desktop (capture_desktop_tab) or the terminal text (read_terminal_snapshot). This is the ‘perceive’ half of the self-hosting loop: after install_apk_from_backend deploys a build, capture_haven_ui lets the agent see the result and diff it. Returns the image plus { width, height, imageWidth, imageHeight, format }. width/height are the FULL window in pixels — pass tap_haven_ui / swipe_haven_ui coordinates in THAT space even when the returned image was downscaled via maxWidth. If Settings → screen security (FLAG_SECURE) is on, returns { secure: true } with no image (capture is intentionally blocked). Errors if Haven is not in the foreground.

dump_haven_ui · asks once per session

Dump Haven’s OWN foreground UI as a structured element list — the in-app equivalent of uiautomator dump, so you get EXACT control bounds instead of estimating them off a capture_haven_ui image. Returns { width, height, count, nodes:[{text, contentDescription, editableText, role, clickable, disabled, bounds:[left,top,right,bottom], centerX, centerY, window?}] } in the SAME window-pixel space tap_haven_ui / swipe_haven_ui use — read a control’s centerX/centerY and tap it directly. Nodes from the activity window have no window field; nodes from an overlay that renders in its OWN window (e.g. the consent sheet, window: consent-sheet) are labelled with it (#355) — those bounds are that window’s own pixel space. capture_haven_ui still photographs the activity window only, and tap_haven_ui refuses entirely while a consent prompt is pending, so an overlay can be observed but never tapped by an agent. FLAG_SECURE blocks it. Read-only.

present_app · asks once per session

Show the user a LIVE, interactive single application window inline in Haven. Launches command as a Wayland app under a cage kiosk inside the active proot guest, exposes it over VNC, and embeds the live view in a bottom sheet over whatever screen the user is on (pinch-zoom, pan, drag and fullscreen all work; the user can interact). Use this to collaborate in a real GUI app — an image viewer, a media/audio player, a PDF/whiteboard tool — rather than pushing a static image with present_media. command is the guest shell command cage runs (e.g. ‘imv /root/board.png’, ‘mpv /root/clip.mp4’); the app and any Wayland deps must already be installed in the guest. Returns { presented, sessionId, vncPort, state } once the window is up. Multiple app windows can run at once: each call launches another cage; the newest is shown full-overlay and any previous one is backgrounded to a draggable edge icon (tap to bring it back). The user backgrounds a window by tapping outside it (keeps it running) and tears it down with the Dismiss button or the edge-icon close. If the window comes up grey/blank or vanishes, read the app’s own stdout/stderr with read_app_window_log (works even after it crashed) instead of wrapping the command in a logging script.

present_media · no per-call prompt

Show the user an image — or play a short sound — inline in Haven. A bottom sheet floats over whatever screen the user is on, rendering the image (or an audio card with a play button) plus an optional caption. The “here, look at / listen to this” channel: use it when you have something visual or audible you want the user to perceive directly. Reference the media by a file Haven can reach — profileId (“local” for the device / proot-guest cache, or an SSH/SMB/rclone profile id) + path — or by a ready url (e.g. a serve_file loopback URL). Haven streams the file into a local handle; the bytes never pass through the agent context. mimeType is inferred from the file (extension, else content sniff) when omitted; set it for audio. Only image/* and audio/* are supported. Returns immediately ({ presented }) as soon as the request is accepted — Haven fetches/stages the file and shows the sheet in the background, so a slow transfer can’t turn a delivered image into a timeout; a staging failure is logged (not returned). The user dismisses the sheet at their leisure.

present_web · no per-call prompt

Show the user HTML, an SVG, or a PDF inline in an in-app WebView — the interactive rung between present_media (a static image) and present_app (a full live VNC app). Pass a url (e.g. a serve_file loopback URL or any web page), or reference a file with profileId (“local” for the device / proot-guest cache, or an SSH/SMB/rclone profile id) + path, which Haven serves over a loopback URL. A PDF is paged; HTML/SVG render live (pinch-zoom + pan). Floats in a bottom sheet over whatever screen the user is on; bytes never pass through the agent context. Returns immediately: a url acks with { presented, id, url }; a file reference acks with { presented } and is staged/shown in the background (a staging failure is logged, not returned). The user dismisses it at their leisure.

queue_self_message · asks every call

Extra capability switch — off by default: queue_terminal_input is disabled — enable in Settings → Agent endpoint → Allow agents to queue terminal input.

DEPRECATED: alias for queue_terminal_input. Use queue_terminal_input — same arguments, same behaviour, plus a submitKey parameter you didn’t have here.

raise_notification · no per-call prompt

Post a real Android system notification on Haven’s behalf so the agent can drive notification-listener / wake / DND / silencer apps during F-Droid tester reviews without needing a second device. Always posts to the dedicated ‘agent.test.notifications’ channel (created on first use) so the user can mute agent notifications cleanly without affecting Haven’s own connection / renewal notifications. Returns { posted, id, channel } — keep the id around if you want to dismiss or replace the notification later (a future tool). Notifications use Haven’s app identity, so notification-listener apps see package=sh.haven.app. Requires the POST_NOTIFICATIONS runtime grant (declared in the manifest, granted by the user on first Haven launch); the call fails with a clear error if notifications have been disabled in system settings.

read_last_turn · no per-call prompt
Return the target session’s latest completed turn as text — the receive half of agent↔agent conversation (#226). When the session is idle at an OSC 133-integrated shell prompt, returns the last command’s output (semantic COMMAND_OUTPUT between the newest COMMAND_INPUT and COMMAND_FINISHED, scrollback included; source=”osc133”). Otherwise (Claude Code and other REPLs that don’t emit shell-style 133) falls back to scraping the last ●/⏺-bulleted block above the input box from the visible screen (source=”scrape” — inherently heuristic; long replies that scrolled off-screen are truncated to what’s visible). Returns { sessionId, text null, source null, truncated }. Call await_turn first so you read a finished turn, not a partial one.
send_to_agent · asks once per session

Deliver one message to another agent’s REPL (or any raw-mode prompt) as a single submitted turn: paste the text (bracketed-paste when the target has enabled it, plain otherwise), settle, then Enter — and return the resulting screen (last ~50 lines by default, captured after a short render delay). A convenience wrapper over send_terminal_input tuned for agent↔agent / REPL conversation, so you don’t hand-assemble the body-then-Enter sequence. Use list_sessions (chosenSessionName + isAgentRepl) to pick the target; pair with await_turn + read_last_turn for the full send → wait → read loop. Returns { sessionId, delivered, bytesSent, snapshot }.

swipe_haven_ui · asks every call

Inject a swipe/drag into HAVEN’S OWN UI from (fromX, fromY) to (toX, toY) in window pixels (the coordinate space capture_haven_ui reports), over durationMs split into N steps. Drives pager flings (swipe between Connections/Terminal/Files tabs), list scrolls, and bottom-sheet drags. Refused while a consent prompt is showing and when Haven is not foreground. Returns { delivered, reason?, fromX, fromY, toX, toY, durationMs, steps }. Verify with capture_haven_ui.

tap_haven_ui · asks every call

Inject a tap (or, with holdMs > 0, a press-and-hold) into HAVEN’S OWN UI at window-pixel (x, y) — the same coordinate space capture_haven_ui reports in its width/height. This is the ‘drive’ half of the self-hosting loop: read a control’s position from a capture_haven_ui image, then tap it. Drives the real touch pipeline (Compose clickables, nav tabs, dialog buttons). Refused while a consent prompt is showing (so an injected tap can’t self-confirm) and when Haven is not foreground. Returns { delivered, reason?, x, y, holdMs }. Verify the effect with a follow-up capture_haven_ui.

Agent endpoint, device & diagnostics (13)

Pairing, standing policies, app info/update, preferences, and device diagnostics.

create_standing_policy · asks every call

Propose a Tier-3 STANDING POLICY: a scoped, rate-capped, expiring grant that lets THIS client call the listed tools without a per-call consent prompt. The user’s tap on this tool’s consent sheet IS the installation — the sheet spells out the full scope. Use it when a workflow needs many consented calls in a row (e.g. a tap_haven_ui/swipe_haven_ui drive-and-verify loop) so the user grants the loop once instead of per tap. toolNames must be existing tools; some can never be covered (the policy tools themselves, install_apk_*, unpair_mcp_client). argConstraints (optional) pins arguments: every key given must exactly equal the call’s argument (e.g. {“profileId”:”"} scopes the grant to one connection). Covered calls are still written to the audit log; the rate ceiling makes extra calls fall back to normal prompts; the policy expires on its own and can be revoked any time from Haven's Agent activity screen or via revoke_standing_policy. Returns { id, expiresAt }.

get_app_info · no per-call prompt

Return Haven version, which optional features are available in this build, and mcpCarriers — which MCP transports are actually open right now (a WireGuard-collision warning if the WG carrier is shadowed by a system VPN, and whether the near/SSH carrier is currently riding a connected interactive session — see McpNearCarrier).

get_pending_consent · no per-call prompt

Return the consent/pairing prompts Haven is currently showing or holding, oldest first, or { pending: false } when none. Each entry: { id, toolName, clientHint, summary, isPairing, offerTimedAllow, requestedAt }. The consent sheet renders in its own window that capture_haven_ui / dump_haven_ui cannot see (#355), so this is the only way an agent can tell “my call is waiting for the user” from “my call was denied” — a backgrounded call now HOLDS for foreground rather than failing instantly (#337). toolName ‘_pairing’ marks a pairing request. Read-only: this cannot answer a prompt, and no tool can — only the user can, on the device.

get_preference · no per-call prompt

Read a Haven user preference by key. Whitelisted keys: terminal_scrollback_rows, terminal_tap_to_position_cursor, terminal_font_size, terminal_color_scheme, terminal_auto_switch_scheme, terminal_light_color_scheme, terminal_dark_color_scheme, terminal_locale, mouse_input_enabled, terminal_right_click, mcp_tunnel_endpoint_profile_id, mcp_wireguard_enabled, mcp_lan_bind_enabled, mcp_wireguard_tunnel_config_id, usb_guest_exposure_enabled, connection_logging_enabled, remap_low_ports (#300 proot launch toggle), share_storage_with_guest (#301 proot launch toggle), bind_android_system (#304 proot launch toggle). Returns { key, value } where value’s type follows the preference’s type (int / boolean / string). Colour-scheme values are TerminalColorScheme enum names.

install_apk_from_backend · asks every call

Install an APK from a path on any connected backend (local, SSH/SFTP, SMB, rclone, Reticulum). Streams APK bytes via the existing FileBackend abstraction. Same Shizuku/system-installer fallback as install_apk_from_url. Because backend transfers can be slow (a big APK over SFTP/rclone/Reticulum), this validates synchronously (missing file, directory, size cap → immediate error) then streams + installs in the background, returning {pending:true, staging:true} right away rather than blocking past the request timeout. Poll get_app_info: activeInstall has live phase/bytes while in flight (#331), lastInstall the terminal outcome (and /mcp reconnect if Haven is updating itself). Gated by Settings → Agent endpoint → “Allow agents to read file contents” and confirmed per-call.

install_apk_from_url · asks every call

Download an APK from a URL and install it on the device. Validates the URL synchronously, then downloads + installs in the BACKGROUND and returns {pending:true, staging:true} immediately — a large APK on a slow link would otherwise outlast the request timeout (#331). Poll get_app_info: activeInstall carries the live phase (connecting/downloading/installing) and bytes/totalBytes while in flight; lastInstall carries the terminal outcome. With Shizuku running and granted, install is silent via pm install; without it a ‘tap to install’ prompt/notification appears on-device. A truncated transfer (dropped connection) is rejected against the advertised Content-Length rather than staged — a partial APK still passes the zip-magic check and would fail on-device with ‘problem parsing the package’, so lastInstall reports the short read instead. Useful for agent-driven self-update or sideloading over VPN where wireless ADB isn’t reachable. NOTE: Android’s network-security policy blocks cleartext http:// to anything but localhost, so an http:// URL on the LAN (e.g. a workstation IP) is rejected — use https://, or install_apk_from_backend (SFTP/rclone/Reticulum) which carries no cleartext.

list_paired_clients · no per-call prompt

List the MCP clients paired with Haven — the clientInfo.name values that passed the first-connect pairing prompt and may call tools. For each: name; autoApprove (true when the user has enabled ‘Skip approval prompts’ for it under Settings → Agent endpoint → Paired MCP clients, so its calls bypass per-call consent); and isCaller (true for the client making this request). Read-only.

list_standing_policies · no per-call prompt

List Tier-3 standing policies: id, client, description, covered tools, argConstraints, maxCallsPerMinute, expiresAt, remainingMinutes, enabled, active. Read-only.

open_developer_settings · no per-call prompt

Open Android’s Developer Options screen via ACTION_APPLICATION_DEVELOPMENT_SETTINGS so the user can flip Wireless debugging or other developer toggles. Tap-equivalent — the screen opens but no setting is changed without the user touching it.

read_logcat · asks once per session

Read recent Android system log lines via Shizuku, so the agent can observe foreign-app behaviour (network calls, crashes, lifecycle) during F-Droid tester reviews. Requires Shizuku running + granted (no separate READ_LOGS grant on Haven — logcat is read as Shizuku’s shell uid, which already has the permission). Optional packageName resolves to a --uid filter via pm list packages -U; combine with filter for tag-level narrowing. Returns the raw logcat block; the agent parses it. lines is capped at 5000 and the response payload is capped at 256 KiB (truncated:true when either limit hits). Use this whenever an MR review needs log-level observation of a non-Haven app — the Haven local shell’s Alpine proot can’t reach /system/bin/logcat.

revoke_standing_policy · no per-call prompt

Revoke (delete) a standing policy by id — see list_standing_policies. Pure privilege reduction, so no prompt; the user’s kill-switch lives on the Agent activity screen.

set_preference · asks once per session

Write a Haven user preference. Whitelisted keys (and their types): terminal_scrollback_rows (int 100..25000), terminal_tap_to_position_cursor (bool), terminal_font_size (int 8..32), mouse_input_enabled (bool), terminal_right_click (bool), terminal_color_scheme (string — a TerminalColorScheme enum name, e.g. HAVEN, DRACULA, NORD, GRUVBOX; case-insensitive), terminal_auto_switch_scheme (bool — when true the active scheme follows system light/dark via the light/dark keys), terminal_light_color_scheme (string scheme name), terminal_dark_color_scheme (string scheme name), terminal_background_opacity (float 0.0..1.0 — below 1.0 the terminal renders over the device wallpaper), terminal_locale (string, e.g. zh_CN.UTF-8 — exported to local terminal sessions as LANG/LC_ALL; glibc distros need the locale generated first), mcp_tunnel_endpoint_profile_id (string SSH profile id, empty to clear), mcp_wireguard_enabled (bool), mcp_lan_bind_enabled (bool — also bind the device Wi-Fi/LAN address for direct same-network reach), mcp_wireguard_tunnel_config_id (string tunnel config id the MCP server keeps up as its WG carrier, empty to clear), usb_guest_exposure_enabled (bool — master gate for usb_attach_to_guest), connection_logging_enabled (bool — audit-log connection lifecycle events to Settings → View connection log; off by default; enable before reproducing a connection issue, then read get_connection_log), gpu_use_venus (bool — experimental venus+zink GPU stack for accelerated desktops; off = virgl/virpipe), remap_low_ports (bool — #300 proot launch toggle: remap guest privileged ports +2000), share_storage_with_guest (bool — #301 proot launch toggle: mount /storage + /sdcard into the local guest; default on), bind_android_system (bool — #304 proot launch toggle: bind Android’s read-only /system, /vendor, /apex, /product, /system_ext, /odm into the guest so it can run Android native binaries like getprop/toybox; default off, exposes device internals). Takes effect on the next local session/command. Returns { key, value }.

unpair_mcp_client · asks once per session

Remove a paired MCP client from Haven’s allowlist. It must be approved again via a fresh pairing prompt the next time it connects, and any persistent auto-approval for it is revoked. Use list_paired_clients for exact names. Note: this gates new connections — a client with an already-established session may keep working until Haven restarts. The pairing allowlist is the trust boundary, so there is intentionally no MCP tool to add a client (that only happens through the on-device pairing prompt) or to grant a client auto-approval (that’s UI-only). Gated by consent.