Agent-readable docs index: /llms.txt. Full docs in one file: /llms-full.txt. Download /docs.zip to grep all markdown files locally.

Control your iPhonewith AI agents

Control real iPhones from a Mac. Screenshots over USB, taps over Bluetooth. No jailbreak, no Xcode, no app on the phone.
npx ghosthand list npx ghosthand screenshot -o home.png # 414x896 on an iPhone XR: screen points npx ghosthand tap 207 448 # same coordinates as the screenshot npx ghosthand scroll down 5 # mouse wheel, ~100 points per tick npx ghosthand tap --text "Settings" # OCR finds the text, taps its center npx ghosthand type "hello" --enter secret-provider | npx ghosthand type --stdin # secrets stay out of argv npx ghosthand key home # Home Screen, not Back npx ghosthand tap 207 448 --screenshot s.png --screentext npx ghosthand screentext --language it-IT
Note
Every command except list needs a lease, so two agents never use the same iPhone at once. ghosthand acquire --reason "<task>" prints an id like 42; pass -l 42 to every command and run ghosthand release -l 42 at the end. The examples in this README leave out -l to stay short. See Leases.
agent / CLI / curl Mac iPhone │ ┌───────────────────────┐ USB screen capture │ HTTP (unix socket) │ │<─────────────────── screen frames └────────────────────>│ ghosthand daemon │ │ OCR · leases · state │ Bluetooth Classic HID │ │──────────────────> AssistiveTouch └───────────────────────┘ mouse + keyboard pointer, keys

Features

  • Screenshots over USB, PNG or JPEG, about 0.1s each once the capture is open
  • Tap, swipe, key, type through a Bluetooth mouse and keyboard served by the Mac
  • Screen points by default: screenshots, OCR boxes and taps share one small coordinate space, sized for LLM vision
  • Screen text with on-device OCR (Vision), boxes in screenshot coordinates
  • Unlock a locked iPhone with its passcode, no touch; lock state in list
  • Lock over the Bluetooth keyboard, verified over USB
  • Several iPhones at once, each controlled on its own
  • Leases: one agent per iPhone at a time; others wait or get a clear "leased by" error
  • HTTP API described by OpenAPI, usable from any language
  • Remote daemon over TCP with a bearer token and HTTPS

Install

Needs macOS 14 or later on Apple silicon or Intel. The npm package ships one universal binary; no Node code runs at runtime. The Bluetooth Classic private API is tested on macOS 26.3 only.
npx ghosthand list # run without installing bunx ghosthand list # same, with Bun npm install -g ghosthand # install the `ghosthand` command
The first run asks for Camera access (screen capture) and, on the first tap, Bluetooth access for your terminal.
To compile it yourself, see Build from source.

Setup (once per iPhone)

Two manual connection steps: USB trust and Bluetooth pairing. AssistiveTouch is automatic. Screenshots work after you plug in, trust, and unlock the iPhone, while its display is awake.

1. Plug in and trust

Connect the iPhone over USB, unlock it, and tap Trust. Then check:
ghosthand list
Set Auto-Lock to Never (recommended): Settings > Display & Brightness > Auto-Lock > Never. A locked iPhone gives no screenshots, and unlock needs the passcode. Keep Low Power Mode off: it forces Auto-Lock to 30 seconds.

2. Pair the Mac as a mouse and keyboard

Run any input command. The daemon makes the Mac discoverable and prints the steps:
ghosthand key home
On the iPhone, open Settings > Bluetooth, tap the Mac's name (for example "MacBook Pro") under Other Devices, then Pair.
  • The Mac is discoverable only while a command waits for pairing.
  • After Pair, the iPhone opens the connection itself. The command continues by itself.
  • Later runs reconnect the paired iPhone from the Mac in about 2 s.
  • After a report map change, forget the Mac on the iPhone and pair again: the iPhone keeps the map it read at pairing.
  • Pairing fails, or the Mac is gone from the list: forget on both sides, then pair again. See Pairing no longer works.

3. AssistiveTouch (automatic)

iOS accepts a mouse only through AssistiveTouch. With it off, the iPhone shows no pointer and ignores every click (tested). ghosthand turns it on over USB before every tap, swipe, wake, and unlock, and before pairing. No touch on the phone is needed, and it works while locked:
ghosthand assistivetouch # on / off ghosthand assistivetouch off # give the phone back to a person; next tap turns it on
The check is a USB lockdown read of com.apple.Accessibility / AssistiveTouchEnabledByiTunes, about 35 ms (a write is about 40 ms).
Hide the floating button (recommended). On the same page, turn Always Show Menu OFF (Italian: "Mostra sempre menu"). AssistiveTouch stays on and the pointer still works, but the round menu button no longer covers the screen. With it on, it sits over buttons (it covered the Messages send arrow in tests) and fades in and out, which is noise in screenshots. You do not need its Home action; the keyboard has one:
ghosthand key home # Home Screen (HID consumer Menu key; tested on iOS 15.8)

Use it with an agent

Install the skill so your agent knows the commands, the lease rules and the feedback loop:
npx -y skills add https://ghosthand.dev
This works with Claude Code, Cursor, OpenCode, Codex and other agents that read skills. The skill tells the agent to read ghosthand --help first; the help text is the full agent manual.
Then ask in plain words, with the iPhone plugged in and unlocked:
Use ghosthand to open Messages on my iPhone and reply "on my way" to Anna.
The agent takes a lease, works in a loop (read the screen, act, read the result), and releases the phone when it is done:
ghosthand screentext ghosthand tap --text "Anna" --screenshot s.png --screentext ghosthand type "on my way" --enter --screentext
See Agent feedback loop for the rules the agent follows.

