# mac.doublespeed.ai — Xcode as an API

Build, test, archive and run iOS apps on a fleet of Apple Silicon Macs from any Linux box, container, or coding agent. Native Swift/SwiftUI, React Native, Expo and CocoaPods projects. Results come back as structured data. There is also a live iOS Simulator session you can open in a browser.

Two products, one fleet:

1. **Builds + cloud simulator** — `build`, `test`, `archive`, `run` via the `ds` CLI or HTTP. Available now.
2. **Mac CI/CD** — the same jobs from your CI (works now from any Linux runner), plus GitHub App PR checks and signed TestFlight builds (early access).

Contact for keys: hassan@doublespeed.ai

---

## Accounts, orgs and pricing

- **Sign up** at [/signup](/signup) (email + password or Google). The first organization you create gets **$200 of credits** — about 65 hours of Mac time. It is granted once per user: a second org by the same user, or an org you were invited to, does not get another grant.
- **Organizations** own API keys, credits and usage. Owners and admins manage members, invites, billing and settings; members use and manage API keys. Invite teammates from the Members page with a link (7-day, single-use, optionally locked to an email).
- **API keys** (`dsx_…`) are created and revoked on the dashboard; usage is attributed per key. The org id is the `tenant_id` you see in usage reports.
- **Pricing**: **$0.05 per Mac-minute**, metered per second of Mac time actually held (queueing is free), rounded up to the cent per job. Prepaid credits, no expiry, no minimums. Jobs are refused with `INSUFFICIENT_CREDITS` (HTTP 402) when the balance reaches $0; a running job is never interrupted. Buy credits on the Billing page ($10 / $25 / $100 / $500, card via Stripe).
- For comparison, GitHub-hosted macOS runners are $0.08/min rounded up per job; see the [benchmark](/benchmark) for a measured run.

## Try it with no account: `ds xcode check`

```bash
npx -y @doublespeed/ds xcode check .     # no DS_API_KEY needed
```

Runs on a real Mac in the fleet and reports, in seconds: which `.xcworkspace` / `.xcodeproj` / `Package.swift` it found, the schemes and targets (`xcodebuild -list`), whether every Swift file parses (`swiftc -parse`, errors as `file:line:col`), and whether the plists are valid. It does **not** compile, run tests or boot a simulator — that needs a key. Limits: 10 checks per day per address, 25 MB snapshot, lowest queue priority. Exit code 1 with `error.code = CHECK_FAILURE` when something is wrong. Agents: use this to prove structure before asking the human for a key.

## Quickstart

```bash
npm install -g @doublespeed/ds   # Node 20+, any OS
export DS_API_KEY=dsx_...                               # issued per tenant
cd your-app                                             # .xcodeproj / .xcworkspace / Package.swift / React Native / Expo

ds xcode build .        # compile; errors as file:line:column
ds xcode test .         # XCTest; per-test results + .xcresult
ds xcode run .          # live simulator link you can tap through in a browser
ds xcode archive . --setting CODE_SIGNING_ALLOWED=NO --setting CODE_SIGNING_REQUIRED=NO
```

Exit code is `0` on `succeeded`, `1` otherwise, so it composes with `&&` and CI steps. Add `--json` for machine-readable output.

Defaults: `DS_API_URL=https://api.mac.doublespeed.ai/v1/xcode` (also proxied at `https://mac.doublespeed.ai/v1/xcode`).

---

## Give your agent a Mac (MCP server, skill, rules)

