# Simulator API reference

Start an iPhone or iPad Simulator, build and run a native app on it, read and tap its screen by label, show it live, save its state, and hear when it ends. A phone costs $0.90 an hour, billed per minute.

## TypeScript

```typescript
import { NoMacSimulator } from "@nomac/simulator";

const nomac = new NoMacSimulator({ apiKey: process.env.NOMAC_API_KEY });
const phone = await nomac.phones.start({ label: "user-42", reuse: true });
await phone.waitUntilReady();
const { root } = await phone.fetchGit({ url: "https://github.com/acme/demo" });
console.log((await phone.tool("app_run", { path: root })).text);
await phone.stop();
```

## Basics

**Base URL** `https://nomac.app/api/v1/simulator`

**Auth** `Authorization: Bearer nmk_…`, the account's key, or a phone's own `nmp_…` token.

**Errors** `{ "error": { "code", "message" } }` with a meaningful status: the code for your code to branch on, the message for a person.

**Retries** A start, a tool call, a fetch and a saved state take an `Idempotency-Key`: a retry with the same key reaches the same phone or call, never a second one.

**SDKs and agents** [@nomac/simulator](/content/docs/sdks/index.html) for TypeScript. Agents use [simulator-use](https://github.com/NoMac-app/simulator-use), `https://mcp.nomac.app/simulator`, or NoMac's `https://mcp.nomac.app/mcp` with the Mac's tools beside `phone` and `phone_*`.

## Phones

### POST/sessions

Starts a phone and answers at once, usually `queued`; read it until `ready`, seconds from the pool. When every phone is in use, the start waits in line for free, up to 5 minutes, and `queue` gives its place.

**device** `iphone`, iPhone 17 Pro (the default), `iphone-pro-max`, iPhone 17 Pro Max (the 6.9-inch App Store screenshot size), or `ipad`, iPad Pro 11-inch (M5), all on iOS 27.

**label** Your own name for the phone, such as your user's id.

**reuse** With a label, answers the live phone with that label instead of a new one, with `reused: true`.

**limits** `max_duration_seconds` (7,200 by default, 28,800 at most), `idle_timeout_seconds` (600, from 60 to 3,600) and `spend_cap_microunits` ($5). A tool call or a person's input through a control link counts as activity; watching does not.

An account runs two phones at once; [NoMac Platform](/content/platform/index.html) runs more. After 10 phones stopped before they were ready within 10 minutes, a start answers `start_rate_limited` (429) with `retry_after_seconds`.

### GET/sessions?label=user-42

The account's phones, newest first, or those with a label.

### GET / DELETE/sessions/:id

Reads or stops a phone. Its apps and data go with it; its workspace stays for the next phone with the same label, so the next build is incremental. The answer carries `links`: `view` and `control` while it runs, and `replay`.

In its last 15 minutes before its time limit, its spend cap or the end of the credit, this answer and every tool answer carry `stop_notice`: when it stops, why, and what to do.

### POST/sessions/:id/extend

`{ minutes? }`, 60 by default: keeps a running phone longer, up to 8 hours in all, its spend cap rising with it. The account's key only. A phone at 8 hours answers `session_at_longest`.

## Tools

### POST/sessions/:id/tools/:name

Runs one of simulator-use's tools on the phone, the body its arguments.

**app_run** Builds an Xcode project with Xcode 27.1, installs and launches it. A CocoaPods project's pods are installed first, from its `Podfile.lock`; `env` gives the app launch variables.

**tests_run** XCTest and XCUITest, each case as it finishes.

**ui_look** The screen as text; `screenshot_file` saves it as a full-size PNG.

**ui_act** Taps, typing and swipes by label.

**app_control**, **app_logs**, **screen_record**, **devices**, **job_status** Apps and settings, logs, video, the devices, and long calls.

It answers within 45 seconds, or with the call's `id` to read at `GET /sessions/:id/calls/:call`; `POST …/calls/:call/cancel` stops it. Files an answer names are download links.

### POST/sessions/:id/sync/git

`{ url, ref?, secret? | token? }`: a repository into the workspace. Answers `root`, the path the tools take. A private one on github.com, gitlab.com or bitbucket.org needs `token`, used for this fetch and kept nowhere (a phone's token may send one), or `secret`, the name of the account's secret that holds it (the account's key only).

### POST/sessions/:id/sync/upload, then /sync/fetch

An archive of up to 2 GiB: a `.tar.gz` or `.zip` of a project or of a Simulator`.app`.

- `upload` with `{}` answers an `upload` id, an `upload_url` that takes any archive until it expires (each PUT replaces the last), and an `upload_command` that packs the folder it runs in and sends it, leaving out build output, dependencies, version control and key files. With `{ size, sha256 }`, the URL takes exactly that archive.
- `fetch` with `{ upload, name? }` or `{ sha256, name? }` unpacks it, writing only what changed, and answers `root` and `app`. A large upload may answer `running` first: send the same request again.

## Live view and your users

### POST/sessions/:id/view

`{ control?, minutes? }`: a link a person opens with no sign-in, to watch or use the phone, one viewer holding input at a time. It works until it expires (2 hours by default, a day at most) or the phone ends. Embed it with `@nomac/phone`; the picture is H.264 over a WebSocket, with no TURN servers.

### POST / DELETE/sessions/:id/token

`{ minutes? }`: an `nmp_…` token for that phone only, for your user or your agent. It reaches the phone's tools, files, state, view, replay and tunnel until it expires (2 hours by default, a day at most), and after the phone ends still reads it and its replay. It cannot start, list or stop phones, make another token, use the account's secrets or reach your webhooks, and stops working when the key that made it is revoked.

`DELETE`, with the account's key, revokes every token and link the phone has so far, view, control, file and replay links included. A view already open closes within 20 seconds, and the phone runs on.

### GET/sessions/:id/replay

Every phone is recorded: `recording`, then `saving` for up to 15 minutes after it stops, then `ready` with its video, its calls (typed text left out) and a `link` a person opens. Kept 7 days.

## Saved state and the local backend

### POST/sessions/:id/state

`{ action: "save" | "restore", name }` keeps or puts back the phone's apps, their data, its keychain and permissions, encrypted under a key of their own. A restore works on any phone of the same model and iOS with the same label, on any host; the phone restarts in about a minute, so send the same request again with the same key to read it. `{ action: "list" }` and `{ action: "delete", name }` manage them.

A workspace and saved states are shared only by phones with the same label, and the account's phones with no label share theirs, so one of your users never reaches another's. States are kept until deleted, or 90 days after the account's last phone.

### DELETE/labels/:label

Deletes what NoMac keeps for a label when your user deletes their account: its saved states, its phones' replays and file copies, and its workspace, whose key is destroyed at once. Answers what it deleted, such as `{ deleted: { states: 2, workspaces: 1, replays: 3, files: 0 } }`; a repeat finds nothing left.

The account's key only. Stop the label's phones first (`label_in_use`); for 15 minutes after the last one ends, its replay is still being saved (`label_busy`, with `retry_after_seconds`). The phones' records stay under the label for billing, so use your own user id, not an email.

### POST/sessions/:id/tunnel

The phone's tunnel to a backend on the developer's machine: a WebSocket `url`, its `protocol` and a 5-minute `token` that opens only that tunnel. `simulator-use cloud tunnel 8080` speaks it: the phone's apps reach `http://127.0.0.1:<its port>`, and each request is listed with its status and time.

While it is open, anything on the phone reaches that backend: its apps, pages in its Safari and the project's build. Nothing outside the phone does.

## Webhooks

### GET / POST/webhooks

`POST` `{ url, events? }`, an https URL at a public address, with `phone.ready` and `phone.ended` by default. Answers the webhook with its `secret`, shown once. Up to 5 per account. The list gives each one's `failing`: deliveries given up in the last 7 days.

### DELETE/webhooks/:id

Ends a webhook; nothing more is sent to it.

### GET/webhooks/:id/deliveries

Its deliveries, newest first, kept 30 days (`?limit=`, 50 by default): each `delivered`, `retrying` (with `next_attempt_at`) or `gave_up` after 12 tries, with `last_error`.

Each delivery is a JSON POST: `{ id, type, created_at, data: { session, reason? } }`. `phone.ended`'s `reason` is `stopped`, `idle`, `time_limit`, `spend_cap`, `credit`, `crashed`, `no_capacity` or `start_failed`.

The `nomac-signature` header is `t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body" under the secret>`: check it, and refuse an old `t`, before trusting a delivery. A delivery not answered 2xx within 5 seconds is sent again 1, 2, 4 and up to 60 minutes later, 12 times in all, and can arrive more than once: key on its `id`.

## TypeScript

```typescript
import { verifyWebhook } from "@nomac/simulator";

const event = await verifyWebhook(rawBody, request.headers.get("nomac-signature"), process.env.NOMAC_WEBHOOK_SECRET);
if (event.type === "phone.ended") console.log(event.data.session.label, event.data.reason);
```

## Coverage

iOS only for now, with Android later. Phones run on Simulator hosts in the US; when every phone is in use, a start waits in line instead of failing.