Agent feedback loop

Each tool call is the slow part, not the phone. So an action returns the new screen in the same call: --screenshot saves it, --screentext prints its text. One call per step instead of three.
┌─────────────────────────────────────────────────────────────────┐ v │ 1. see the screen screentext (text + boxes, no image) │ or screenshot -o s.png (~500 tokens) │ 2. act, and see the result tap / swipe / key / type │ + --screenshot s.png --screentext │ 3. read the printed text, and s.png if the text is not enough │ 4. decide the next action toward the user's task ─────────────────────┘
export GHOSTHAND_LANGUAGE=it-IT # phone language for OCR, same as --language ghosthand list ghosthand screentext ghosthand tap --text "Tommaso De Rossi" --screenshot s.png --screentext ghosthand tap 200 610 --screenshot s.png --screentext # x,y read from s.png ghosthand type "ciao" --enter --screentext
Set the OCR language once for a non-English phone: GHOSTHAND_LANGUAGE=it-IT (a BCP 47 tag) applies to screentext, --screentext and tap --text. --language overrides it. English is always included. Without it, accents and local words are misread.
  • Text first, image when needed. --screentext output is cheap and often enough to decide (a new title, a sent message). Read s.png for icons, layout, or text OCR misses.
  • Both flags use one frame: the daemon waits for a still screen (up to 2 s after frames start). When both flags are set, the CLI gets a native PNG, runs OCR on it, and resizes that same image for saving. No second capture or public frame ID. A blank or unchanged image is not proof that input failed.
  • Check focus before you type. type sends keys to the field that has focus, and you cannot see which one from the command. Read a screenshot first: the cursor must be in the field you want. In Messages, Enter in the To: field adds the recipient but keeps focus there, so type "ciao" --enter put "ciao" into To: (tested on iOS 18.7). Tap the message field, check the screenshot, then type.
    ghosthand type "3336052139" --enter # To: field, adds the recipient ghosthand tap 200 562 --screenshot s.png # check focus in the image ghosthand type "ciao" --enter --screentext # Enter sends
  • Tap text, not pixels: tap --text "..." saves a screenshot read and coordinate guessing. On the Home Screen, app labels do not open apps (tested on iOS 15): tap the icon, about 40 points above the label.
  • No clever loops. Do not script loops that wait for a text you guess ("Open", "Done"). The next screen is hard to predict: iOS shows sheets and alerts at any time (the App Store asks to confirm Install, then can say "requires iOS 16 or later"), and a loop that waits for one word waits forever (this happened while installing Reddit on the 6s). Use a loop only when a skill or document you follow asks for it explicitly. Otherwise use the plain feedback loop: one action, read the whole screen, decide the next action.
  • Failed capture after an action: the error says action completed. Do not repeat the action blindly; take a screenshot first.
  • No frames: screenshot and screentext reject a locked iPhone quickly. If list reports unlocked but capture still gets no frame, see USB screen capture stops.
Screen shrunk to the bottom half? That is iOS Reachability, not a hidden dialog. The app and its sheets slide down, the top half is empty, and a sheet can look stale or dimmed. A tap in the empty top area restores it (tested on an XR, iOS 18.7: tap 207 100). Check for it first when a screen looks wrong. A stale screen can also come from a secure field: tap outside it and read again.
ghosthand tap 207 100 --screenshot s.png --screentext # empty top area: full screen returns
Some iOS dialogs are invisible to USB capture and OCR (tested on an XR, iOS 18.7: the App Store location prompt, the Install sheet, the cellular-data prompt). The page behind looks dimmed or unchanged, and screentext shows no dialog. Take a screenshot, read the image, and tap by position. Enter, Esc, Space, Tab and a center tap did not dismiss the location prompt: a person had to tap Don't Allow. Apple documents Tab and Space for Full Keyboard Access, but that setting is off by default.
Secure password fields freeze frames for 10 to 30 seconds. Wait; do not type again.
Important
Leave the iPhone unlocked. Do not run ghosthand lock at the end of a task. Locking stops USB screen capture and makes the next action less reliable. Use unlock only when list shows [locked].

CLI

CommandDoes
listiPhones on USB, [locked], their Bluetooth link state, and who leases them
acquire --reason task [--force] [--idle-timeout s] [--label text]take the iPhone for a task; prints the lease id
wait --reason task [--timeout s]wait until another lease ends, then acquire; prints the lease id
releaseend the lease
screenshot [-o file.png|.jpg] [--settle]save the screen; --settle waits until it stops changing
screentext [--language it-IT] [--json]OCR of the screen: x,y wxh text per line. English is always included
tap <x> <y>tap at screenshot coordinates
tap --text <text> [--index n]tap the center of an OCR text line (exact line wins, else contains)
swipe <x1> <y1> <x2> <y2> [--duration ms]drag with the button held (on a list row it taps the row)
scroll <up|down> <ticks> [--at x,y]mouse wheel scroll, ~100 points per tick; scrolls lists that swipe cannot. See docs/scroll.md
key <combo>home, enter, esc, backspace, arrows, pageup, pagedown, end, volumeup
mute / unmutesilence all iPhone sound, or turn it back on; confirmed from the iPhone log
type <text> | type --stdin [--enter]validated US-ASCII text; stdin keeps secrets out of arguments
unlock --stdinwake and unlock (or GHOSTHAND_PASSCODE); no screenshot while locked
assistivetouch [on|off]show or set AssistiveTouch over USB; pointer input turns it on by itself
locklock over Bluetooth with Control-Command-Q
rename <name>set the iPhone's name over USB; the id does not change
forgetremove the Mac's Bluetooth bond with the iPhone, to pair again
daemon start|stop|restart|status|runmanage the background daemon
The daemon starts by itself on the first command. After a rebuild, the CLI restarts it.

