> ## Documentation Index
> Fetch the complete documentation index at: https://docs.phoneuse.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Fetch a frame

> Returns raw image bytes, not JSON and not base64.

A frame is metered BEFORE the pixels are returned. If the metering write fails, the frame is withheld and the request fails: the path that could hand out an uncounted frame is the path that must not exist. The implication runs one way only, and it is stated rather than hidden: a frame that was delivered was counted, but a frame that was counted may not have been delivered, because a client can disconnect between the two.

Running out of budget refuses the frame; it does not end the run. A worker that takes one last look before reporting success should not have that success turned into a failure.

A frame is also refused, with the same 409, while the run is parked for a person (`needs_user_control`) or on hold (`paused`): the device is not observed by the worker while a person has stopped it, so a worker should wait and retry, not report failure.

A person may drive the same device beside this run without stopping it. What they did since this run's previous frame rides in three response headers, so a worker can look again before it decides: `X-Phonebase-Others-Actions`, `X-Phonebase-Others-Last-At` (absent when the count is 0) and `X-Phonebase-Others-By` (absent when the count is 0; otherwise `person`, `agent` or both, comma separated). The driver this surface exists for reads no headers and loses nothing.

This operation reads no `Authorization` header. The run token in the path IS the credential, so the whole url is secret: never log it, paste it, or commit it. Every example writes `{runToken}` for that reason.



## OpenAPI

````yaml /api/openapi.json get /vrx/{runToken}/v1/devices/{deviceId}/screenshot
openapi: 3.1.0
info:
  title: Phonebase HTTP API
  version: 0.1.0
  summary: >-
    Every HTTP operation the control plane mounts, generated from the app's own
    route table.
  description: >-
    The control plane exposes two HTTP surfaces, and reading the boundary
    between them matters more than reading any single endpoint.


    The CONTROL surface is what your backend and your operators call. It takes a
    bearer token in the `Authorization` header, and two credential channels
    resolve to the same authorization context: an OAuth 2.1 access token for
    interactive clients, or an org scoped API key for headless ones.


    The DEVICE surface, under `/vrx/{runToken}/v1`, is what a worker calls while
    executing one run. Its credential is the run token in the url path, and it
    reads no `Authorization` header at all.


    Device control itself is not REST. An agent taps, swipes and reads the
    screen over the Model Context Protocol at `POST /mcp`, one tool call at a
    time. There is no `POST /v1/devices/{id}/tap`, and if you are looking for
    one, start with the tool reference instead.


    Operations marked `"x-phonebase-modes": ["hosted"]` exist only on a hosted
    deployment; a local checkout does not mount them.
servers:
  - url: '{origin}'
    description: Your control plane origin.
    variables:
      origin:
        default: https://mcp.phonebase.co
        enum:
          - https://mcp.phonebase.co
          - http://127.0.0.1:8788
        description: >-
          The hosted deployment serves the public origin its metadata document
          advertises, and that is the default here. Select the loopback entry
          instead when you are running a local checkout, which serves the port
          `PORT` names.
security: []
tags:
  - name: Apps
    description: Save immutable application builds and manage explicit device operations.
  - name: Managed Proxy
    description: >-
      Organization-owned fixed egress resources, independent one-time orders and
      verified network relationships.
  - name: Health
    description: Liveness, unauthenticated.
  - name: Gateway enrollment
    description: >-
      How a new gateway computer trades a one-time enrollment code for its
      tunnel credential. Unauthenticated: the code is the credential.
  - name: Gateway releases
    description: >-
      What a gateway computer installs: the current edge release of a channel.
      Called with the gateway's own credential.
  - name: MCP
    description: The JSON-RPC endpoint an agent drives devices through.
  - name: Copilot
    description: >-
      Drive a device by chatting. A signed in person's own conversations,
      streamed. Hosted deployments only.
  - name: Discovery
    description: Where to authenticate. Served on hosted deployments only.
  - name: Runs
    description: Start, watch and steer a run.
  - name: API keys
    description: Mint and revoke headless credentials. Hosted deployments only.
  - name: Device surface
    description: What a worker calls with its run token to observe and act.
  - name: Usage
    description: Read your org's own receipts and consumption. Hosted deployments only.
  - name: Devices
    description: Read the fleet your credential can see. Hosted deployments only.
  - name: Notifications
    description: >-
      What a person needs to know when they are not watching, and which emails
      they receive. Hosted deployments only.
  - name: Webhooks
    description: >-
      Subscribe your own system to what happens here, and read what was
      delivered. Hosted deployments only.
  - name: Provisioning
    description: >-
      Whether an organisation is provisioned, and how it becomes so. Hosted
      deployments only, and both operations answer a declared 503 on a
      deployment that cannot provision yet.
