From 156404176d5e756b92d510f5197c76ab139044a7 Mon Sep 17 00:00:00 2001 From: finalerock44 Date: Wed, 11 Nov 2026 12:19:00 +0000 Subject: [PATCH 1/2] docs: OpenAPI 3.1 spec for the REST API public/openapi.yaml describes every endpoint in the REST API section (uploads, results, flows, device sessions, IP addresses), served at https://docs.devicecloud.dev/openapi.yaml and linked from the overview. It is written from dcd/api, and responses captured from the dev API for all 30 operations validate against it. CI lints it with Redocly CLI 2.71.2 after the link check. redocly.yaml turns off three rules that don't fit: no license, no 4xx on GET /ip-addresses, and oneOf-of-required branches. CLAUDE.md asks for the spec to change with any page under api/. The overview's endpoint table also gains the Device Sessions row it was missing. --- .github/workflows/deploy.yml | 2 + CLAUDE.md | 4 + api/overview.md | 5 + public/openapi.yaml | 4817 ++++++++++++++++++++++++++++++++++ redocly.yaml | 12 + 5 files changed, 4840 insertions(+) create mode 100644 public/openapi.yaml create mode 100644 redocly.yaml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ef5ec7f..1b38abf 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -38,6 +38,8 @@ jobs: - run: npm ci - run: npm run build - run: npm run check + - name: Lint the OpenAPI spec + run: npx --yes @redocly/cli@2.71.2 lint public/openapi.yaml - name: Deploy to Cloudflare if: github.ref == 'refs/heads/main' diff --git a/CLAUDE.md b/CLAUDE.md index accbc04..9b58a8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,10 @@ CONTRIBUTING.md for how to run, write and add pages. - **`security/ip-addresses.md` is generated** from the IP-addresses API by `.github/scripts/render-ip-addresses.mjs`. Never edit it by hand. Change the script instead. +- **`public/openapi.yaml` describes the REST API section** and is served at + https://docs.devicecloud.dev/openapi.yaml. A change to a page under `api/` + changes the spec in the same PR. CI lints it with Redocly + (`npx @redocly/cli@2.71.2 lint public/openapi.yaml`). - **Callouts and tabs use VitePress syntax** (`::: info`, `::: warning`, `::::tabs` with `== Tab name`). GitBook syntax (`{% hint %}`, `{% tabs %}`) no longer renders. diff --git a/api/overview.md b/api/overview.md index 39710d3..c14f75a 100644 --- a/api/overview.md +++ b/api/overview.md @@ -48,4 +48,9 @@ Check the `statusCode` field in the body as well as the HTTP status. A malformed | [Uploads](uploads.md) | List uploads and poll run status | | [Results](results.md) | Fetch results, reports, and artifacts for an upload | | [Flows](flows.md) | Aggregated analytics and run history per flow file | +| [Device Sessions](device-sessions.md) | Start a device and drive it, for AI agents | | [IP Addresses](ip-addresses.md) | Current egress IPs for firewall allow-listing (no API key required) | + +## OpenAPI specification + +Every endpoint above is described in an [OpenAPI 3.1 specification](https://docs.devicecloud.dev/openapi.yaml). Import it into Postman, Insomnia or Bruno, generate a client from it, or give it to an AI agent. diff --git a/public/openapi.yaml b/public/openapi.yaml new file mode 100644 index 0000000..265ccca --- /dev/null +++ b/public/openapi.yaml @@ -0,0 +1,4817 @@ +# DeviceCloud REST API: OpenAPI 3.1 description of the endpoints in the +# docs' REST API section, served at https://docs.devicecloud.dev/openapi.yaml. +# Written by hand from dcd/api (the code wins over the pages) and checked +# against real responses. Change it with any page under api/. Lint: +# npx @redocly/cli@2.71.2 lint public/openapi.yaml +openapi: 3.1.0 +info: + title: DeviceCloud REST API + version: 1.0.0 + summary: Test results, run status, flow analytics and device sessions for DeviceCloud. + description: | + The DeviceCloud REST API gives you programmatic access to the same data as the + console: test runs and their results, reports and artifacts, flow analytics, + and device sessions for AI agents. + + To start test runs, use the [`dcd` CLI](https://docs.devicecloud.dev/cli-reference/cloud) + or one of the CI integrations. This API reads their results. + + ## Authentication + + Send an API key in the `x-app-api-key` header. Create keys in the console under + **Settings → API Keys**. A missing or invalid key answers `403`. + + A DeviceCloud login session also works, which is how the CLI authenticates after + `dcd login`: send `Authorization: Bearer ` together with + `x-dcd-org: `. For scripts and CI, use an API key. + + A key limited to **Device sessions only** reaches the `/device/*` endpoints and + nothing else in this API. Other endpoints answer it with `403` and + `"error": "API_KEY_SCOPE"`. + + ## Rate limits + + The API accepts up to 60 requests a minute. Over the limit it answers `429` + with a `Retry-After` header. These endpoints aren't rate limited, so they're + safe to poll: `GET /results/{uploadId}`, `GET /uploads/status` and + `GET /ip-addresses`. The device session endpoints have their own limits, + described on each one. + + ## Errors + + Errors are JSON. Most have the shape of `ErrorEnvelope`: the HTTP status in + `statusCode`, a human-readable `message`, and sometimes a machine-readable + `error` code. Match on `error` codes, never on `message` text. + + `GET /results/{uploadId}`, `GET /flows` and `GET /flows/runs` are the exception. + They answer HTTP `200` and report a client error in the body as + `{ "statusCode": 400, "message": "..." }`, so check `statusCode` in the body + as well as the HTTP status. + + ## Compatibility + + Fields may be added to any response at any time. Ignore fields you don't know. + Existing fields aren't renamed, removed or re-typed. + termsOfService: https://docs.devicecloud.dev/legal/terms-and-conditions + contact: + name: DeviceCloud support + email: support@devicecloud.dev + url: https://docs.devicecloud.dev +externalDocs: + description: REST API guide + url: https://docs.devicecloud.dev/rest-api/overview +servers: + - url: https://api.devicecloud.dev +security: + - apiKey: [] + - bearerAuth: [] + orgHeader: [] +tags: + - name: Uploads + description: | + An upload is one test run: the flows and app binary sent by `dcd cloud`, and + the tests they produced. List uploads and poll a run's status. + externalDocs: + url: https://docs.devicecloud.dev/rest-api/uploads + - name: Results + description: | + One upload's test results, its JUnit and HTML reports, and its artifacts + (logs, screenshots and videos). + externalDocs: + url: https://docs.devicecloud.dev/rest-api/results + - name: Flows + description: | + Analytics across every run of each flow file: pass rates, run counts, + durations and run history. Pass rates are `passed / (passed + failed)`, + leaving out cancelled and unfinished runs. + externalDocs: + url: https://docs.devicecloud.dev/rest-api/flows + - name: Device sessions + description: >- + Start, list, read and stop device sessions (cloud iOS simulators and Android emulators), and list the + devices you can start. A session is named by its id, such as `brave-teal-otter`, in every path. + - name: Device control + description: >- + Drive a session's device: install and launch apps, send one action, run Maestro flows, and read the + screen and the app's device logs. + - name: Flow bundles + description: >- + Upload a zip of Maestro flows, then run it with `run` as `flowUploadId`. A bundle can be up to 50 MB. + - name: Human handoff + description: >- + Ask a person to do something on the device that an agent can't, such as entering a 2FA code, and find + out when they hand the session back. + - name: Session artifacts + description: >- + What a session leaves behind: the timeline of its commands, its video, its device log, and the screen + after each action. + - name: IP addresses + description: | + The test runners' egress IP addresses, for firewall allow-lists. No API key + needed. + externalDocs: + url: https://docs.devicecloud.dev/rest-api/ip-addresses +paths: + /uploads/list: + get: + operationId: listUploads + tags: [Uploads] + summary: List uploads + description: | + Your organization's uploads, newest first. + parameters: + - name: name + in: query + description: | + Only uploads with this name. `*` is a wildcard, and a name with a + wildcard matches without regard to case. Without one, the match is exact. + schema: + type: string + example: Nightly* + - name: from + in: query + description: | + Only uploads created at or after this time. ISO 8601: a date + (`2026-01-01`) or a date and time (`2026-01-01T00:00:00Z`). + schema: + type: string + example: '2026-01-01' + - name: to + in: query + description: | + Only uploads created at or before this time. A date without a time + means midnight at the start of that day (UTC), so `to=2026-01-31` + leaves out uploads made on 31 January. Use `2026-02-01` or + `2026-01-31T23:59:59Z` to include them. + schema: + type: string + example: '2026-02-01' + - name: limit + in: query + description: How many uploads to return. At most 1,000 come back per request. + schema: + type: integer + minimum: 1 + default: 20 + - name: offset + in: query + description: How many uploads to skip, for paging. + schema: + type: integer + minimum: 0 + default: 0 + responses: + '200': + description: A page of uploads. + content: + application/json: + schema: + $ref: '#/components/schemas/UploadList' + '400': + description: '`from` or `to` isn''t ISO 8601, `from` is after `to`, or `limit` or `offset` isn''t a valid number.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + example: + statusCode: 400 + message: '"from" date must be before or equal to "to" date' + error: '"from" date must be before or equal to "to" date' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/RateLimited' + '503': + $ref: '#/components/responses/KeyCheckUnavailable' + /uploads/status: + get: + operationId: getUploadStatus + tags: [Uploads] + summary: Get upload status + description: | + The overall status of a run and the latest attempt of each of its tests. + Made for polling: this endpoint isn't rate limited. + + The overall `status` is worked out from the tests: + + - `FAILED` as soon as any test has failed or been cancelled, even while + others still run. Check `tests` if you need every test to finish. + - Otherwise `RUNNING`, `PENDING` or `QUEUED` while tests are in progress. + - `PASSED` when every test has passed. + + Pass `uploadId` or `name`. With both, `uploadId` is tried first. + parameters: + - name: uploadId + in: query + description: The upload's ID. + schema: + type: string + format: uuid + - name: name + in: query + description: The upload's name. When several uploads share it, the most recent one. + schema: + type: string + responses: + '200': + description: The run's status. + content: + application/json: + schema: + $ref: '#/components/schemas/UploadStatus' + '400': + description: Neither `uploadId` nor `name` was given, or `uploadId` isn't a UUID. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + missing: + summary: Neither parameter + value: + statusCode: 400 + message: Either uploadId or name must be provided + error: Either uploadId or name must be provided + notUuid: + summary: Malformed upload ID + value: + statusCode: 400 + message: Validation failed (uuid is expected) + error: Bad Request + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: No upload in your organization matches. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + example: + statusCode: 404 + message: Upload not found + error: Upload not found + '503': + $ref: '#/components/responses/KeyCheckUnavailable' + /results/{uploadId}: + get: + operationId: getResults + tags: [Results] + summary: Get results + description: | + Every test result for an upload, including each retry as its own entry. + Made for polling: this endpoint isn't rate limited. + + The full view includes each test's run settings (`config`) and stored files + (`result_files`, with the step log inlined when it is 512 KB or smaller), so + large uploads can run to several megabytes. To follow a run's progress, use + `view=summary`, or `GET /uploads/status` if you only need the latest + attempt of each test. + + Responses carry an `ETag`. Send it back in `If-None-Match`, with + `Cache-Control: max-age=0`, and the API answers `304` with no body when + nothing has changed. (Fetch-based clients add `Cache-Control: no-cache` + to a request with `If-None-Match`, and the API then always sends the full + response.) + + An upload ID with no results in your organization answers an empty + `results` list, not `404`. + parameters: + - $ref: '#/components/parameters/UploadId' + - name: view + in: query + description: | + `summary` returns only the fields needed to follow a run's progress: + `id`, `test_file_name`, `status`, `fail_reason`, `duration_seconds`, + `retry_of`, `cancellation_reason`, `simulator_name`, and a `config` + holding only `deviceName` and `osVersion`. Any other value, or none, + returns the full view. + schema: + type: string + enum: [summary] + - $ref: '#/components/parameters/IfNoneMatch' + responses: + '200': + description: | + The results, or an error reported in the body (`statusCode: 400`). + headers: + ETag: + $ref: '#/components/headers/ETag' + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/ResultsResponse' + - $ref: '#/components/schemas/InBodyError' + examples: + summary: + summary: view=summary + value: + statusCode: 200 + results: + - id: 4500 + test_file_name: ./login-test/onboarding.yaml + status: FAILED + retry_of: null + duration_seconds: 32 + fail_reason: 'Element not found: Text matching regex next.* not found' + cancellation_reason: null + simulator_name: pixel-7-api-34 + config: + deviceName: Pixel 7 + osVersion: '34' + full: + summary: Full view (config and result_files shortened) + value: + statusCode: 200 + results: + - id: 4500 + test_file_name: ./login-test/onboarding.yaml + test_name: Onboarding + status: FAILED + retry_of: null + created_at: '2026-01-15T10:30:04.512+00:00' + duration_seconds: 32 + fail_reason: 'Element not found: Text matching regex next.* not found' + cancellation_reason: null + simulator_name: pixel-7-api-34 + platform: android + config: + deviceName: Pixel 7 + osVersion: '34' + maestroVersion: 2.10.0 + result_files: + id: 9120 + result_id: 4500 + main_log: '...' + '304': + $ref: '#/components/responses/NotModified' + '400': + $ref: '#/components/responses/InvalidUploadId' + '403': + $ref: '#/components/responses/Forbidden' + '503': + $ref: '#/components/responses/KeyCheckUnavailable' + /results/{uploadId}/report: + get: + operationId: getJunitReport + tags: [Results] + summary: Download JUnit report + description: | + One JUnit XML report covering the latest attempt of every test in the + upload. A test without a report of its own gets a generated test case, so + every test appears. + parameters: + - $ref: '#/components/parameters/UploadId' + responses: + '200': + description: The JUnit XML report. + headers: + Content-Disposition: + description: '`attachment; filename="report-.xml"`' + schema: + type: string + content: + application/xml: + schema: + type: string + example: | + + + + + + + '400': + $ref: '#/components/responses/InvalidUploadId' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NoResults' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/MessageOnlyServerError' + '503': + $ref: '#/components/responses/KeyCheckUnavailable' + /results/{uploadId}/html-report: + get: + operationId: getHtmlReport + tags: [Results] + summary: Download HTML report + description: | + A ZIP holding the HTML report (`report.html`) with the screenshots, videos + and logs of the latest attempt of every test in the upload. + parameters: + - $ref: '#/components/parameters/UploadId' + responses: + '200': + description: The report as a ZIP, streamed as it is built. + headers: + Content-Disposition: + description: '`attachment; filename="report-.zip"`' + schema: + type: string + content: + application/zip: + schema: + type: string + contentMediaType: application/zip + '400': + $ref: '#/components/responses/InvalidUploadId' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NoResults' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/MessageOnlyServerError' + '503': + $ref: '#/components/responses/KeyCheckUnavailable' + /results/{uploadId}/download: + post: + operationId: downloadArtifacts + tags: [Results] + summary: Download artifacts + description: | + A ZIP of the upload's logs, screenshots and videos. Each test's files sit in + a folder named after its result ID (`/logs/`, + `/screenshots/`, `/videos/`), and a JUnit `report.xml` + for the whole upload is at the root. Every attempt's files are included, + so a retried test's failed attempt can still be debugged. + + The ZIP is streamed as it is built. An error after streaming starts ends + the download early, leaving a truncated ZIP. + parameters: + - $ref: '#/components/parameters/UploadId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DownloadArtifactsRequest' + example: + results: FAILED + responses: + '201': + description: The artifacts as a ZIP. (The status is `201`, not `200`.) + headers: + Content-Disposition: + description: '`attachment; filename="artifacts.zip"`' + schema: + type: string + content: + application/zip: + schema: + type: string + contentMediaType: application/zip + '400': + description: | + `uploadId` isn't a UUID (JSON), or the ZIP couldn't be assembled (the + message as plain text). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + example: + statusCode: 400 + message: Validation failed (uuid is expected) + error: Bad Request + text/plain: + schema: + type: string + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NoResults' + '429': + $ref: '#/components/responses/RateLimited' + '503': + $ref: '#/components/responses/KeyCheckUnavailable' + /flows: + get: + operationId: getFlowSummaries + tags: [Flows] + summary: Get flow summaries + description: | + Pass rates, run counts, average durations and a daily status breakdown for + every flow in your organization: the data on the console's Flows page. + parameters: + - $ref: '#/components/parameters/PlatformFilter' + - $ref: '#/components/parameters/AppIdFilter' + - name: days + in: query + description: How many days to look back. Ignored when `startDate` is set. + schema: + type: integer + minimum: 1 + default: 14 + - name: startDate + in: query + description: | + Start of the range, ISO 8601. Overrides `days`. A date without a time + means the start of that day (UTC). + schema: + type: string + example: '2026-01-01' + - name: endDate + in: query + description: | + End of the range, ISO 8601. A date without a time means the end of that + day (UTC). Defaults to now when `startDate` is set. + schema: + type: string + example: '2026-01-31' + - name: tags + in: query + description: Comma-separated tags. Returns flows that have any of them. + schema: + type: string + example: smoke,critical + responses: + '200': + description: | + The flow summaries, or an error reported in the body (`statusCode: 400`), + for example for a date that can't be read. + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/FlowSummaries' + - $ref: '#/components/schemas/InBodyError' + examples: + flows: + summary: Flow summaries + value: + statusCode: 200 + flows: + - flow_name: Login flow + file_name: ./auth/login.yaml + pass_rate: 94 + passed_runs: 47 + failed_runs: 3 + total_runs: 50 + avg_duration: 38 + last_run_at: '2026-01-31T14:22:00+00:00' + tags: [smoke, critical] + daily_data: + '2026-01-31': passed + '2026-01-30': mixed + '2026-01-29': null + error: + summary: Error in the body + value: + statusCode: 400 + message: Invalid time value + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/ServerError' + '503': + $ref: '#/components/responses/KeyCheckUnavailable' + /flows/runs: + get: + operationId: getFlowRuns + tags: [Flows] + summary: Get runs for a flow + description: Every run of one flow file, newest first. + parameters: + - name: fileName + in: query + required: true + description: | + The flow's path exactly as `GET /flows` returns it in `file_name`: + relative to the flows folder that was run, starting with `./`. + schema: + type: string + example: ./auth/login.yaml + - $ref: '#/components/parameters/PlatformFilter' + - $ref: '#/components/parameters/AppIdFilter' + - name: limit + in: query + description: How many runs to return. + schema: + type: integer + minimum: 1 + default: 100 + - name: startDate + in: query + description: Start of the range, ISO 8601. A date without a time means the start of that day (UTC). + schema: + type: string + - name: endDate + in: query + description: End of the range, ISO 8601. A date without a time means the end of that day (UTC). + schema: + type: string + responses: + '200': + description: | + The runs, or an error reported in the body (`statusCode: 400`), for + example when `fileName` is missing. + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/FlowRuns' + - $ref: '#/components/schemas/InBodyError' + examples: + runs: + summary: Runs + value: + statusCode: 200 + runs: + - id: 4500 + status: PASSED + createdAt: '2026-01-31T14:22:00+00:00' + durationSeconds: 35 + failReason: null + testUploadId: 7e12345f-eb12-12ec-a30b-bb1234f1d12a + uploadName: Nightly regression + missing: + summary: Missing fileName + value: + statusCode: 400 + message: fileName query parameter is required. + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/ServerError' + '503': + $ref: '#/components/responses/KeyCheckUnavailable' + /device/models: + get: + tags: [Device sessions] + operationId: listDeviceModels + summary: List the devices you can start + description: >- + The iOS simulators and Android emulators a session can ask for, with their OS versions and defaults. + `model`, `os` and `googlePlay` on POST /device/sessions take these values. The list follows the fleet, + so a device or OS version the fleet drops disappears here too. + responses: + '200': + description: The devices, by platform. + content: + application/json: + schema: + $ref: '#/components/schemas/DeviceCatalogue' + example: + ios: + - model: iphone-16 + name: iPhone 16 + os: ['18', '26', '27'] + default_os: '26' + google_play: false + default: true + - model: iphone-17 + name: iPhone 17 + os: ['26', '27'] + default_os: '26' + google_play: false + default: false + - model: ipad-pro-m5-11 + name: iPad Pro 11-inch (M5) + os: ['26'] + default_os: '26' + google_play: false + default: false + android: + - model: pixel-10 + name: Pixel 10 + os: ['36', '37'] + default_os: '36' + google_play: false + default: true + - model: pixel-8 + name: Pixel 8 + os: ['34', '35', '36'] + default_os: '36' + google_play: false + default: false + - model: pixel-6 + name: Pixel 6 + os: ['32', '35'] + default_os: '35' + google_play: false + default: false + - model: pixel-10 + name: Pixel 10 + os: ['36'] + default_os: '36' + google_play: true + default: false + - model: pixel-8 + name: Pixel 8 + os: ['34'] + default_os: '34' + google_play: true + default: false + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/LiveRateLimited' + + /device/sessions: + post: + tags: [Device sessions] + operationId: startDeviceSession + summary: Start a device session + description: >- + Asks for a device. A new session is `queued` until a device is free, then moves through `provisioning` + to `ready` on its own: poll GET /device/sessions/{id} until it is `ready`. Billing starts when a device + is assigned. The session ends after 5 idle minutes, at its maximum duration, or when you stop it. + This endpoint has no per-minute rate limit, but your organization can only have so many sessions + open at once. + parameters: + - name: x-dcd-source + in: header + required: false + description: >- + Which interface sent the request: `cli`, `mcp`, `web` or `api`, in any case. Here it is recorded + only on the timeline entry for the build named in `binaryUploadId`. Any other value, or none, + counts as `api`. It is a label, not a credential. + schema: + type: string + enum: [cli, mcp, web, api] + default: api + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/StartSessionRequest' + example: + platform: android + model: pixel-10 + os: '36' + binaryUploadId: 3f1c9a52-7d4e-4b8a-9c21-6e0f5a2b7d13 + clientLabel: claude-code + maxDurationMin: 30 + responses: + '201': + description: The session was created. It is usually still `queued`. + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + example: + session: + id: brave-teal-otter + live_id: 4182 + state: queued + release_reason: null + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: null + released_at: null + expires_at: null + queue: + position: 1 + eta_s: 25 + waited_s: 0 + reason: waiting_for_device + time_remaining_s: null + idle_remaining_s: null + cost_so_far_usd: 0 + last_crash: null + install: + binary_upload_id: 3f1c9a52-7d4e-4b8a-9c21-6e0f5a2b7d13 + status: installing + error: null + warnings: + - 'Queued at position 1 for 0s, roughly 25s more to a device (an estimate). All devices are busy; this is not an error. The session starts on its own. Once it is ready it is billed per minute and stops after 5 idle minutes, even if no command has been sent, so keep checking its status until it is ready (over MCP, get_device_status with wait: true) instead of leaving it.' + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + '400': + description: >- + The request is invalid. These errors carry no `error` code, only a `message`: a missing or unknown + `platform`, an unknown `model`, an `os` the model doesn't offer, `googlePlay` on iOS, a bad + `deviceLocale`, `clientLabel` or + `maxDurationMin`, a build that isn't your organization's or is for the other platform, or + `model`/`os`/`googlePlay` mixed with `androidDevice`/`androidApiLevel`. + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + examples: + platform: + summary: A missing or unknown platform + value: + statusCode: 400 + message: platform must be ios or android. + unknownOs: + summary: An OS version the model doesn't offer + value: + statusCode: 400 + message: 'Pixel 10 does not offer API level 34. Options: 36, 37.' + badLocale: + summary: A malformed locale + value: + statusCode: 400 + message: Invalid deviceLocale format (expected e.g. "de_DE"). + wrongBuild: + summary: A build for the other platform + value: + statusCode: 400 + message: Binary platform (ios) does not match session platform (android) + '402': + description: >- + `LIVE_SPEND_CAP`: your organization reached its monthly spend cap on device sessions (the body adds + `limit` and `spent`, in USD). A payment problem (an overdue subscription, or not enough credits) + also answers 402, with no `error` code. + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + examples: + spendCap: + summary: Monthly spend cap reached + value: + statusCode: 402 + message: Your organization's live session spend cap of $50.00 for this month has been reached ($50.12 spent). Ask an admin to raise it, or wait for next month. + error: LIVE_SPEND_CAP + limit: 50 + spent: 50.12 + noCredits: + summary: Not enough credits + value: + statusCode: 402 + message: Not enough credits to start a live session. + '403': + description: >- + `LIVE_NOT_ENABLED`: device sessions aren't enabled for your organization. A missing or invalid API + key also answers 403 (see the shared `Forbidden` response). + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + example: + statusCode: 403 + message: Live testing is in beta and not enabled for your organization. Contact support to request access. + error: LIVE_NOT_ENABLED + '409': + description: >- + `LIVE_CONCURRENCY_LIMIT`: your organization has as many sessions open as it may. `limit` is that + number. Stop a session first. + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + example: + statusCode: 409 + message: Maximum of 2 concurrent live sessions reached. Stop an existing session before starting another. + error: LIVE_CONCURRENCY_LIMIT + limit: 2 + '500': + description: The session could not be created because of a problem on DeviceCloud's side. + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + example: + statusCode: 500 + message: Failed to create live session + '503': + description: >- + Your organization has a spend cap and the spend so far could not be read. No `error` code. Retry. + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + example: + statusCode: 503 + message: Could not check your live spend cap right now (a temporary problem on our side). Please retry. + + get: + tags: [Device sessions] + operationId: listDeviceSessions + summary: List your organization's sessions + description: >- + Your organization's open sessions (`queued` through `needs_human`), newest first, at most 100. + With `all=true`, sessions that ended in the last 24 hours are included too. + parameters: + - name: all + in: query + required: false + description: Also list sessions that ended in the last 24 hours. `1` works as well as `true`. + schema: + type: boolean + default: false + responses: + '200': + description: The sessions' blocks, newest first. + content: + application/json: + schema: + $ref: '#/components/schemas/SessionListResponse' + example: + sessions: + - id: brave-teal-otter + live_id: 4182 + state: active + release_reason: null + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: null + expires_at: '2026-11-10T15:00:21.000Z' + queue: null + time_remaining_s: 3021 + idle_remaining_s: 291 + cost_so_far_usd: 0.3 + last_crash: null + install: + binary_upload_id: 3f1c9a52-7d4e-4b8a-9c21-6e0f5a2b7d13 + status: installed + error: null + warnings: [] + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + - id: calm-blue-heron + live_id: 4177 + state: released + release_reason: idle_timeout + platform: ios + model: iphone-16-ios-26.4 + client_label: cursor + created_at: '2026-11-10T11:30:00.000Z' + started_at: '2026-11-10T11:30:40.000Z' + released_at: '2026-11-10T11:52:10.000Z' + expires_at: null + queue: null + time_remaining_s: null + idle_remaining_s: null + cost_so_far_usd: 0.66 + last_crash: null + install: null + warnings: [] + monitor_url: https://console.devicecloud.dev/device/calm-blue-heron + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/LiveRateLimited' + + /device/sessions/{id}: + parameters: + - name: id + in: path + required: true + description: The session id, such as `brave-teal-otter`. The numeric `live_id` works too. + schema: + type: string + example: brave-teal-otter + get: + tags: [Device sessions] + operationId: getDeviceSession + summary: Get one session + description: >- + One session's block: its state, time and idle time left, cost so far, install status and warnings. + Works on ended sessions too. If the device has stopped reporting for over a minute, the session is + ended (`heartbeat_lost`) when you read it. + responses: + '200': + description: The session's block. + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + example: + session: + id: brave-teal-otter + live_id: 4182 + state: active + release_reason: null + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: null + expires_at: '2026-11-10T15:00:21.000Z' + queue: null + time_remaining_s: 3021 + idle_remaining_s: 291 + cost_so_far_usd: 0.3 + last_crash: null + install: + binary_upload_id: 3f1c9a52-7d4e-4b8a-9c21-6e0f5a2b7d13 + status: installed + error: null + warnings: [] + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/LiveNotFound' + '429': + $ref: '#/components/responses/LiveRateLimited' + delete: + tags: [Device sessions] + operationId: stopDeviceSession + summary: Stop a session + description: >- + Stops the session and releases the device. Billing stops. Stopping an open session is never refused + for the rate limit. Stopping a session that has already ended changes nothing: it returns the session + as it is, and counts as a read. + parameters: + - name: x-dcd-source + in: header + required: false + description: >- + Which interface sent the request: `cli`, `mcp`, `web` or `api`, in any case. It is recorded on the + stop's timeline entry. Any other value, or none, counts as `api`. It is a label, not a credential. + schema: + type: string + enum: [cli, mcp, web, api] + default: api + responses: + '200': + description: The session after the stop. `state` is usually `released` with `release_reason` `user_stop`. + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + example: + session: + id: brave-teal-otter + live_id: 4182 + state: released + release_reason: user_stop + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: '2026-11-10T14:20:05.000Z' + expires_at: null + queue: null + time_remaining_s: null + idle_remaining_s: null + cost_so_far_usd: 0.6 + last_crash: null + install: + binary_upload_id: 3f1c9a52-7d4e-4b8a-9c21-6e0f5a2b7d13 + status: installed + error: null + warnings: [] + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/LiveNotFound' + '429': + $ref: '#/components/responses/LiveRateLimited' + + /device/sessions/{id}/install: + parameters: + - name: id + in: path + required: true + description: The session id, such as `brave-teal-otter`. The numeric `live_id` works too. + schema: + type: string + example: brave-teal-otter + post: + tags: [Device control] + operationId: installApp + summary: Install a build + description: >- + Installs an uploaded build (`binaryUploadId`), or a build downloaded from an https URL (`url`, up to + 500 MB). Android takes an `.apk`; iOS takes a zipped simulator `.app`, not an `.ipa`. The request + returns once the build is checked; the device installs it in the background. Watch `session.install` + until its `status` is `installed`. A command sent meanwhile waits up to 45 s for the install, then + answers `LIVE_NOT_READY`. + parameters: + - name: x-dcd-source + in: header + required: false + description: >- + Which interface sent the request: `cli`, `mcp`, `web` or `api`, in any case. It is recorded as the + command's `source` on the timeline. Any other value, or none, counts as `api`. It is a label, not a + credential. + schema: + type: string + enum: [cli, mcp, web, api] + default: api + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/InstallRequest' + examples: + upload: + summary: An uploaded build + value: + binaryUploadId: 3f1c9a52-7d4e-4b8a-9c21-6e0f5a2b7d13 + url: + summary: A build at a URL + value: + url: https://ci.example.com/artifacts/app-release.apk + responses: + '201': + description: The build is on its way to the device. + content: + application/json: + schema: + $ref: '#/components/schemas/InstallResponse' + example: + app: + binary_upload_id: 8a2d4c6e-1f3b-4d5a-8e7c-9b0a1c2d3e4f + app_id: com.example.app + reused: false + session: + id: brave-teal-otter + live_id: 4182 + state: active + release_reason: null + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: null + expires_at: '2026-11-10T15:00:21.000Z' + queue: null + time_remaining_s: 2981 + idle_remaining_s: 300 + cost_so_far_usd: 0.33 + last_crash: null + install: + binary_upload_id: 8a2d4c6e-1f3b-4d5a-8e7c-9b0a1c2d3e4f + status: installing + error: null + warnings: + - 'The app is still installing. The session says "ready" because the device is up, but commands wait for the install (up to 45s) and then answer LIVE_NOT_READY. Over MCP, get_device_status with wait: true waits for it.' + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + '400': + description: >- + `LIVE_INVALID_APP`: neither or both of `binaryUploadId` and `url` were given, or the build isn't + your organization's, is for the other platform, or isn't an app this device can install. + `LIVE_APP_DOWNLOAD_FAILED`: the build at `url` couldn't be downloaded (not https, not a public host, + an error status, too large, or too slow). + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + examples: + invalidApp: + summary: A build for the other platform + value: + statusCode: 400 + message: Binary platform (ios) does not match session platform (android) + error: LIVE_INVALID_APP + downloadFailed: + summary: The URL couldn't be downloaded + value: + statusCode: 400 + message: 'Downloading the app failed: the server answered 404.' + error: LIVE_APP_DOWNLOAD_FAILED + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/LiveNotFound' + '409': + description: >- + `LIVE_SESSION_ENDED`: the session has ended (the body includes its `session` block). + `LIVE_HUMAN_IN_CONTROL`: a person has taken control in the console; wait for the hand-back. + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + examples: + humanInControl: + summary: A person has control + value: + statusCode: 409 + message: 'A person has taken control of this device. Wait for them to hand it back: poll get_human_status (MCP), run `dcd device human --wait`, or poll GET /device/sessions/:id/human until handed_back is true.' + error: LIVE_HUMAN_IN_CONTROL + sessionEnded: + summary: The session has ended + value: + statusCode: 409 + message: Device session "brave-teal-otter" has ended (idle_timeout). Start a new one. + error: LIVE_SESSION_ENDED + session: + id: brave-teal-otter + live_id: 4182 + state: expired + release_reason: idle_timeout + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: '2026-11-10T14:31:02.000Z' + expires_at: null + queue: null + time_remaining_s: null + idle_remaining_s: null + cost_so_far_usd: 0.93 + last_crash: null + install: + binary_upload_id: 8a2d4c6e-1f3b-4d5a-8e7c-9b0a1c2d3e4f + status: installed + error: null + warnings: [] + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + '429': + description: >- + `LIVE_RATE_LIMITED`: over the per-session command limit (60 a minute; the body has `limit` and + `retry_after_s`), or too many installs from a URL are running on the API at once (no + `retry_after_s`; retry in a minute). No `Retry-After` header is sent. + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + examples: + commandLimit: + summary: Over the command limit + value: + statusCode: 429 + message: 'Too many commands for this session: at most 60 a minute. Retry in 23s.' + error: LIVE_RATE_LIMITED + limit: 60 + retry_after_s: 23 + urlInstalls: + summary: Too many installs from a URL at once + value: + statusCode: 429 + message: Too many installs from URL are running. Retry in a minute. + error: LIVE_RATE_LIMITED + '503': + description: The downloaded build could not be stored. No `error` code. Retry. + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + example: + statusCode: 503 + message: The build could not be stored (a problem on our side). Please retry. + + /device/sessions/{id}/launch: + parameters: + - name: id + in: path + required: true + description: The session id, such as `brave-teal-otter`. The numeric `live_id` works too. + schema: + type: string + example: brave-teal-otter + post: + tags: [Device control] + operationId: launchApp + summary: Launch an app + description: >- + Launches the installed build, or any app by `appId` (such as `com.apple.Preferences`). `clearState` + wipes the app's data first. The request waits for the command to finish, up to 120 s, after waiting up + to 45 s for a device that is still starting or installing. A launch that fails on the device still + answers 201, with `result.success` false. + parameters: + - name: x-dcd-source + in: header + required: false + description: >- + Which interface sent the request: `cli`, `mcp`, `web` or `api`, in any case. It is recorded as the + command's `source` on the timeline. Any other value, or none, counts as `api`. It is a label, not a + credential. + schema: + type: string + enum: [cli, mcp, web, api] + default: api + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/LaunchRequest' + example: + clearState: true + responses: + '201': + description: The command ran. `result.success` says whether it worked. + content: + application/json: + schema: + $ref: '#/components/schemas/CommandResponse' + example: + result: + success: true + output: '{"success":true,"commands_executed":1}' + session: + id: brave-teal-otter + live_id: 4182 + state: active + release_reason: null + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: null + expires_at: '2026-11-10T15:00:21.000Z' + queue: null + time_remaining_s: 2941 + idle_remaining_s: 300 + cost_so_far_usd: 0.33 + last_crash: null + install: + binary_upload_id: 8a2d4c6e-1f3b-4d5a-8e7c-9b0a1c2d3e4f + status: installed + error: null + warnings: [] + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + '400': + description: >- + `LIVE_INVALID_ACTION`: `appId` isn't a bundle id or package name, or `clearState` isn't a boolean. + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + example: + statusCode: 400 + message: appId must be a bundle id or package name (letters, digits, . _ -). + error: LIVE_INVALID_ACTION + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/LiveNotFound' + '409': + description: >- + `LIVE_SESSION_ENDED`: the session has ended (the body includes its `session` block). + `LIVE_HUMAN_IN_CONTROL`: a person has taken control in the console; wait for the hand-back. + `LIVE_NO_APP`: no `appId` was given and no app is installed. + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + examples: + noApp: + summary: No app installed or named + value: + statusCode: 409 + message: No app is installed in this session. Install one, or pass appId. + error: LIVE_NO_APP + humanInControl: + summary: A person has control + value: + statusCode: 409 + message: 'A person has taken control of this device. Wait for them to hand it back: poll get_human_status (MCP), run `dcd device human --wait`, or poll GET /device/sessions/:id/human until handed_back is true.' + error: LIVE_HUMAN_IN_CONTROL + sessionEnded: + summary: The session has ended + value: + statusCode: 409 + message: Device session "brave-teal-otter" has ended (idle_timeout). Start a new one. + error: LIVE_SESSION_ENDED + session: + id: brave-teal-otter + live_id: 4182 + state: expired + release_reason: idle_timeout + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: '2026-11-10T14:31:02.000Z' + expires_at: null + queue: null + time_remaining_s: null + idle_remaining_s: null + cost_so_far_usd: 0.93 + last_crash: null + install: + binary_upload_id: 8a2d4c6e-1f3b-4d5a-8e7c-9b0a1c2d3e4f + status: installed + error: null + warnings: [] + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + '425': + $ref: '#/components/responses/LiveNotReady' + '429': + $ref: '#/components/responses/LiveRateLimited' + + /device/sessions/{id}/actions: + parameters: + - name: id + in: path + required: true + description: The session id, such as `brave-teal-otter`. The numeric `live_id` works too. + schema: + type: string + example: brave-teal-otter + post: + tags: [Device control] + operationId: performAction + summary: Perform one action + description: >- + Sends one action to the device: `tap`, `type`, `swipe` or `press_key`. Each becomes one Maestro + command. The request waits for it to finish, up to 120 s, after waiting up to 45 s for a device that + is still starting or installing. An action that fails on the device still answers 201, with + `result.success` false. A `type` action with `sensitive: true` sends its text as a secret: it never + appears in the timeline and is masked in `inspect`. + parameters: + - name: x-dcd-source + in: header + required: false + description: >- + Which interface sent the request: `cli`, `mcp`, `web` or `api`, in any case. It is recorded as the + command's `source` on the timeline. Any other value, or none, counts as `api`. It is a label, not a + credential. + schema: + type: string + enum: [cli, mcp, web, api] + default: api + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DeviceAction' + examples: + tapText: + summary: Tap an element by its text + value: + type: tap + text: Log in + tapPoint: + summary: Tap a point, in percent of the screen + value: + type: tap + x: 50 + y: 82 + typeSecret: + summary: Type a password without recording it + value: + type: type + text: correct-horse-battery + sensitive: true + swipe: + summary: Swipe up + value: + type: swipe + direction: up + pressKey: + summary: Press back + value: + type: press_key + key: back + responses: + '201': + description: The action ran. `result.success` says whether it worked. + content: + application/json: + schema: + $ref: '#/components/schemas/CommandResponse' + example: + result: + success: true + output: '{"success":true,"commands_executed":1}' + session: + id: brave-teal-otter + live_id: 4182 + state: active + release_reason: null + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: null + expires_at: '2026-11-10T15:00:21.000Z' + queue: null + time_remaining_s: 2925 + idle_remaining_s: 300 + cost_so_far_usd: 0.33 + last_crash: null + install: + binary_upload_id: 8a2d4c6e-1f3b-4d5a-8e7c-9b0a1c2d3e4f + status: installed + error: null + warnings: [] + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + '400': + description: '`LIVE_INVALID_ACTION`: the action is malformed. The message says how.' + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + examples: + tapTargets: + summary: A tap with no target, or more than one + value: + statusCode: 400 + message: 'tap takes exactly one target: x and y (percentages), text, or id.' + error: LIVE_INVALID_ACTION + badKey: + summary: An unknown key + value: + statusCode: 400 + message: key must be one of back, home, enter, backspace, tab, volume_up, volume_down, lock, power. + error: LIVE_INVALID_ACTION + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/LiveNotFound' + '409': + description: >- + `LIVE_SESSION_ENDED`: the session has ended (the body includes its `session` block). + `LIVE_HUMAN_IN_CONTROL`: a person has taken control in the console; wait for the hand-back. + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + examples: + humanInControl: + summary: A person has control + value: + statusCode: 409 + message: 'A person has taken control of this device. Wait for them to hand it back: poll get_human_status (MCP), run `dcd device human --wait`, or poll GET /device/sessions/:id/human until handed_back is true.' + error: LIVE_HUMAN_IN_CONTROL + sessionEnded: + summary: The session has ended + value: + statusCode: 409 + message: Device session "brave-teal-otter" has ended (idle_timeout). Start a new one. + error: LIVE_SESSION_ENDED + session: + id: brave-teal-otter + live_id: 4182 + state: expired + release_reason: idle_timeout + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: '2026-11-10T14:31:02.000Z' + expires_at: null + queue: null + time_remaining_s: null + idle_remaining_s: null + cost_so_far_usd: 0.93 + last_crash: null + install: + binary_upload_id: 8a2d4c6e-1f3b-4d5a-8e7c-9b0a1c2d3e4f + status: installed + error: null + warnings: [] + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + '425': + $ref: '#/components/responses/LiveNotReady' + '429': + $ref: '#/components/responses/LiveRateLimited' + '503': + $ref: '#/components/responses/LiveSecretsUnavailable' + + /device/sessions/{id}/run: + parameters: + - name: id + in: path + required: true + description: The session id, such as `brave-teal-otter`. The numeric `live_id` works too. + schema: + type: string + example: brave-teal-otter + post: + tags: [Device control] + operationId: runFlow + summary: Run a Maestro flow + description: >- + Runs Maestro on the device: `yaml` for one flow, or `flowUploadId` for a flow bundle (`flow` names + the entry file; without it, every flow in the bundle runs). `env` passes secrets and variables. The + request waits for the flow up to 110 s, or `waitSeconds`. A flow that takes longer answers `running` + with a `command_id`: follow it with GET /device/sessions/{id}/commands/{commandId}. A failed run + answers 201 with `status` `failed`; its result says where it failed and lists the app's visible text + just after. + parameters: + - name: x-dcd-source + in: header + required: false + description: >- + Which interface sent the request: `cli`, `mcp`, `web` or `api`, in any case. It is recorded as the + command's `source` on the timeline. Any other value, or none, counts as `api`. It is a label, not a + credential. + schema: + type: string + enum: [cli, mcp, web, api] + default: api + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RunRequest' + examples: + inline: + summary: One flow, inline, with a secret + value: + yaml: "appId: com.example.app\n---\n- tapOn: \"Email\"\n- inputText: \"qa@example.com\"\n- tapOn: \"Password\"\n- inputText: ${PASSWORD}\n- tapOn: \"Log in\"\n- assertVisible: \"Welcome\"" + env: + PASSWORD: correct-horse-battery + bundle: + summary: One flow from a bundle, waiting longer + value: + flowUploadId: 0b6f2d1e-8a3c-4f5b-9e7d-1c2a3b4c5d6e + flow: flows/login.yaml + waitSeconds: 240 + responses: + '201': + description: >- + The flow passed, failed, or is still `running` (then `result` is null). + content: + application/json: + schema: + $ref: '#/components/schemas/RunResponse' + examples: + failed: + summary: A failed inline flow + value: + command_id: 9d1b6f3e-4c1a-4f7e-9a51-2b8f0c6d7e21 + status: failed + result: + success: false + error: 'Assertion is false: "Star at the center" is visible (at flow.yaml:3)' + output: '{"success":false,"message":"Assertion is false: \"Star at the center\" is visible","failed_at":{"file":"flow.yaml","line":3,"command":"assertVisible: \"Star at the center\"","call_chain":[]}}' + failed_at: + file: flow.yaml + line: 3 + command: 'assertVisible: "Star at the center"' + call_chain: [] + screen_text: + - Star at the centre + - Next + session: + id: brave-teal-otter + live_id: 4182 + state: active + release_reason: null + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: null + expires_at: '2026-11-10T15:00:21.000Z' + queue: null + time_remaining_s: 2869 + idle_remaining_s: 300 + cost_so_far_usd: 0.36 + last_crash: null + install: + binary_upload_id: 8a2d4c6e-1f3b-4d5a-8e7c-9b0a1c2d3e4f + status: installed + error: null + warnings: [] + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + passedBundle: + summary: A bundle whose flows all passed + value: + command_id: 2c7e9a40-5b1d-4e8f-a3c6-7d9e0f1a2b3c + status: passed + result: + success: true + output: '{"success":true,"device_id":"emulator-5554","total_flows":2,"total_commands_executed":14,"results":[{"file":"flows/login.yaml","success":true,"message":"ok"},{"file":"flows/search.yaml","success":true,"message":"ok"}],"message":"ok"}' + flows: + - file: flows/login.yaml + success: true + error: null + - file: flows/search.yaml + success: true + error: null + session: + id: brave-teal-otter + live_id: 4182 + state: active + release_reason: null + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: null + expires_at: '2026-11-10T15:00:21.000Z' + queue: null + time_remaining_s: 2650 + idle_remaining_s: 300 + cost_so_far_usd: 0.48 + last_crash: null + install: + binary_upload_id: 8a2d4c6e-1f3b-4d5a-8e7c-9b0a1c2d3e4f + status: installed + error: null + warnings: [] + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + running: + summary: A flow that outlasted the wait + value: + command_id: 5e8f1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b + status: running + result: null + session: + id: brave-teal-otter + live_id: 4182 + state: active + release_reason: null + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: null + expires_at: '2026-11-10T15:00:21.000Z' + queue: null + time_remaining_s: 2420 + idle_remaining_s: 300 + cost_so_far_usd: 0.6 + last_crash: null + install: + binary_upload_id: 8a2d4c6e-1f3b-4d5a-8e7c-9b0a1c2d3e4f + status: installed + error: null + warnings: [] + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + '400': + description: >- + `LIVE_INVALID_ACTION`: neither or both of `yaml` and `flowUploadId`, a `flow` without a bundle or + with an unsafe path, `waitSeconds` out of range, a malformed `env`, or a `flowUploadId` that isn't a + bundle of your organization's. + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + examples: + neither: + summary: Neither yaml nor flowUploadId + value: + statusCode: 400 + message: Pass either yaml (one flow) or flowUploadId (a bundle of flows), not both and not neither. + error: LIVE_INVALID_ACTION + reservedEnv: + summary: A reserved env name + value: + statusCode: 400 + message: env names starting with DCD_ are reserved ("DCD_TOKEN"). + error: LIVE_INVALID_ACTION + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/LiveNotFound' + '409': + description: >- + `LIVE_SESSION_ENDED`: the session has ended (the body includes its `session` block). + `LIVE_HUMAN_IN_CONTROL`: a person has taken control in the console; wait for the hand-back. + `LIVE_NO_APP`: the inline flow has no header, uses the header's app (a bare `launchApp`), and no + app is installed. + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + examples: + humanInControl: + summary: A person has control + value: + statusCode: 409 + message: 'A person has taken control of this device. Wait for them to hand it back: poll get_human_status (MCP), run `dcd device human --wait`, or poll GET /device/sessions/:id/human until handed_back is true.' + error: LIVE_HUMAN_IN_CONTROL + noApp: + summary: No app installed or named + value: + statusCode: 409 + message: No app is installed in this session. Install one, or pass appId. + error: LIVE_NO_APP + sessionEnded: + summary: The session has ended + value: + statusCode: 409 + message: Device session "brave-teal-otter" has ended (idle_timeout). Start a new one. + error: LIVE_SESSION_ENDED + session: + id: brave-teal-otter + live_id: 4182 + state: expired + release_reason: idle_timeout + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: '2026-11-10T14:31:02.000Z' + expires_at: null + queue: null + time_remaining_s: null + idle_remaining_s: null + cost_so_far_usd: 0.93 + last_crash: null + install: + binary_upload_id: 8a2d4c6e-1f3b-4d5a-8e7c-9b0a1c2d3e4f + status: installed + error: null + warnings: [] + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + '425': + $ref: '#/components/responses/LiveNotReady' + '429': + $ref: '#/components/responses/LiveRateLimited' + '503': + $ref: '#/components/responses/LiveSecretsUnavailable' + + /device/sessions/{id}/commands/{commandId}: + parameters: + - name: id + in: path + required: true + description: The session id, such as `brave-teal-otter`. The numeric `live_id` works too. + schema: + type: string + example: brave-teal-otter + - name: commandId + in: path + required: true + description: The `command_id` that `run` returned. Any command id from this session's timeline works. + schema: + type: string + format: uuid + example: 5e8f1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b + get: + tags: [Device control] + operationId: getCommand + summary: Get a command's status and result + description: >- + The status and result of a command, for a `run` that answered `running`. By default it answers at + once. With `waitSeconds`, it waits for a running command to finish. Works on ended sessions too. A + failed result gets `screen_text` (the app's visible text now) while the session is open. + parameters: + - name: waitSeconds + in: query + required: false + description: How long to wait for a running command to finish, in seconds. + schema: + type: number + minimum: 0 + maximum: 300 + default: 0 + responses: + '200': + description: The command's status, and its result once it has finished. + content: + application/json: + schema: + $ref: '#/components/schemas/RunResponse' + example: + command_id: 5e8f1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b + status: passed + result: + success: true + output: '{"success":true,"commands_executed":42}' + session: + id: brave-teal-otter + live_id: 4182 + state: active + release_reason: null + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: null + expires_at: '2026-11-10T15:00:21.000Z' + queue: null + time_remaining_s: 2300 + idle_remaining_s: 280 + cost_so_far_usd: 0.66 + last_crash: null + install: + binary_upload_id: 8a2d4c6e-1f3b-4d5a-8e7c-9b0a1c2d3e4f + status: installed + error: null + warnings: [] + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + '400': + description: '`LIVE_INVALID_ACTION`: `waitSeconds` isn''t a number from 0 to 300.' + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + example: + statusCode: 400 + message: waitSeconds is a number of seconds from 0 to 300. + error: LIVE_INVALID_ACTION + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: >- + `LIVE_NOT_FOUND`: no such session in your organization. `LIVE_COMMAND_NOT_FOUND`: no such command + in this session. + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + examples: + commandNotFound: + summary: No such command + value: + statusCode: 404 + message: No command "5e8f1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b" in this session. + error: LIVE_COMMAND_NOT_FOUND + sessionNotFound: + summary: No such session + value: + statusCode: 404 + message: No device session "brave-teal-otter" in your organization. + error: LIVE_NOT_FOUND + '429': + $ref: '#/components/responses/LiveRateLimited' + + /device/sessions/{id}/inspect: + parameters: + - name: id + in: path + required: true + description: The session id, such as `brave-teal-otter`. The numeric `live_id` works too. + schema: + type: string + example: brave-teal-otter + get: + tags: [Device control] + operationId: inspectScreen + summary: Read the screen as compact JSON + description: >- + The screen as a flat list of elements with text, an id, a label or a hint, using Maestro's short key + names. It waits up to 5 s for a screen read taken after your last command. On Android, the on-screen + keyboard and the System UI are left out and counted in `hidden`, unless `all` is set. This session's + secret values are masked. + parameters: + - name: all + in: query + required: false + description: Also list the keyboard's keys and the System UI. `1` works as well as `true`. + schema: + type: boolean + default: false + responses: + '200': + description: The screen, and the session's block. + content: + application/json: + schema: + $ref: '#/components/schemas/InspectResponse' + example: + screen: + width: 1080 + height: 2400 + keys: + b: bounds [left,top][right,bottom] + txt: text (use as tapOn text) + rid: resource id (use as tapOn id) + a11y: accessibility label (use as tapOn text) + hint: hint / placeholder text + checked: 'checked (only when true; assert with checked: true)' + selected: 'selected (only when true; assert with selected: true)' + focused: has focus (only when true) + enabled: false when disabled (only shown then) + elements: + - b: '[63,412][1017,566]' + rid: com.example.app:id/email + hint: Email + - b: '[63,602][1017,756]' + rid: com.example.app:id/password + hint: Password + - b: '[63,792][540,868]' + txt: Remember me + checked: true + - b: '[63,904][1017,1052]' + txt: Log in + rid: com.example.app:id/login + hidden: + keyboard: 0 + system: 5 + fresh: true + session: + id: brave-teal-otter + live_id: 4182 + state: active + release_reason: null + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: null + expires_at: '2026-11-10T15:00:21.000Z' + queue: null + time_remaining_s: 2930 + idle_remaining_s: 296 + cost_so_far_usd: 0.33 + last_crash: null + install: + binary_upload_id: 8a2d4c6e-1f3b-4d5a-8e7c-9b0a1c2d3e4f + status: installed + error: null + warnings: [] + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/LiveNotFound' + '409': + $ref: '#/components/responses/LiveSessionEnded' + '425': + $ref: '#/components/responses/LiveNotReady' + '429': + $ref: '#/components/responses/LiveRateLimited' + + /device/sessions/{id}/screenshot: + parameters: + - name: id + in: path + required: true + description: The session id, such as `brave-teal-otter`. The numeric `live_id` works too. + schema: + type: string + example: brave-teal-otter + get: + tags: [Device control] + operationId: takeScreenshot + summary: Take a screenshot + description: >- + The screen as a JPEG data URL. It waits up to 3 s for a frame taken after the request arrived, so it + shows the result of your last command. `fresh` is false when no new frame came in time; the + screenshot is then the latest there is. + responses: + '200': + description: The screenshot, and the session's block. + content: + application/json: + schema: + $ref: '#/components/schemas/ScreenshotResponse' + example: + screenshot: + data_url: data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8U... + captured_at: '2026-11-10T14:10:31.402Z' + fresh: true + session: + id: brave-teal-otter + live_id: 4182 + state: active + release_reason: null + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: null + expires_at: '2026-11-10T15:00:21.000Z' + queue: null + time_remaining_s: 2930 + idle_remaining_s: 296 + cost_so_far_usd: 0.33 + last_crash: null + install: + binary_upload_id: 8a2d4c6e-1f3b-4d5a-8e7c-9b0a1c2d3e4f + status: installed + error: null + warnings: [] + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/LiveNotFound' + '409': + $ref: '#/components/responses/LiveSessionEnded' + '425': + $ref: '#/components/responses/LiveNotReady' + '429': + $ref: '#/components/responses/LiveRateLimited' + + /device/sessions/{id}/logs: + parameters: + - name: id + in: path + required: true + description: The session id, such as `brave-teal-otter`. The numeric `live_id` works too. + schema: + type: string + example: brave-teal-otter + get: + tags: [Device control] + operationId: getDeviceLogs + summary: Read the app's device logs + description: >- + The installed app's recent device log lines: its logcat lines on Android, its unified log on iOS. + `level` and `grep` narrow them; with either, the newest 2000 lines are searched and the newest `lines` + matches come back. The answer stops at 24 KB, keeping the newest lines, with `truncated: true` when + older ones were left out. Read from the device on request, so it waits up to 45 s for a device that is + still starting. + parameters: + - name: x-dcd-source + in: header + required: false + description: >- + Which interface sent the request: `cli`, `mcp`, `web` or `api`, in any case. It is recorded as the + command's `source` on the timeline. Any other value, or none, counts as `api`. It is a label, not a + credential. + schema: + type: string + enum: [cli, mcp, web, api] + default: api + - name: lines + in: query + required: false + description: How many lines to return. Values out of range are clamped; a value that isn't a number reads as 200. + schema: + type: integer + minimum: 1 + maximum: 2000 + default: 200 + - name: level + in: query + required: false + description: >- + Only lines at this level and above. iOS's unified log has no warning level, so `warning` gives its + errors and faults. + schema: + type: string + enum: [warning, error] + - name: grep + in: query + required: false + description: Only lines containing this text, ignoring case. + schema: + type: string + responses: + '200': + description: The log lines, oldest first, and the session's block. + content: + application/json: + schema: + $ref: '#/components/schemas/LogsResponse' + example: + logs: + lines: + - '11-10 14:09:30.512 4021 4021 E AuthRepository: Login failed: HTTP 401' + - '11-10 14:09:31.087 4021 4058 E AuthRepository: Login failed again: HTTP 401' + source: logcat + app_id: com.example.app + scanned: 2000 + session: + id: brave-teal-otter + live_id: 4182 + state: active + release_reason: null + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: null + expires_at: '2026-11-10T15:00:21.000Z' + queue: null + time_remaining_s: 2860 + idle_remaining_s: 300 + cost_so_far_usd: 0.36 + last_crash: null + install: + binary_upload_id: 8a2d4c6e-1f3b-4d5a-8e7c-9b0a1c2d3e4f + status: installed + error: null + warnings: [] + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + '400': + description: '`LIVE_INVALID_ACTION`: `level` isn''t `warning` or `error`.' + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + example: + statusCode: 400 + message: level must be one of warning, error. + error: LIVE_INVALID_ACTION + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/LiveNotFound' + '409': + $ref: '#/components/responses/LiveSessionEnded' + '425': + $ref: '#/components/responses/LiveNotReady' + '429': + $ref: '#/components/responses/LiveRateLimited' + '502': + description: >- + `LIVE_LOGS_UNAVAILABLE`: the device logs couldn't be read, for example on an older runner. The body + includes the `session` block. Retry later. + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + example: + statusCode: 502 + message: 'Could not read the device logs: Invalid Command. The runner may be too old to support logs; retry later.' + error: LIVE_LOGS_UNAVAILABLE + session: + id: brave-teal-otter + live_id: 4182 + state: active + release_reason: null + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: null + expires_at: '2026-11-10T15:00:21.000Z' + queue: null + time_remaining_s: 2860 + idle_remaining_s: 300 + cost_so_far_usd: 0.36 + last_crash: null + install: + binary_upload_id: 8a2d4c6e-1f3b-4d5a-8e7c-9b0a1c2d3e4f + status: installed + error: null + warnings: [] + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + + /device/sessions/{id}/timeline: + parameters: + - name: id + in: path + required: true + description: The session id, such as `brave-teal-otter`. The numeric `live_id` works too. + schema: + type: string + example: brave-teal-otter + get: + tags: [Session artifacts] + operationId: getTimeline + summary: List the session's timeline + description: >- + Every command and event in the session, newest first: who sent it, its status and result, and a link + to the screenshot taken after it. Long inputs and outputs are cut to 2000 characters. Secrets are never + shown. Page with `limit` and `before`. Works on ended sessions too. + parameters: + - name: limit + in: query + required: false + description: Entries per page. Values out of range are clamped. + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + - name: before + in: query + required: false + description: Only entries older than this. Pass the last page's `next_before`. + schema: + type: string + format: date-time + responses: + '200': + description: One page of the timeline, and the session's block. + content: + application/json: + schema: + $ref: '#/components/schemas/TimelineResponse' + example: + session: + id: brave-teal-otter + live_id: 4182 + state: active + release_reason: null + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: null + expires_at: '2026-11-10T15:00:21.000Z' + queue: null + time_remaining_s: 2860 + idle_remaining_s: 290 + cost_so_far_usd: 0.36 + last_crash: null + install: + binary_upload_id: 8a2d4c6e-1f3b-4d5a-8e7c-9b0a1c2d3e4f + status: installed + error: null + warnings: [] + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + timeline: + - command_id: 9d1b6f3e-4c1a-4f7e-9a51-2b8f0c6d7e21 + kind: run + source: mcp + client_label: claude-code + actor: api_key + actor_email: null + status: failed + created_at: '2026-11-10T14:09:12.418Z' + completed_at: '2026-11-10T14:09:31.902Z' + input: "appId: com.example.app\n---\n- tapOn: \"Next\"\n- assertVisible: \"Star at the center\"" + output: null + error: '{"success":false,"message":"Assertion is false: \"Star at the center\" is visible","failed_at":{"file":"flow.yaml","line":3,"command":"assertVisible: \"Star at the center\"","call_chain":[]}}' + redacted: false + screenshot: + url: https://cdn.devicecloud.dev/organizations/7/device-sessions/4182/screenshot/9d1b6f3e-4c1a-4f7e-9a51-2b8f0c6d7e21.jpg?exp=1794323400&sig=Vb2y0pQ8xJ3kM1nR5tW7zA9cE4fH6iL0oS2uX8dG1qY + content_type: image/jpeg + bytes: 84213 + created_at: '2026-11-10T14:09:32.104Z' + - command_id: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d + kind: tap + source: web + client_label: claude-code + actor: user + actor_email: ann@example.com + status: passed + created_at: '2026-11-10T14:08:40.020Z' + completed_at: '2026-11-10T14:08:41.377Z' + input: "---\n- tapOn:\n point: \"50%,82%\"" + output: '{"success":true,"commands_executed":1}' + error: null + redacted: false + screenshot: null + next_before: '2026-11-10T14:08:40.020Z' + '400': + description: '`LIVE_INVALID_ACTION`: `before` isn''t a timestamp.' + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + example: + statusCode: 400 + message: before must be a timestamp (next_before from the last page). + error: LIVE_INVALID_ACTION + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/LiveNotFound' + '429': + $ref: '#/components/responses/LiveRateLimited' + + /device/sessions/{id}/artifacts: + parameters: + - name: id + in: path + required: true + description: The session id, such as `brave-teal-otter`. The numeric `live_id` works too. + schema: + type: string + example: brave-teal-otter + get: + tags: [Session artifacts] + operationId: listSessionArtifacts + summary: List the session's files + description: >- + Download links to the session's video, its device log, and the screenshot and screen dump taken after + each action, newest first. Links work for about an hour. The video and log arrive up to a minute or + so after the session ends; `pending` lists what is still on its way. Page the actions with `limit` + and `before`. Works on ended sessions until the files are cleaned up. + parameters: + - name: limit + in: query + required: false + description: Actions per page. Values out of range are clamped. + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + - name: before + in: query + required: false + description: Only actions older than this. Pass the last page's `next_before`. + schema: + type: string + format: date-time + responses: + '200': + description: The session's files, and its block. + content: + application/json: + schema: + $ref: '#/components/schemas/ArtifactsResponse' + example: + session: + id: brave-teal-otter + live_id: 4182 + state: released + release_reason: user_stop + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: '2026-11-10T14:20:05.000Z' + expires_at: null + queue: null + time_remaining_s: null + idle_remaining_s: null + cost_so_far_usd: 0.6 + last_crash: null + install: + binary_upload_id: 8a2d4c6e-1f3b-4d5a-8e7c-9b0a1c2d3e4f + status: installed + error: null + warnings: [] + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + recording: + url: https://cdn.devicecloud.dev/organizations/7/device-sessions/4182/video/session.mp4?exp=1794324060&sig=Qm4r8Tz1kP6wX0bN3vY5cJ7hL9dF2sA8eG1uI4oK6nM + content_type: video/mp4 + bytes: 48211763 + created_at: '2026-11-10T14:20:51.330Z' + logs: + url: https://cdn.devicecloud.dev/organizations/7/device-sessions/4182/logs/device.log?exp=1794324060&sig=Hc7nP2xR9wK4mT1yB6vZ3qL8dJ5fA0sE2gU7iO4kN1c + content_type: text/plain + bytes: 1203344 + created_at: '2026-11-10T14:20:49.012Z' + actions: + - command_id: 9d1b6f3e-4c1a-4f7e-9a51-2b8f0c6d7e21 + created_at: '2026-11-10T14:09:32.104Z' + screenshot: + url: https://cdn.devicecloud.dev/organizations/7/device-sessions/4182/screenshot/9d1b6f3e-4c1a-4f7e-9a51-2b8f0c6d7e21.jpg?exp=1794324060&sig=Vb2y0pQ8xJ3kM1nR5tW7zA9cE4fH6iL0oS2uX8dG1qY + content_type: image/jpeg + bytes: 84213 + created_at: '2026-11-10T14:09:32.104Z' + hierarchy: + url: https://cdn.devicecloud.dev/organizations/7/device-sessions/4182/hierarchy/9d1b6f3e-4c1a-4f7e-9a51-2b8f0c6d7e21.json?exp=1794324060&sig=Ld3kR7nW1xP9mZ5tB2vQ8yF4cJ6hA0sE3gU1iO7kN2b + content_type: application/json + bytes: 23110 + created_at: '2026-11-10T14:09:32.388Z' + next_before: '2026-11-10T14:09:32.104Z' + pending: [] + '400': + description: >- + `before` isn't a timestamp. Unlike the timeline, this error carries no `error` code. + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + example: + statusCode: 400 + message: before must be a timestamp (next_before from the last page). + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/LiveNotFound' + '429': + $ref: '#/components/responses/LiveRateLimited' + + /device/sessions/{id}/request-human: + parameters: + - name: id + in: path + required: true + description: The session id, such as `brave-teal-otter`. The numeric `live_id` works too. + schema: + type: string + example: brave-teal-otter + post: + tags: [Human handoff] + operationId: requestHuman + summary: Ask a person for help + description: >- + Asks a person to do something on the device, such as entering a 2FA code. The session moves to + `needs_human` and its idle timer pauses, for up to your organization's limit (30 minutes by default), + and never past the session's maximum duration. When the session starts waiting, your organization's + webhook gets a `device.needs_human` event. Asking again while it waits updates the reason. Poll GET + /device/sessions/{id}/human until `handed_back` is true. Allowed while a person has control. + parameters: + - name: x-dcd-source + in: header + required: false + description: >- + Which interface sent the request: `cli`, `mcp`, `web` or `api`, in any case. It is recorded on the + timeline entry. Any other value, or none, counts as `api`. It is a label, not a credential. + schema: + type: string + enum: [cli, mcp, web, api] + default: api + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RequestHumanRequest' + example: + reason: Enter the 2FA code sent to the test phone, then hand back + responses: + '201': + description: The session is waiting for a person. + content: + application/json: + schema: + $ref: '#/components/schemas/HumanResponse' + example: + session: + id: brave-teal-otter + live_id: 4182 + state: needs_human + release_reason: null + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: null + expires_at: '2026-11-10T15:00:21.000Z' + queue: null + time_remaining_s: 2901 + idle_remaining_s: null + cost_so_far_usd: 0.36 + last_crash: null + install: + binary_upload_id: 8a2d4c6e-1f3b-4d5a-8e7c-9b0a1c2d3e4f + status: installed + error: null + warnings: + - 'Waiting for a human: Enter the 2FA code sent to the test phone, then hand back. The session ends in 1800s unless they hand it back. Poll get_human_status (MCP) or run `dcd device human --wait`; the idle timer is paused meanwhile.' + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + human: + waiting: true + reason: Enter the 2FA code sent to the test phone, then hand back + since: '2026-11-10T14:12:00.000Z' + expires_at: '2026-11-10T14:42:00.000Z' + handed_back: false + note: null + handed_back_at: null + human_in_control: false + controller_user_id: null + '400': + description: '`LIVE_INVALID_ACTION`: `reason` is missing, blank, or over 500 characters.' + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + example: + statusCode: 400 + message: 'reason is required (up to 500 characters): what the human should do, e.g. "Enter the 2FA code sent to the test phone".' + error: LIVE_INVALID_ACTION + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/LiveNotFound' + '409': + $ref: '#/components/responses/LiveSessionEnded' + '425': + description: >- + `LIVE_NOT_READY`: the session isn't `ready`, `active` or `needs_human` yet (it is still queued or + provisioning). The body includes the `session` block. + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + example: + statusCode: 425 + message: The device is not ready yet; ask for a human once the session is ready. + error: LIVE_NOT_READY + session: + id: brave-teal-otter + live_id: 4182 + state: provisioning + release_reason: null + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: null + expires_at: '2026-11-10T15:00:21.000Z' + queue: null + time_remaining_s: 3591 + idle_remaining_s: null + cost_so_far_usd: 0.01 + last_crash: null + install: null + warnings: [] + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + '429': + $ref: '#/components/responses/LiveRateLimited' + + /device/sessions/{id}/human: + parameters: + - name: id + in: path + required: true + description: The session id, such as `brave-teal-otter`. The numeric `live_id` works too. + schema: + type: string + example: brave-teal-otter + get: + tags: [Human handoff] + operationId: getHumanStatus + summary: Check whether a person has handed back + description: >- + Whether the session is still waiting for a person (`waiting`, `reason`, `expires_at`), or was handed + back (`handed_back`, with the person's `note`). Also says whether a person has taken control in the + console. Works on ended sessions too. + responses: + '200': + description: The human status, and the session's block. + content: + application/json: + schema: + $ref: '#/components/schemas/HumanResponse' + example: + session: + id: brave-teal-otter + live_id: 4182 + state: active + release_reason: null + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: null + expires_at: '2026-11-10T15:00:21.000Z' + queue: null + time_remaining_s: 2675 + idle_remaining_s: 286 + cost_so_far_usd: 0.48 + last_crash: null + install: + binary_upload_id: 8a2d4c6e-1f3b-4d5a-8e7c-9b0a1c2d3e4f + status: installed + error: null + warnings: [] + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + human: + waiting: false + reason: null + since: null + expires_at: null + handed_back: true + note: Code entered, you're on the home screen + handed_back_at: '2026-11-10T14:15:40.000Z' + human_in_control: false + controller_user_id: null + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/LiveNotFound' + '429': + $ref: '#/components/responses/LiveRateLimited' + + /device/sessions/{id}/hand-back: + parameters: + - name: id + in: path + required: true + description: The session id, such as `brave-teal-otter`. The numeric `live_id` works too. + schema: + type: string + example: brave-teal-otter + post: + tags: [Human handoff] + operationId: handBack + summary: Hand the session back to the agent + description: >- + Ends the wait for a person. The session is `active` again, with a fresh idle window, and anyone in + control is released. `note` is passed on to the agent, such as "Code entered, you're on the home + screen". + parameters: + - name: x-dcd-source + in: header + required: false + description: >- + Which interface sent the request: `cli`, `mcp`, `web` or `api`, in any case. It is recorded on the + timeline entry. Any other value, or none, counts as `api`. It is a label, not a credential. + schema: + type: string + enum: [cli, mcp, web, api] + default: api + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/HandBackRequest' + example: + note: Code entered, you're on the home screen + responses: + '201': + description: The session is back with the agent. + content: + application/json: + schema: + $ref: '#/components/schemas/HumanResponse' + example: + session: + id: brave-teal-otter + live_id: 4182 + state: active + release_reason: null + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: null + expires_at: '2026-11-10T15:00:21.000Z' + queue: null + time_remaining_s: 2681 + idle_remaining_s: 300 + cost_so_far_usd: 0.48 + last_crash: null + install: + binary_upload_id: 8a2d4c6e-1f3b-4d5a-8e7c-9b0a1c2d3e4f + status: installed + error: null + warnings: [] + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + human: + waiting: false + reason: null + since: null + expires_at: null + handed_back: true + note: Code entered, you're on the home screen + handed_back_at: '2026-11-10T14:15:40.000Z' + human_in_control: false + controller_user_id: null + '400': + description: '`LIVE_INVALID_ACTION`: `note` is over 2000 characters.' + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + example: + statusCode: 400 + message: note is at most 2000 characters. + error: LIVE_INVALID_ACTION + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/LiveNotFound' + '409': + description: >- + `LIVE_NOT_WAITING_FOR_HUMAN`: the session isn't waiting for a person. `LIVE_SESSION_ENDED`: the + session has ended. Both include the `session` block. + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + examples: + notWaiting: + summary: Nobody was asked + value: + statusCode: 409 + message: The session is not waiting for a human. + error: LIVE_NOT_WAITING_FOR_HUMAN + session: + id: brave-teal-otter + live_id: 4182 + state: active + release_reason: null + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: null + expires_at: '2026-11-10T15:00:21.000Z' + queue: null + time_remaining_s: 2681 + idle_remaining_s: 241 + cost_so_far_usd: 0.48 + last_crash: null + install: + binary_upload_id: 8a2d4c6e-1f3b-4d5a-8e7c-9b0a1c2d3e4f + status: installed + error: null + warnings: [] + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + sessionEnded: + summary: The session has ended + value: + statusCode: 409 + message: Device session "brave-teal-otter" has ended (idle_timeout). Start a new one. + error: LIVE_SESSION_ENDED + session: + id: brave-teal-otter + live_id: 4182 + state: expired + release_reason: idle_timeout + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: '2026-11-10T14:31:02.000Z' + expires_at: null + queue: null + time_remaining_s: null + idle_remaining_s: null + cost_so_far_usd: 0.93 + last_crash: null + install: + binary_upload_id: 8a2d4c6e-1f3b-4d5a-8e7c-9b0a1c2d3e4f + status: installed + error: null + warnings: [] + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + '429': + $ref: '#/components/responses/LiveRateLimited' + + /device/bundles/upload-url: + post: + tags: [Flow bundles] + operationId: createBundleUploadUrl + summary: Get URLs to upload a flow bundle to + description: >- + Returns a new bundle id and signed URLs to `PUT` the zip to: `upload` and `mirror`. Either may be + null. Upload to each one given, then register the bundle with POST /device/bundles. + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BundleUploadUrlRequest' + example: + bytes: 18342 + responses: + '201': + description: The bundle id and where to upload it. + content: + application/json: + schema: + $ref: '#/components/schemas/BundleUploadUrls' + example: + id: 0b6f2d1e-8a3c-4f5b-9e7d-1c2a3b4c5d6e + upload: + path: 7/device-bundles/0b6f2d1e-8a3c-4f5b-9e7d-1c2a3b4c5d6e.zip + signedUrl: https://xyzcompany.supabase.co/storage/v1/object/upload/sign/organizations/7/device-bundles/0b6f2d1e-8a3c-4f5b-9e7d-1c2a3b4c5d6e.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJvcmdhbml6YXRpb25zLzcifQ.sig + mirror: + path: organizations/7/device-bundles/0b6f2d1e-8a3c-4f5b-9e7d-1c2a3b4c5d6e.zip + signedUrl: https://s3.us-west-004.backblazeb2.com/example-bucket/organizations/7/device-bundles/0b6f2d1e-8a3c-4f5b-9e7d-1c2a3b4c5d6e.zip?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=3600&X-Amz-Signature=3b1f + '400': + description: '`LIVE_INVALID_ACTION`: `bytes` isn''t a whole number from 1 to 50 MB.' + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + example: + statusCode: 400 + message: bytes is the zip's size, at most 50 MB. + error: LIVE_INVALID_ACTION + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/LiveRateLimited' + '503': + description: Neither upload URL could be signed. No `error` code. Retry. + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + example: + statusCode: 503 + message: Could not prepare the bundle upload. Please retry. + + /device/bundles: + post: + tags: [Flow bundles] + operationId: registerBundle + summary: Register an uploaded flow bundle + description: >- + Records a bundle you uploaded, after checking that the zip is in storage. Say which URLs you uploaded + to with `supabase` and `backblaze`. Registering the same bundle again returns it unchanged, so it is + safe to retry. Pass its `id` to `run` as `flowUploadId`. Bundles are deleted a day after upload, once + no command needs them. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterBundleRequest' + example: + id: 0b6f2d1e-8a3c-4f5b-9e7d-1c2a3b4c5d6e + sha: 9f2c4a1b7e3d5f6a8b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a + bytes: 18342 + supabase: true + backblaze: true + responses: + '201': + description: The registered bundle. + content: + application/json: + schema: + $ref: '#/components/schemas/BundleResponse' + example: + bundle: + id: 0b6f2d1e-8a3c-4f5b-9e7d-1c2a3b4c5d6e + sha: 9f2c4a1b7e3d5f6a8b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a + bytes: 18342 + encrypted: false + created_at: '2026-11-10T14:05:12.881Z' + '400': + description: >- + `LIVE_INVALID_ACTION`: `id`, `sha`, `bytes` or `enc` is malformed, or the zip isn't in storage at the + places named. + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + examples: + notUploaded: + summary: The zip isn't in storage + value: + statusCode: 400 + message: The bundle was not found in storage. Upload the zip to the URLs from upload-url first, then register it. + error: LIVE_INVALID_ACTION + badSha: + summary: A malformed hash + value: + statusCode: 400 + message: sha is the SHA-256 of the uploaded zip, in hex. + error: LIVE_INVALID_ACTION + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/LiveRateLimited' + /ip-addresses: + get: + operationId: getIpAddresses + tags: [IP addresses] + summary: Get IP addresses + description: | + The test runners' egress IP addresses, so firewall automation can keep an + allow-list in sync. No API key needed, and not rate limited. Responses + are cacheable for an hour: polling hourly is plenty. Watch `updatedAt` to + notice a change. + security: [] + responses: + '200': + description: The current addresses. + headers: + Cache-Control: + description: '`public, max-age=3600`' + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/IpAddresses' +components: + securitySchemes: + apiKey: + type: apiKey + in: header + name: x-app-api-key + description: An API key from **Settings → API Keys** in the console. + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: | + A DeviceCloud login session's access token, as `dcd login` uses. Send it + with `x-dcd-org`. + orgHeader: + type: apiKey + in: header + name: x-dcd-org + description: | + The team (organization) ID to act as, with a Bearer token. The signed-in + user must be a member of that team. + parameters: + UploadId: + name: uploadId + in: path + required: true + description: The upload's ID. + schema: + type: string + format: uuid + example: 7e12345f-eb12-12ec-a30b-bb1234f1d12a + IfNoneMatch: + name: If-None-Match + in: header + description: The `ETag` of a previous response. Answers `304` if nothing has changed. + schema: + type: string + example: W/"115-QMD/XiKlxwtOKM/qnGnVrtcbhVo" + PlatformFilter: + name: platform + in: query + description: Only runs on this platform. + schema: + $ref: '#/components/schemas/Platform' + AppIdFilter: + name: appId + in: query + description: Only runs of this app (bundle ID or package name). + schema: + type: string + example: com.example.app + headers: + ETag: + description: A weak validator for the response body. Send it back in `If-None-Match`. + schema: + type: string + example: W/"115-QMD/XiKlxwtOKM/qnGnVrtcbhVo" + RetryAfter: + description: Seconds to wait before retrying. + schema: + type: integer + responses: + Forbidden: + description: | + No credentials, an invalid or expired API key, a Bearer token without a + valid `x-dcd-org`, or a key whose scope doesn't reach this endpoint + (`"error": "API_KEY_SCOPE"`). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + invalidKey: + summary: Invalid API key + value: + statusCode: 403 + message: Invalid API key. Please check your API key and ensure it is valid for this environment. + error: Forbidden + scope: + summary: Key scope too narrow + value: + statusCode: 403 + message: This API key is limited to device_sessions and can't reach /uploads/list. Use a key with full access for this. + error: API_KEY_SCOPE + RateLimited: + description: Over 60 requests a minute. Wait for `Retry-After` seconds. + headers: + Retry-After: + $ref: '#/components/headers/RetryAfter' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + example: + statusCode: 429 + message: 'ThrottlerException: Too Many Requests' + KeyCheckUnavailable: + description: | + The API key couldn't be checked because of a temporary problem on + DeviceCloud's side. This says nothing about whether the key is valid. Retry. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + example: + statusCode: 503 + message: Could not validate the API key right now (a temporary problem on our side). Please retry. + error: Service Unavailable + InvalidUploadId: + description: '`uploadId` isn''t a UUID.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + example: + statusCode: 400 + message: Validation failed (uuid is expected) + error: Bad Request + NotModified: + description: Nothing has changed since the response whose `ETag` you sent. No body. + headers: + ETag: + $ref: '#/components/headers/ETag' + NoResults: + description: The upload has no results in your organization, or doesn't exist. + content: + application/json: + schema: + $ref: '#/components/schemas/MessageOnlyError' + example: + message: No results found for this upload + MessageOnlyServerError: + description: The report couldn't be built. + content: + application/json: + schema: + $ref: '#/components/schemas/MessageOnlyError' + example: + message: Failed to merge JUNIT reports + ServerError: + description: A problem on DeviceCloud's side. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + example: + statusCode: 500 + message: Internal server error + LiveNotFound: + description: '`LIVE_NOT_FOUND`: no session with this id in your organization.' + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + example: + statusCode: 404 + message: No device session "brave-teal-otter" in your organization. + error: LIVE_NOT_FOUND + LiveSessionEnded: + description: >- + `LIVE_SESSION_ENDED`: the session has ended. The body includes its `session` block, with why it ended. + Start a new session. + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + examples: + sessionEnded: + summary: The session has ended + value: + statusCode: 409 + message: Device session "brave-teal-otter" has ended (idle_timeout). Start a new one. + error: LIVE_SESSION_ENDED + session: + id: brave-teal-otter + live_id: 4182 + state: expired + release_reason: idle_timeout + platform: android + model: pixel-10-api-36 + client_label: claude-code + created_at: '2026-11-10T14:00:00.000Z' + started_at: '2026-11-10T14:00:21.000Z' + released_at: '2026-11-10T14:31:02.000Z' + expires_at: null + queue: null + time_remaining_s: null + idle_remaining_s: null + cost_so_far_usd: 0.93 + last_crash: null + install: + binary_upload_id: 8a2d4c6e-1f3b-4d5a-8e7c-9b0a1c2d3e4f + status: installed + error: null + warnings: [] + monitor_url: https://console.devicecloud.dev/device/brave-teal-otter + LiveNotReady: + description: >- + `LIVE_NOT_READY` (425 Too Early): the device is still starting, or an app is still installing. A + command first waits up to 45 s for it (not while the session is queued), then answers this; `phase` + says where the device is. Screenshot and inspect answer it, with the `session` block instead of + `phase`, while the device has no screen to show yet. Retry shortly. + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + examples: + starting: + summary: The device is still starting + value: + statusCode: 425 + message: The device is not ready yet (starting_device). Wait for state "ready", then retry. + error: LIVE_NOT_READY + phase: starting_device + installing: + summary: An app is still installing + value: + statusCode: 425 + message: 'The app is still installing (installing_app). Wait until the session''s install.status is "installed" (over MCP, get_device_status with wait: true waits for it), then retry.' + error: LIVE_NOT_READY + phase: installing_app + LiveRateLimited: + description: >- + `LIVE_RATE_LIMITED`: over a per-minute limit. Commands (install, launch, actions, run, request-human, + hand-back) are limited to 60 a minute per session. Reads (session status, commands, inspect, + screenshot, logs, timeline, artifacts, human status, and stopping an ended session) are limited to 240 + a minute per session. Session lists, device models and bundle uploads are limited to 120 a minute per + organization. The body gives the `limit` and `retry_after_s`, the seconds until the window resets. No + `Retry-After` header is sent. Windows are fixed and one minute long, and each API instance keeps its + own count. + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + example: + statusCode: 429 + message: 'Too many requests for this session: at most 240 a minute. Retry in 17s.' + error: LIVE_RATE_LIMITED + limit: 240 + retry_after_s: 17 + LiveSecretsUnavailable: + description: >- + `LIVE_SECRETS_UNAVAILABLE`: this server can't accept secrets (`env` on run, or `sensitive` text) right + now. Send the command without them. + content: + application/json: + schema: + $ref: '#/components/schemas/LiveError' + example: + statusCode: 503 + message: Secrets (env, sensitive text) are not available on this server. Send the command without them. + error: LIVE_SECRETS_UNAVAILABLE + schemas: + ErrorEnvelope: + type: object + description: | + The error body most endpoints use. Some errors add more fields; ignore + those you don't know. + required: [statusCode, message] + properties: + statusCode: + type: integer + description: The HTTP status. + message: + description: | + Human-readable text. Request validation errors give a list, one + entry per problem. + oneOf: + - type: string + - type: array + items: + type: string + error: + type: string + description: | + A machine-readable code such as `API_KEY_SCOPE` or a `LIVE_*` code, + or the HTTP status text (`Bad Request`). Match on codes, never on + status text. + additionalProperties: true + InBodyError: + type: object + description: | + A client error that `GET /results/{uploadId}`, `GET /flows` and + `GET /flows/runs` report in the body of an HTTP `200`. + required: [statusCode, message] + properties: + statusCode: + type: integer + const: 400 + message: + type: string + MessageOnlyError: + type: object + description: The error body of the report and artifact downloads. + required: [message] + properties: + message: + type: string + TestStatus: + type: string + description: | + A test's state. `QUEUED` means waiting for a device. More values may be + added. + enum: [PENDING, QUEUED, RUNNING, PASSED, FAILED, CANCELLED] + Platform: + type: string + enum: [android, ios] + Upload: + type: object + required: [id, name, created_at, consoleUrl] + properties: + id: + type: string + format: uuid + name: + type: [string, 'null'] + description: The name given with `dcd cloud --name`, if any. + created_at: + type: string + format: date-time + consoleUrl: + type: string + format: uri + description: The upload in the console. + UploadList: + type: object + required: [uploads, total, limit, offset] + properties: + uploads: + type: array + items: + $ref: '#/components/schemas/Upload' + total: + type: integer + description: How many uploads match, across every page. + limit: + type: integer + offset: + type: integer + example: + uploads: + - id: 7e12345f-eb12-12ec-a30b-bb1234f1d12a + name: Nightly regression + created_at: '2026-01-15T10:30:00+00:00' + consoleUrl: https://console.devicecloud.dev/results?upload=7e12345f-eb12-12ec-a30b-bb1234f1d12a + total: 42 + limit: 20 + offset: 0 + UploadStatus: + type: object + required: [uploadId, status, name, createdAt, appBinaryId, consoleUrl, tests] + properties: + uploadId: + type: string + format: uuid + status: + $ref: '#/components/schemas/TestStatus' + description: | + The run's overall status, worked out from its tests. Never + `CANCELLED`: a cancelled test makes the run `FAILED`. + name: + type: [string, 'null'] + createdAt: + type: [string, 'null'] + format: date-time + appBinaryId: + type: string + format: uuid + description: The ID of the app binary the run tested. + consoleUrl: + type: string + format: uri + description: The upload in the console, opened at its most recently created test. + tests: + type: array + description: The latest attempt of each test. + items: + $ref: '#/components/schemas/UploadStatusTest' + supersededBy: + type: string + format: uuid + description: | + Only on a run that a newer run from the same CI context replaced + (`dcd cloud --cancel-previous`): the newer upload's ID. Its `status` + is still `FAILED`, because its cancelled tests count against it, so + check this field before failing a CI job. + example: + uploadId: 7e12345f-eb12-12ec-a30b-bb1234f1d12a + status: FAILED + name: Nightly regression + createdAt: '2026-01-15T10:30:00+00:00' + appBinaryId: 67894274-b789-4c1e-80d4-da8998998999 + consoleUrl: https://console.devicecloud.dev/results?upload=7e12345f-eb12-12ec-a30b-bb1234f1d12a&result=4500 + tests: + - name: ./login-test/onboarding.yaml + status: FAILED + durationSeconds: 32 + failReason: 'Element not found: Text matching regex next.* not found' + createdAt: '2026-01-15T10:30:04+00:00' + device: pixel-7-api-34 + UploadStatusTest: + type: object + required: [name, status] + properties: + name: + type: string + description: The flow file, relative to the flows folder that was run. + status: + $ref: '#/components/schemas/TestStatus' + durationSeconds: + type: integer + description: Left out until the test has a duration. + failReason: + type: string + description: Only on a `FAILED` test with a recorded reason. + createdAt: + type: string + format: date-time + device: + type: string + description: The device the test ran on, such as `pixel-7-api-34` or `iPhone 16 - 18`. + ResultsResponse: + type: object + required: [statusCode, results] + properties: + statusCode: + type: integer + const: 200 + results: + type: array + description: Every attempt of every test, ordered by `id`. + items: + $ref: '#/components/schemas/Result' + Result: + type: object + description: | + One attempt at one test. Fields marked "full view only" are left out with + `view=summary`. + required: + - id + - test_file_name + - status + - retry_of + - duration_seconds + - fail_reason + - cancellation_reason + - simulator_name + - config + properties: + id: + type: integer + description: The result ID. + test_file_name: + type: string + description: The flow file, relative to the flows folder that was run. + example: ./login-test/onboarding.yaml + test_name: + type: [string, 'null'] + description: The flow's name. Full view only. + status: + $ref: '#/components/schemas/TestStatus' + description: | + The test's state. This endpoint still reports a queued test as + `PENDING` rather than `QUEUED`, so treat both as "not started yet". + retry_of: + type: [integer, 'null'] + description: For a retry, the `id` of the attempt it retried. + created_at: + type: string + format: date-time + description: Full view only. + duration_seconds: + type: [integer, 'null'] + fail_reason: + type: [string, 'null'] + cancellation_reason: + type: [string, 'null'] + description: | + Why a cancelled test was cancelled, when a reason was recorded. + `superseded_by:` means a newer run from the same CI context + replaced this one. `Cancelled: payment for this run failed.` means the + run's payment failed after its tests were created. A test you cancel + yourself has `null`. Match on the `superseded_by:` prefix, as more + reasons may be added. + simulator_name: + type: string + description: The device the test ran on, such as `pixel-7-api-34`. + platform: + $ref: '#/components/schemas/Platform' + description: Full view only. + config: + type: object + description: | + The test's run settings. With `view=summary`, only `deviceName` and + `osVersion`, each left out when not recorded. + properties: + deviceName: + type: string + example: Pixel 7 + osVersion: + type: [string, number] + example: '34' + additionalProperties: true + result_files: + type: [object, 'null'] + description: | + References to the test's stored logs, screenshots and video, with the + step log itself in `main_log` when it is 512 KB or smaller. Full view + only. To download the files, use `POST /results/{uploadId}/download`. + additionalProperties: true + DownloadArtifactsRequest: + type: object + required: [results] + properties: + results: + type: string + enum: [FAILED, ALL] + description: | + `FAILED` for the failed tests' files only, `ALL` for every test's. + Any other value counts as `ALL`. + FlowSummaries: + type: object + required: [statusCode, flows] + properties: + statusCode: + type: integer + const: 200 + flows: + type: array + items: + $ref: '#/components/schemas/FlowSummary' + FlowSummary: + type: object + required: + - flow_name + - file_name + - last_run_at + - total_runs + - passed_runs + - failed_runs + - pass_rate + - avg_duration + - daily_data + - tags + properties: + flow_name: + type: string + file_name: + type: string + description: The flow file, relative to the flows folder that was run. Pass it to `GET /flows/runs`. + last_run_at: + type: string + format: date-time + total_runs: + type: integer + description: | + Every run in the range whatever its status, including cancelled and + unfinished runs and each retry, so it can be more than + `passed_runs` + `failed_runs`. + passed_runs: + type: integer + failed_runs: + type: integer + pass_rate: + type: integer + minimum: 0 + maximum: 100 + description: '`passed / (passed + failed)` as a whole percentage. `0` when nothing passed or failed.' + avg_duration: + type: integer + description: Average duration in seconds. + daily_data: + type: object + description: | + Each day with runs (`YYYY-MM-DD`, UTC) and how it went: `passed` (runs + passed and none failed), `failed` (runs failed and none passed), + `mixed` (both), or `null` (runs, but none passed or failed, for example + all cancelled). Days without runs have no entry. + additionalProperties: + type: [string, 'null'] + enum: [passed, failed, mixed, null] + tags: + type: array + items: + type: string + FlowRuns: + type: object + required: [statusCode, runs] + properties: + statusCode: + type: integer + const: 200 + runs: + type: array + items: + $ref: '#/components/schemas/FlowRun' + FlowRun: + type: object + required: [id, status, createdAt, durationSeconds, failReason, testUploadId, uploadName] + properties: + id: + type: integer + description: The result ID. + status: + $ref: '#/components/schemas/TestStatus' + createdAt: + type: string + format: date-time + durationSeconds: + type: [integer, 'null'] + failReason: + type: [string, 'null'] + testUploadId: + type: string + format: uuid + description: The upload the run belongs to. + uploadName: + type: [string, 'null'] + IpAddresses: + type: object + required: [updatedAt, ipAddresses, ranges] + properties: + updatedAt: + type: string + format: date + description: When the list last changed. + ipAddresses: + type: array + description: Every address as a bare IP, the simplest thing to allow-list. + items: + type: string + ranges: + type: array + description: Each range in detail. If your firewall takes CIDR notation, allow-list these. + items: + $ref: '#/components/schemas/IpRange' + example: + updatedAt: '2026-07-06' + ipAddresses: [46.17.215.144, 46.17.215.145, 83.217.174.249] + ranges: + - cidr: 46.17.215.144/32 + platforms: [android, ios] + type: egress + description: Test runner egress + IpRange: + type: object + required: [cidr, platforms, type, description] + properties: + cidr: + type: string + description: The range in CIDR notation. A single host is a `/32`. + platforms: + type: array + description: The runner platforms whose traffic leaves from this range. + items: + $ref: '#/components/schemas/Platform' + type: + type: string + description: The kind of traffic. Currently always `egress`, outbound from the app under test. + enum: [egress] + description: + type: string + LiveError: + type: object + description: >- + The error body. `statusCode` equals the HTTP status and `message` is for people. `error`, when present, + is a stable code to branch on. Some errors add fields: `session` (the session block), `phase` + (LIVE_NOT_READY from a command), `limit` and `retry_after_s` (LIVE_RATE_LIMITED), `limit` + (LIVE_CONCURRENCY_LIMIT), and `limit` and `spent` (LIVE_SPEND_CAP). Ignore fields you don't know. + required: [statusCode, message] + properties: + statusCode: + type: integer + description: The HTTP status. + example: 409 + message: + type: string + description: What went wrong, in words. Don't branch on it. + error: + type: string + description: |- + A stable code. Absent on errors that carry none: validation and payment errors when starting a session, an invalid `before` on artifacts, and 5xx errors other than the ones below. + + - `LIVE_NOT_ENABLED` (403): device sessions aren't enabled for your organization. + - `LIVE_CONCURRENCY_LIMIT` (409): your organization has as many sessions open as it may. + - `LIVE_SPEND_CAP` (402): your organization reached its monthly spend cap. + - `LIVE_NOT_FOUND` (404): no such session in your organization. + - `LIVE_NOT_READY` (425): the device is still starting, or an app is installing. Retry shortly. + - `LIVE_SESSION_ENDED` (409): the session has ended. + - `LIVE_INVALID_ACTION` (400): the request is malformed. The message says how. + - `LIVE_INVALID_APP` (400): the build isn't one this device can install, or the install request named neither or both of a build and a URL. + - `LIVE_APP_DOWNLOAD_FAILED` (400): the build at the URL couldn't be downloaded. + - `LIVE_NO_APP` (409): no app is installed, and none was named. + - `LIVE_COMMAND_NOT_FOUND` (404): no such command in this session. + - `LIVE_LOGS_UNAVAILABLE` (502): the device logs couldn't be read. + - `LIVE_SESSION_REQUIRED`: MCP only, never returned by this API. More than one session is open, so pass `session_id`. + - `LIVE_RATE_LIMITED` (429): too many requests. Wait `retry_after_s` seconds. + - `LIVE_NOT_WAITING_FOR_HUMAN` (409): hand-back on a session that isn't waiting for a person. + - `LIVE_HUMAN_IN_CONTROL` (409): a person has taken control in the console. Wait for them to hand back. + - `LIVE_CONSOLE_ONLY` (403): only a person signed in to the console can do this (take control). + - `LIVE_NOT_IN_CONTROL` (409): only the person in control can do this (remove a held step). + - `LIVE_SECRETS_UNAVAILABLE` (503): secrets can't be accepted right now. + enum: + - LIVE_NOT_ENABLED + - LIVE_CONCURRENCY_LIMIT + - LIVE_SPEND_CAP + - LIVE_NOT_FOUND + - LIVE_NOT_READY + - LIVE_SESSION_ENDED + - LIVE_INVALID_ACTION + - LIVE_INVALID_APP + - LIVE_APP_DOWNLOAD_FAILED + - LIVE_NO_APP + - LIVE_COMMAND_NOT_FOUND + - LIVE_LOGS_UNAVAILABLE + - LIVE_SESSION_REQUIRED + - LIVE_RATE_LIMITED + - LIVE_NOT_WAITING_FOR_HUMAN + - LIVE_HUMAN_IN_CONTROL + - LIVE_CONSOLE_ONLY + - LIVE_NOT_IN_CONTROL + - LIVE_SECRETS_UNAVAILABLE + session: + $ref: '#/components/schemas/SessionBlock' + description: >- + The session's block, on LIVE_SESSION_ENDED, LIVE_NOT_WAITING_FOR_HUMAN, LIVE_LOGS_UNAVAILABLE, and + LIVE_NOT_READY from screenshot, inspect and request-human. + phase: + type: [string, 'null'] + description: >- + LIVE_NOT_READY from a command only. The device's phase: `pending`, `cloning_device`, `starting_device`, + `booting_hierarchy` or `booting_screenshot` while it starts; `downloading_app` or `installing_app` + while an app installs. Null when the device has reported no phase yet. + example: installing_app + limit: + type: number + description: >- + LIVE_RATE_LIMITED from a per-minute limit: requests allowed a minute. LIVE_CONCURRENCY_LIMIT: sessions + your organization may have open. LIVE_SPEND_CAP: the monthly cap, in USD. + retry_after_s: + type: integer + minimum: 0 + description: LIVE_RATE_LIMITED from a per-minute limit. Seconds until the window resets. + spent: + type: number + description: LIVE_SPEND_CAP only. What your organization has spent on device sessions this month, in USD. + + SessionBlock: + type: object + description: >- + The compact block every device session response carries as `session`, so you always know the state, + the time left and the cost without another call. + required: + - id + - live_id + - state + - release_reason + - platform + - model + - client_label + - created_at + - started_at + - released_at + - expires_at + - queue + - time_remaining_s + - idle_remaining_s + - cost_so_far_usd + - last_crash + - install + - warnings + - monitor_url + properties: + id: + type: string + description: The session id. Every path takes it. + example: brave-teal-otter + live_id: + type: integer + description: The session's numeric id. Paths take it too. + example: 4182 + state: + type: string + description: >- + Where the session is. `queued`: waiting for a free device. `provisioning`: a device is starting. + `ready`: the device is up. `active`: commands have been sent. `needs_human`: waiting for a person. + `released`, `expired` and `failed` are ended. + enum: [queued, provisioning, ready, active, needs_human, released, expired, failed] + release_reason: + type: [string, 'null'] + description: Why the session ended. Null while it is open. + enum: + - user_stop + - idle_timeout + - max_duration + - queue_timeout + - spend_cap + - runner_error + - heartbeat_lost + - needs_human_timeout + - other + - null + platform: + type: string + enum: [ios, android] + model: + type: [string, 'null'] + description: >- + The device, in the form GET /device/models uses: `iphone-16-ios-26.4`, or `pixel-10-api-36` + (`-play` with Google Play). The OS version gets more precise once the device has started. + example: pixel-10-api-36 + client_label: + type: [string, 'null'] + description: Who is driving the session, such as `claude-code`. + created_at: + type: string + format: date-time + description: When the session was asked for. + started_at: + type: [string, 'null'] + format: date-time + description: When a device was assigned, which is when billing starts. Null while queued. + released_at: + type: [string, 'null'] + format: date-time + description: When it ended. Null while it is open. + expires_at: + type: [string, 'null'] + format: date-time + description: When its maximum duration ends it. Null before a device is assigned, and once it has ended. + queue: + description: Only while `queued`. Null otherwise. + oneOf: + - $ref: '#/components/schemas/SessionQueue' + - type: 'null' + time_remaining_s: + type: [integer, 'null'] + minimum: 0 + description: Seconds until the maximum duration. Null before a device is assigned, and once it has ended. + idle_remaining_s: + type: [integer, 'null'] + minimum: 0 + description: >- + Seconds until the idle timeout (5 minutes without a command). Null when the device isn't running, + and while the session waits for a person. + cost_so_far_usd: + type: [number, 'null'] + description: >- + What the session has cost so far at your organization's rate, in USD: the charge if it ended now. + Null when it couldn't be read. + last_crash: + description: The app's most recent crash in this session, or null. + oneOf: + - $ref: '#/components/schemas/AppCrash' + - type: 'null' + install: + description: The build the session was asked to install, and where that install is. Null when none was asked for. + oneOf: + - $ref: '#/components/schemas/SessionInstall' + - type: 'null' + warnings: + type: array + description: Plain-English notices, such as a long queue, an upcoming idle timeout, a crash or a failed install. + items: + type: string + monitor_url: + type: string + format: uri + description: The session's page in the console, where people can watch the screen and take control. + example: https://console.devicecloud.dev/device/brave-teal-otter + + SessionQueue: + type: object + required: [position, eta_s, waited_s, reason] + properties: + position: + type: [integer, 'null'] + description: The session's place in the queue. + eta_s: + type: [integer, 'null'] + description: >- + An estimate of the seconds still to wait. Null when there is no basis for one, or once the wait has + run past the usual one. + waited_s: + type: [integer, 'null'] + description: Seconds since the session was created. + reason: + type: string + description: >- + `fleet_busy`: the platform is at the fleet-wide limit on device sessions. `waiting_for_device`: under + that limit, waiting for a free device. Neither is an error; the session starts on its own. + enum: [fleet_busy, waiting_for_device] + + SessionInstall: + type: object + required: [binary_upload_id, status, error] + properties: + binary_upload_id: + type: string + description: The build asked for. + status: + type: string + description: '`installing` (the device hasn''t finished it), `installed`, or `failed`.' + enum: [installing, installed, failed] + error: + type: [string, 'null'] + description: Why the install failed. Null unless `status` is `failed`. + + AppCrash: + type: object + required: [at, summary] + properties: + at: + type: string + description: When the app crashed, as the device saw it. + summary: + type: string + description: A one-line summary of the crash. GET .../logs has the details. + + SessionResponse: + type: object + required: [session] + properties: + session: + $ref: '#/components/schemas/SessionBlock' + + SessionListResponse: + type: object + required: [sessions] + properties: + sessions: + type: array + maxItems: 100 + description: The sessions' blocks, newest first. + items: + $ref: '#/components/schemas/SessionBlock' + + DeviceModel: + type: object + required: [model, name, os, default_os, google_play, default] + properties: + model: + type: string + description: What `model` takes, such as `iphone-16` or `pixel-10`. + name: + type: string + description: The device's name, such as `iPhone 16`. + os: + type: array + description: What `os` takes for this model. The iOS major version, or the Android API level. + items: + type: string + default_os: + type: string + description: The OS version a session gets when it names none. + google_play: + type: boolean + description: An Android image with Google Play services. Start it with `googlePlay` true. + default: + type: boolean + description: The model a session gets when it names none. + + DeviceCatalogue: + type: object + required: [ios, android] + properties: + ios: + type: array + items: + $ref: '#/components/schemas/DeviceModel' + android: + type: array + items: + $ref: '#/components/schemas/DeviceModel' + + StartSessionRequest: + type: object + required: [platform] + properties: + platform: + type: string + enum: [ios, android] + model: + type: string + description: A model from GET /device/models, such as `iphone-16` or `pixel-10`. Default is the platform's default model. + os: + type: string + description: >- + The iOS major version (such as `26`) or Android API level (such as `36`), from GET /device/models. + A string. Default is the model's default. + googlePlay: + type: boolean + description: Android only. An image with Google Play services. Sending it on iOS is a 400. + binaryUploadId: + type: string + description: An uploaded build to install once the device is ready. It must be your organization's and for this platform. + deviceLocale: + type: string + pattern: '^[a-z]{2,3}_[A-Z]{2}$' + description: The device's locale, such as `de_DE`. Default is the device's own. + maxDurationMin: + type: integer + minimum: 1 + maximum: 60 + description: >- + End the session after this many minutes. Default and ceiling is your organization's limit, which is + at most 60. + clientLabel: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9 ._/-]{0,63}$' + maxLength: 64 + description: Who is driving the session, such as `claude-code`. Shown to people watching it. + record: + type: boolean + default: true + description: >- + Record the session's video. Only `false` turns it off. A screenshot after each action and the device + log are kept either way. + androidDevice: + type: string + deprecated: true + description: >- + Older form of `model` for Android, such as `pixel-8`. Needs `androidApiLevel`, and can't be mixed with + `model`, `os` or `googlePlay`. + androidApiLevel: + type: string + deprecated: true + description: Older form of `os` for Android, such as `34`. Needs `androidDevice`. + + InstallRequest: + type: object + description: Pass `binaryUploadId` or `url`, not both. + properties: + binaryUploadId: + type: string + description: An uploaded build of your organization's, for the session's platform. + url: + type: string + format: uri + pattern: '^https://' + description: >- + An https link to an `.apk`, or to a zipped simulator `.app` for iOS, up to 500 MB. It must be a public + host with no user name or password in the URL. The download may take up to 120 s and follow up to 5 + redirects. A build your organization already has (the same bytes) is reused. + oneOf: + - required: [binaryUploadId] + - required: [url] + + InstallResponse: + type: object + required: [app, session] + properties: + app: + type: object + required: [binary_upload_id, app_id, reused] + properties: + binary_upload_id: + type: string + description: The build being installed. For a URL, the build it was stored as. + app_id: + type: [string, 'null'] + description: The build's bundle id or package name. Null when it isn't known. + reused: + type: boolean + description: >- + True when no new build was stored: always for `binaryUploadId`, and for a `url` whose build your + organization already had. + session: + $ref: '#/components/schemas/SessionBlock' + + LaunchRequest: + type: object + properties: + appId: + type: string + minLength: 1 + maxLength: 255 + pattern: '^[A-Za-z0-9._-]+$' + description: A bundle id or package name. Default is the installed build's. + example: com.apple.Preferences + clearState: + type: boolean + description: Clear the app's data before launching it. + + DeviceAction: + description: One action. `type` picks which, and its other fields. + oneOf: + - $ref: '#/components/schemas/TapAction' + - $ref: '#/components/schemas/TypeAction' + - $ref: '#/components/schemas/SwipeAction' + - $ref: '#/components/schemas/PressKeyAction' + discriminator: + propertyName: type + mapping: + tap: '#/components/schemas/TapAction' + type: '#/components/schemas/TypeAction' + swipe: '#/components/schemas/SwipeAction' + press_key: '#/components/schemas/PressKeyAction' + + TapAction: + type: object + description: >- + Tap one target: a point (`x` and `y`), an element's `text`, or its `id`. Exactly one. Text and ids are + matched literally, not as regular expressions. + required: [type] + properties: + type: + type: string + const: tap + x: + type: number + minimum: 0 + maximum: 100 + description: Percent of the screen's width, from the left. Rounded to a whole number. + y: + type: number + minimum: 0 + maximum: 100 + description: Percent of the screen's height, from the top. Rounded to a whole number. + text: + type: string + minLength: 1 + maxLength: 1000 + description: The element's text or accessibility label (`txt` or `a11y` in inspect). + id: + type: string + minLength: 1 + maxLength: 1000 + description: The element's id (`rid` in inspect). + oneOf: + - required: [x, y] + - required: [text] + - required: [id] + + TypeAction: + type: object + description: Type text into the focused field. + required: [type, text] + properties: + type: + type: string + const: type + text: + type: string + minLength: 1 + maxLength: 1000 + sensitive: + type: boolean + default: false + description: >- + The text is a secret. It is encrypted before it is stored, never shown in the timeline, and masked + in inspect. Answers 503 LIVE_SECRETS_UNAVAILABLE when the server can't take secrets. + + SwipeAction: + type: object + description: Swipe in a `direction`, or `from` one point `to` another. Not both. + required: [type] + properties: + type: + type: string + const: swipe + direction: + type: string + enum: [up, down, left, right] + from: + $ref: '#/components/schemas/ScreenPoint' + to: + $ref: '#/components/schemas/ScreenPoint' + oneOf: + - required: [direction] + - required: [from, to] + + PressKeyAction: + type: object + description: Press a device key. + required: [type, key] + properties: + type: + type: string + const: press_key + key: + type: string + enum: [back, home, enter, backspace, tab, volume_up, volume_down, lock, power] + + ScreenPoint: + type: object + description: A point on the screen, in percent. Each value is rounded to a whole number. + required: [x, y] + properties: + x: + type: number + minimum: 0 + maximum: 100 + description: Percent of the screen's width, from the left. + y: + type: number + minimum: 0 + maximum: 100 + description: Percent of the screen's height, from the top. + + CommandResult: + type: object + description: >- + How the command went on the device. A command that waited 120 s without finishing fails with a timeout + error. One held because a person took control fails with a message saying it will run after the + hand-back; follow it on the timeline. + required: [success] + properties: + success: + type: boolean + output: + type: string + description: What Maestro reported. Often a JSON document in a string. + error: + type: string + description: Why it failed. + + CommandResponse: + type: object + required: [result, session] + properties: + result: + $ref: '#/components/schemas/CommandResult' + session: + $ref: '#/components/schemas/SessionBlock' + + RunRequest: + type: object + description: Pass `yaml` or `flowUploadId`, not both. + properties: + yaml: + type: string + pattern: '\S' + description: >- + One Maestro flow, run whole: a header naming the app, `---`, then commands. Without a header, the + installed build's app id is used. + flowUploadId: + type: string + format: uuid + description: A flow bundle of your organization's, from POST /device/bundles. + flow: + type: string + pattern: '^(?!/)(?!.*(^|/)\.\.(/|$))[\w .@+-]+(/[\w .@+-]+)*\.ya?ml$' + description: >- + With `flowUploadId` only. The entry file inside the bundle, as a relative path such as + `flows/login.yaml`. Without it, every flow in the bundle runs. + env: + type: object + description: >- + Secrets and variables for the flow, used in the YAML as `${NAME}`. They are encrypted before they are + stored, never shown in the timeline, and masked in the output and in inspect. Names starting with + `DCD_` (any case) are reserved. Answers 503 LIVE_SECRETS_UNAVAILABLE when the server can't take + secrets. + maxProperties: 50 + propertyNames: + pattern: '^(?![Dd][Cc][Dd]_)[A-Za-z_][A-Za-z0-9_]{0,63}$' + additionalProperties: + type: string + maxLength: 4096 + example: + PASSWORD: correct-horse-battery + waitSeconds: + type: number + minimum: 0 + maximum: 300 + default: 110 + description: >- + How long to wait for the flow before answering `running`, in seconds. Time spent waiting for the + device to be ready counts against it. + oneOf: + - required: [yaml] + - required: [flowUploadId] + + RunResponse: + type: object + required: [command_id, status, result, session] + properties: + command_id: + type: string + format: uuid + description: The command's id. Follow a `running` one at GET /device/sessions/{id}/commands/{commandId}. + status: + type: string + description: '`running` when the flow outlasted the wait.' + enum: [passed, failed, running] + result: + description: The flow's result. Null while it is `running`. + oneOf: + - $ref: '#/components/schemas/RunResult' + - type: 'null' + session: + $ref: '#/components/schemas/SessionBlock' + + RunResult: + type: object + required: [success] + properties: + success: + type: boolean + output: + type: string + description: >- + Maestro's report, usually a JSON document in a string. Variable values are never included, and a + failed run's command count is left out. + error: + type: string + description: >- + Why the run failed, in words: for a bundle, which flows failed and why. Includes where it failed when + that is known. + flows: + type: array + description: Each flow's outcome, for a run that reported them (a bundle). + items: + $ref: '#/components/schemas/RunFlowResult' + failed_at: + $ref: '#/components/schemas/FailureLocation' + failed_at_candidates: + type: array + minItems: 2 + description: Two or more commands fit the error, and any of them may have failed. Instead of `failed_at`. + items: + $ref: '#/components/schemas/FailureLocation' + screen_text: + type: array + maxItems: 60 + description: >- + A failed run only: the app's visible text just after the failure, without the keyboard and System UI. + Present when the screen could be read. + items: + type: string + + RunFlowResult: + type: object + required: [file, success, error] + properties: + file: + type: string + description: The flow's path inside the bundle. + success: + type: boolean + error: + type: [string, 'null'] + failed_at: + $ref: '#/components/schemas/FailureLocation' + failed_at_candidates: + type: array + minItems: 2 + items: + $ref: '#/components/schemas/FailureLocation' + + FailureLocation: + type: object + description: Where a flow failed. + required: [file, line, command, call_chain] + properties: + file: + type: string + description: The file, as a path inside the bundle. `flow.yaml` for an inline flow. + line: + type: integer + minimum: 1 + description: The line in that file, from 1. + command: + type: [string, 'null'] + description: The command as written, on one line. Null when only the position is known. + call_chain: + type: array + description: The `runFlow` lines that led there, outermost first, such as `main.yaml:4`. + items: + type: string + + CompactElement: + type: object + description: >- + One element on the screen. Keys other than `b` appear only when they apply; the state keys only when + they aren't the default. + required: [b] + properties: + b: + type: string + description: Bounds, `[left,top][right,bottom]`, in the screen's own units. + example: '[63,904][1017,1052]' + txt: + type: string + description: Text. Use it as tapOn text. + rid: + type: string + description: Resource id. Use it as tapOn id. + a11y: + type: string + description: Accessibility label, when it differs from `txt`. Use it as tapOn text. + hint: + type: string + description: Hint or placeholder text. + checked: + type: boolean + const: true + selected: + type: boolean + const: true + focused: + type: boolean + const: true + enabled: + type: boolean + const: false + description: Present only when the element is disabled. + + CompactScreen: + type: object + required: [width, height, keys, elements] + properties: + width: + type: number + height: + type: number + keys: + type: object + description: What each element key means. The same in every response. + additionalProperties: + type: string + elements: + type: array + items: + $ref: '#/components/schemas/CompactElement' + keyboard_open: + type: boolean + const: true + description: Android only. An on-screen keyboard is showing. + hidden: + type: object + description: >- + Android only, and only when some were left out: how many keyboard keys and System UI elements (status + bar, navigation bar) were hidden. `all=true` shows them. + required: [keyboard, system] + properties: + keyboard: + type: integer + minimum: 0 + system: + type: integer + minimum: 0 + + InspectResponse: + type: object + required: [screen, session] + properties: + screen: + allOf: + - $ref: '#/components/schemas/CompactScreen' + - type: object + required: [fresh] + properties: + fresh: + type: boolean + description: False when no screen read came after your last command in time; this is then the latest. + session: + $ref: '#/components/schemas/SessionBlock' + + ScreenshotResponse: + type: object + required: [screenshot, session] + properties: + screenshot: + type: object + required: [data_url, captured_at, fresh] + properties: + data_url: + type: string + description: The screen as a JPEG data URL. + captured_at: + type: [string, 'null'] + format: date-time + description: When the device took it, on the device host's clock. + fresh: + type: boolean + description: False when no new frame came within 3 s; this is then the latest there is. + session: + $ref: '#/components/schemas/SessionBlock' + + LogsResponse: + type: object + required: [logs, session] + properties: + logs: + type: object + required: [lines, source, app_id, scanned] + properties: + lines: + type: array + description: The lines, oldest first, at most 24 KB in all. + items: + type: string + source: + type: string + description: '`logcat` (Android), `ios-unified-log` (iOS), or `none` before an app is installed.' + enum: [logcat, ios-unified-log, none] + app_id: + type: [string, 'null'] + description: The app the lines belong to. Null when no app is installed. + scanned: + type: integer + minimum: 0 + description: How many of the app's newest lines were read to find these. + truncated: + type: boolean + const: true + description: Present only when older matching lines were left out to keep the answer under 24 KB. + session: + $ref: '#/components/schemas/SessionBlock' + + HumanStatus: + type: object + required: + - waiting + - reason + - since + - expires_at + - handed_back + - note + - handed_back_at + - human_in_control + - controller_user_id + properties: + waiting: + type: boolean + description: The session is `needs_human`. Keep polling. + reason: + type: [string, 'null'] + description: Why help was asked for, while waiting. + since: + type: [string, 'null'] + format: date-time + description: When help was asked for, while waiting. + expires_at: + type: [string, 'null'] + format: date-time + description: While waiting, when the session ends unless a person hands it back. + handed_back: + type: boolean + description: A person handed the session back since help was last asked for. + note: + type: [string, 'null'] + description: The person's note, if they left one. + handed_back_at: + type: [string, 'null'] + format: date-time + human_in_control: + type: boolean + description: A person has taken control in the console. Commands that change the device answer 409 LIVE_HUMAN_IN_CONTROL meanwhile. + controller_user_id: + type: [string, 'null'] + description: The console user who has control, if anyone. + + HumanResponse: + type: object + required: [session, human] + properties: + session: + $ref: '#/components/schemas/SessionBlock' + human: + $ref: '#/components/schemas/HumanStatus' + + RequestHumanRequest: + type: object + required: [reason] + properties: + reason: + type: string + minLength: 1 + maxLength: 500 + description: What the person should do. Leading and trailing spaces are trimmed before the length check. + example: Enter the 2FA code sent to the test phone, then hand back + + HandBackRequest: + type: object + properties: + note: + type: string + maxLength: 2000 + description: A note for the agent. Trimmed; a blank note counts as none. + example: Code entered, you're on the home screen + + ArtifactLink: + type: object + required: [url, content_type, bytes, created_at] + properties: + url: + type: [string, 'null'] + format: uri + description: A download link that works for about an hour. Null when no link could be made. + content_type: + type: string + description: Such as `video/mp4`, `text/plain`, `image/jpeg` or `application/json`. + bytes: + type: [integer, 'null'] + minimum: 0 + created_at: + type: string + format: date-time + + ArtifactAction: + type: object + required: [command_id, created_at, screenshot, hierarchy] + properties: + command_id: + type: [string, 'null'] + format: uuid + description: The command the screen was captured after. + created_at: + type: string + format: date-time + screenshot: + description: The screen after the action. + oneOf: + - $ref: '#/components/schemas/ArtifactLink' + - type: 'null' + hierarchy: + description: The screen dump after the action, as JSON. + oneOf: + - $ref: '#/components/schemas/ArtifactLink' + - type: 'null' + + ArtifactsResponse: + type: object + required: [session, recording, logs, actions, next_before, pending] + properties: + session: + $ref: '#/components/schemas/SessionBlock' + recording: + description: The session's video. Null until it arrives, or when the session wasn't recorded. + oneOf: + - $ref: '#/components/schemas/ArtifactLink' + - type: 'null' + logs: + description: The session's device log file. Null until it arrives. + oneOf: + - $ref: '#/components/schemas/ArtifactLink' + - type: 'null' + actions: + type: array + description: The screen after each action, newest first. + items: + $ref: '#/components/schemas/ArtifactAction' + next_before: + type: [string, 'null'] + format: date-time + description: Pass as `before` for the next, older page. Null on the last page. + pending: + type: array + description: >- + Files still on their way after the session ended: ask again shortly. Empty while the session is open, + and when nothing more is expected. + items: + type: string + enum: [recording, logs] + + TimelineEntry: + type: object + required: + - command_id + - kind + - source + - client_label + - actor + - actor_email + - status + - created_at + - completed_at + - input + - output + - error + - redacted + - screenshot + properties: + command_id: + type: string + format: uuid + kind: + type: string + enum: + - exec + - run + - tap + - type + - swipe + - press_key + - launch + - install + - stop + - logs + - human_input + - request_human + - hand_back + source: + type: string + description: Where it came from, from the `x-dcd-source` header. + enum: [cli, mcp, web, api] + client_label: + type: [string, 'null'] + actor: + type: [string, 'null'] + description: Sent by a signed-in user, or with an API key. + enum: [user, api_key, null] + actor_email: + type: [string, 'null'] + description: The user's email when `actor` is `user` and it could be looked up. + status: + type: string + enum: [pending, running, passed, failed, timeout] + created_at: + type: string + format: date-time + completed_at: + type: [string, 'null'] + format: date-time + input: + type: [string, 'null'] + description: What was sent, such as the YAML. Cut to 2000 characters, with a trailing `…`. + output: + type: [string, 'null'] + description: Cut to 2000 characters. + error: + type: [string, 'null'] + description: Cut to 2000 characters. + redacted: + type: boolean + description: The command carried a secret, which is not shown. + screenshot: + description: The screen after it, when one was captured. + oneOf: + - $ref: '#/components/schemas/ArtifactLink' + - type: 'null' + + TimelineResponse: + type: object + required: [session, timeline, next_before] + properties: + session: + $ref: '#/components/schemas/SessionBlock' + timeline: + type: array + description: Newest first. + items: + $ref: '#/components/schemas/TimelineEntry' + next_before: + type: [string, 'null'] + format: date-time + description: Pass as `before` for the next, older page. Null on the last page. + + BundleUploadUrlRequest: + type: object + properties: + bytes: + type: integer + minimum: 1 + maximum: 52428800 + description: The zip's size in bytes, at most 50 MB. + + SignedUpload: + type: object + required: [path, signedUrl] + properties: + path: + type: string + description: Where the zip will be stored. + signedUrl: + type: string + format: uri + description: '`PUT` the zip here.' + + BundleUploadUrls: + type: object + required: [id, upload, mirror] + properties: + id: + type: string + format: uuid + description: The bundle's id. Register it with this. + upload: + description: One copy. Null when it couldn't be signed. + oneOf: + - $ref: '#/components/schemas/SignedUpload' + - type: 'null' + mirror: + description: The other copy. Null when it couldn't be signed. Upload to both when both are given. + oneOf: + - $ref: '#/components/schemas/SignedUpload' + - type: 'null' + + RegisterBundleRequest: + type: object + required: [id, sha, bytes] + description: Set `supabase` or `backblaze` true, or both, for the URLs you uploaded to. + properties: + id: + type: string + format: uuid + description: The id from POST /device/bundles/upload-url. + sha: + type: string + pattern: '^[0-9a-fA-F]{64}$' + description: The SHA-256 of the zip, in hex. + bytes: + type: integer + minimum: 1 + maximum: 52428800 + description: The zip's size in bytes, at most 50 MB. + supabase: + type: boolean + description: You uploaded to the `upload` URL. + backblaze: + type: boolean + description: You uploaded to the `mirror` URL. + enc: + $ref: '#/components/schemas/BundleEnvelope' + + BundleEnvelope: + type: object + description: For a zip encrypted on the client, the key envelope it was encrypted with. Used by the `dcd` CLI. + required: [v, kek, wrapped_key] + properties: + v: + type: number + kek: + type: number + wrapped_key: + type: string + + FlowBundle: + type: object + required: [id, sha, bytes, encrypted, created_at] + properties: + id: + type: string + format: uuid + description: Pass it to `run` as `flowUploadId`. + sha: + type: string + description: The zip's SHA-256, lowercase hex. + bytes: + type: integer + encrypted: + type: boolean + description: The zip was encrypted on the client. + created_at: + type: string + format: date-time + + BundleResponse: + type: object + required: [bundle] + properties: + bundle: + $ref: '#/components/schemas/FlowBundle' diff --git a/redocly.yaml b/redocly.yaml new file mode 100644 index 0000000..a246e74 --- /dev/null +++ b/redocly.yaml @@ -0,0 +1,12 @@ +# Redocly lint config for public/openapi.yaml (the REST API spec). CI runs +# `npx @redocly/cli@2.71.2 lint public/openapi.yaml`, see deploy.yml. +extends: + - recommended +rules: + # The API isn't open source, so the spec names no license. + info-license: off + # GET /ip-addresses takes no input and has no client errors. + operation-4xx-response: off + # Flags `oneOf: [{required: [x, y]}, {required: [text]}]`, which is valid + # JSON Schema: the branches name properties declared on the parent schema. + no-required-schema-properties-undefined: off From bde4e23d1b182b7fc4f1077d0e399e6b5547c8f2 Mon Sep 17 00:00:00 2001 From: finalerock44 Date: Wed, 11 Nov 2026 12:19:00 +0000 Subject: [PATCH 2/2] docs: correct device sessions rate limits and error notes Found while writing the OpenAPI spec against the code. Handing back counts as a command, and a command's status and the human status count as reads. A rate-limited answer carries retry_after_s in the body rather than just saying how long to wait, and starting a session has no per-minute limit. Some errors (a malformed start, for one) have a message but no error code, and error responses mostly don't carry the session block. --- api/device-sessions.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/api/device-sessions.md b/api/device-sessions.md index 5fdbae4..7c68e0c 100644 --- a/api/device-sessions.md +++ b/api/device-sessions.md @@ -2,7 +2,7 @@ The REST API behind [Agent Devices](../mcp/agent-devices.md). The MCP tools call these same endpoints. Authenticate with an [API key](../getting-started/api-keys.md) in `x-app-api-key`, as for the rest of the [REST API](overview.md#authentication). A key limited to **Device sessions only** can reach these endpoints, the hosted MCP server and the build upload endpoints, but nothing else. -A session is named by its `id` (for example `brave-teal-otter`) in every path. Every response includes a `session` block: its state, time and idle time left, cost so far, warnings and console link. [The tool reference](../mcp/agent-devices-tools.md#the-session-block) lists its fields. +A session is named by its `id` (for example `brave-teal-otter`) in every path. Every successful response about a session includes a `session` block: its state, time and idle time left, cost so far, warnings and console link. [The tool reference](../mcp/agent-devices-tools.md#the-session-block) lists its fields. ## Sessions @@ -67,15 +67,15 @@ These endpoints have their own limits instead of the API's general one: | What | Limit | |---|---| -| Commands (install, launch, actions, run, asking a person) | 60 a minute, per session | -| Reads (status, inspect, screenshot, logs, timeline, artifacts) | 240 a minute, per session | +| Commands (install, launch, actions, run, asking a person, handing back) | 60 a minute, per session | +| Reads (status, inspect, screenshot, logs, a command's status, whether a person is needed, timeline, artifacts) | 240 a minute, per session | | Session lists, device models and bundle uploads | 120 a minute, per organization | -Over a limit, the API answers `LIVE_RATE_LIMITED` with how long to wait. Stopping an open session is never refused. +Over a limit, the API answers HTTP `429` with `LIVE_RATE_LIMITED`, and `retry_after_s` in the body is how many seconds to wait. Stopping an open session is never refused. Starting a session has no per-minute limit, but your organization can only have so many sessions open at once (`LIVE_CONCURRENCY_LIMIT`). ## Errors -Errors are JSON with a `message` and a stable `error` code: +Errors are JSON with a `message`. Most also have a stable `error` code, listed below. A few have only the `message`, such as a malformed request to start a session. | Code | Meaning | |---|---|