Secret text from stdin

Pipe secrets directly after checking focus. The CLI validates all input before any key is sent, and does not echo rejected characters.
secret-provider | ghosthand type --stdin
One final LF or CRLF is removed. Other whitespace is preserved. LF sends Enter and tab moves focus. --keep-final-newline preserves LF; --enter adds one more Enter. Unsupported controls, invalid UTF-8 and non-ASCII fail before input.

Mute

mute sets a state; it does not toggle. It is safe to run twice.
ghosthand mute # muted iPhone XR Tommy ghosthand unmute
The keyboard Mute key (consumer 0xE2) toggles iOS full mute: all output, media in every app too. The ringer switch does not silence app media. iOS has no mute state readable over USB lockdown, so the daemon reads SpringBoard's log line after each press:
Mac ──BT Mute key──> SpringBoard ──FullMute──> audiomxd ^ │ └──USB syslog_relay─────┘ "Updated fullyMuted to true"
If the phone was already in the asked state, the first press toggles it away and a second press toggles it back (about 50 ms of sound). No log line in 3 s fails with mute-unconfirmed and no second press. Tested on an XR, iOS 18.7. Full mute stays on when screen capture starts or stops.

Bounded text verification

Send once, then check OCR. tap, swipe, scroll, key, type and wake accept an expected case-insensitive text substring:
ghosthand tap --text "General" --wait-text "About" --timeout-ms 5000
The deadline starts after input completes, not during Bluetooth pairing. Default: 5000 ms; range: 1...60000 ms. Capture/OCR reads are cancelled at the client deadline. A timeout says input was sent and reports completed OCR reads, including unchanged text. It never repeats input. OCR cannot prove that input caused a matching screen, especially if that text was already present.
Verification failure stops the command before optional screenshot/OCR output. Read the screen separately before deciding whether another action is safe. Locked-phone, Bluetooth and USB errors retain their own evidence; missing frames alone cannot identify their cause. Never lock a phone to repair capture.
Environment variables set a default for every command; a flag overrides them:
VariableSame asExample
GHOSTHAND_LANGUAGE--language (OCR in screentext, --screentext, tap --text)it-IT
GHOSTHAND_SCALE--scale (image and coordinate size)1 for native pixels
GHOSTHAND_PASSCODEpasscode for unlockuser-confirmed secret; prefer unlock --stdin
GHOSTHAND_LEASE-l, --lease (every device command)supported, but agents must pass -l 42 explicitly

Leases: one agent per iPhone

Every command that uses an iPhone needs a lease. Two agents on one Mac can no longer tap the same phone at the same time. list needs none.
ghosthand acquire -d 7EF219AD-69B4-423E-B8C4-4F4A6140822F --label ses_123 --reason "reply to Anna" # prints 42 ghosthand tap -l 42 207 448 # runs on the lease's iPhone; no -d ghosthand release -l 42
agent A: acquire ──> lease 42 ──> tap -l 42, type -l 42 ────────> release -l 42 agent B: acquire ──> error: in use by "ses_123" for "reply to Anna": acquired 2m3s ago, last command 4s ago, ends after 9m56s idle agent B: decides: give up / tell the user, or wait --reason "..." --timeout 600 ─────── waits ──────> lease 43
  • Always pass --reason with the task. A busy acquire fails at once and shows the label, its reason, when it was acquired and its last command, so the next agent can decide to give up instead of waiting.
  • wait is a separate command on purpose: an agent must read who holds the phone before it waits. It retries every second (no queue order). On timeout it shows the current label and reason.
  • Idle timeout: a lease ends 10 minutes after its last command (--idle-timeout), so a crashed agent never blocks the phone.
  • --force takes it from a lease whose holder is gone; that holder's next command fails with lease-invalid.
  • After wait or --force, take a screenshot first. Someone else used the phone.
  • Pass the ID explicitly with -l. Tool-call environments may not persist; do not rely on exporting GHOSTHAND_LEASE.
  • IDs increase in SQLite and are not reused after expiry, force, release or a daemon restart. Do not delete or restore state.db.
  • --label is only display text, e.g. your agent session id, so others can find who holds the phone. It is not checked, not unique, and an acquire with the same label does not resume an active lease.
  • Lease numbers are coordination IDs, not authentication. TCP access still requires the daemon bearer token. Anyone with daemon access can acquire or force leases.
  • Leases live in the lease table of state.db, so they survive a daemon restart (a rebuild restarts it).

Coordinates: screen points

Screenshots, screentext boxes, tap and swipe use one space: iOS screen points. A point is 2 native pixels on @2x iPhones and 3 on @3x ones, so an iPhone XR (828x1792) gives a 414x896 image. Plus models are the exception: they render at 1080 px for 414 points, and ghosthand knows the native size of each iPhone screen. Read a spot in the image, send the same numbers.
iPhoneNative pixelsDefault (points)Claude tokensGPT tokens (32 px patches x1.2)
6s (@2x)750x1334375x667336303
XR (@2x)828x1792414x896480437
14 Pro (@3x)1179x2556393x852465422
Why points: UI text stays readable (17 pt body text is 17 px). A screenshot costs about a quarter of the native tokens on @2x iPhones and an eighth on @3x. It also stays under every model's resize limit, so the model sees exactly the pixels the CLI maps back. Claude models before 4.7 shrink anything over 1568 px or 1568 tokens (28 px patches), and an XR screen at native size would be shrunk to an unknown scale (Claude vision, OpenAI vision).
Other sizes with --scale (a fraction of native pixels) or GHOSTHAND_SCALE. Use the same value for every command, because it also changes how tap reads coordinates:
ghosthand screenshot -o full.png --scale 1 # native pixels, 828x1792 ghosthand tap 414 896 --scale 1 # native pixel coordinates
screentext always runs OCR on native pixels (small text reads better), then scales the boxes.

