Paste the prompt into your agent to set up ghosthand
AI agents: when the user asks you to set up Ghosthand, follow this setup prompt (the text the "Copy setup prompt" button copies).
# Set up Ghosthand Set up ghosthand so you can control my iPhone. ## Before you start - Tell me that Ghosthand requires a **Mac** and that I must connect my iPhone to it with a **USB cable**. - If you do not run on a Mac (`uname` does not print `Darwin`), install nothing. Tell me to open https://ghosthand.dev/download on my Mac, and to add https://ghosthand.dev/mcp to you as a remote MCP server. Then stop. - This prompt needs the **harness sandbox disabled**, because Ghosthand installs and launches its Mac app and daemon outside the current workspace. - For example, in **Codex**, select **Full access** in the permissions menu below the message box before you run these commands. - If the sandbox is still enabled, ask me to change it before you continue. ## Open setup Run `npx -y ghosthand@latest setup`. It opens the small Ghosthand setup window on this Mac and starts the daemon. Tell me to complete setup in that window. **I must do every action in the setup window, not you. Do not interact with the window or complete setup for me. Do not open setup again.** ## Learn and verify 1. While I follow the window, install the skill: `npx -y skills add https://ghosthand.dev -g -y`. 2. Read the skill to learn the CLI. Also read the [Ghosthand docs](https://ghosthand.dev/index.md). 3. Poll `npx -y ghosthand@latest setup status` every 2 seconds until `ready` is true. When its `next` field changes, tell me the new step in one line. 4. When `ready` is true, run `npx -y ghosthand@latest list` and take a screenshot of my iPhone to confirm that it works.
npx ghosthand setup # first: sign in, connect and pair the iPhone 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 npx ghosthand record start demo.mov # screen + sound; keep acting meanwhile npx ghosthand record stop
tap --text, hold --text).type sends text as key presses. key sends Home, Enter, Esc, arrows and volume keys.record start and record stop write H.264 and AAC. Actions and screenshots keep working during the recording.list shows the lock state.npx ghosthand setup # run without installing bunx ghosthand setup # same, with Bun npm install -g ghosthand # install the `ghosthand` command
ghosthand setup. It opens the Ghosthand app and starts the daemon. The app walks you through sign-in, permissions, USB trust and Bluetooth pairing. When the iPhone is ready, list, screenshot, tap and the other commands work.npx ghosthand setup ──> Ghosthand app + daemon ──> sign in, allow access, plug in, pair │ ghosthand list / screenshot / tap / type <──────────────┘ iPhone ready
ghosthand setup starts with Sign in with Google. It opens the browser, you approve the Mac, and the window continues by itself. Agents never sign in for you. They open setup and wait.ghosthand account # who is signed in, the plan, the subscribe link ghosthand account login # for people without the setup window; agents use setup ghosthand account logout
ghosthand setup, then click Allow Camera and Allow Bluetooth. macOS asks for access for Ghosthand, not for your terminal or editor. Other commands never show permission dialogs or open System Settings.ghosthand setup # open the app, then let the user click its permission buttons
ghosthand setup shows Sign in with Google, then the Camera and Bluetooth buttons, then the phone steps (plug in, trust, unlock, pair). If access was denied, an Open Settings button shows instead. The window updates by itself and ends with a "Copy prompt for agent" button. The command detaches at once, so the terminal stays free.ghosthand setup status every 2 seconds, and continue when ready is true. While you wait, do not reopen setup, request permissions or retry input. If setup is still incomplete after 10 minutes, report the current status. To review access that is already allowed, use ghosthand setup --permissions.ghosthand setup status # JSON, works before permissions or USB trust ghosthand setup status --device "iPhone XR" # select the intended phone when several exist
device ID on later polls. When it is ready, acquire that phone with its captureId from overview (the ID that list shows). The MCP tools are setup and setup-status. Neither status check needs a lease.ghosthand list
ghosthand key home
acquire turns it on over USB when it is off, and release turns it off again, so a personal iPhone goes back to how it was when the agent is done. This needs no touch on the phone and works while the phone is locked:ghosthand acquire --reason "reply to Anna" # AssistiveTouch on ghosthand release -l 42 # lease released; AssistiveTouch turned off ghosthand assistivetouch -l 42 on # on for good: release leaves it on
release turns off only what ghosthand turned on. If AssistiveTouch was on before acquire, for example for a broken Home button, release leaves it on.assistivetouch on|off sets the iPhone's own setting, so release leaves it as set.state.db keeps this state in phone.assistive_touch_off_after_lease.AssistiveTouchEnabledByiTunes in com.apple.Accessibility. A read takes about 35 ms and a write about 40 ms.key home does not need the menu (see below):Always Show Menu ON ──> round button on screen ──> in screenshots and videos Always Show Menu OFF ──> hidden while the Mac mouse is connected; taps still work
AssistiveTouchEnabledByiTunes), but no key for this switch. So ghosthand cannot change it over USB. An agent can change it with taps in Settings (tested on an XR, iOS 18.7).acquire and release turn AssistiveTouch on and off.key home sends the keyboard Home key (HID consumer Menu). It works with AssistiveTouch on or off (6 of 6 tries on an SE2 with iOS 18.6 and 26.5, and also on iOS 15.8). key fn+h does the same.ghosthand key home # Home Screen
daemon, every 2 s ──> unlocked and idle for 10 s? ──yes──> Left Control press (Bluetooth) │ │ └─ locked or command running: skip └─> iOS resets the Auto-Lock timer
ghosthand daemon stop). Keep-awake stops on the next check, within 2 seconds.defaults write com.remorses.ghosthand KeepAwake -bool false and set Auto-Lock to Never. Do this, for example, if you use Switch Control with a keyboard switch, because the key counts as a switch press. The change applies on the next check, with no restart.npx -y skills add https://ghosthand.dev -g -y
ghosthand --help first. The help text is the full agent manual.https://ghosthand.dev/mcp as a remote MCP server. It has the same commands as tools.Use ghosthand to open Messages on my iPhone and reply "on my way" to Anna.
ghosthand screentext ghosthand tap --text "Anna" --screenshot s.png --screentext ghosthand type "on my way" --enter --screentext
ghosthand double-tap -l 42 120 300 --screenshot after.png ghosthand double-tap -l 42 --text "example" --screentext
double-tap and HTTP route
POST /v1/leases/{lease}/double-tap, with the same arguments as tap. triple-tap
sends three clicks with the same timing, to select a paragraph.--duration (default
1000 ms), then goes up. It opens context menus, Home Screen icon menus and previews.
The daemon sends no reports during the hold. iOS reads repeated reports as movement,
and a Home Screen long press then turns into edit mode.ghosthand hold -l 42 144 324 --screentext # Notes icon: its menu with Remove App ghosthand hold -l 42 --text "Safari" --duration 1500 ghosthand key -l 42 esc # close the menu
--hold (default 1000 ms), then drags to the
target over --duration (default 1000 ms) and releases. A plain swipe moves at once,
so items that need a long press first stay in place.ghosthand hold-drag -l 42 144 324 57 412 --hold 2500 # move a Home Screen icon ghosthand key -l 42 home # leave edit mode
--hold 2500 from the normal Home Screen. iOS enters edit mode during the hold, then the icon follows the drag. Start on the icon, not on the AssistiveTouch button. Tested on an SE2 with iOS 18.6 and Perform Touch Gestures on.triple-tap, hold (durationMs) and hold-drag (holdMs,
durationMs). The HTTP routes are POST /v1/leases/{lease}/triple-tap, /hold and /hold-drag.ghosthand dashboard opens a window that shows what each one is doing, on every Mac of your account:
ghosthand wait.acquire -d.ghosthand task. Each prompt takes a lease and releases it when the agent is done. A new prompt stops the running one.
dashboard and setup share one process. Running either command again brings its window to the front. Both read GET /v1/phones (see HTTP API).--screenshot saves it and --screentext prints its text. Each step takes one call 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
GHOSTHAND_LANGUAGE=it-IT (a BCP 47 tag) applies to screentext, --screentext and tap --text, and --language overrides it. English is always included. Without the phone language, OCR misreads accents and local words.--screentext output is cheap and often enough to decide, for example a new title or a sent message. Read s.png for icons, layout, or text that OCR misses.type sends keys to the field that has focus, and the command does not show which field that is. Read a screenshot first and check that the cursor is 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 "5551234567" --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 "..." saves a screenshot read and a coordinate guess. On the Home Screen, app labels do not open apps (tested on iOS 15). Tap the icon, about 40 points above the label.screenshot and screentext reject a locked iPhone at once. If list reports the iPhone unlocked but capture still gets no frame, see USB screen capture stops.tap 207 100). When a screen looks wrong, check for Reachability first. A secure field can also make the screen look stale. Tap outside the field and read again.ghosthand tap 207 100 --screenshot s.png --screentext # empty top area: full screen returns
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, and a person had to tap Don't Allow. Apple documents Tab and Space for Full Keyboard Access, but that setting is off by default.screenshot --native does it in one command, in about 2 s. It presses cmd+shift+3, reads the new PNG over USB, scales it to screen points (the numbers tap takes), then deletes it from Photos.ghosthand screenshot -l 42 --native -o real.png # read real.png: the dialog is there ghosthand tap -l 42 120 401 --screenshot s.png # tap the button you read ghosthand hold -l 42 120 401 --duration 400 # button ignored the tap? press it once more
ghosthand key -l 42 cmd+shift+3 # iOS saves a real screenshot ghosthand photo pull -l 42 -o . # newest Camera Roll file, e.g. ./IMG_0004.PNG ghosthand photo delete -l 42 IMG_0004.PNG # remove it from Photos again
screenshot --native scales it for you. With the manual steps, pass its numbers with tap --scale 1 (native pixels). See Coordinates.ghosthand setup --permissions once and click Allow Photos with the iPhone plugged in and unlocked (macOS asks about removable volumes). Without this access, the screenshot stays in Photos and the output says which file to delete.hold --duration 400 and a stationary swipe all behaved the same. Earlier reports said that a 400 ms press works where a tap fails (location prompt, Photos Delete Photo sheet), but those were second presses too. Read the screen, then press once more.ghosthand hold -l 42 --text "Do Not Sign Out" --duration 5000 after taps and 1500...2500 ms holds failed. A native screenshot then showed Sign-In & Security. This is one success, not proof that the duration caused it. Check the result before you repeat input.lock, then unlock to dismiss it without accepting an option. This cleared a Maps prompt on an XR with iOS 18.7, but it may not work for every dialog. First get a user-confirmed passcode from the secret provider. If you have none, ask the user before you lock the iPhone. Try once, then check the screen. Never retry a failed passcode.ghosthand lock -l 42 secret-provider | ghosthand unlock -l 42 --stdin --screenshot s.png --screentext
type. Do not use the dots to check the typed password, and never type it again. Submit, then check the next screen or the error.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].Use ghosthand to turn off the SIM PIN on my iPhone. The PIN is SIM_PIN in sigillo.
0000 or 1234.ghosthand acquire --reason "turn off SIM PIN" # prints 42 ghosthand tap -l 42 --text "Settings" --screentext # or tap its Home Screen icon ghosthand tap -l 42 --text "Cellular" --screentext # "Mobile Service" in some regions ghosthand tap -l 42 --text "SIM PIN" --screenshot s.png # dual SIM: tap the line first ghosthand tap -l 42 340 180 --screenshot s.png # the switch, x,y read from s.png ghosthand tap -l 42 60 520 --screenshot s.png # one keypad digit; check one new dot # ... one tap per digit, then check: exactly 4 dots ghosthand tap -l 42 --text "Done" --screentext # once; the switch is now off ghosthand release -l 42
type says "typed" and the field stays empty (tested on an SE2 with iOS 18.6). Tap each digit on the on-screen keypad and check that one dot appears after each tap.list needs a lease, so two agents never use the same iPhone at once. ghosthand acquire --reason "<task>" prints an ID with its Mac, like studio:42. Pass -l studio:42 to every command and run ghosthand release -l studio:42 at the end. To stay short, the examples in this README write -l 42 or leave out -l. See Leases.file, photo, app, record, setup, account and daemon, as in ghosthand file push and ghosthand record stop. setup and account also run alone. MCP tool names join the words with - (file-push).| Command | Does |
list | iPhones 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 |
release | end the lease; turns AssistiveTouch off if acquire turned it on |
screenshot [-o file.png|.jpg] [--settle] [--native] | save the screen; --settle waits until it stops changing; --native shows dialogs USB capture hides |
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) |
double-tap <x> <y>, triple-tap <x> <y> | two or three clicks at one point: select a word or a paragraph |
hold <x> <y> [--duration ms] | long press (context menus, Home Screen icon menu); also --text |
swipe <x1> <y1> <x2> <y2> [--duration ms] | drag with the button held; content moves by the drag length (no momentum) |
hold-drag <x1> <y1> <x2> <y2> [--hold ms] [--duration ms] | long press, then drag while held: move Home Screen icons |
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 / unmute | silence 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 |
wake | turn the display on and check that USB frames arrive |
unlock --stdin | wake and unlock (or GHOSTHAND_PASSCODE); no screenshot while locked |
record start <file.mov|.mp4> [--fps n] [--max-duration s] | record screen and sound to a file; stops by itself after 5 min. See Record the screen |
record stop | stop the recording; prints duration, size and fps |
assistivetouch [on|off] | show or set AssistiveTouch over USB; acquire turns it on by itself |
lock | lock over Bluetooth with Control-Command-Q |
rename <name> | set the iPhone's name over USB; the id does not change |
forget | remove the Mac's Bluetooth bond with the iPhone, to pair again |
random <name|lastname|username|birthdate|password> | random account detail, saved in state.db with the lease's task and iPhone; random list shows them all |
file list <path> [--app id] | list a folder over USB: /DCIM, /Downloads, or an app's /Documents. See Files |
file pull <iphone path> [local] / file push <local> <iphone path> | copy a file or folder from or to the iPhone over USB; --app id for an app's Documents |
file remove <iphone path> | remove a file or folder over USB; refuses /DCIM |
photo list / photo pull [--last n] | newest Camera Roll files, or copy them to this Mac |
photo delete <name...> | delete photos so Photos forgets them too (ImageCaptureCore); needs Allow Photos |
app list / app install <file.ipa> / app uninstall <id> | installed apps with their Home Screen page; install a signed IPA; uninstall |
info | battery, storage, iOS build, serial, language, time zone, SIM; works while locked |
logs [--process p] [--grep text] [--seconds n] | iPhone system log over USB, from now on |
restart / shutdown | restart or turn off over USB; ask the user first |
setup | window for people: connect a new iPhone step by step |
setup status | setup readiness as JSON: missing permissions, disconnected phones |
dashboard | window for people: every iPhone, its agent, task, last command and live screen |
account / account login / account logout | this Mac's ghosthand.dev account and plan |
daemon start|stop|restart|status|run | manage the background daemon |
secret-provider | ghosthand type --stdin
--keep-final-newline keeps the final LF, and --enter adds one more Enter. Unsupported control characters, invalid UTF-8 and non-ASCII text fail before any input.key. To replace the text in a field, select all and delete it:ghosthand key -l 42 cmd+a ghosthand key -l 42 backspace
cmd, shift, alt (option), ctrl and fn (Globe). The daemon sends fn as the standard Globe key of non-Apple keyboards (Consumer AC Keyboard Layout Select, 0x29D). key fn alone is a Globe tap. Keys in a combo go down in order, so tab+h holds Tab, then presses H.| Combo | Action |
cmd+a | select all |
alt+backspace | delete the previous word |
cmd+backspace | delete to start of line |
shift+left | extend the selection by one letter |
cmd+left / cmd+right | go to start / end of line |
cmd+shift+left / right | select to start / end of line |
cmd+up / cmd+down | go to start / end of the text |
cmd+shift+up / down | select to start / end of the text |
cmd+c, cmd+v, cmd+z | copy, paste, undo |
tab / shift+tab | next / previous field or focused item |
space | activate the focused item |
esc | dismiss menus, suggestions, some dialogs |
cmd+l, cmd+f, cmd+r | Safari: address bar, find on page, reload |
fn+h | Home Screen |
fn+up | App Switcher |
fn+c, fn+n | Control Center, Notification Center |
tab+h | show all commands (only with Full Keyboard Access on) |
fn+h, fn+up, fn+c and fn+n work with Full Keyboard Access off. tab+h shows the command list in every app (Safari, Messages, Settings, Home Screen), but only when Settings > Accessibility > Keyboards & Typing > Full Keyboard Access is on. The other combos are iPadOS shortcuts, so read the screen after each one.fn fix? Forget the Mac on the iPhone and pair again. The old report map dropped the Globe usage, because iOS read the logical max 0xB0 as -80. The new pairing also adds scroll left|right.| Combo | Action | Tested |
tab / shift+tab | move focus to the next / previous group | yes (the first Tab focuses the AssistiveTouch button) |
| arrows | move focus inside a group (list rows); in a text field they move the cursor | yes |
space | activate the focused item | yes |
esc, tab+b | go back | yes |
tab+f | find: type an item's name, Enter activates it | yes in Settings; no result on the Home Screen |
tab+h | show all commands | yes |
tab+m | contextual menu (long press) | no |
tab+z | actions of the focused item, for example delete a row | no |
tab+l | lock screen | no |
tab+r | rotate device | no |
tab+g | keyboard gestures (touch, swipes, zoom, two-finger gestures) | no |
tab+x | Accessibility Shortcut | no |
fn+s | Siri | no |
ctrl+tab / ctrl+shift+tab | next / previous item | no |
tab+f and the first tab+b did nothing, and the second press worked. Check the screen after each key.ctrl+alt+shift+cmd+s calls Emergency SOS and ctrl+alt+shift+cmd+r restarts the iPhone. Never send them.mute sets a state. It does not toggle. You can run it twice safely.ghosthand mute # muted iPhone XR Tommy ghosthand unmute
0xE2) toggles iOS full mute, which silences all output, including media in every app. The ringer switch does not silence app media. USB lockdown cannot read the mute state, 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"
mute-unconfirmed and sends no second press. Tested on an XR with iOS 18.7. Full mute stays on when screen capture starts or stops.ghosthand record start demo.mov --fps 30 # returns at once ghosthand tap --text "Settings" ghosthand record stop # /Users/me/demo.mov 12.4s 2.1 MB 828x1792 30 fps sound stopped by request
iPhone ──USB──> capture session ──┬──> latest frame ──> screenshot, screentext, tap --text └──> fps cap ──> AVAssetWriter (H.264 + AAC) ──> demo.mov sound output ────────────────────────────┘ (added only while recording)
| Rule | Detail |
| Auto stop | after --max-duration (default 300 s, max 3600), when the lease ends, or when the daemon stops. The file is complete in each case |
| Size | 3-5 MB per minute on a still screen, at most about 17 MB per minute while it moves (measured on an XR) |
| Path | a new .mov or .mp4 on the daemon's Mac. It never overwrites a file |
| Sound | the iPhone's sound output. --no-audio leaves it out. With sound, start and stop pause media that plays on the iPhone, so start the recording first, then press play |
| Clean video | turn Always Show Menu off in Settings > Accessibility > Touch > AssistiveTouch, or the round AssistiveTouch button shows in the video. See AssistiveTouch. iOS still draws the mouse pointer dot, so it stays in the video |
tap, swipe, scroll, key, type and wake accept a text that you expect on the next screen. The match is a case-insensitive substring:ghosthand tap --text "General" --wait-text "About" --timeout-ms 5000
| Variable | Same as | Example |
GHOSTHAND_LANGUAGE | --language (OCR in screentext, --screentext, tap --text) | it-IT |
GHOSTHAND_SCALE | --scale (image and coordinate size) | 1 for native pixels |
GHOSTHAND_PASSCODE | passcode for unlock | user-confirmed secret; prefer unlock --stdin |
GHOSTHAND_LEASE | -l, --lease (every device command) | supported, but agents must pass -l 42 explicitly |
ghosthand photo pull -l 42 --last 3 -o ./photos # newest Camera Roll files to this Mac ghosthand file push -l 42 report.pdf /Downloads/report.pdf ghosthand app list -l 42 # bundle ids, versions, Home Screen page ghosthand info -l 42 # battery, storage, iOS build, time zone ghosthand logs -l 42 --process appstored --seconds 10
┌── com.apple.afc ─────────────> /var/mobile/Media (DCIM, Downloads) ghosthand ──USB lockdown─┼── house_arrest ──────────────> an app's Documents (file sharing apps) (MobileDevice.framework)├── installation_proxy ────────> app list, install, uninstall ├── springboardservices ───────> Home Screen page of each app ├── diagnostics_relay ─────────> restart, shutdown └── syslog_relay ──────────────> system log ImageCaptureCore (like Image Capture.app) ──────────────> delete photos so Photos forgets them
/DCIM. photo list shows the newest first, and photo pull copies them. file pull copies any path, and copies a folder as a folder.ghosthand photo list -l 42 --limit 5 # 845 KB 2026-10-10 10:52 /DCIM/100APPLE/IMG_0008.PNG ghosthand file pull -l 42 /DCIM/100APPLE/IMG_0008.PNG shot.png ghosthand file pull -l 42 /DCIM/100APPLE ./camera-roll # whole folder
| Path | What is there |
/DCIM/100APPLE/IMG_0001.PNG | Camera Roll. Screenshots are PNG, camera photos HEIC or JPG, videos MOV |
/Downloads | iOS download databases. Not the Files app's Downloads |
/Books, /PublicStaging, /iTunes_Control | Books, app install staging, media sync |
--app <bundle id> + /Documents/... | an app's Documents, only for apps with file sharing ([file sharing] in app list) |
ghosthand file push -l 42 report.pdf /Downloads/report.pdf ghosthand file push -l 42 movie.mp4 /Documents/movie.mp4 --app org.videolan.vlc-ios ghosthand file remove -l 42 /Downloads/report.pdf
--app). Then it shows in that app, and in Files > On My iPhone > that app when the app supports it./DCIM. ghosthand upload puts the file on ghosthand.dev and prints a short link to a page with a Save button. Safari's share sheet then offers Save Video or Save Image:ghosthand upload clip.mp4 # https://ghosthand.dev/u/3fK9xQ2mZr7T, expires in 1 day ghosthand key -l 42 cmd+space # open Safari, then cmd+l, type the link, enter ghosthand tap -l 42 --text Save # wait until the button says Save, not Loading... ghosthand tap -l 42 --text "Save Video"
file push into VLC, then Files > Share > Save Video.ghosthand photo delete -l 42 IMG_0008.PNG
/DCIM files with file remove. Photos keeps a broken entry (tested on an SE2 with iOS 18.6), so file remove refuses /DCIM. photo delete uses ImageCaptureCore, like Image Capture.app, so Photos forgets the photo too. The delete is permanent. It skips Recently Deleted, and with iCloud Photos the delete syncs. It needs the iPhone unlocked and the optional Allow Photos access from ghosthand setup --permissions.ghosthand app list -l 42 # com.burbn.instagram Instagram 450.1.0 (page 2) # com.example.beta Beta 1.2 [installing] (App Library only) ghosthand app install -l 42 MyApp.ipa # signed for this iPhone: ad hoc, enterprise, development ghosthand app uninstall -l 42 com.example.app
dock, page N, or page N, folder <name>. Swipe to that page, then tap the icon.[installing] marks an App Store download that is not done. Trust it over the App Store button label, which can show Resume during the download.app install takes only IPAs signed for this iPhone.ghosthand info -l 42 # battery: 100%, on power # storage: 51.0 GB free of 57.4 GB # sim tray: SIM card in # sim line 2: Vodafone_Ho_it, physical SIM, MCC 222 MNC 10 ghosthand logs -l 42 --process SpringBoard --grep fullyMuted --seconds 5 ghosthand restart -l 42 # ask the user first
logs starts, so run it while the event you watch happens. After restart, a person must type the passcode once. Until then, iOS ignores the Bluetooth keyboard. After shutdown, a person must press the side button to turn the iPhone on.list needs no lease.ghosthand acquire -d 7EF219AD-69B4-423E-B8C4-4F4A6140822F --label ses_123 --reason "reply to Anna" # prints studio:42 (42 below) 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
--reason with the task. If the phone is busy, acquire fails at once. The error 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. wait retries every second, with no queue order. On timeout it shows the current label and reason.--idle-timeout), so a crashed agent never blocks the phone.--force takes the phone from a lease whose holder is gone. That holder's next command fails with lease-invalid.wait or --force, take a screenshot first. Someone else used the phone.-l. Tool-call environments may not keep variables between calls, so do not rely on an exported GHOSTHAND_LEASE.state.db.--label is only display text, for example your agent session ID, so others can find who holds the phone. The daemon does not check it, it need not be unique, and an acquire with the same label does not resume an active lease.studio gets studio:42, also on that Mac itself, so the ID works from every Mac of the account (see Several Macs). Only a signed-out Mac returns a plain 42. A plain 42 also works on its own Mac.lease table of state.db, so they survive a daemon restart, for example after a rebuild.screentext boxes, tap and swipe use one coordinate 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, and send the same numbers.| iPhone | Native pixels | Default (points) | Claude tokens | GPT tokens (32 px patches x1.2) |
| 6s (@2x) | 750x1334 | 375x667 | 336 | 303 |
| XR (@2x) | 828x1792 | 414x896 | 480 | 437 |
| 14 Pro (@3x) | 1179x2556 | 393x852 | 465 | 422 |
--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, where small text reads better, then scales the boxes.| iPhone Mirroring | Ghosthand |
| Unavailable in the EU | No EU restriction |
| iPhone must stay locked; unlocking ends mirroring | iPhone stays unlocked for screen capture and control |
| Same Apple Account with two-factor authentication on both devices | No matching Apple Accounts needed; USB trust and Bluetooth pairing instead |
| Only one iPhone at a time | Several iPhones per Mac; 2 tested, Bluetooth limits still apply |
| Wi-Fi and Bluetooth must be on for both devices | Screen capture and input need USB and Bluetooth, not iPhone Wi-Fi; the Mac still needs internet for account and remote features |
| Opening Control Center requires macOS 27 or later | ghosthand key fn+c opens it on the iPhone; tested on iOS 18.6 |
| Connection automatically pauses after inactivity | The daemon keeps connected, unlocked iPhones awake; leases still expire after 10 idle minutes by default |
ghosthand setup. Keep the phone unlocked and its screen awake. See Setup.| Tradeoff | ghosthand | Appium / WDA |
| Phone setup | USB trust + Bluetooth pairing | Development setup + signed runner |
| Signing renewal | None; no runner installed | Free Personal Team: renew WDA provisioning every 7 days; paid membership avoids the weekly limit |
| Input | Mouse, keyboard, and wheel through AssistiveTouch | Native UI-testing gestures, including multi-touch |
| Find controls | Screenshots, OCR, coordinates | UI elements and attributes, plus screenshots and coordinates |
| Runtime dependencies | USB capture + Bluetooth link | Running WDA / XCTest session |
| App lifecycle | Navigate through the visible UI | Direct launch, terminate, and state queries |
| Text | US-ASCII only | Broader text support, depending on the field |
| Fleet size | Bluetooth limit: about 7 per Mac, 2 tested | No Bluetooth limit; depends on host resources and USB |
testmanagerd crashes. With ghosthand, Bluetooth can disconnect, USB capture can freeze or miss system dialogs, and private macOS APIs can change. Mouse drags do not always act like finger swipes, and there is no UI element tree or multi-touch. See Limits.ghosthand control -l 42 xctest # builds WebDriverAgent once (1-3 min), then starts it ghosthand control -l 42 bluetooth # back to the default
ghosthand setup --xctest. See docs/xctest-mode.md for details.| ghosthand | Appium (XCUITest) | |
| App on the iPhone | none | WebDriverAgent (signed) |
| Xcode, Developer Mode | no | yes |
| Screen | USB capture | XCTest |
| Input | Bluetooth Classic HID | XCTest |
| iOS 15 | yes over BLE (relative pointer); not tested over Classic | yes |
| Unlock with passcode | yes | no |
| iPhones per Mac | ~7, 2 tested | many |
acquire -d. Each lease runs on its own iPhone:ghosthand list # mac studio: # 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. studio:42 (42 below) ghosthand acquire -d "Test iPhone" # prints e.g. studio:43 ghosthand screenshot -l 42 -o a.png ghosthand tap -l 43 206 332 ghosthand release -l 42 ghosthand release -l 43
~/.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
list shows a warning. Plug them in one at a time, once.ghosthand acquire -d 7EF219AD-69B4-423E-B8C4-4F4A6140822F --reason "rename" # prints a lease id, e.g. 42 ghosthand rename -l 42 "iPhone XR" ghosthand release -l 42 # 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).| Limit | Value | Why |
| Bluetooth Classic | 7 connected devices | Apple: "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 |
| USB | 127 devices per bus | Not the problem. Power is. Use a powered hub, because each iPhone charges while it is controlled |
| Screen capture CPU | not measured | one video stream per iPhone |
ghosthand setup. Then list on any of them shows the iPhones of every online Mac of the account, and a lease on another Mac works like a local one:ghosthand list # mac studio: # 7EF219AD-... Tommy's iPhone iPhone XR (iPhone11,8) iOS 18.7 (bluetooth connected) # mac laptop: # 0A1B2C3D-... Test iPhone iPhone SE (iPhone12,8) iOS 17.6 (bluetooth connected) ghosthand acquire -d 7EF219AD-... --reason "reply to Anna" # studio:42 ghosthand tap -l studio:42 --text General --screenshot s.png
CLI (laptop) ──unix socket──> daemon (laptop) ──/macs/studio/v1/..., Mac session──> hub hub (ghosthand.dev) ──WebSocket──> daemon (studio) ──USB + Bluetooth──> iPhone
acquire finds the Mac. If only another Mac has the -d ID or name, acquire takes the iPhone there. A Mac with no iPhone takes the account's only iPhone without -d.list skips a Mac with an older ghosthand and shows a warning. It also warns about a Mac that does not answer within 10 s.# 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
tap "Lab Mac 1", then Pair.listlist gets devices from the macOS screen-capture device list that QuickTime uses, not from a Bluetooth scan. Keep the USB cable attached and trust the Mac once. list does not show a phone that has only Bluetooth. The Mac needs Camera permission. To list a phone, it needs neither a Bluetooth connection nor AssistiveTouch. If a permission is missing, list shows no system dialog. It tells the agent to open ghosthand setup and poll setup status until the phone is ready.mac studio: the Mac with the cable (`this Mac:` while signed out) 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
7EF219AD-… after a rename, an iOS update and a cable replug (tested). Save the ID and pass it with -d. We did not test it across different Macs.list 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; ...
list starts Bluetooth when an iPhone uses Bluetooth mode, and reconnects paired phones:| State | Meaning |
connected | input works |
disconnected | paired. The Mac reconnects in about 2 s. If the state stays, Bluetooth is off on the iPhone or the iPhone is out of range. The Mac cannot tell these apart |
not paired | pair from iPhone Settings > Bluetooth. The next input command waits for it |
list waits (up to 25 s) only for a trusted phone, which capture can list a few seconds after a daemon start.PasswordProtected from iOS lockdown over the trusted USB connection, with the private macOS MobileDevice.framework. It does not need a screen frame. [locked] means the passcode is required. Without [locked], lockdown reports the phone unlocked or the daemon could not read the lock state. Capture and lockdown give different IDs. See More iPhones for how the daemon matches them.unlock uses only the Bluetooth keyboard. USB screen capture gives no frames while the iPhone is locked, so unlock runs a timed sequence of about 3 s:enter ──> screen on ──1 s──> enter ──> passcode screen ──1 s──> type passcode once └──> check USB lock state
source:mouse and the lock screen dropped them. After a report map change, forget and pair the iPhone again, because iOS keeps the old map from pairing.PasswordProtected) through the private MobileDevice.framework. list shows [locked], and unlock checks the state before and after.unlock never retries a wrong passcode. Failed tries count toward the iPhone lockout. Ghosthand does not store the passcode.screenshot and screentext check the lock state first.screentext can find a button label and its pixel box, and the agent picks the next tap from that result.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 http://localhost/v1/phones # setup step, lease, last command, waiting agents 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
/v1/leases/{lease}/ and run on the lease's iPhone. A lease that is unknown, expired, released or taken with force gets lease-invalid (403). An acquire while another lease holds the phone gets device-leased (409). Its detail shows the label, reason, acquire time and last call. To wait, retry.Accept: text/event-stream to get progress as server-sent events.waitText, screenshot=png|jpeg and screentext=true. The daemon runs them after the input on the same lease. The result has screenshot (base64) and screen (OCR lines). tap also takes {"text":"General","index":1} instead of x and y.curl --unix-socket $S -H 'content-type: application/json' \ -d '{"text":"General"}' "$L/tap?waitText=About&screentext=true"
application/problem+json with a stable code. In event-stream mode the status is 200 and the problem arrives as the final error event.# on the Mac with the iPhones ghosthand tunnel url # https://ghosthand.dev/macs/studio ghosthand tunnel url --new-token # prints export GHOSTHAND_URL=... and export GHOSTHAND_TOKEN=... # anywhere else: paste those two export lines, then curl -H "Authorization: Bearer $GHOSTHAND_TOKEN" $GHOSTHAND_URL/v1/devices
script ──HTTPS /macs/studio/v1/..., Bearer API token──> ghosthand.dev hub ──WebSocket──> daemon
tunnel url --new-token creates it and shows it once. ghosthand tunnel revoke <id> ends it. Only the Mac can create or revoke tokens.POST /v1/leases returns studio:42. The plain 42 also works on its own Mac's URL.GET https://ghosthand.dev/macs lists your Macs and whether they are online. Request bodies through the tunnel are at most 20 MB.https://ghosthand.dev/mcp as a remote MCP server. Every CLI command is a tool with the same name and the same help text, so agents without a shell (Claude Code, Cursor, opencode) can drive the iPhones. The client signs in with the same Google account as your Macs.claude mcp add --transport http ghosthand https://ghosthand.dev/mcp
// opencode.json { "mcp": { "ghosthand": { "type": "remote", "url": "https://ghosthand.dev/mcp" } } } // .cursor/mcp.json { "mcpServers": { "ghosthand": { "url": "https://ghosthand.dev/mcp" } } }
MCP client ──POST /mcp, OAuth──> ghosthand.dev hub (one per account) ├─ lease "studio:42" ──> Mac studio ├─ mac: "laptop" ──────> Mac laptop └─ list ───────────────> every Mac, joined ▲ WebSocket the daemon opens (session token) └── daemon ──> tunnel.sock, /mcp and /v1 ──> Controller
acquire returns studio:42. Pass it as is, and it picks the Mac.list shows each iPhone under its Mac. Pass mac to acquire, wait, setup and setup status. With one Mac online, mac is optional.lease, text and index on tap, waitText, timeoutMs, screenshot: true and screentext: true. Screenshots come back as image content, not files.initialize) both work. When the client closes a request, the tool stops.initialize and tools/list itself, and every tool call returns the next step for the user. That step is the download link, or which Mac is offline and since when. A Mac that never had an iPhone answers with the plug-in and Trust steps.mcp.swift handle MCP. The hub in website/src/tunnel.ts only routes. While no Mac is online, it answers from the generated website/mcp-tools.json.ghosthand daemon status shows this Mac's name in the account.acquire, the daemon checks the plan with ghosthand.dev and sends the account's session token. GHOSTHAND_SERVER overrides the server, for example with a local vite dev. The token is in ~/.ghosthand/state.db, mode 0600. If ghosthand.dev cannot be reached, the daemon allows only the iPhones it last saw as allowed, until that answer expires. The daemon also sends usage telemetry, which you can turn off. See the privacy policy.who can reach the phone how local user who owns ~/.ghosthand ──> unix socket, file mode 0600 MCP client of your account ────────> ghosthand.dev/mcp: OAuth, then the Mac's tunnel script of your account ────────────> ghosthand.dev/macs/<mac>: API token, then the Mac's tunnel
| Action | Effect |
ghosthand daemon stop | no screenshots, no input, until the next command starts it |
| unplug the cable | no screenshots and no input, because every command finds the phone over USB first |
| iPhone: Settings > Bluetooth > the Mac > (i) > Forget This Device | no input until you pair again |
| iPhone: Settings > General > Transfer or Reset > Reset > Reset Location & Privacy | removes the USB trust of every computer |
unlock gets it for each call. Pipe it to unlock --stdin instead of passing it as an argument.cmd+h did nothing in tests. cmd+space opens Spotlight, so key cmd+space, type Safari and key enter open an app.key home goes Home, and key esc sends Escape. Neither one is sure to go back in Settings or Safari. Ghosthand does not expose AC Back (Consumer usage 0x0224), because its behavior on iOS is not verified. The descriptors do not change.unlock tries to wake a locked iPhone, but USB capture may still return no frame. An unlocked iPhone with the screen off needs a tap from you.list does not show a phone on Bluetooth or Wi-Fi alone.~/.ghosthand/daemon.log. The log has each command and its errors, Bluetooth pairing, screen capture restarts, and the MCP calls from the remote tunnel. Each line starts with a UTC timestamp.tail -n 100 ~/.ghosthand/daemon.log # last lines tail -f ~/.ghosthand/daemon.log # follow live while you run a command grep -iE 'error|fail' ~/.ghosthand/daemon.log | tail # recent errors only
2026-10-08T13:02:40Z error no-device: No iPhone found on USB. A charge-only cable does not work. 2026-10-08T16:43:31Z no iPhone visible yet, restarting the screen capture helper
| File | What writes it |
~/.ghosthand/daemon.log | the daemon (ghosthand daemon start, or any command that starts it) |
~/.ghosthand/app.log | the setup and dashboard windows, and their app install step |
| your terminal (stderr) | ghosthand daemon run: the daemon in the foreground, for debugging |
ghosthand daemon status prints the log path, the pid, and the Bluetooth state.: > ~/.ghosthand/daemon.log. Never delete state.db.list stays at bluetooth disconnected.ghosthand forget with a lease on that iPhone. It removes the Mac's bond, like System Settings > Bluetooth > the iPhone > (i) > Forget This Device.ghosthand key home. It makes the Mac discoverable and waits.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). ...
ghosthand daemon restart. It fixed a stuck capture that failed with Recording Stopped (AVFoundationErrorDomain -11808) (iOS 18.7).ghosthand unlock fails.ghosthand daemon stop| Entry | What it is |
Morse’s iPhone (2) | the iPhone screen over USB (what ghosthand uses) |
Morse’s iPhone (2) Camera | the iPhone camera (Continuity Camera), not the screen |
... Camera. Only the one on USB has a screen entry.tap --text "Settings" goes from command to touch. OCR finds the text on the last USB frame, the Controller converts its center to screen fractions, and the Bluetooth driver sends the HID click.CBClassicManager.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 (rel.), wheels │ │ │ │ report 2: keyboard, 6: media │ │ │ └──────────────────────────────────┘ └─────────────────────────────┘
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
addServiceWithData: takes the SDP record in bluetoothd's own element format, not the SDP wire format. We found the format by reverse engineering bluetoothd.sendMsg:35 with kCBMsgArgDiscoverableState makes the Mac discoverable.CBClassicPeer.handleMsg:args: to install them.CBL2CAPChannel. Releasing it closes its socket, and the iPhone shows "Connection unsuccessful".kCMIOHardwarePropertyAllowScreenCaptureDevices. QuickTime uses the same path. launchd then starts iOSScreenCaptureAssistant, a macOS helper that sets up each USB iPhone, and AVFoundation lists the iPhone. Only the daemon sets the flag, and the helper exits with its last client.daemon ──sets CMIO flag──> launchd ──starts──> iOSScreenCaptureAssistant │ sets up each USB iPhone once, on attach v daemon <──────────── one AVCaptureDevice per iPhone (frames) ──── AVFoundation
kAMDDeviceDisconnectedError in its log). We saw this with 4 iPhones. The control plane then says "Screen capture does not show this iPhone yet".tick ──> every trusted, unlocked USB iPhone listed? ──yes──> stop the timer (usually 1st tick) │ ├── none listed ──────────────> flag off (helper exits), next tick on (fresh helper) │ └── some missing on 2 ticks ──> same restart, at most 3 times, in a row (11 s) then log which iPhones are still missing
list warns, the log says screen capture does not list <name>, and a replug fixes it.screen capture does not list iPhone, restarting the screen capture helper screen capture devices on again screen capture lists iPhone, Naomi’s iPhone, Morse’s iPhone, iPhone XR Tommy
USB lockdown BluetoothAddress ──── EC:CE:D7:A4:9B:C1 ────> Bluetooth HID link
~/.ghosthand/state.db is a SQLite database. Device matches and pointer modes are caches, with one row per iPhone keyed by Apple's UDID. The lease table holds active leases, and lease_counter keeps the highest issued ID, so the daemon never reuses an ID (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
| Table | Written when | If the row is missing |
phone | a command sees a new iPhone. capture_matched_by: only-new-phone or same-name (see More iPhones). assistive_touch_off_after_lease: 1 when ghosthand turned AssistiveTouch on, so it goes off once no lease holds the iPhone | matched again the same way |
bluetooth_pointer | the first tap or swipe | iOS 17+: absolute again, no check. Older iOS: the pointer check runs on the next tap |
state.db. macOS keeps the bond. A phone paired while the daemon was stopped, or with no state.db, reconnects on the next command (tested).phone row (below).state.db to get past that error.sqlite3, also while the daemon runs, because the daemon keeps no copy in memory. Turn on foreign_keys. The sqlite3 shell has it off, and without it, deleting a phone does not delete its pointer row.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
phone row. Plug in new phones with the same name one at a time.bluetooth_pointer row. ghosthand forget also deletes it, but it removes the Bluetooth bond too, so you must pair again.state.db. Resetting its counter can give a new lease an old ID.| iOS | Pointer | Tap | Accuracy |
| 18.7 | absolute: report 1 says "go to X, Y" | ~0.3 s | exact |
| 15.8 | relative over BLE (iOS ignored absolute X/Y); not tested yet over Classic | ~0.45 s | 0.2 pt mean, 0.4 pt max (40 targets) |
list shows the mode, for example (bluetooth connected, relative pointer).| iPhone | Last iOS | Pointer | Tap |
| 6s, 6s Plus, 7, 7 Plus, SE (1st gen) | 15 | relative over BLE (tested on 6s); absolute over Classic not tested | ~0.45 s relative, < 0.5 pt off |
| 8, 8 Plus, X | 16 | absolute tried first, not tested | |
| XS, XS Max, XR, SE (2nd gen, 2020) and newer | 17+ | absolute (XR tested) | ~0.3 s, exact |
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
gain (1.000 at the default speed) and scales the table by it. Other speeds are not tested.--duration. The daemon spreads the reports over the duration and sizes them so the drag ends exactly at the target.CLI / curl / any language │ HTTP over ~/.ghosthand/daemon.sock (mode 0600, no token) v Hummingbird ──> generated API (openapi.yaml) ──> own thread ──> Controller ^ │ /mcp and /v1, via tunnel.sock MCP clients ──> ghosthand.dev/mcp (OAuth) ────┐ scripts ──> ghosthand.dev/macs/<mac>/v1 (API token) ──> WebSocket the daemon opens while signed in │ ┌───────────────────────────────────────┬───────────────────┤ v v v FrameStream per iPhone (AVFoundation) Lockdown lock state HIDDevice: BT Classic OCR (Vision) (MobileDevice) HID, CBClassicManager
website/openapi.yaml is the daemon's API spec and the source of truth. swift-openapi-generator builds the server and the client from it.unlock, taps) run one at a time per iPhone. Different iPhones run in parallel.~/.ghosthand/daemon.sock, daemon.log, daemon.lock, state.db.daemon/ submodule of this repo. Pro subscribers get read access to it on GitHub (pricing). Everything else in this repo is MIT.