Simulator API reference · NoMac

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

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 for TypeScript. Agents use 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 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.

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

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.