Sign inRequest access

From a build to a link in six steps.

You need a Simbase key and something to run: a simulator .app or an .apk. Your agent can do every step below itself; they’re written out so you know what it’s doing.

1. Your key

Sign up with your invite (no invite yet? join the waitlist), then create a key from your account. It starts sb_, is shown once, and belongs to your organisation. Keep it in an environment variable, not in the repo.

$ export SIMBASE_KEY=sb_…$ curl -s https://api.simbase.dev/v1/me -H "Authorization: Bearer $SIMBASE_KEY"

2. Connect your agent

The MCP server is streamable HTTP at https://api.simbase.dev/mcp, with the same bearer key.

Claude Code

$ claude mcp add --transport http simbase https://api.simbase.dev/mcp --header "Authorization: Bearer sb_…"

Cursor

Add to ~/.cursor/mcp.json, or .cursor/mcp.json in the repo if everyone on the project should have it (then use an env var, not the literal key):

{
  "mcpServers": {
    "simbase": {
      "url": "https://api.simbase.dev/mcp",
      "headers": { "Authorization": "Bearer sb_…" }
    }
  }
}

Anything else

Any client that supports a remote MCP server with a custom header works: URL https://api.simbase.dev/mcp, header Authorization: Bearer sb_…. If your client supports MCP sign-in (OAuth), you can leave the header off and sign in through the browser instead. No MCP at all? Everything below is plain HTTP.

3. Upload a build

Simbase runs what you built; it never sees your source. For iOS that’s a simulator build — an .ipa is signed for hardware and won’t install. Zip the .app with ditto, which keeps the executable bit that plain zip in some CI setups drops.

iOS

$ xcodebuild -scheme App -sdk iphonesimulator -configuration Debug \  -derivedDataPath build build$ ditto -c -k --keepParent build/Build/Products/Debug-iphonesimulator/App.app App.zip $ curl -s https://api.simbase.dev/v1/artifacts \  -H "Authorization: Bearer $SIMBASE_KEY" \  -H "content-type: application/octet-stream" \  -H "x-filename: App.zip" \  --data-binary @App.zip

Android

$ ./gradlew assembleDebug $ curl -s https://api.simbase.dev/v1/artifacts \  -H "Authorization: Bearer $SIMBASE_KEY" \  -H "content-type: application/octet-stream" \  -H "x-filename: app-debug.apk" \  --data-binary @app/build/outputs/apk/debug/app-debug.apk

Either way you get an artifact back. Its id is what a run refers to:

{
  "artifactId": "art_8Kq2…",
  "sha256": "4f1c…e07a",
  "bytes": 38211042,
  "filename": "App.zip",
  "platform": "ios"
}

Already have the build at an HTTPS URL, say a CI artifact? Skip the upload and pass app.url instead. The fetch is HTTPS-only and refuses private, loopback and link-local addresses, including on redirects.

4. Run it

Through MCP, ask your agent in plain words. It calls run_device_test, which takes the same fields as the REST request below.

“Build the app for the simulator and upload it to Simbase. Then on an iPhone 17 Pro: open Settings, turn on Beta features, and screenshot it as beta-on. Put the evidence link in the PR description.”

Over HTTP:

$ curl -s https://api.simbase.dev/v1/runs \  -H "Authorization: Bearer $SIMBASE_KEY" \  -H "content-type: application/json" \  -d '{    "app": { "artifactId": "art_8Kq2…" },    "device": { "platform": "ios", "name": "iPhone 17 Pro" },    "label": "settings: beta toggle",    "screenshots": ["beta-on"],    "steps": [      { "action": "tap", "id": "tab_settings" },      { "action": "wait_for", "label": "Settings" },      { "action": "tap", "id": "beta_toggle" },      { "action": "screenshot", "name": "beta-on" }    ]  }'

The response is { run } with status: "pending" and a queuePosition. Explicit steps are the only way to describe a hosted run — a plain-English scenario is refused with invalid_request. Your agent writes the steps; it knows the app, it just changed it.

5. Wait for it

Long-poll until the run is finished. Each call holds for up to timeoutMs (at most 60 seconds) and returns the run either way; call again if it isn’t terminal yet.

$ curl -s "https://api.simbase.dev/v1/runs/$RUN_ID/wait?timeoutMs=60000" \  -H "Authorization: Bearer $SIMBASE_KEY"