paths:
  /vrx/{runToken}/v1/devices/{deviceId}/screenshot:
    get:
      tags:
        - Device surface
      summary: Fetch a frame
      description: >-
        Returns raw image bytes, not JSON and not base64.


        A frame is metered BEFORE the pixels are returned. If the metering write
        fails, the frame is withheld and the request fails: the path that could
        hand out an uncounted frame is the path that must not exist. The
        implication runs one way only, and it is stated rather than hidden: a
        frame that was delivered was counted, but a frame that was counted may
        not have been delivered, because a client can disconnect between the
        two.


        Running out of budget refuses the frame; it does not end the run. A
        worker that takes one last look before reporting success should not have
        that success turned into a failure.


        A frame is also refused, with the same 409, while the run is parked for
        a person (`needs_user_control`) or on hold (`paused`): the device is not
        observed by the worker while a person has stopped it, so a worker should
        wait and retry, not report failure.


        A person may drive the same device beside this run without stopping it.
        What they did since this run's previous frame rides in three response
        headers, so a worker can look again before it decides:
        `X-Phonebase-Others-Actions`, `X-Phonebase-Others-Last-At` (absent when
        the count is 0) and `X-Phonebase-Others-By` (absent when the count is 0;
        otherwise `person`, `agent` or both, comma separated). The driver this
        surface exists for reads no headers and loses nothing.


        This operation reads no `Authorization` header. The run token in the
        path IS the credential, so the whole url is secret: never log it, paste
        it, or commit it. Every example writes `{runToken}` for that reason.
      operationId: getVrxByRunTokenV1DevicesByDeviceIdScreenshot
      parameters:
        - name: runToken
          in: path
          required: true
          description: The run token. Write `{runToken}`; never write a real one down.
          schema:
            type: string
        - name: deviceId
          in: path
          required: true
          description: >-
            Must be the device this run is bound to. Anything else is refused as
            not found.
          schema:
            type: string
      responses:
        '200':
          description: The frame, as image bytes. The media type says which encoding.
          headers:
            X-Phonebase-Others-Actions:
              description: >-
                How many actions principals other than this run took on the
                device since the run's previous frame (or in the last sixty
                seconds, for a first frame). Pending and committed receipts
                count.
              schema:
                type: string
            X-Phonebase-Others-Last-At:
              description: >-
                When the newest of them was requested, ISO 8601 on the control
                plane's clock. Absent when the count is 0.
              schema:
                type: string
            X-Phonebase-Others-By:
              description: >-
                `person`, `agent`, or `person,agent`: who they were. Absent when
                the count is 0.
              schema:
                type: string
          content:
            image/png: {}
            image/jpeg: {}
        '404':
          description: >-
            Unknown, expired, terminal, or addressed at a device this run does
            not own. All four answer identically, so holding a token tells you
            nothing about which it was.


            If you reached this with an org API key rather than a run token, the
            code is misleading and the message says so: nothing is missing, this
            is the worker channel and your credential does not apply to it.
            Drive devices yourself over the MCP endpoint instead.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeviceError'
        '409':
          description: >-
            The frame was refused: either the run has no further steps (its
            budget or deadline is spent) or the run is parked for a person and
            not currently driving the device. Both cases leave the run alive.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeviceError'
        '429':
          description: >-
            Over the request budget for this caller. Hosted deployments only.
            Which budget applied depends on whether this request carried an
            `Authorization` header, not on which endpoint it was aimed at. Wait
            `Retry-After` seconds and retry the same request: the refusal
            happens before any work, so nothing was partially applied. See
            /api/rate-limits.
          headers:
            Retry-After:
              description: >-
                Whole seconds until one request is available again. Computed
                from the caller's own remaining allowance, not a fixed constant.
              schema:
                type: string
            RateLimit-Limit:
              description: >-
                The budget in effect for this caller class, in requests per
                minute.
              schema:
                type: string
            RateLimit-Remaining:
              description: >-
                Whole requests that were still available when this request was
                admitted. Zero on a 429.
              schema:
                type: string
            RateLimit-Reset:
              description: >-
                Seconds until the allowance is back to full. Longer than
                `Retry-After`, which is the wait for a single request.
              schema:
                type: string
            RateLimit-Policy:
              description: The budget and its window, as `<limit>;w=60`.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RateLimitedError'
        '503':
          description: >-
            The device is not reachable, the frame carries no identity, or
            metering was unavailable.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeviceError'
      security: []
components:
  schemas:
    DeviceError:
      type: object
      properties:
        ok:
          type: boolean
          const: false
        error:
          type: object
          properties:
            code:
              type: string
              enum:
                - DEVICE_NOT_FOUND
                - INVALID_ACTION
                - FRAME_VIEWPORT_MISMATCH
                - COORDINATE_OUT_OF_REACH
                - ACTION_UNAVAILABLE
                - FENCING_TOKEN_STALE
                - STALE_FRAME
                - HARDWARE_OFFLINE
                - FRAME_UNAVAILABLE
                - ACTION_OUTCOME_UNKNOWN
              description: The dialect code to branch on.
            message:
              type: string
              description: >-
                A constant summary per branch. Internal error text is never
                forwarded here.
            details:
              type: object
              description: >-
                Structured detail for the branches that have any (spec §8,
                §15.4). `STALE_FRAME` carries `reason` (`unknown_frame`,
                `superseded`, `expired`, `viewport_changed`, `others_acted`;
                read anything else as `unknown_frame`), and an
                `ACTION_UNAVAILABLE` raised by a full device queue carries
                `retryAfterMs`. Absent on every other branch, and it never
                carries internal text.
              additionalProperties: true
          required:
            - code
            - message
          additionalProperties: false
      required:
        - ok
        - error
      additionalProperties: false
      description: >-
        The device surface answers in the dialect's own error shape, not the
        control surface envelope.
    RateLimitedError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              const: rate_limited
              description: Always `rate_limited` on this response.
            message:
              type: string
              description: >-
                A human readable summary. The numbers are in the headers, not in
                here.
          required:
            - code
            - message
          additionalProperties: false
      required:
        - error
      additionalProperties: false
      description: >-
        Over the request budget. Raised by the rate limiter before any handler
        runs, which is why its code is not one of the action codes: nothing was
        attempted, so no action failed.

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.