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 $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 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
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/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
- Iterate with
build, verify withtest, look withrun.buildis the fast loop (3–25 s warm). Onlyrunboots a simulator. - 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). - 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:
{"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 $200 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.
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
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. - 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; 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.