Why this instead of Appium?

Great for letting an agent control your own phone without a development setup. After installing ghosthand on the Mac, there are just two manual connection steps:
  1. USB: connect the iPhone, unlock it, and accept Trust This Computer.
  2. Bluetooth: pair the Mac as a mouse and keyboard from the iPhone's Settings.
Nothing to install on the iPhone. No Xcode, Developer Mode, developer account, signing certificates, or test runner. AssistiveTouch is enabled automatically; accept the Mac's Camera and Bluetooth permissions when prompted. Keep the phone unlocked and its screen awake. See Setup.
Appium on iOS uses WebDriverAgent (WDA), a signed test runner that controls the UI through Apple's XCTest framework. It can control existing App Store apps too, not just apps you develop. Its device setup requires trust, Developer Mode on modern iOS, and provisioning for WDA.
TradeoffghosthandAppium / WDA
Phone setupUSB trust + Bluetooth pairingDevelopment setup + signed runner
Signing renewalNone; no runner installedFree Personal Team: renew WDA provisioning every 7 days; paid membership avoids the weekly limit
InputMouse, keyboard, and wheel through AssistiveTouchNative UI-testing gestures, including multi-touch
Find controlsScreenshots, OCR, coordinatesUI elements and attributes, plus screenshots and coordinates
Runtime dependenciesUSB capture + Bluetooth linkRunning WDA / XCTest session
App lifecycleNavigate through the visible UIDirect launch, terminate, and state queries
TextUS-ASCII onlyBroader text support, depending on the field
Fleet sizeBluetooth limit: about 7 per Mac, 2 testedNo Bluetooth limit; depends on host resources and USB

No developer account or weekly signing renewal

A paid account is not mandatory for Appium. A free Xcode Personal Team can sign WDA, but Apple limits it to 3 registered devices and provisioning profiles that expire after 7 days. After expiry, the runner cannot be launched with that profile; rebuild/re-sign and reinstall it with renewed provisioning. Your other apps and their accounts are not affected. See Apple's Personal Team limits.
Ghosthand avoids this maintenance entirely. There is no iPhone app to sign or renew. A paid Apple Developer Program membership costs US$99 per year (regional pricing varies) and avoids the free team's weekly limit, but WDA certificates and profiles still need renewal when they expire.

Reliability

My experience: when I tried an Appium-like XCTest setup, the test runner frequently crashed. USB capture plus Bluetooth input has been more reliable for controlling my own phone. This is personal experience, not a controlled benchmark or a claim that everyone's Appium setup crashes. Removing WDA avoids runner crashes, signing failures, and XCTest idle waits, not every possible failure.
Known issues exist on both sides. Appium's troubleshooting guide documents unresponsive real devices and testmanagerd crashes. Here, Bluetooth can disconnect, USB capture can freeze or miss system dialogs, and private macOS APIs can change. Mouse drags are not always finger swipes, and there is no UI element tree or multi-touch. See Limits.
Closer to a real mouse-and-keyboard user. The iPhone receives Bluetooth HID input through AssistiveTouch, the same input path a person can use with physical peripherals, rather than an XCTest runner. This avoids runner-specific signals and may make automation harder to distinguish from that legitimate use, but we have not measured detection rates. Pointer input, screen capture, and automated behavior can still provide signals. Neither approach is proven to prevent bot detection or account restrictions.

Alternatives

ghosthandiPhone MirroringAppium (XCUITest)
App on the iPhonenonenoneWebDriverAgent (signed)
Xcode, Developer Modenonoyes
ScreenUSB captureWi-Fi mirrorXCTest
InputBluetooth Classic HIDMac inputXCTest
iOS 15yes over BLE (relative pointer); not tested over Classicno (iOS 18+)yes
Unlock with passcodeyesnono
iPhones per Mac~7, 2 tested1many
Account, cloudnone, local daemonsame Apple ID on Mac and iPhonenone

More iPhones

Multiple iPhones work after both connection steps on each one. Pick one with acquire -d; each lease runs on its own iPhone:
ghosthand list # 7EF219AD-... Morse's iPhone iPhone XR (iPhone11,8) iOS 18.7 (bluetooth connected, absolute pointer) # 42B874EC-... Test iPhone iPhone 6s (iPhone8,1) iOS 15.8.4 (bluetooth connected, relative pointer) ghosthand acquire -d 7EF219AD-69B4-423E-B8C4-4F4A6140822F # prints e.g. 42 ghosthand acquire -d "Test iPhone" # prints e.g. 43 ghosthand screenshot -l 42 -o a.png ghosthand tap -l 43 206 332 ghosthand release -l 42 ghosthand release -l 43
The Mac is one Bluetooth mouse and keyboard for all iPhones, but each report goes to one iPhone only (its own L2CAP channel). Input to one phone never reaches another.
Names do not matter; the id does. Screen capture and USB lockdown give each iPhone unrelated IDs, and the name is the only field both show. So the daemon matches them once and saves the match in ~/.ghosthand/state.db (see State database):
capture id 7EF219AD-... ── saved once ──> UDID 00008020-... ──> lock state, BT address first match: the only new iPhone on USB, or else the only one with that name
After that, a rename changes nothing. Only several new iPhones with the same name, plugged in together, cannot be matched: list warns. Plug them in one at a time once.
Rename from the Mac over USB, no touch needed (works while locked):
ghosthand rename "iPhone XR" # on the lease's iPhone # renamed "iPhone" to "iPhone XR". The id stays 7EF219AD-69B4-423E-B8C4-4F4A6140822F
  • list and acquire -d "iPhone XR" use the new name at once. The Bluetooth link stays connected (tested on iOS 18.7).
  • macOS screen capture (QuickTime, daemon logs) keeps the old name until you unplug and plug in the cable.

