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:
- Builds + cloud simulator —
build,test,archive,runvia thedsCLI or HTTP. Available now. - 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 (email + password or Google). The first organization you create gets $100 of credits — about 33 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 thetenant_idyou 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 for a measured run.
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
--timeout 7200is a hard two-hour limit, including periods when the viewer is connected.--idle 7200allows up to two hours without viewer activity; whichever limit is reached first ends the session.- The session occupies one Mac slot and is billed from
readyuntil it terminates. Queue time is not billed. - Closing the viewer tab does not release the slot immediately. The same URL remains valid until the session is stopped or a limit is reached.
- Run
xcode simulator stop <session-id>when testing is finished to release the simulator promptly. - When the session terminates, its viewer URL expires. A new session receives a new URL.
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.
- For immediate repeated opens, keep your existing session and use
simulator launch; no new allocation, build, simulator boot, or automation startup is needed. Usereloadfor a new build. - Ending a session revokes its link. The worker finishes accepted operations, shuts down and erases the device (including apps/data/keychain), then prepares clean inventory again. Do not depend on app data surviving between different sessions, even within the same organization.
- If all compatible slots are active, building, or replenishing, a new request queues. The next request immediately after releasing all three slots can therefore include refill time.
- The stocked model is
iPhone 16 Pro; other models can require a cold start.--xcodechooses a toolchain, not an iOS version. Prewarming is a capacity optimization, not a guaranteed startup SLA or worker assignment. Network/control-plane time and app install/launch time still apply. - Keeping a reserved session for up to two hours avoids allocation/refill between app opens, but holds and bills that slot for the reservation. A source build needs another free Mac while your simulator remains live; if all three are reserved, builds queue until capacity is released.
Give your agent a Mac (MCP server, skill, rules)
- MCP server —
@doublespeed/mcpexposesbuild_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-skillsthenclaude plugin install ds-xcode@doublespeed. - Cursor rule / AGENTS.md: copy from doublespeed-main/agent-skills.
GET /v1/xcode/capabilitiesreturns Xcode versions, simulator runtimes and device names, so an agent can pick--xcode/--destinationwithout guessing.
For agents: how to use this well
- Build once and keep a simulator for visual iteration. Use
build → simulator create → install, thenbuild → simulator reloadafter changes. Userunonly for a one-off smoke test because everyrunis a new build/job/session/link. - Always pass
--jsonwhen a program reads the output. You get one JSON line per event and a final job object; parsediagnostics[]andresult.testsinstead 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.codebefore the message.BUILD_FAILURE/TEST_FAILUREmean your code;DEPENDENCY_FAILUREmeans the project's dependencies;UNSUPPORTED_XCODEmeans 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). Arunsession ends after 15 idle minutes by default (--idle). - Build workspaces are deleted after each job. A live persistent simulator remains allocated until stopped, idle, timed out, cancelled, or failed. Keep that same session for repeated opens and reloads. Ended sessions are erased and replaced by clean prewarmed inventory; app data does not persist between sessions. 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]
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]
| 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 create | 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 / simulator create | End the session after S seconds without 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:
{"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:
- Create an API key at /dashboard → API keys (sign up first; new accounts get $100 of credits).
- In your GitHub repo: Settings → Secrets and variables → Actions → New repository secret, name
DS_API_KEY, value the key. - 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
- A healthy Mac whose Xcode matches
xcode_versionclaims 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). - Fresh
0700workspace; blobs downloaded and hash-verified; paths validated. - Dependencies by project kind:
- native (
.xcodeproj/.xcworkspace/Package.swift): nothing; SwiftPM resolves inside the job. - cocoapods (
Podfilebeside the project, or inios/):pod install. - react-native (
package.jsonwithreact-native+ios/):npm ci/yarn/pnpm/bunchosen from the lockfile, thenpod install. - expo (
package.jsonwithexpo, no generatedios/): JS install →npx expo prebuild --platform ios→pod install. Per-tenant caches fornode_modules(by lockfile hash), package-manager caches and CocoaPods. Failures →DEPENDENCY_FAILUREwith the tool's last lines.
- native (
- Project/scheme inference: one
.xcworkspacewins over projects; else one.xcodeproj; elsePackage.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_SCHEMElisting the choices. xcodebuildwith-derivedDataPathand-clonedSourcePackagesDirPathinside the workspace; logs streamed line by line; diagnostics parsed (file:line:col: error:, project-levelpath: error:incl. signing errors, XCTest case results and assertion failures,Executed N testssummaries).- Collect artifacts (
.xcresult,.xcarchivewith metadata, optional.app), zip, upload, record sha256. - For
runand 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. - 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 foundxcrun: command not found/unable to find utility "simctl"SDK "iphonesimulator" cannot be locatedUnable to find a device matching the provided destination specifierexpo run:ios— "iOS apps can only be built on macOS"react-native run-ioson Linux- Build an iOS app on Linux (without a Mac)
- Run an iOS Simulator from Docker / Codespaces / a sandbox
- Let Claude Code, Cursor or Codex build and run an 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 26or--xcode 16(or an exact26.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. 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.