mac.doublespeed.ai

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

Builds + cloud simulatords 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 →

NaN× faster, NaN× 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
$NaN 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_...          # $200 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 simulatorbuild, 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
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)

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]
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'runSimulator 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 SrunEnd the session after S seconds with no viewer 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 /dashboardAPI 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:
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

  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 iospod 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:

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; 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.