Metadata-Version: 2.5
Name: nomac
Version: 0.1.0
Summary: NoMac from Python: iPhone and iPad Simulators in the cloud for a platform's users and agents, their tools, live view, saved state, tokens and webhooks.
Project-URL: Homepage, https://nomac.app/platform
Project-URL: Documentation, https://nomac.app/docs/simulator
License-Expression: MIT
License-File: LICENSE
Keywords: agents,ios,iphone,mcp,nomac,simulator,xcode
Classifier: Framework :: AsyncIO
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Description-Content-Type: text/markdown

# nomac

[NoMac Simulator](https://nomac.app/platform) from Python: iPhone and iPad Simulators in the cloud, for a platform's users and agents. Start a phone, build and run a native app on it, read and tap its screen by label, show it live in your page, save its state, and hear when it is ready or ends.

```sh
pip install nomac
```

Python 3.10 and later, one dependency (`httpx`), typed. `NoMac` for sync code, `AsyncNoMac` for asyncio: the same calls, awaited.

```python
from nomac import NoMac

nomac = NoMac()  # NOMAC_API_KEY from the environment
phone = nomac.phones.start(label="user-42", reuse=True)
phone.wait_until_ready()
root = phone.fetch_git("https://github.com/acme/app", token=github_token)["root"]
print(phone.tool("app_run", path=root, scheme="App").text)
phone.stop()
```

```python
from nomac import AsyncNoMac

async with AsyncNoMac() as nomac:
    phone = await nomac.phones.start(label="user-42", reuse=True)
    await phone.wait_until_ready()
    print((await phone.tool("ui_look")).text)
    await phone.stop()
```

A phone costs $0.80 an hour, billed per minute from the account's prepaid credit, with a one-minute minimum. Waiting in line is free.

## Phones

`nomac.phones.start(device=, label=, reuse=, limits=)` starts a phone and answers it at once, usually `queued`; `phone.wait_until_ready()` follows it. A phone from the pool is ready in seconds. When every phone is in use, the start waits in line, free, up to 5 minutes.

- `device`: `"iphone"` (iPhone 17 Pro, the default), `"ipad"` (iPad Pro 11-inch (M5)) or `"iphone-pro-max"` (iPhone 17 Pro Max), all on iOS 27.
- `label`: your own name for the phone, such as your user's id: letters, digits and `. _ : @ -`, up to 64.
- `reuse`: with `label`, answer the live phone with that label instead of starting another (`phone.reused`).
- `limits`: `max_duration_seconds` (7,200 by default, 28,800 at most), `idle_timeout_seconds` (600 by default, 60 to 3,600) and `spend_cap_microunits` (`"5000000"`, $5, by default).

An account runs two phones at once by default; [NoMac Platform](https://nomac.app/platform) runs more. A retry of a start with the same `idempotency_key` answers the same phone, never a second one.

`nomac.phones.list(label=)` lists the account's phones, newest first, and `nomac.phones.get(id)` reads one. `phone.session` has its state, device, label, cost and deadlines; `phone.refresh()` reads it again. `phone.extend(minutes=)` keeps a running phone longer, up to 8 hours in all. `phone.stop()` ends it: its apps and data go with it, and its workspace (projects and build output) stays for the next phone with the same label, so the next build is incremental.

## Tools

`phone.tool(name, arguments)` runs one of [simulator-use](https://simulator-use.dev)'s tools on the phone, its arguments as a dict or as keywords, and answers a `ToolResult`: `text` says what happened and what to do next, `content` has its text and images, `is_error`, and `stop_notice` in the phone's last 15 minutes. A long build is followed to its end.

| Tool | |
| --- | --- |
| `app_run` | Builds an Xcode project or workspace (`path`, `scheme`) with Xcode 27.1, installs it and launches it. |
| `tests_run` | Runs XCTest and XCUITest, each case's result as it finishes. |
| `ui_look` | The screen as text, one line per element with its label and position. |
| `ui_act` | Taps, types, swipes and keys by label: `steps=['tap "Sign In"', "fill #email a@b.co"]`. |
| `app_control` | Installs, launches and terminates apps, opens links, sets permissions, appearance, locale, time zone and location. |
| `app_logs`, `screen_record`, `devices`, `job_status`, `job_cancel` | Logs, video, the phone and Xcodes, long jobs. |

Projects reach the phone's workspace with `phone.fetch_git(url, ref=, token=)` or `phone.upload_archive(data, name=)` (bytes or a file's path: a `.tar.gz` or `.zip` of a project, or of a `.app` built for the Simulator, up to 2 GiB). Both answer `root`, the path `app_run` and `tests_run` take; only what changed is written.

A private repository on github.com, gitlab.com or bitbucket.org takes its token one of two ways, and it goes only to that host:

- `token`: the token itself, sent with this fetch and kept nowhere, not even for the phone's next fetch. Your user's token stays with you, and a short-lived one (GitHub's one-hour tokens) works. A phone's token may send one.
- `secret`: the name of one of the account's own secrets that holds it, for your own repositories (the account's key only).

Values your app needs go with the run, as `env` on `app_run` (`"API_URL=https://staging.example KEY=..."`): the phone keeps them for its relaunches until it stops, and they are erased with it.

## Live view and your users

`phone.links["view"]` (watch) and `phone.links["control"]` (use) are links a person opens with no NoMac sign-in; `phone.view_link(control=True, minutes=120)` makes one for your user. [`@nomac/phone`](https://www.npmjs.com/package/@nomac/phone) embeds it in your page.

`phone.token(minutes=)` gives a token for that phone only, to hand your own user or agent: `NoMac(token=...)` reaches that phone's tools, files, state, view and replay until the token expires (2 hours by default, a day at most). It cannot start, list or stop phones, make another token, use the account's secrets, or reach your webhooks. `phone.revoke_tokens()` revokes every token and link of the phone made so far.

Phones are kept apart by `label`: a workspace and saved states are shared only by phones with the same label. Give each of your users their own label, and one user's phone, or its token, never reaches another's.

## Saved state and replays

`phone.save_state("signed-in")` keeps the phone's apps, their data, its keychain and permissions for the phones with its label; `phone.restore_state("signed-in")` puts them back onto any of them of the same model and iOS. `phone.list_states()` and `phone.delete_state(name)` manage them. Every phone is recorded: `phone.replay()` answers its video and its calls (typed text left out) once it has stopped, kept 7 days.

## Deleting a user's data

When one of your users deletes their account, `nomac.labels.delete("user-42")` deletes what NoMac keeps for that label: its saved states, its phones' replays and file copies, and its workspace (the key to its encrypted volume is destroyed at once, so it never opens again; the host deletes the volume on its own sweep). It answers `{"deleted": {"states", "workspaces", "replays", "files"}}`; a repeat finds nothing left. 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`, `details["retry_after_seconds"]`). The phones' records (times and cost, under the label) stay for billing, so use your own user id as the label, not an email.

## Webhooks

```python
hook = nomac.webhooks.create("https://platform.example/nomac", events=["phone.ready", "phone.ended"])
# Keep hook["secret"]: it is shown once.
```

Check every delivery before trusting it, with the raw body as received:

```python
from nomac import verify_webhook

event = verify_webhook(request.body, request.headers["nomac-signature"], os.environ["NOMAC_WEBHOOK_SECRET"])
if event["type"] == "phone.ended":
    release(event["data"]["session"]["label"], event["data"]["reason"])
```

`phone.ended` carries the label, the time billed and the cost, and why it ended (`stopped`, `idle`, `time_limit`, `spend_cap`, `credit`, `crashed`, `no_capacity` or `start_failed`). A delivery can come more than once: key on its `id`. `nomac.webhooks.list()`, `delete(id)` and `deliveries(id)` manage them.

## Errors

A refusal is a `NoMacError` with NoMac's `code` (for code to branch on), `message` (for a person), HTTP `status` and `details`, such as `session_limit_reached` (409) or `insufficient_credit` (402). A read, a stop and any request with a request key are sent again when NoMac is busy for a moment.

The REST API it calls is at [nomac.app/docs/simulator](https://nomac.app/docs/simulator); `nomac.request(method, path, body=, idempotency_key=)` calls it directly.

MIT licensed.
