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/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 | |---|---| 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