Statuses move pending → preparing → running, then end in one of five:

StatusMeaning
passedEvery step ran and the screenshots were taken.
failedThe run completed, and something your steps expected didn’t hold. A verdict on your app.
errorThe build, install or harness broke. Not a verdict on your app, and not billed.
timeoutThe run exceeded timeoutMs.
cancelledYou cancelled it with POST /v1/runs/:id/cancel.

6. Read the evidence

A finished run carries an evidenceUrl: a page with the verdict, the named screenshots, the executed steps, the recording and the timings. It’s private to your organisation by default; who can open it is up to you. The same data is in the JSON:

{
  "id": "run_5TzQ…",
  "status": "passed",
  "device": { "name": "iPhone 17 Pro", "runtime": "26.3", "platform": "ios" },
  "verdict": { "pass": true, "summary": "The Beta features toggle reads On." },
  "screenshots": [
    { "name": "beta-on", "url": "https://api.simbase.dev/e/q7Hc2vX9mTzR/shot-beta-on.png" }
  ],
  "video": { "gifUrl": "…/preview.gif", "mp4Url": "…/video.mp4" },
  "evidenceUrl": "https://api.simbase.dev/e/q7Hc2vX9mTzR",
  "executedSteps": [ … ],
  "timings": { "queueMs": 400, "installMs": 3100, "stepsMs": 7200, "occupancyMs": 15300 }
}

The PR comment links to it. Screenshots don’t render inline there unless the run is public, because GitHub fetches images without signing in. Evidence lasts 7 days on Free and up to 180 on paid plans.

executedSteps is exactly what ran. Save it in the repo and submit it again after the next change — same build, same steps, same screens.

Run request

The body of POST /v1/runs and the arguments of run_device_test. Only app is required.

FieldTypeNotes
app.artifactIdstringFrom POST /v1/artifacts. Give exactly one of artifactId, url, or bundleId.
app.urlhttps URLA build Simbase fetches for you.
app.bundleIdstringAn app already on the device.
app.launchArgsstring[]iOS launch arguments.
app.launchEnvobjectEnvironment on iOS, string intent extras on Android. Point the build at any backend without rebuilding.
stepsStep[]What to do. See step actions.
screenshotsstring[]Names you expect back.
assertstringWhat the run is meant to show, in a sentence. Kept with the run.
device.platform"ios" | "android"Inferred from the build when omitted.
device.namestringe.g. "iPhone 17 Pro".
device.runtimestringe.g. "26.3".
timeoutMsintegerUp to 1,800,000 (30 minutes).
resetPolicy"relaunch" | "uninstall" | "erase"How clean the device is before your run. Default uninstall.
recordbooleanRecord video and a GIF. Default true.
labelstringUp to 200 characters. Shown on the evidence page.
metadataobjectAnything you want back, e.g. a git sha or PR number.

Step actions

Each step is an object with an action. Elements are found by accessibility id, label or value, or by coordinates.

ActionFields
tapid | label | value | x,y · waitTimeoutMs
double_tapid | label | x,y
typetext
clear_text · press_enter
swipe · panstartX, startY, endX, endY · durationMs
gesturepreset: scroll-up/down/left/right, swipe-from-left/right/top/bottom-edge
pinchcx, cy, scale · durationMs
two_finger_presscx, cy · holdMs
buttonbutton: home, lock, side-button, … (Android keys too)
waitms
wait_forid | label · timeoutMs
screenshotname
open_urlurl — deep links and universal links
appearancemode: light | dark
permissionservice, grant
launch · relaunch · terminatelaunch takes args, env
describe_uiRecords the accessibility tree into the evidence.

REST reference

Base URL https://api.simbase.dev. Every /v1 route and /mcp takes Authorization: Bearer sb_…. Runs and artifacts belong to your organisation; anything else is a 404, never a 403.

MethodPathWhat it does
GET/healthzLiveness. No auth.
GET/v1/meThe organisation and key you’re authenticated as.
POST/v1/artifactsUpload a build as the raw body; name it with x-filename. Returns an artifact.
POST/v1/runsSubmit a run. Returns { run }.
GET/v1/runsYour organisation’s recent runs. Returns { runs }.
GET/v1/runs/:idOne run. Returns { run }.
GET/v1/runs/:id/waitLong-poll until terminal, ?timeoutMs up to 60000. Returns { run }.
POST/v1/runs/:id/cancelCancel a queued or running run. Returns { run }.
GET/v1/runs/:id/files/:nameAn evidence file, authenticated.
POST/mcpMCP over streamable HTTP. Same key.
POST/oidc/github/tokenExchange a GitHub Actions OIDC token and your org id for an sbci_ token (1 hour).
GET/e/:slugThe evidence page. Private to your organisation unless it has opted in to public links.
GET/e/:slug/:nameAn evidence file, e.g. shot-beta-on.png. Same access as the page.

