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

HTTP API to control an iPhone from code

The ghosthand daemon serves every CLI command as an HTTP route. The CLI and the MCP server are thin clients of this API, so anything they do, your scripts can do too, from any computer.
your script ──HTTPS, API token──> ghosthand.dev ──WebSocket the Mac opened──> ghosthand daemon ──USB──────> screenshots, OCR (any computer) /macs/studio/v1/... └─Bluetooth─> taps, swipes, typing

Quick start

1. On the Mac with the iPhones, get the tunnel URL and an API token:
ghosthand tunnel url # https://ghosthand.dev/macs/studio ghosthand tunnel url --new-token # prints export GHOSTHAND_URL=... and export GHOSTHAND_TOKEN=...
Copy the two lines to the other computer or your script's secrets. The token is shown once. The ghosthand CLI does not need them: on another Mac, sign that Mac in and ghosthand list shows the iPhones of every Mac of the account.
2. In your script, on any computer, send the token as a bearer header:
# 1. take the iPhone curl "$GHOSTHAND_URL/v1/leases" -H "Authorization: Bearer $GHOSTHAND_TOKEN" \ -H 'content-type: application/json' \ -d '{"reason":"reply to Anna in Messages","label":"nightly-script"}' # {"id":"studio:42","device":"7EF219AD-...","deviceName":"Tommy's iPhone",...} (42 works too on this URL) # 2. read the screen curl "$GHOSTHAND_URL/v1/leases/42/text" -H "Authorization: Bearer $GHOSTHAND_TOKEN" curl -o screen.png "$GHOSTHAND_URL/v1/leases/42/screenshot" -H "Authorization: Bearer $GHOSTHAND_TOKEN" # 3. tap a button by its text, and get the new screen text in the same call curl "$GHOSTHAND_URL/v1/leases/42/tap?screentext=true" -H "Authorization: Bearer $GHOSTHAND_TOKEN" \ -H 'content-type: application/json' -d '{"text":"Messages"}' # 4. give the iPhone back curl -X DELETE "$GHOSTHAND_URL/v1/leases/42" -H "Authorization: Bearer $GHOSTHAND_TOKEN"

In TypeScript

const url = process.env.GHOSTHAND_URL! const headers = { authorization: `Bearer ${process.env.GHOSTHAND_TOKEN}`, 'content-type': 'application/json' } const lease = await fetch(`${url}/v1/leases`, { method: 'POST', headers, body: JSON.stringify({ reason: 'check the inbox', label: 'inbox-script' }), }).then((r) => r.json()) try { const tap = await fetch(`${url}/v1/leases/${lease.id}/tap?screentext=true`, { method: 'POST', headers, body: JSON.stringify({ text: 'Mail' }), }).then((r) => r.json()) console.log(tap.screen) } finally { await fetch(`${url}/v1/leases/${lease.id}`, { method: 'DELETE', headers }) }

Transport and authentication

There are two ways to reach a daemon:
WhereURLAuth
Any computer (tunnel)https://ghosthand.dev/macs/<mac>Authorization: Bearer <API token>
The Mac itselfunix socket ~/.ghosthand/daemon.socknone: file permissions
The tunnel needs no open port. While a Mac is signed in, the daemon keeps a WebSocket to ghosthand.dev, so it works behind NAT and on a laptop. ghosthand daemon status shows the URL.
API tokens belong to your account and work for every Mac of it. Manage them on a Mac:
ghosthand tunnel url --new-token --name ci # create; the id goes to stderr ghosthand tunnel revoke <id> # scripts that use it get 401 at once
Find your Macs: GET https://ghosthand.dev/macs returns every Mac of the account and whether it is online. The slug in the URL is the name in that list.
curl https://ghosthand.dev/macs -H "Authorization: Bearer $GHOSTHAND_TOKEN" # {"macs":[{"slug":"studio","name":"Studio","online":true},{"slug":"laptop","name":"Laptop","online":false}]}
Limits of the tunnel:
  • A request body is at most 20 MB. For bigger files use ghosthand upload.
  • A Mac that is off or signed out answers 503 mac-offline. A daemon that is restarting is waited for up to 5 seconds.
  • Browser requests (Origin or Sec-Fetch-Site headers) get 403 and no CORS headers are sent. Call it from a server or a native client.
The generated cURL examples on the endpoint pages use the tunnel URL. The Socket tab shows the same request over the unix socket, and the CLI tab shows the matching ghosthand command.
A token lets a script drive your iPhones like a person holding them. Store it like a password. Only the Mac can create or revoke tokens, so a leaked token cannot make more.
MCP agents use https://ghosthand.dev/mcp (OAuth, same Google account) instead. It needs no token, and its lease ids carry the Mac, e.g. studio:42, because MCP has no URL per Mac.

Leases

