mac.doublespeed.ai

Xcode as an API, on a Mac fleet we own.

Builds + cloud simulator — ds xcode build | test | archive | run from any Linux box or agent; structured errors, test results, artifacts, and a live iOS Simulator link. Available now.

Mac CI/CD — PR checks in three lines with doublespeed-main/xcode-action: errors and failing tests annotated on the diff, no macOS runner. Signed TestFlight builds in early access. Benchmark vs GitHub's macOS runners →

4.0× faster, 6× cheaper — Real app — Wikipedia iOS, 367 unit tests, same commit, same Xcode 16.4, push to greenHow it was measured →
GitHub macOS runner
$0.72 per run · macos-15, list price
doublespeed fleet
$0.12 per run · ubuntu runner + Mac time

Fleet time is a warm build (unchanged files cached on the worker); first-ever build of the repo: 4m 49s. Namespace publishes 3m 18s for this repo on M4/M5 Max hardware. Cost: GitHub bills macOS at $0.08/min rounded up per job; we bill $0.05/min of Mac time per second, plus the Linux runner.

6.6× faster, 15× cheaper — Hello-world app — overhead only, push to greenHow it was measured →
GitHub macOS runner
$0.40 per run · macos-15, list price
doublespeed fleet
$0.03 per run · ubuntu runner + Mac time

Median of 12 runs on 2026-08-27; GitHub failed 1 of 6. Cost: GitHub bills macOS at $0.08/min rounded up per job; we bill $0.05/min of Mac time per second, plus the Linux runner.

npm install -g @doublespeed/ds
export DS_API_KEY=dsx_...          # $100 free at /signup
ds xcode run .

Everything below is written for both humans and agents. copies it as Markdown; it is also served raw at /docs.md and /llms.txt.

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

Try it with no account: ds xcode check

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

npm install -g @doublespeed/ds   # Node 20+, any OS (or use npx --yes @doublespeed/ds@latest)
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).

Build once, open many times (recommended for simulator iteration)

Do not use a new xcode run job for every app open. Build the simulator .app once, allocate one bare simulator, and keep its session ID and viewer URL while you iterate:

The fleet proactively keeps one clean, booted iPhone 16 Pro per Mac ready for allocation, including automation and input control. You do not need to upload an app before creating a session. When matching ready capacity is available, allocation attaches to that simulator instead of booting iOS and starting automation on the request path. App installation/launch happens afterward.

# 1. Build and download a simulator-compatible app bundle.
npx --yes @doublespeed/ds@latest xcode build . \
  --scheme MyApp \
  --xcode 16 \
  --out MyApp.app.zip

# 2. Create one persistent simulator. This prints <session-id> and the viewer URL.
npx --yes @doublespeed/ds@latest xcode simulator create \
  --device "iPhone 16 Pro" \
  --xcode 16 \
  --idle 900 \
  --timeout 7200 \
  --label app=vibecode

# 3. Install and launch the prebuilt app in that session.
npx --yes @doublespeed/ds@latest xcode simulator install <session-id> \
  MyApp.app.zip --launch

After a source change, rebuild the zip and reload it into the same session:

npx --yes @doublespeed/ds@latest xcode build . \
  --scheme MyApp \
  --xcode 16 \
  --out MyApp.app.zip

npx --yes @doublespeed/ds@latest xcode simulator reload <session-id> MyApp.app.zip

reload terminates the running app, installs the new bundle, and relaunches it. The session ID, simulator, app data, and viewer URL stay the same. A build by itself does not update the running simulator; call reload after each new build. A new simulator create or xcode run command creates a new job and therefore a new viewer URL.

Closing the viewer tab does not immediately stop the session. Check or stop it explicitly:

npx --yes @doublespeed/ds@latest xcode simulator status <session-id> --json
npx --yes @doublespeed/ds@latest xcode simulator stop <session-id>

Reserve one simulator for up to two hours

For a focused testing window, this command holds one simulator and keeps its viewer URL stable for up to two hours:

npx --yes @doublespeed/ds@latest xcode simulator create \
  --device "iPhone 16 Pro" \
  --xcode 16 \
  --idle 7200 \
  --timeout 7200 \
  --label customer=vibecode

Prewarmed capacity versus a reserved session

Prewarming is automatic; use the same simulator create command above. There are three Macs with one job slot each: three total slots, not three active sessions plus three spare simulators. An unused standby is kept booted and health-checked without a customer session. It does not require your app, and background prewarming/refill is not billed to your organization.


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

For agents: how to use this well


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]
ds xcode simulator create [--device 'iPhone 16 Pro'] [--xcode 16] [--idle 900] [--timeout 7200] [--label K=V]...
ds xcode simulator list [--label K=V] [--limit N] [--json]
ds xcode simulator status <session_id> [--json]
ds xcode simulator install <session_id> <app.zip|simulator.ipa> [--launch] [--json]
ds xcode simulator reload <session_id> <app.zip|simulator.ipa> [--json]
ds xcode simulator launch <session_id> <bundle_id> [--json]
ds xcode simulator stop <session_id> [--json]
FlagApplies toMeaning
--scheme XallScheme; inferred when exactly one app scheme exists
--workspace X.xcworkspace / --project X.xcodeprojallContainer; inferred when unambiguous; relative to the snapshot root
--configuration Debug|ReleaseallDefault Debug (archive: Release)
--destination '<xcodebuild destination>'build/test/archiveDefaults: build generic/platform=iOS Simulator, test platform=iOS Simulator,name=iPhone 16, archive generic/platform=iOS
--device 'iPhone 16 Pro'run / simulator createSimulator to boot
--xcode 26allRequired 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)allEnvironment for the build; values are redacted from logs. Reserved: PATH, HOME, DEVELOPER_DIR, DYLD_*, TMPDIR
--setting KEY=VALUE (repeatable)allxcodebuild build settings, e.g. CODE_SIGNING_ALLOWED=NO
--only-testing T / --skip-testing T (repeatable), --test-plan PtestXCTest filters, Target/Suite/testCase
--collect-appbuildUpload the produced .app as an artifact
--timeout SallJob cap, 30–7200 s (default 1800)
--priority Nall0 = first, default 100, FIFO within a class
--idle Srun / simulator createEnd the session after S seconds without activity (60–7200, default 900)
--no-followallSubmit and print the job, don't stream
--jsonallMachine-readable output