How many iPhones per Mac

Plan for at most 7 per Mac; 2 are tested.
LimitValueWhy
Bluetooth Classic7 connected devicesApple: "seven is the maximum number of Bluetooth devices that can be connected to your Mac at once; three to four is a practical limit". Keyboard, trackpad and AirPods count too
USB127 devices per busNot the problem. Power is: use a powered hub, each iPhone charges while it is controlled
Screen capture CPUnot measuredone video stream per iPhone
More than 7 iPhones needs a second Mac. Run a daemon on each one and reach it with --url (see Remote daemon).

Several Macs: give each a unique name

The iPhone lists the Mac by its computer name. Two Macs called "MacBook Pro" look the same in Settings > Bluetooth, so an iPhone can pair with the wrong one. Rename each Mac before pairing:
# System Settings > General > About > Name, or: sudo scutil --set ComputerName "Lab Mac 1" sudo scutil --set LocalHostName "lab-mac-1" # optional: network name, no spaces
  • The Bluetooth radio uses the computer name. There is one name per Mac; ghosthand cannot show its own name next to it.
  • The pairing steps print the name to tap: tap "Lab Mac 1", then Pair.
  • An iPhone that paired before the rename can keep showing the old name. Forget the Mac on the iPhone (Settings > Bluetooth > (i) > Forget This Device) and pair again.

Devices in list

Only USB-connected iPhones appear. list gets devices from macOS's QuickTime screen-capture device list, not from a Bluetooth scan. Keep the USB cable attached and trust the Mac once. A Bluetooth-only phone is not listed. Camera permission is required on the Mac; neither a Bluetooth connection nor AssistiveTouch is required just to list a phone. The first list asks for Bluetooth access, because it starts Bluetooth.
7EF219AD-69B4-423E-B8C4-4F4A6140822F Morse’s iPhone (2) iPhone XR (iPhone11,8) iOS 18.7.10 (bluetooth connected, absolute pointer) USB capture ID name model and iOS (USB) Bluetooth link, pointer mode
The ID is stable. It is the USB screen-capture ID, not the Apple UDID. On the same Mac, the XR kept 7EF219AD-… after a rename, an iOS update, and a cable replug (tested). Save it and pass it with -d. Not tested across different Macs.
No phone fails fast. When screen capture lists nothing, the daemon asks USB lockdown why, in about 0.1 s, instead of waiting 25 s:
error: No iPhone in macOS screen capture. USB check: - iPhone di Carla (2): on USB but not trusted (AMDeviceStartSession 0xe8000025). Unlock it and tap Trust on the iPhone; ...
Bluetooth link state comes from the iPhone's Bluetooth address (read over USB) and the Mac's open HID channels. list starts Bluetooth and reconnects paired phones:
StateMeaning
connectedinput works
disconnectedpaired; the Mac reconnects in about 2 s. If it stays, Bluetooth is off on the iPhone or it is out of range. The Mac cannot tell these apart
not pairedpair from iPhone Settings > Bluetooth; the next input command waits for it
It waits (up to 25 s) only for a trusted phone, which capture can list a few seconds after a daemon start.
Locked phones stay listed. The daemon reads PasswordProtected from iOS lockdown over the trusted USB connection using macOS's private MobileDevice.framework. It does not need a screen frame. [locked] means the passcode is required; its absence means lockdown reports unlocked. If the lock state cannot be read, the field is absent. Capture and lockdown IDs differ; see More iPhones for how they are matched.

Unlock

unlock does not use OCR. USB screen capture does not yield frames while this iPhone is locked, so it cannot inspect the passcode screen. The Bluetooth mouse and keyboard run a timed sequence:
click ──> screen on ──> two fast swipes up ──> check USB lock state for 4 s ├─ unlocked by Face ID ──> done └─ still locked └──> type passcode once └──> check lock state
  • Keys cannot wake the screen. iOS treats our keys as coming from a mouse and drops them on the lock screen. A click usually wakes it.
  • Face ID runs first. The first swipe can show notifications; the second opens the passcode flow. unlock polls the USB lock state for up to 4 s, returns if Face ID already unlocked the phone, then types the passcode once if it is still locked. It cannot confirm which screen is shown before typing.
  • Lock state comes over USB from lockdown (PasswordProtected) through the private MobileDevice.framework. list shows [locked]; unlock checks it before and after.
  • A wrong passcode is never retried. Failed tries count toward the iPhone lockout. The passcode is not stored.
  • Locked screenshots fail immediately. USB capture does not return frames from this phone while locked; screenshot and screentext check the lock state first.
  • OCR is for the agent loop after unlock, not for the locked-screen sequence. For example, screentext can find a button label and its pixel box; the agent chooses the next tap based on that result.

HTTP API

