> ## 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.

# Act on the device

> Look before you touch: an action is refused until this run has fetched at least one frame, because coordinates with no frame behind them have no meaning.

The action is bound to the frame it was decided on. Matching geometry is not enough on its own, since two different screens at the same resolution compare equal, so a superseded frame is refused and you are told to observe again. Retrying a request whose response you lost replays the recorded outcome instead of touching the screen twice.

A person may drive the same device beside this run, and their gestures go ahead of the run's waiting ones. Every response carries `others`, what principals other than this run did since the frame this action was bound to, and by default an action is refused `STALE_FRAME` when that count is above zero: re-observe as you already do. Send `refuseIfOthersActed: false` to be informed instead of stopped. Gestures on one device run one at a time in arrival order, and a device with too many waiting answers `ACTION_UNAVAILABLE`; wait and retry, the run is untouched.

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 post /vrx/{runToken}/v1/devices/{deviceId}/actions
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}/actions:
    post:
      tags:
        - Device surface
      summary: Act on the device
      description: >-
        Look before you touch: an action is refused until this run has fetched
        at least one frame, because coordinates with no frame behind them have
        no meaning.


        The action is bound to the frame it was decided on. Matching geometry is
        not enough on its own, since two different screens at the same
        resolution compare equal, so a superseded frame is refused and you are
        told to observe again. Retrying a request whose response you lost
        replays the recorded outcome instead of touching the screen twice.


        A person may drive the same device beside this run, and their gestures
        go ahead of the run's waiting ones. Every response carries `others`,
        what principals other than this run did since the frame this action was
        bound to, and by default an action is refused `STALE_FRAME` when that
        count is above zero: re-observe as you already do. Send
        `refuseIfOthersActed: false` to be informed instead of stopped. Gestures
        on one device run one at a time in arrival order, and a device with too
        many waiting answers `ACTION_UNAVAILABLE`; wait and retry, the run is
        untouched.


        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: postVrxByRunTokenV1DevicesByDeviceIdActions
      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
      requestBody:
        description: One action.
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DeviceAction'
      responses:
        '200':
          description: The action was applied.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeviceActionResult'
        '400':
          description: The body is not JSON, or the action is malformed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeviceError'
        '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 run is no longer driving the device, the frame is superseded,
            somebody else acted after it and you asked to be refused when they
            do, the frame size does not match, the lease has moved on, the
            device has too many actions waiting, or the device cannot do this.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeviceError'
        '413':
          description: >-
            The request body exceeds the server's limit (1 MiB). Refused before
            the body is parsed, so nothing was partially applied. Retrying the
            same body will fail the same way: send less. Per-field limits are
            tighter than this and are documented on the fields themselves; this
            is the backstop underneath them. The shape is the same on ordinary
            JSON surfaces, /vrx included. The explicitly configured APK content
            stream documents its own byte ceiling.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayloadTooLargeError'
        '422':
          description: A coordinate is outside the frame.
          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.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeviceError'
      security: []