--json output

One line per event, then the final job object:

{"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 → API keys (sign up first; new accounts get $100 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:
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 runs exactly this on every PR next to a GitHub macOS runner doing the same job; the comparison is on the benchmark page.

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.

5. Simulator sessions (no source, no build)

For "build once, open many times" flows: create a bare simulator from available prewarmed capacity, install a prebuilt .app into it, and reuse that same live session on every open. A new session is a clean allocation, not a way to recover apps or data from an ended one.

POST /simulators {"device":"iPhone 16","idle_timeout_seconds":900,"labels":{...}}
GET  /simulators/{id}    (list: GET /simulators?label=k:v)
DELETE /simulators/{id}  → cancel

Each session carries a lifecycle you can bill on: session_status is creating → ready → terminated, with ready_at, terminated_at, and terminated_reason (idle | ended | timeout | cancelled | failed). Interactive time = terminated_at − ready_at. Once ready, the response includes viewer_url and api_url — a token-authorized JSON API on the worker: POST {api_url}/apps/install {"url":"https://…/MyApp.app.zip"} installs a prebuilt simulator .app (zip or simulator .ipa), then /tap, /type, /scroll, /screenshot, /tree, /logs, /simctl, /end.

The browser viewer is latency-first and reports rendered FPS, WebSocket RTT, input acknowledgement latency, and dropped frames in its header. The worker and browser discard stale frames instead of letting them queue, so a slow client catches up to the newest frame. The latest production iPhone 16 Pro check delivered about 55 fps with zero server buffering; actual FPS and RTT vary by client and network. Coordinate taps now use direct simulator HID input to bypass XCTest's tap overhead; WebDriverAgent still handles video, accessibility queries, and other gestures, and is the tap fallback if direct input is unavailable. This applies to both the browser viewer and POST {api_url}/tap; no CLI upgrade is required. Instance info (GET {api_url}) reports tap_backend as simulator_hid or wda. The viewer shows immediate local touch feedback separately from the acknowledgement, which waits for the input backend to finish dispatching the gesture—not just receive the request. An acknowledgement does not mean the resulting video frame has reached your screen. The metrics tooltip shows worker processing time (including queued input), while input latency includes network RTT. Rapid queued taps are spaced to preserve distinct touches; network RTT remains a lower bound on remote acknowledgements.

For the same flow through the CLI, use xcode build --out, xcode simulator create, then simulator install --launch; rebuild and simulator reload without changing the session or viewer URL. Repeated registration of identical app bytes is skipped by SHA-256.

Prewarmed inventory: one clean iPhone 16 Pro is prepared per idle Mac before allocation. Keep the same live session to preserve apps/data and avoid repeat allocation. Ended sessions are erased, then clean inventory is replenished; new sessions do not inherit the old app/data. Standby/refill time is free. Matching capacity can be busy or replenishing, so a warm allocation time or a particular worker is not guaranteed.

Minimal end-to-end in curl

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 and simulator sessions: attach a matching clean prewarmed simulator (or cold-start a different device), optionally install + launch, stream JPEG frames from WebDriverAgent over WebSocket, and forward taps/swipes/text. On end: revoke the URL, drain accepted operations, shut down and erase the device, and replenish clean prewarmed inventory. Preparation happens before requests whenever capacity is available; it does not create extra job slots.
  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:

Error codes

CodeMeaningWhat to do
INVALID_REQUESTBad field, unknown/pending source_id, project not in snapshot, no simulator of that nameFix the request; message says which field
AMBIGUOUS_PROJECT / AMBIGUOUS_SCHEMEMore than one candidatePass --project/--workspace or --scheme from the listed options
UNSUPPORTED_XCODENo 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_FAILUREds xcode check found syntax/structure problemsFix 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 leftBuy credits on the dashboard Billing page; running jobs are never interrupted
DEPENDENCY_FAILUREnpm/yarn/pnpm, expo prebuild or pod install failedRead the tail in error.message; fix lockfile/Podfile
BUILD_FAILURExcodebuild non-zero with compiler/linker/signing errorsRead diagnostics[] (file, line, column, message)
TEST_FAILURETests ran, some failedresult.tests.cases[] with failure_message, file, line
ARCHIVE_FAILUREArchive failed (usually signing)Supply signing settings or archive unsigned with CODE_SIGNING_ALLOWED=NO
TIMEOUTExceeded timeout_secondsRaise the cap or split the work
CANCELLEDCancelled by the caller (also how a stopped run session ends)—
WORKER_FAILUREInfrastructure (worker lost). Retried up to 3 attempts automaticallyNothing; if it persists, contact us

Fleet today

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. A simulator may stay booted briefly for the same org's next session, but it is always erased (app, data, keychain) before serving a different org. 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.