The daemon serves HTTP on a unix socket, described by OpenAPI at GET /v1/openapi.yaml. Any language with an HTTP client can drive it.
S=~/.ghosthand/daemon.sock curl --unix-socket $S http://localhost/v1/devices curl --unix-socket $S -H 'content-type: application/json' \ -d '{"device":"default","label":"curl","reason":"reply to Anna"}' http://localhost/v1/leases # {"id":"42",…} L=http://localhost/v1/leases/42 curl --unix-socket $S $L/screenshot -o s.png curl --unix-socket $S -H 'content-type: application/json' \ -d '{"x":207,"y":448}' $L/tap # screen points, like the screenshot curl --unix-socket $S $L/assistive-touch # {"enabled":true} curl --unix-socket $S -X DELETE $L # release
  • Screenshots and input live under /v1/leases/{lease}/ and run on the lease's iPhone. Unknown, expired, released or taken with force: lease-invalid (403). Acquire while held: device-leased (409); the detail shows the label, reason, acquire time and last call. Retry to wait.
  • Input routes return JSON. Send Accept: text/event-stream to get progress as server-sent events.
  • Errors are application/problem+json with a stable code. In event-stream mode the status is 200 and the problem arrives as the final error event.

Remote daemon

# on the Mac with the iPhones GHOSTHAND_TOKEN=secret ghosthand daemon start --port 7420 --host 0.0.0.0 \ --tls-cert cert.pem --tls-key key.pem # anywhere else ghosthand --url https://mac.local:7420 --token secret --ca cert.pem list
A host other than localhost needs TLS, so the token never crosses the network in clear text.

Security

Warning
Whoever reaches the daemon can use the iPhone like a person holding it: open apps, read messages, type.
Everything runs on your Mac. No account, no cloud, nothing installed on the iPhone.
who can reach the phone how local user who owns ~/.ghosthand ──> unix socket, file mode 0600 remote client ─────────────────────> TCP with --port: Bearer token, TLS off localhost
The iPhone accepts input only from a Mac that has all three:
  1. USB cable plugged in, and the phone trusts this Mac (Trust prompt + passcode).
  2. Bluetooth pairing accepted on the phone in Settings > Bluetooth.
  3. The daemon running.
Cut access by removing any one of them:
ActionEffect
ghosthand daemon stopno screenshots, no input, until the next command starts it
unplug the cableno screenshots and no input: every command finds the phone over USB first
iPhone: Settings > Bluetooth > the Mac > (i) > Forget This Deviceno input until you pair again
iPhone: Settings > General > Transfer or Reset > Reset > Reset Location & Privacyremoves the USB trust of every computer
  • The passcode is never stored. unlock gets it per call; prefer a direct pipe to unlock --stdin, not an argument.
  • Apple Pay still needs you. It asks for a double press of the side button, which the Mac cannot press.

Limits

  • AssistiveTouch must be on for tap and swipe. ghosthand turns it on over USB; its floating button can be hidden (see AssistiveTouch).
  • Keyboard without AssistiveTouch is not tested.
  • cmd+space and cmd+h did nothing in tests.
  • No verified global Back command. key home goes Home; key esc sends Escape. Neither is a promise of Back in Settings or Safari. AC Back (Consumer usage 0x0224) is not exposed because reliable iOS behavior has not been verified. Descriptors are unchanged.
  • Screen off: no frames. unlock tries to wake a locked iPhone; USB capture may still return no frame. An unlocked one with the screen off needs a tap from you.
  • Several new iPhones with one name, plugged in together, cannot be matched to their USB data. Plug them in one at a time once (see More iPhones).
  • Many fast daemon restarts can hide the iPhones for about a minute. macOS throttles the screen capture helper.
  • About 7 iPhones per Mac, set by Bluetooth, not by ghosthand. Only 2 tested. See How many iPhones per Mac.
  • The cable must stay in. Screenshots only come over USB; a phone on Bluetooth or Wi-Fi alone is not listed.
  • Typing is US-ASCII only: no accents or emoji. There is no length limit.

Pairing no longer works: forget on both sides

Symptoms: Pair on the iPhone ends with "Connection unsuccessful", the Mac is missing from the iPhone's Bluetooth list, or list stays at bluetooth disconnected.
Cause: one side still has an old bond and the other side does not. This happens after a Forget on only one side, a phone reset, a phone paired with an older ghosthand (BLE), or a report map change. The side with the old key rejects the new pairing (tested on the iPhone 6s and XR).
Fix: remove the bond on both sides, then pair again.
  1. iPhone: Settings > Bluetooth > the Mac > (i) > Forget This Device.
  2. Mac: ghosthand forget (with a lease on that iPhone). It removes the Mac's bond (same as System Settings > Bluetooth > the iPhone > (i) > Forget This Device).
  3. Run any input command, for example ghosthand key home. It makes the Mac discoverable and waits.
  4. iPhone: Settings > Bluetooth, tap the Mac under Other Devices, then Pair.
The Mac is listed on the iPhone only while a command waits for pairing. With no command waiting, it is not discoverable, and a new iPhone does not see it.

USB screen capture stops (iOS bug): restart the daemon, then the iPhone

iOS can stop sending its screen over USB while macOS still lists it. list works and the lock state is correct, but screenshot gets no frame and no error:
error: No frame from Morse’s iPhone (2) after 8s (session running, 0 frames since open). ...
  • First try ghosthand daemon restart. It fixed a stuck capture that failed with Recording Stopped (AVFoundationErrorDomain -11808) (iOS 18.7).
  • If frames still do not arrive, restart the iPhone. Replugging the cable does not help, and in some cases the daemon restart did not help either (tested on iOS 18.7).
  • This is an iOS bug, not ghosthand. A plain AVFoundation program gets no frames either, and QuickTime Player crashed when it opened the same iPhone screen.
  • Known Apple reports with the same symptom: FB21608780 (after "Stop Mirroring" in Control Center, fixed only by a reboot) and FB22281473 (screen capture trust revoked).
  • After the restart, unlock the iPhone by hand once. Before the first unlock, iOS ignores the Bluetooth keyboard, so ghosthand unlock fails.