components:
  schemas:
    DeviceAction:
      description: >-
        One action, discriminated by `action`. Coordinates are SCREENSHOT PIXELS
        of the frame you last fetched, and `screen` must echo that frame's size:
        a mismatch means every coordinate aims at the wrong thing, so it is
        refused rather than scaled.


        Every variant also takes `refuseIfOthersActed`, this service's one
        addition to the dialect: the action is refused `STALE_FRAME` if another
        principal (a person driving the same device beside this run, for
        instance) acted after the frame it is bound to. On by default for a run,
        which always binds a frame; send `false` to be told through `others`
        instead. A worker that already re-observes on `STALE_FRAME` needs no new
        code.
      oneOf:
        - type: object
          properties:
            action:
              type: string
              const: tap
            coordinate_space:
              type: string
              const: screenshot_pixels
            screen:
              type: object
              properties:
                width:
                  type: integer
                height:
                  type: integer
              required:
                - width
                - height
              additionalProperties: false
            x:
              type: integer
            'y':
              type: integer
            refuseIfOthersActed:
              $ref: '#/components/schemas/RefuseIfOthersActed'
          required:
            - action
            - coordinate_space
            - screen
            - x
            - 'y'
          additionalProperties: false
          title: tap
        - type: object
          properties:
            action:
              type: string
              const: swipe
            coordinate_space:
              type: string
              const: screenshot_pixels
            screen:
              type: object
              properties:
                width:
                  type: integer
                height:
                  type: integer
              required:
                - width
                - height
              additionalProperties: false
            x1:
              type: integer
            y1:
              type: integer
            x2:
              type: integer
            y2:
              type: integer
            duration_ms:
              type: integer
              minimum: 1
              description: >-
                Swipe duration in milliseconds, a positive integer. Defaults to
                1000 when omitted; zero, a fraction or a string is refused
                rather than read as the default. Values above 10000 milliseconds
                are clamped to that ceiling rather than refused: a longer swipe
                runs at the ceiling instead of failing.
            refuseIfOthersActed:
              $ref: '#/components/schemas/RefuseIfOthersActed'
          required:
            - action
            - coordinate_space
            - screen
            - x1
            - y1
            - x2
            - y2
          additionalProperties: false
          title: swipe
        - type: object
          properties:
            action:
              type: string
              const: type_text
            text:
              type: string
              maxLength: 4096
              description: >-
                At most 4096 characters, the same ceiling the MCP type_text tool
                publishes. Longer text is refused, not cut.
            refuseIfOthersActed:
              $ref: '#/components/schemas/RefuseIfOthersActed'
          required:
            - action
            - text
          additionalProperties: false
          title: type_text
          description: >-
            A request that also asks to clear the field is refused, because the
            arm cannot clear one.
        - type: object
          properties:
            action:
              type: string
              const: press_button
            button:
              type: string
              enum:
                - home
                - back
                - enter
                - delete
                - app_switch
                - power
                - volume_up
                - volume_down
              description: Matched case insensitively.
            refuseIfOthersActed:
              $ref: '#/components/schemas/RefuseIfOthersActed'
          required:
            - action
            - button
          additionalProperties: false
          title: press_button
        - type: object
          properties:
            action:
              type: string
              const: open_app
            package:
              type: string
              description: The application id. Must be non empty.
            refuseIfOthersActed:
              $ref: '#/components/schemas/RefuseIfOthersActed'
          required:
            - action
            - package
          additionalProperties: false
          title: open_app
    DeviceActionResult:
      type: object
      properties:
        ok:
          type: boolean
          const: true
        others:
          $ref: '#/components/schemas/Others'
      required:
        - ok
        - others
      additionalProperties: false
      description: >-
        The action was applied. `others` says what principals other than this
        run did on the device since the frame the action was bound to; the
        driver ignores this body, and a worker that reads it looks again before
        its next decision when `actions` is above zero.
    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.
    PayloadTooLargeError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              const: payload_too_large
              description: Always `payload_too_large` on this response.
            message:
              type: string
              description: A human readable summary naming the limit in bytes.
          required:
            - code
            - message
          additionalProperties: false
      required:
        - error
      additionalProperties: false
      description: >-
        Returned when the request body exceeds the server limit. Identical on
        every surface, including /vrx.
    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.
    RefuseIfOthersActed:
      type: boolean
      description: >-
        Refuse this gesture (409 `stale_frame`, reason `others_acted`) if
        another principal acted on the device after `observedFrame` was taken.
        Sending `true` needs `observedFrame`; without it the body is refused as
        `invalid_argument`. Left out, it defaults by who is acting: on for an
        API key, a copilot or a run that sends `observedFrame`, off for a person
        acting directly, and off whenever no `observedFrame` is sent. You are
        told through `others` either way.
    Others:
      type: object
      properties:
        actions:
          type: integer
          description: >-
            How many actions principals other than you took on this device since
            your anchor. Pending and committed receipts count; failed ones do
            not.
        lastAt:
          type:
            - string
            - 'null'
          format: date-time
          description: >-
            When the newest of them was requested, on this service's clock. Null
            when `actions` is 0.
        by:
          type: array
          items:
            type: string
            enum:
              - person
              - agent
            description: Who they were.
          description: >-
            `person` is a signed-in person acting directly; `agent` is an API
            key, a copilot, or a run. Each appears at most once, person first.
      required:
        - actions
        - lastAt
        - by
      additionalProperties: false
      description: >-
        What everybody else did on this device since you last looked. On a
        gesture the anchor is the frame you bound it to, else your previous
        gesture that reached the device (a refused one never anchors), else the
        last sixty seconds. A person may drive the same device beside an agent's
        run; each is informed of the other through this and neither is stopped.

````

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