- **MCP server** — `@doublespeed/mcp` exposes `build_sim`, `test_sim`, `build_run_sim`, `list_sims`, `discover_projs`, `list_schemes`, `stop_app_sim`, `archive_app`, `job_status`, `doctor` (same names as XcodeBuildMCP, so prompts written for a local Mac work here). Claude Code: `claude mcp add doublespeed -e DS_API_KEY=dsx_... -- npx -y @doublespeed/mcp`. Cursor / Codex / Windsurf: `{"mcpServers":{"doublespeed":{"command":"npx","args":["-y","@doublespeed/mcp"],"env":{"DS_API_KEY":"dsx_..."}}}}`.
- **Claude Code plugin** (skill + MCP): `claude plugin marketplace add doublespeed-main/agent-skills` then `claude plugin install ds-xcode@doublespeed`.
- **Cursor rule / AGENTS.md**: copy from [doublespeed-main/agent-skills](https://github.com/doublespeed-main/agent-skills).
- `GET /v1/xcode/capabilities` returns Xcode versions, simulator runtimes and device names, so an agent can pick `--xcode` / `--destination` without guessing.

## For agents: how to use this well

- **Iterate with `build`, verify with `test`, look with `run`.** `build` is the fast loop (3–25 s warm). Only `run` boots a simulator.
- **Always pass `--json` when a program reads the output.** You get one JSON line per event and a final job object; parse `diagnostics[]` and `result.tests` instead of the raw log.
- **Never re-upload.** Source sync is content-addressed: re-running in the same directory uploads only files whose hash changed.
- **Read `error.code` before the message.** `BUILD_FAILURE`/`TEST_FAILURE` mean your code; `DEPENDENCY_FAILURE` means the project's dependencies; `UNSUPPORTED_XCODE` means the fleet's Xcode is too old for the project; `AMBIGUOUS_*` means pass `--scheme`/`--project`. Infrastructure problems (`WORKER_FAILURE`) are retried automatically; your bugs never are.
- **Don't guess flags.** If the tool says "multiple schemes found: A, B", pick one explicitly; it will not choose for you.
- **Long jobs:** default timeout is 30 min (`--timeout`, max 7200 s). A `run` session ends after 15 idle minutes by default (`--idle`).
- **Everything is deleted after the job** (workspace, DerivedData, simulator contents). Artifacts you asked for are kept and downloadable for 5-minute windows via signed URLs.

---

## CLI reference

```
ds xcode build|test|archive|run [dir] [flags]
ds xcode status <job_id> [--json]
ds xcode logs <job_id> [--after N] [--json]      # follow / resume the event stream
ds xcode cancel <job_id> | ds xcode stop <job_id> # stop is the alias for run sessions
ds xcode artifacts <job_id> [--download <artifact_id> --out <file>]
ds xcode jobs [--limit N] [--status queued|preparing|running|succeeded|failed|cancelled]
```

| Flag | Applies to | Meaning |
|---|---|---|
| `--scheme X` | all | Scheme; inferred when exactly one app scheme exists |
| `--workspace X.xcworkspace` / `--project X.xcodeproj` | all | Container; inferred when unambiguous; relative to the snapshot root |
| `--configuration Debug\|Release` | all | Default `Debug` (`archive`: `Release`) |
| `--destination '<xcodebuild destination>'` | build/test/archive | Defaults: build `generic/platform=iOS Simulator`, test `platform=iOS Simulator,name=iPhone 16`, archive `generic/platform=iOS` |
| `--device 'iPhone 16 Pro'` | run | Simulator to boot |
| `--xcode 26` | all | Required Xcode major or exact version (`26`, `16`, `26.5`); default `26`, the newest stable on the fleet. The job waits for a worker that has it |
| `--env KEY=value` (repeatable) | all | Environment for the build; values are redacted from logs. Reserved: `PATH`, `HOME`, `DEVELOPER_DIR`, `DYLD_*`, `TMPDIR` |
| `--setting KEY=VALUE` (repeatable) | all | xcodebuild build settings, e.g. `CODE_SIGNING_ALLOWED=NO` |
| `--only-testing T` / `--skip-testing T` (repeatable), `--test-plan P` | test | XCTest filters, `Target/Suite/testCase` |
| `--collect-app` | build | Upload the produced `.app` as an artifact |
| `--timeout S` | all | Job cap, 30–7200 s (default 1800) |
| `--priority N` | all | 0 = first, default 100, FIFO within a class |
| `--idle S` | run | End the session after S seconds with no viewer activity (60–7200, default 900) |
| `--no-follow` | all | Submit and print the job, don't stream |
| `--json` | all | Machine-readable output |

### `--json` output

One line per event, then the final job object:

```json
{"event":"phase","data":{"phase":"preparing"}}
{"event":"log","data":{"stream":"stdout","line":"CompileSwift normal arm64 ..."}}
{"event":"diagnostic","data":{"severity":"error","file":"Sources/App/Foo.swift","line":42,"column":13,"message":"cannot find 'bar' in scope"}}
{"event":"preview","data":{"url":"https://xcode-...phonesfarmstream.com/s/<token>","device":"iPhone 16 Pro","expires_at":"...","screen":{"width":402,"height":874,"scale":3},"bundle_id":"com.example.App"}}
{"event":"completed","data":{"status":"failed","exit_code":65,"error_code":"BUILD_FAILURE"}}
{"id":"<job uuid>","operation":"build","status":"failed","error":{"code":"BUILD_FAILURE","message":"1 error — Sources/App/Foo.swift:42: cannot find 'bar' in scope"},"diagnostics":[...],"result":{...},"artifacts":[...],"timings":{"queued_ms":800,"preparing_ms":700,"dependencies_ms":0,"execution_ms":4300,"total_ms":6100}}
```

Final job object fields: `id`, `operation`, `status`, `error {code, message} | null`, `exit_code`, `diagnostics[] {severity, message, file?, line?, column?, target?}`, `result.tests {executed, passed, failed, skipped, cases[] {suite, name, status, duration_seconds, failure_message?, file?, line?}}`, `result.archive {bundle_id, version, build, platforms[], app_name}`, `result.preview {ended_reason, duration_ms, inputs, device}`, `result.dependencies {kind, steps[]}`, `artifacts[] {id, type: app|xcresult|xcarchive, name, size_bytes, sha256}`, `timings`, `preview` (run jobs, `{url, expires_at, device, screen, bundle_id}`).

File paths in diagnostics are relative to the snapshot root.

---

## GitHub Actions: PR checks in three lines

The Mac work runs on the fleet, so your CI job is an ordinary Linux runner. Setup, start to finish:

1. Create an API key at [/dashboard](/dashboard) → **API keys** (sign up first; new accounts get $200 of credits).
2. In your GitHub repo: **Settings → Secrets and variables → Actions → New repository secret**, name `DS_API_KEY`, value the key.
3. Commit `.github/workflows/ios.yml`:

```yaml
name: ios
on: [pull_request, push]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: doublespeed-main/xcode-action@v1
        with:
          api-key: ${{ secrets.DS_API_KEY }}
```

That's it. Every pull request now builds and tests the app on the fleet; compile errors and failing tests appear as **inline annotations on the PR diff**, and the job summary shows test counts, timings and the job id. Native Swift, React Native, Expo and CocoaPods projects all work with no extra steps.

Options (all optional): `command: build | test | archive`, `path: ios` for a subdirectory, `scheme`, `destination: platform=iOS Simulator,name=iPhone 16 Pro`, `xcode: 16`, `configuration`, `args: --setting CODE_SIGNING_ALLOWED=NO --only-testing AppTests`, `fail-on-error: false` (then branch on the `status` output). Outputs: `job-id`, `status`, `error-code`.

Working example: [doublespeed-main/mac-ci-benchmark](https://github.com/doublespeed-main/mac-ci-benchmark) runs exactly this on every PR next to a GitHub macOS runner doing the same job; the comparison is on the [benchmark page](/benchmark).

Without the action (any CI system): `npm install -g @doublespeed/ds`, set `DS_API_KEY`, run `ds xcode test .`. It exits non-zero on `BUILD_FAILURE` / `TEST_FAILURE` and prints errors as `file:line:col: error:`; add `--json` for the machine-readable stream. Pull requests from forks cannot read secrets, so limit the workflow to `push` and same-repo PRs, or use `pull_request_target` with care.

## HTTP API

Base URL: `https://api.mac.doublespeed.ai/v1/xcode`. Header: `Authorization: Bearer dsx_…`. JSON in and out. Errors: `{"error":{"code","message","details?"}}` with HTTP 400 `INVALID_REQUEST`, 401 `UNAUTHORIZED`, 404 `NOT_FOUND`, 405, 409 `SOURCE_NOT_READY`, 413 `SOURCE_TOO_LARGE`, 429 `QUEUE_LIMIT_EXCEEDED`, 500 `INTERNAL_ERROR`. A build or test *failure* is a normal job result (HTTP 200 on `GET /jobs/{id}`), not an HTTP error.

### 1. Sync source (content-addressed)

```
POST /sources
{"files":[{"path":"Sources/App/main.swift","sha256":"<hex>","size":1234,"mode":420}, ...]}
→ 200 (ready) | 202 (pending)
{"source_id":"src_<24 hex>","status":"ready|pending","file_count":9,"total_bytes":19357,
 "missing":[{"sha256":"<hex>","size":1234,"upload_url":"https://..."}]}
```

Rules: paths are relative, forward-slash, no `..`, no absolute paths, case-insensitively unique; `mode` is `420` (0644) or `493` (0755); sha256 is of the file bytes. Exclude `.git`, `DerivedData`, `build`, `.build`, `node_modules`, `Pods`, `*.xcresult`, `*.xcarchive` (the CLI honours `.gitignore` and `.dsignore`). Limits: 50 000 files, 2 GB per snapshot, 200 MB per file. `source_id` is the hash of the manifest, so identical trees share an id per tenant.

For each `missing` entry: `PUT <upload_url>` with the raw bytes (`Content-Type: application/octet-stream`). Then:

```
POST /sources/{source_id}/complete   → 200 {"status":"ready"} | 409 {"status":"pending","missing":[...]}
GET  /sources/{source_id}            → status
```

Uploaded blobs are size-checked against the manifest and hash-verified again on the worker.

### 2. Submit a job

```
POST /jobs
{"operation":"build|test|archive|run","source_id":"src_...","xcode_version":"16",
 "scheme":"App","workspace":"App.xcworkspace","project":"App.xcodeproj","configuration":"Debug",
 "destination":"platform=iOS Simulator,name=iPhone 16 Pro",
 "environment":{"API_BASE_URL":"https://staging.example.com"},
 "options":{"only_testing":["AppTests/MathTests"],"skip_testing":[],"test_plan":"CI",
            "build_settings":{"CODE_SIGNING_ALLOWED":"NO"},"collect_app":true,"idle_timeout_seconds":900},
 "timeout_seconds":1800,"priority":100}
→ 202 {"id":"<uuid>","status":"queued", ...job}
```

Only `operation`, `source_id` and `xcode_version` are required. Pass `workspace` or `project`, not both. `xcode_version` "16" matches any 16.x on a worker.

### 3. Follow it

```
GET /jobs/{id}/events        Accept: text/event-stream
```

SSE with `id: <seq>` on every event; types `phase`, `log`, `diagnostic`, `preview`, `completed`. The server closes the stream after ~55 s; reconnect with `Last-Event-ID: <seq>` (or `?after=<seq>`) — nothing is lost, events are durable and strictly ordered. Without the `Accept` header the same route returns a JSON page: `{"job_status", "events":[{seq,type,data,at}], "next_after"}`.

```
GET /jobs/{id}               → the job object (see fields above); for run jobs includes "preview"
GET /jobs?limit=20&status=…  → {"jobs":[...]}
POST /jobs/{id}/cancel       → idempotent; queued jobs cancel instantly, running ones within ~20 s
```

### 4. Artifacts and usage

```
GET /jobs/{id}/artifacts/{artifact_id}               → 302 to a 5-minute signed URL
GET /jobs/{id}/artifacts/{artifact_id}?format=json   → {"artifact":{...},"url":"...","expires_in":300}
GET /usage?from=<iso>&to=<iso>&group=day|api_key|operation|worker|none
   → {"buckets":[{"bucket","attempts","billable_seconds","in_flight_attempts","in_flight_seconds"}],"totals":{...}}
```

Billable seconds = time a Mac slot was held by your attempts (preparing → cleanup). Queue wait is not billed. The ledger is append-only.

### Minimal end-to-end in curl

```bash
API=https://api.mac.doublespeed.ai/v1/xcode; H="Authorization: Bearer $DS_API_KEY"
# manifest (one file shown)
SHA=$(shasum -a 256 Package.swift | cut -d' ' -f1); SIZE=$(stat -f%z Package.swift)
REG=$(curl -s -X POST $API/sources -H "$H" -H 'Content-Type: application/json' \
  -d "{\"files\":[{\"path\":\"Package.swift\",\"sha256\":\"$SHA\",\"size\":$SIZE}]}")
SRC=$(echo $REG | jq -r .source_id)
for URL in $(echo $REG | jq -r '.missing[].upload_url'); do curl -s -X PUT "$URL" -H 'Content-Type: application/octet-stream' --data-binary @Package.swift; done
curl -s -X POST $API/sources/$SRC/complete -H "$H"
JOB=$(curl -s -X POST $API/jobs -H "$H" -H 'Content-Type: application/json' \
  -d "{\"operation\":\"build\",\"source_id\":\"$SRC\",\"xcode_version\":\"16\"}" | jq -r .id)
curl -s -N $API/jobs/$JOB/events -H "$H" -H 'Accept: text/event-stream'   # streams until completed
curl -s $API/jobs/$JOB -H "$H" | jq '{status, error, diagnostics}'
```

---

## What happens on the worker

1. A healthy Mac whose Xcode matches `xcode_version` claims the job (durable queue, atomic claim, 60 s lease renewed every 20 s; a dead Mac's job is requeued, a build failure is never retried).
2. Fresh `0700` workspace; blobs downloaded and hash-verified; paths validated.
3. **Dependencies** by project kind:
   - native (`.xcodeproj` / `.xcworkspace` / `Package.swift`): nothing; SwiftPM resolves inside the job.
   - cocoapods (`Podfile` beside the project, or in `ios/`): `pod install`.
   - react-native (`package.json` with `react-native` + `ios/`): `npm ci` / `yarn` / `pnpm` / `bun` chosen from the lockfile, then `pod install`.
   - expo (`package.json` with `expo`, no generated `ios/`): JS install → `npx expo prebuild --platform ios` → `pod install`.
   Per-tenant caches for `node_modules` (by lockfile hash), package-manager caches and CocoaPods. Failures → `DEPENDENCY_FAILURE` with the tool's last lines.
4. Project/scheme inference: one `.xcworkspace` wins over projects; else one `.xcodeproj`; else `Package.swift`. Scheme: the only scheme, or the one named after the app's `.xcodeproj` (CocoaPods workspaces list every pod's scheme). Anything else → `AMBIGUOUS_PROJECT` / `AMBIGUOUS_SCHEME` listing the choices.
5. `xcodebuild` with `-derivedDataPath` and `-clonedSourcePackagesDirPath` inside the workspace; logs streamed line by line; diagnostics parsed (`file:line:col: error:`, project-level `path: error:` incl. signing errors, XCTest case results and assertion failures, `Executed N tests` summaries).
6. Collect artifacts (`.xcresult`, `.xcarchive` with metadata, optional `.app`), zip, upload, record sha256.
7. For `run`: boot the simulator headlessly, install + launch, start WebDriverAgent, stream JPEG frames (~6 fps while someone is watching) over WebSocket, forward taps/swipes/text. On end: shut down and **erase** the simulator.
8. Kill the process tree, delete the workspace, verify it's gone; the worker quarantines itself if cleanup fails.

## Common errors when you don’t have a Mac

Short pages, each with the exact error and the fix — for humans searching and for agents that hit them:

- [`xcodebuild: command not found`](/fix/xcodebuild-command-not-found)
- [`xcrun: command not found` / `unable to find utility "simctl"`](/fix/xcrun-command-not-found)
- [`SDK "iphonesimulator" cannot be located`](/fix/sdk-iphonesimulator-cannot-be-located)
- [`Unable to find a device matching the provided destination specifier`](/fix/unable-to-find-a-device-matching-the-provided-destination-specifier)
- [`expo run:ios` — "iOS apps can only be built on macOS"](/fix/expo-run-ios-on-linux)
- [`react-native run-ios` on Linux](/fix/react-native-run-ios-on-linux)
- [Build an iOS app on Linux (without a Mac)](/fix/build-ios-app-on-linux)
- [Run an iOS Simulator from Docker / Codespaces / a sandbox](/fix/ios-simulator-in-docker-codespaces)
- [Let Claude Code, Cursor or Codex build and run an iOS app](/fix/claude-code-cursor-codex-build-ios-app)

## Error codes

| Code | Meaning | What to do |
|---|---|---|
| `INVALID_REQUEST` | Bad field, unknown/pending `source_id`, project not in snapshot, no simulator of that name | Fix the request; message says which field |
| `AMBIGUOUS_PROJECT` / `AMBIGUOUS_SCHEME` | More than one candidate | Pass `--project`/`--workspace` or `--scheme` from the listed options |
| `UNSUPPORTED_XCODE` | No worker has the requested Xcode, or the project needs a newer toolchain (e.g. "Swift tools version 6.2 but installed 6.1") | Pin a newer Xcode (`--xcode 26`); if no worker has it, lower the requirement |
| `CHECK_FAILURE` | `ds xcode check` found syntax/structure problems | Fix the listed `file:line:col` diagnostics; nothing was compiled |
| `RATE_LIMITED` (429) | Keyless trial limit (10 checks/day/address) | Get a free key at /signup |
| `INSUFFICIENT_CREDITS` (402) | The org has no credits left | Buy credits on the dashboard Billing page; running jobs are never interrupted |
| `DEPENDENCY_FAILURE` | npm/yarn/pnpm, `expo prebuild` or `pod install` failed | Read the tail in `error.message`; fix lockfile/Podfile |
| `BUILD_FAILURE` | xcodebuild non-zero with compiler/linker/signing errors | Read `diagnostics[]` (file, line, column, message) |
| `TEST_FAILURE` | Tests ran, some failed | `result.tests.cases[]` with `failure_message`, `file`, `line` |
| `ARCHIVE_FAILURE` | Archive failed (usually signing) | Supply signing settings or archive unsigned with `CODE_SIGNING_ALLOWED=NO` |
| `TIMEOUT` | Exceeded `timeout_seconds` | Raise the cap or split the work |
| `CANCELLED` | Cancelled by the caller (also how a stopped `run` session ends) | — |
| `WORKER_FAILURE` | Infrastructure (worker lost). Retried up to 3 attempts automatically | Nothing; if it persists, contact us |

## Fleet today

- **Xcode 26.5** (iOS 26.5 SDK — what App Store uploads require since April 2026) and **Xcode 16.4** (iOS 18.6 SDK), side by side. Pin with `--xcode 26` or `--xcode 16` (or an exact `26.5`); a job waits for a worker that has the version it asks for. Expo SDK 55+ and Swift 6.2 projects: use `--xcode 26`.
- Simulators: iPhone 16 family and iPads on iOS 18.6; iPhone 17 family on iOS 26.5.
- Toolchain: Node 20 and 22, CocoaPods 1.17, SwiftPM, WebDriverAgent.
- Capacity is one heavy job per Mac; extra jobs queue (FIFO within priority). Queue depth and worker health are exposed to operators; ask if you need dedicated machines.

## Security and isolation

Workers make outbound connections only. Source blobs are stored per tenant and verified by hash on the worker. Each attempt runs in a fresh workspace that is deleted afterwards; simulators are erased after `run`. Job environment values are redacted from the log stream. API keys are stored hashed; revoke via your contact. Usage is recorded in an append-only ledger per key.