Check with QuickTime Player which sources the Mac sees. Stop the daemon first (the iPhone serves its screen to one app at a time):
  1. ghosthand daemon stop
  2. QuickTime Player > File > New Movie Recording
  3. Click the ⌄ arrow next to the record button. Under Camera:
EntryWhat it is
Morse’s iPhone (2)the iPhone screen over USB (what ghosthand uses)
Morse’s iPhone (2) Camerathe iPhone camera (Continuity Camera), not the screen
  • Screen entry shows the live screen: capture works; the problem is on the Mac side.
  • Screen entry is black, missing, or QuickTime crashes: iOS stopped serving the screen. Restart the iPhone.
  • Two iPhones with the same name both show up as ... Camera. Only the one on USB has a screen entry.

How the Mac becomes a mouse

The daemon publishes a Bluetooth Classic HID service, the same profile a real Bluetooth keyboard uses. macOS has no public API to be a Classic HID device, so it uses CoreBluetooth's private CBClassicManager (Sources/ClassicBluetooth).
Mac (daemon) iPhone ┌──────────────────────────────────┐ ┌─────────────────────────────┐ │ SDP record: HID, report map │ <──SDP query─── │ Settings > Bluetooth > Pair │ │ L2CAP 0x11 control (handshakes) │ <──opens─────── │ │ │ L2CAP 0x13 interrupt │ ──reports─────> │ AssistiveTouch │ │ report 1: mouse (absolute) │ │ pointer + clicks = touches │ │ report 7: mouse (relative) │ │ │ │ report 2: keyboard, 6: media │ │ │ └──────────────────────────────────┘ └─────────────────────────────┘
Report 1 is absolute: X and Y go from 0 to 32767, so a screenshot pixel converts directly to a pointer position (tested on iOS 18.7 over Classic). Report 7 is relative, for iOS versions that ignore absolute positions (iOS 15 over BLE).
Clicks in absolute mode go in both reports. With two mouse collections in the map, iOS 18 ignored the button bits of report 1 over BLE. So a press and release are sent in report 1 and, with zero movement, in report 7.
check: corner ──> move (80,120) pt ──> screenshot P1 corner ──> move (160,120) pt ──> screenshot P2 pointer found in P1 and P2 ──> gain = measured / computed (saved in state.db) tap: computed reports from the last known spot ──> 100 ms pause ──> click
Private API details
  • addServiceWithData: takes the SDP record in bluetoothd's own element format, not the SDP wire format (reversed from bluetoothd).
  • sendMsg:35 with kCBMsgArgDiscoverableState makes the Mac discoverable.
  • CoreBluetooth does not install L2CAP callbacks for other apps, so CBClassicPeer.handleMsg:args: is swizzled to install them.
  • Each CBL2CAPChannel must stay referenced: releasing it closes its socket, and the iPhone shows "Connection unsuccessful".
  • A macOS update can change any of these.

How a phone is found

Each iPhone reports its Bluetooth address over USB lockdown. Its HID link to the Mac uses the same address, so the daemon joins the USB screen and the Bluetooth input by address. No pointer moves, and other connected devices (iPad, other phones) are never touched.
USB lockdown BluetoothAddress ──── EC:CE:D7:A4:9B:C1 ────> Bluetooth HID link

State database

~/.ghosthand/state.db holds SQLite state. Device matches and pointer modes are caches, one row per iPhone keyed by Apple's UDID. The lease table holds active leases, and lease_counter keeps the highest issued ID so IDs are never reused (see Leases).
phone (USB) bluetooth_pointer (Bluetooth) ┌───────────────────────────────────┐ ┌──────────────────────────────────────┐ │ usb_udid PK │<───────│ usb_udid PK, FK, ON DELETE CASCADE │ │ screen_capture_id UNIQUE │ │ mode absolute | relative │ │ capture_matched_by / _at │ │ gain, screen_width_pt, _height_pt │ └───────────────────────────────────┘ │ report_map, found_at │ └──────────────────────────────────────┘
sqlite3 -header -column ~/.ghosthand/state.db 'SELECT * FROM phone; SELECT * FROM bluetooth_pointer' # usb_udid screen_capture_id capture_matched_by capture_matched_at # 00008020-000405C214BB002E 7EF219AD-69B4-423E-B8C4-4F4A6140822F only-new-phone 1790773434 # usb_udid mode gain screen_width_pt screen_height_pt report_map found_at # 00008020-000405C214BB002E absolute classic-a85efc997e025d8f 1790773436
TableWritten whenIf the row is missing
phonea command sees a new iPhone. capture_matched_by: only-new-phone or same-name (see More iPhones)matched again the same way
bluetooth_pointerthe first tap or swipeiOS 17+: absolute again, no check. Older iOS: the pointer check runs on the next tap
  • Not stored, because USB lockdown gives them on every command: name, Bluetooth address, lock state, AssistiveTouch.
  • Bluetooth pairing is not here. macOS keeps the bond. A phone paired while the daemon was stopped, or with no state.db, reconnects on the next command (tested).
  • A saved match is never changed by itself, even while its phone is missing from one list during a plug-in. If one is wrong, delete its phone row (below).
  • Schema changes preserve the lease counter. Cache tables can be recreated, but IDs must keep increasing.