Evidence files a run can have: shot-<name>.png, video.mp4, preview.gif, report.md, app.log, build.log, ui.json.

GitHub Actions

In CI you don’t need a stored key. A GitHub Actions job asks GitHub for an OIDC token, which says which repo, ref and workflow it came from, and exchanges it for a Simbase CI token that lasts an hour. There’s no secret to store, leak or rotate.

An owner of your organisation trusts each repo in /app/settings/access, which also shows your organisation id. The workflow needs permissions: id-token: write so GitHub will mint the token.

The short version: the composite action

packages/access/action does the exchange, uploads the build, runs the steps, waits, and comments the verdict and the evidence link on the pull request.

permissions:
  id-token: write
  contents: read
  pull-requests: write

jobs:
  device-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/build-simulator-app.sh build/MyApp.zip
      - uses: nickbabenko/simbase/packages/access/action@main
        with:
          simbase-url: https://api.simbase.dev
          org: ${{ vars.SIMBASE_ORG }}
          build: build/MyApp.zip
          steps-file: .simbase/smoke.json

The exchange, by hand

Ask GitHub for an ID token with the audience set to exactly https://api.simbase.dev, then post it with your organisation id:

POST https://api.simbase.dev/oidc/github/token
content-type: application/json

{
  "token": "<Actions ID token, requested with audience = https://api.simbase.dev>",
  "org": "<your org id>"
}

You get a bearer token scoped to runs, for one hour:

{
  "access_token": "sbci_…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "runs:read runs:write",
  "org_id": "org_…",
  "repo": "your-org/your-repo"
}

As a workflow step:

- name: Get a Simbase token
  env:
    SIMBASE_ORG: ${{ vars.SIMBASE_ORG }}
  run: |
    ID_TOKEN=$(curl -sfS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
      "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://api.simbase.dev" | jq -r .value)
    TOKEN=$(curl -sfS -X POST https://api.simbase.dev/oidc/github/token \
      -H "content-type: application/json" \
      -d "$(jq -n --arg token "$ID_TOKEN" --arg org "$SIMBASE_ORG" '{token: $token, org: $org}')" \
      | jq -r .access_token)
    echo "::add-mask::$TOKEN"
    echo "SIMBASE_KEY=$TOKEN" >> "$GITHUB_ENV"

Later steps use $SIMBASE_KEY exactly like a key. It expires on its own, and removing the repo’s trust revokes it.

Errors

Every non-2xx response has the same shape. code is for your program; message is for you. Limit errors say which limit, how much you’ve used, and when to retry.

{
  "error": {
    "code": "quota_exceeded",
    "message": "60 device-minutes used in the last 24 hours.",
    "limit": 60,
    "current": 60,
    "retryAfterSeconds": 3480
  }
}
CodeWhen
unauthorizedMissing, malformed or revoked key.
not_foundNo such run or artifact — or it belongs to another organisation.
invalid_requestThe body didn’t validate. Also: a scenario run on the hosted service.
rate_limitedToo many requests, or too many runs in flight at once.
quota_exceededOut of device-minutes for the window.
no_capacityNo device can take this run (say, a runtime the fleet doesn’t hold).
conflictThe run is already in a state that can’t change, e.g. cancelling a finished run.
payload_too_largeUpload over the size cap.
internalOur fault. Retry, and tell us if it persists.

Limits

Per organisation, by plan. The same numbers the API enforces.

LimitFreeIndieStudioScale
Runs in flight (queued count)12410
Device minutes / month2002,0008,00030,000
Device minutes / rolling 24 h604001,5005,000
Evidence lifetime7 days30 days90 days180 days
Every planValue
Upload size500 MB
Run timeoutUp to 30 minutes
Long-poll waitUp to 60 seconds per call

Hit one sooner than you expected? Tell us — the numbers are set for a small fleet, not carved in stone.