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.zipAndroid
$ ./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.apkEither 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:
| Status | Meaning |
|---|---|
| passed | Every step ran and the screenshots were taken. |
| failed | The run completed, and something your steps expected didn’t hold. A verdict on your app. |
| error | The build, install or harness broke. Not a verdict on your app, and not billed. |
| timeout | The run exceeded timeoutMs. |
| cancelled | You 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.
| Field | Type | Notes |
|---|---|---|
| app.artifactId | string | From POST /v1/artifacts. Give exactly one of artifactId, url, or bundleId. |
| app.url | https URL | A build Simbase fetches for you. |
| app.bundleId | string | An app already on the device. |
| app.launchArgs | string[] | iOS launch arguments. |
| app.launchEnv | object | Environment on iOS, string intent extras on Android. Point the build at any backend without rebuilding. |
| steps | Step[] | What to do. See step actions. |
| screenshots | string[] | Names you expect back. |
| assert | string | What the run is meant to show, in a sentence. Kept with the run. |
| device.platform | "ios" | "android" | Inferred from the build when omitted. |
| device.name | string | e.g. "iPhone 17 Pro". |
| device.runtime | string | e.g. "26.3". |
| timeoutMs | integer | Up to 1,800,000 (30 minutes). |
| resetPolicy | "relaunch" | "uninstall" | "erase" | How clean the device is before your run. Default uninstall. |
| record | boolean | Record video and a GIF. Default true. |
| label | string | Up to 200 characters. Shown on the evidence page. |
| metadata | object | Anything 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.
| Action | Fields |
|---|---|
| tap | id | label | value | x,y · waitTimeoutMs |
| double_tap | id | label | x,y |
| type | text |
| clear_text · press_enter | — |
| swipe · pan | startX, startY, endX, endY · durationMs |
| gesture | preset: scroll-up/down/left/right, swipe-from-left/right/top/bottom-edge |
| pinch | cx, cy, scale · durationMs |
| two_finger_press | cx, cy · holdMs |
| button | button: home, lock, side-button, … (Android keys too) |
| wait | ms |
| wait_for | id | label · timeoutMs |
| screenshot | name |
| open_url | url — deep links and universal links |
| appearance | mode: light | dark |
| permission | service, grant |
| launch · relaunch · terminate | launch takes args, env |
| describe_ui | Records 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.
| Method | Path | What it does |
|---|---|---|
| /healthz | Liveness. No auth. | |
| /v1/me | The organisation and key you’re authenticated as. | |
| /v1/artifacts | Upload a build as the raw body; name it with x-filename. Returns an artifact. | |
| /v1/runs | Submit a run. Returns { run }. | |
| /v1/runs | Your organisation’s recent runs. Returns { runs }. | |
| /v1/runs/:id | One run. Returns { run }. | |
| /v1/runs/:id/wait | Long-poll until terminal, ?timeoutMs up to 60000. Returns { run }. | |
| /v1/runs/:id/cancel | Cancel a queued or running run. Returns { run }. | |
| /v1/runs/:id/files/:name | An evidence file, authenticated. | |
| /mcp | MCP over streamable HTTP. Same key. | |
| /oidc/github/token | Exchange a GitHub Actions OIDC token and your org id for an sbci_ token (1 hour). | |
| /e/:slug | The evidence page. Private to your organisation unless it has opted in to public links. | |
| /e/:slug/:name | An 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.jsonThe 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
}
}| Code | When |
|---|---|
| unauthorized | Missing, malformed or revoked key. |
| not_found | No such run or artifact — or it belongs to another organisation. |
| invalid_request | The body didn’t validate. Also: a scenario run on the hosted service. |
| rate_limited | Too many requests, or too many runs in flight at once. |
| quota_exceeded | Out of device-minutes for the window. |
| no_capacity | No device can take this run (say, a runtime the fleet doesn’t hold). |
| conflict | The run is already in a state that can’t change, e.g. cancelling a finished run. |
| payload_too_large | Upload over the size cap. |
| internal | Our fault. Retry, and tell us if it persists. |
Limits
Per organisation, by plan. The same numbers the API enforces.
| Limit | Free | Indie | Studio | Scale |
|---|---|---|---|---|
| Runs in flight (queued count) | 1 | 2 | 4 | 10 |
| Device minutes / month | 200 | 2,000 | 8,000 | 30,000 |
| Device minutes / rolling 24 h | 60 | 400 | 1,500 | 5,000 |
| Evidence lifetime | 7 days | 30 days | 90 days | 180 days |
| Every plan | Value |
|---|---|
| Upload size | 500 MB |
| Run timeout | Up to 30 minutes |
| Long-poll wait | Up 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.