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
12ghosthand tunnel url # https://ghosthand.dev/macs/studio ghosthand tunnel url --new-token # prints export GHOSTHAND_URL=... and export GHOSTHAND_TOKEN=...
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.# 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"
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 }) }
| Where | URL | Auth |
| Any computer (tunnel) | https://ghosthand.dev/macs/<mac> | Authorization: Bearer <API token> |
| The Mac itself | unix socket ~/.ghosthand/daemon.sock | none: file permissions |
ghosthand daemon status shows
the URL.12ghosthand tunnel url --new-token --name ci # create; the id goes to stderr ghosthand tunnel revoke <id> # scripts that use it get 401 at once
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.12curl https://ghosthand.dev/macs -H "Authorization: Bearer $GHOSTHAND_TOKEN" # {"macs":[{"slug":"studio","name":"Studio","online":true},{"slug":"laptop","name":"Laptop","online":false}]}
ghosthand upload.503 mac-offline. A daemon that is restarting
is waited for up to 5 seconds.Origin or Sec-Fetch-Site headers) get 403 and no CORS headers are
sent. Call it from a server or a native client.ghosthand command.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.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
POST /v1/leases fails at once with
device-leased. The detail says who holds it and why, so you can give up or wait.waitSeconds to retry every second instead of failing.force: true ends the other lease. Use it only when its holder is gone.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.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.scale=1 on the screenshot and on the input to work in native pixels instead. Use
the same scale for reading and for input.| Parameter | What it adds |
waitText | Waits until an OCR line contains this text (timeoutMs, max 60 s). |
screenshot | Returns the still screen as base64 png or jpeg in screenshot. |
screentext | Returns the OCR text of the still screen in screen. |
12curl "$GHOSTHAND_URL/v1/leases/42/tap?waitText=About&screentext=true" \ -H "Authorization: Bearer $GHOSTHAND_TOKEN" -H 'content-type: application/json' -d '{"text":"General"}'
wait-text-timeout) means the input was
sent. Read the screen before you decide what to do. Never repeat the input blindly.Accept: text/event-stream to get progress on the same request:123curl -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}'
12345event: 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"]}
result or error (a Problem). The HTTP status is always
200, so read the last event.: ping comment line comes every second.application/problem+json (RFC 9457).
Switch on code; detail tells a person or an agent what to do next.123456{ "code": "device-locked", "title": "iPhone locked", "status": 409, "detail": "Tommy's iPhone is locked. Unlock it before tapping text" }
| Code | Status | Meaning |
invalid-request | 400 | The body or a parameter is wrong. Nothing was sent. |
unauthorized | 401 | Tunnel request without a valid API token. |
permission-denied | 403 | Browser request, Camera access denied, or a unix-socket-only route called through the tunnel. |
lease-invalid | 403 | The lease is unknown, expired, released or forced away. |
not-found | 404 | No such route. |
device-not-found | 404 | No iPhone matches device. |
no-device | 404 | No trusted iPhone on USB. The detail says what USB sees. |
text-not-found | 404 | No OCR line matches the tap text. |
no-recording | 404 | Nothing was recorded since the last stop. |
multiple-devices | 409 | Several iPhones are connected; pass device. |
device-locked | 409 | The iPhone needs its passcode. |
device-leased | 409 | Another lease holds the iPhone. |
lock-failed | 409 | The lock shortcut did not lock the iPhone. |
unlock-failed | 409 | The passcode did not unlock it. It is never retried. |
recording-active | 409 | The iPhone is already recording. |
daemon-busy | 409 | Restart with ifIdle while a lease is active. |
cancelled | 499 | The client closed the stream. |
mac-offline | 503 | The Mac in the tunnel URL is off, signed out or not connected. |
tunnel-error | 502 | The tunnel lost the connection to the Mac mid-request. Try again. |
internal-error | 500 | A bug in the daemon. The detail has the cause. |
recording-failed | 500 | The video writer failed, for example a full disk. |
bluetooth-unavailable | 503 | Bluetooth is off or not allowed on the Mac. |
bluetooth-disconnected | 503 | The Bluetooth link dropped during the input. |
no-frame | 503 | USB screen capture sent no frame. |
pointer-not-found | 503 | The AssistiveTouch pointer was not seen on screen. |
lock-state-unavailable | 503 | The lock state could not be read over USB. |
lockdown-unavailable | 503 | USB lockdown did not answer. |
mute-unconfirmed | 503 | iOS did not confirm the mute state. It never presses again. |
pairing-timeout | 504 | The iPhone did not pair or reconnect in time. |
wait-text-timeout | 504 | The input was sent, but waitText did not appear. |
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.1curl "$GHOSTHAND_URL/v1/openapi.yaml" -H "Authorization: Bearer $GHOSTHAND_TOKEN" > ghosthand.yaml