Screen and input routes run on a lease, so two agents never drive one iPhone at the same time. POST /v1/leases returns an id; every route under /v1/leases/{lease}/ then runs on that iPhone.
POST /v1/leases ──> { "id": "42" } │ v /v1/leases/42/screenshot, /text, /tap, /type, ... (each call resets the idle timer) │ v DELETE /v1/leases/42 or 600 s without a call ──> iPhone free for the next client
  • One holder. While a lease holds the iPhone, POST /v1/leases fails at once with device-leased. The detail says who holds it and why, so you can give up or wait.
  • Waiting. Send waitSeconds to retry every second instead of failing.
  • Force. force: true ends the other lease. Use it only when its holder is gone.
  • AssistiveTouch. Acquire turns it on when it is off. Release turns it off again.
Through the tunnel the id carries the Mac, e.g. studio:42, like over remote MCP and in the CLI for an iPhone on another Mac. On the Mac's own URL the plain number works too. On the unix socket an iPhone of that Mac gets 42. Ids only increase, also across daemon restarts. A label is display text, not a way to resume a lease.

Coordinates

x and y are pixels of the screenshot image. By default images and OCR boxes use screen points (414x896 on an iPhone XR), so a vision model can send the coordinates it reads from the image as they are.
Pass scale=1 on the screenshot and on the input to work in native pixels instead. Use the same scale for reading and for input.

Read the screen after an input

Input routes take extra query parameters that run after the input, on the same lease. One request then replaces three:
ParameterWhat it adds
waitTextWaits until an OCR line contains this text (timeoutMs, max 60 s).
screenshotReturns the still screen as base64 png or jpeg in screenshot.
screentextReturns the OCR text of the still screen in screen.
curl "$GHOSTHAND_URL/v1/leases/42/tap?waitText=About&screentext=true" \ -H "Authorization: Bearer $GHOSTHAND_TOKEN" -H 'content-type: application/json' -d '{"text":"General"}'
An error from these steps (for example wait-text-timeout) means the input was sent. Read the screen before you decide what to do. Never repeat the input blindly.

Progress with server-sent events

An input can wait up to 300 s while the iPhone pairs or reconnects over Bluetooth. Send Accept: text/event-stream to get progress on the same request:
curl -N "$GHOSTHAND_URL/v1/leases/42/tap" -H "Authorization: Bearer $GHOSTHAND_TOKEN" \ -H 'accept: text/event-stream' -H 'content-type: application/json' \ -d '{"x":207,"y":448}'
event: log data: {"message":"checking Bluetooth device 1A2B3C4D by moving its pointer"} event: result data: {"message":"tapped 207,448","logs":["checking Bluetooth device 1A2B3C4D by moving its pointer"]}
  • The stream ends with result or error (a Problem). The HTTP status is always 200, so read the last event.
  • A : ping comment line comes every second.
  • When you close the connection, the daemon stops the operation before its next Bluetooth action. Without the header, a disconnect does not stop it.

Errors

Errors are always application/problem+json (RFC 9457). Switch on code; detail tells a person or an agent what to do next.
{ "code": "device-locked", "title": "iPhone locked", "status": 409, "detail": "Tommy's iPhone is locked. Unlock it before tapping text" }
CodeStatusMeaning
invalid-request400The body or a parameter is wrong. Nothing was sent.
unauthorized401Tunnel request without a valid API token.
permission-denied403Browser request, Camera access denied, or a unix-socket-only route called through the tunnel.
lease-invalid403The lease is unknown, expired, released or forced away.
not-found404No such route.
device-not-found404No iPhone matches device.
no-device404No trusted iPhone on USB. The detail says what USB sees.
text-not-found404No OCR line matches the tap text.
no-recording404Nothing was recorded since the last stop.
multiple-devices409Several iPhones are connected; pass device.
device-locked409The iPhone needs its passcode.
device-leased409Another lease holds the iPhone.
lock-failed409The lock shortcut did not lock the iPhone.
unlock-failed409The passcode did not unlock it. It is never retried.
recording-active409The iPhone is already recording.
daemon-busy409Restart with ifIdle while a lease is active.
cancelled499The client closed the stream.
mac-offline503The Mac in the tunnel URL is off, signed out or not connected.
tunnel-error502The tunnel lost the connection to the Mac mid-request. Try again.
internal-error500A bug in the daemon. The detail has the cause.
recording-failed500The video writer failed, for example a full disk.
bluetooth-unavailable503Bluetooth is off or not allowed on the Mac.
bluetooth-disconnected503The Bluetooth link dropped during the input.
no-frame503USB screen capture sent no frame.
pointer-not-found503The AssistiveTouch pointer was not seen on screen.
lock-state-unavailable503The lock state could not be read over USB.
lockdown-unavailable503USB lockdown did not answer.
mute-unconfirmed503iOS did not confirm the mute state. It never presses again.
pairing-timeout504The iPhone did not pair or reconnect in time.
wait-text-timeout504The input was sent, but waitText did not appear.

OpenAPI document

The daemon serves the exact spec it was built from at GET /v1/openapi.yaml. Generate a typed client from it with any OpenAPI generator. The Swift CLI is built from the same file with swift-openapi-generator.
curl "$GHOSTHAND_URL/v1/openapi.yaml" -H "Authorization: Bearer $GHOSTHAND_TOKEN" > ghosthand.yaml