Fix a bad state with sqlite3, also while the daemon runs (it keeps no copy in memory). Turn on foreign_keys: the sqlite3 shell has it off, and without it the pointer row is not deleted with its phone.
DB=~/.ghosthand/state.db sqlite3 $DB "PRAGMA foreign_keys = ON; DELETE FROM phone WHERE usb_udid = '00008020-000405C214BB002E'" # one iPhone sqlite3 $DB 'DELETE FROM bluetooth_pointer' # all pointer modes
  • Screen from one phone, input to another: delete its phone row. Plug in same-named new phones one at a time.
  • Pointer lands in the wrong spot: delete its bluetooth_pointer row. ghosthand forget also does it, but it removes the Bluetooth bond too, so you must pair again.
  • Never delete or restore state.db. Resetting its counter can give a new lease an old ID.

iOS versions

Tested on iOS 18.7 (iPhone XR) over Bluetooth Classic, and on iOS 15.8.4 (iPhone 6s) with the earlier BLE version. The iPhone sees the Mac as a normal Bluetooth Classic mouse and keyboard (HID profile over L2CAP). The only difference between versions is how the pointer moves.
iOSPointerTapAccuracy
18.7absolute: report 1 says "go to X, Y"~0.3 sexact
15.8relative over BLE (iOS ignored absolute X/Y); not tested yet over Classic~0.45 s0.2 pt mean, 0.4 pt max (40 targets)
  • The mode is found by itself on the first tap: iOS 17 and later use absolute; older versions are checked by moving the pointer. list shows it: (bluetooth connected, relative pointer).
  • Apple does not document absolute pointers for AssistiveTouch, and no changelog mentions them. The iOS 15 behavior was found by testing over BLE: the phone read report 1 but the pointer stayed still.
  • In relative mode the daemon knows exactly how far each report moves the pointer, from Apple's own acceleration table. See Relative pointer.

Which iPhones get which pointer

Absolute first, on every iOS version. iOS 17 and later use it with no check. Older iOS tries absolute on the first tap and falls back to relative only if the pointer does not move.
iPhoneLast iOSPointerTap
6s, 6s Plus, 7, 7 Plus, SE (1st gen)15relative over BLE (tested on 6s); absolute over Classic not tested~0.45 s relative, < 0.5 pt off
8, 8 Plus, X16absolute tried first, not tested
XS, XS Max, XR, SE (2nd gen, 2020) and newer17+absolute (XR tested)~0.3 s, exact
  • The "absolute from iOS 17" claim comes from a vendor of ESP32 BLE HID firmware, not from Apple. Bluetooth Classic HID may behave differently, so older iOS is checked, not assumed.

Relative pointer (iOS 15)

iOS turns each relative report into a fixed distance. It looks up the report's size in IOHIDFamily's built-in acceleration table. Time does not count, and fractions of a point add up. So the daemon computes the reports for any move:
tap 300,500 with the pointer at 40,120 (move +260,+380 pt) on a 6s: (+169,+248) (+170,+247) ── speed 300, past the flat end of the table ──> 214.9 pt each (+16,+24) ── speed 28 ─────────────────────────────────> 30.0 pt (0,-1) ── speed 1: 0.2 pt, lands within 0.2 pt 100 ms pause, then click ~0.45 s in total
  • Measured on an iPhone 6s (iOS 15.8.4): 15 report sizes, diagonals, and bursts vs 100 ms gaps all matched the table to 0.5 pt. 40 taps in a row landed 0.2 pt from the target on average, with no drift. Details: docs/relative-pointer.md.
  • The daemon remembers where the pointer is. Only when it does not know (daemon start, Bluetooth reconnect, rotation, 60 s idle) does it push the pointer into the top-left corner first (+0.3 s).
  • Tracking Speed: the pointer check measures a gain (1.000 at the default speed) and scales the table by it. Other speeds are not tested.
  • Swipes follow --duration: reports are spread over it and sized so the drag ends exactly at the target.

Build from source

Needs Swift 5.9 or later. Xcode is not needed: the Command Line Tools (xcode-select --install) build it too. Tested with Command Line Tools (Swift 6.1) and Xcode 26 (Swift 6.3).
git clone https://github.com/remorses/ghosthand cd ghosthand swift build -c release cp .build/release/ghosthand /usr/local/bin/

Publish to npm

npm/ is the npm package. pnpm build there compiles arm64 and x86_64 release builds, merges them with lipo into one universal npm/dist/ghosthand, and checks that ghosthand --version matches package.json. Bump ghosthandVersion in Sources/ghosthand/daemon.swift and the package version together.
cd npm pnpm publish # prepublishOnly cleans dist and runs the build
Architecture
CLI / curl / any language │ HTTP over ~/.ghosthand/daemon.sock (mode 0600, no token) │ HTTP or HTTPS over TCP with --port (Bearer token) v Hummingbird ──> generated API (openapi.yaml) ──> own thread ──> Controller │ ┌───────────────────────────────────────┬───────────────────┤ v v v FrameStream per iPhone (AVFoundation) Lockdown lock state HIDDevice: BT Classic OCR (Vision) (MobileDevice) HID, CBClassicManager
  • Sources/ghosthand/openapi.yaml is the source of truth. swift-openapi-generator builds the server and the client from it.
  • AVFoundation and CoreBluetooth need the main run loop, so HTTP runs in a detached task.
  • Each request runs on its own thread. Capture, OCR, and lock state have per-iPhone locks; input sequences (unlock, taps) are serialized per iPhone. Different iPhones run in parallel.
  • Files: ~/.ghosthand/daemon.sock, daemon.log, daemon.lock, state.db.