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

# Read a run, optionally waiting for it to move

> Returns the run as it is now. With `waitMs` it waits for the state to leave `sinceState` and returns as soon as it does, or at the deadline with whatever the state is then. It never hangs: the transport is stateless and there is no push channel.

EITHER ID WORKS HERE. Send the `runId` `POST /v1/runs` returned, or the run's `agentRunId`: the id every receipt the run produced carries and the one a permalink is built on. An id starting `run_` is read as an agentRunId, anything else as a runId. Both answer with the same run through the same checks, `waitMs` included. The writes below take a runId only.

This returns the run only. Its action trail is a separate, paged resource, `GET /v1/receipts?agentRunId=...`, and it is not folded in here: it is a paged collection with its own cursor and its own retention.

This route applies the calling key's device narrowing: a run on a device your key cannot address is 404, the same 404 an unknown id gets.



## OpenAPI

````yaml /api/openapi.json get /v1/runs/{runId}
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:
  /v1/runs/{runId}:
    get:
      tags:
        - Runs
      summary: Read a run, optionally waiting for it to move
      description: >-
        Returns the run as it is now. With `waitMs` it waits for the state to
        leave `sinceState` and returns as soon as it does, or at the deadline
        with whatever the state is then. It never hangs: the transport is
        stateless and there is no push channel.


        EITHER ID WORKS HERE. Send the `runId` `POST /v1/runs` returned, or the
        run's `agentRunId`: the id every receipt the run produced carries and
        the one a permalink is built on. An id starting `run_` is read as an
        agentRunId, anything else as a runId. Both answer with the same run
        through the same checks, `waitMs` included. The writes below take a
        runId only.


        This returns the run only. Its action trail is a separate, paged
        resource, `GET /v1/receipts?agentRunId=...`, and it is not folded in
        here: it is a paged collection with its own cursor and its own
        retention.


        This route applies the calling key's device narrowing: a run on a device
        your key cannot address is 404, the same 404 an unknown id gets.
      operationId: getV1RunsByRunId
      parameters:
        - name: runId
          in: path
          required: true
          description: >-
            The runId returned when the run was started, or the run's agentRunId
            (it starts `run_`).
          schema:
            type: string
        - name: waitMs
          in: query
          required: false
          description: >-
            How long to wait for a state change, in whole milliseconds, 0 to
            25000 (25 seconds). A longer wait is refused, not shortened: loop on
            the call instead of holding one open.
          schema:
            type: integer
            minimum: 0
            maximum: 25000
        - name: sinceState
          in: query
          required: false
          description: >-
            Return as soon as the state is no longer this one. Without it, the
            call returns immediately. Must be one of the run states: an unknown
            value is refused rather than treated as a state the run is never in.
          schema:
            type: string
            enum:
              - queued
              - running
              - needs_user_control
              - paused
              - succeeded
              - failed
              - canceled
            description: A run state.
      responses:
        '200':
          description: The run.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RunView'
        '400':
          description: >-
            `waitMs` was not an integer from 0 to 25000, or `sinceState` was not
            a run state. Or the id was not printable text of the allowed length.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: No credential, or one that was refused.
          headers:
            WWW-Authenticate:
              description: >-
                A bearer challenge. On a hosted deployment it names the metadata
                document to discover the authorization server from, and it adds
                `error="invalid_token"` when a credential was presented and
                rejected.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UnauthorizedError'
        '403':
          description: >-
            Authenticated, but this credential does not carry what the call
            needs. Every authenticated endpoint can also answer
            `org_not_served_by_instance` here: the organization named by the
            credential belongs to a different authentication instance, so
            re-authenticating will not help.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: >-
            No run under this id that your org can see. A run owned by another
            org answers exactly the same way, so this is not an existence
            oracle.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '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 authentication backend could not be reached. The service fails
            closed: an infrastructure fault is refused, never waved through.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      security:
        - bearerAuth: []
components:
  schemas:
    RunView:
      type: object
      properties:
        runId:
          type: string
        agentRunId:
          type: string
        deviceId:
          type: string
        goal:
          type: string
          description: >-
            What the run was asked to accomplish, in the words it was started
            with.
        executor:
          type: string
          enum:
            - phonebase
            - self_hosted
          description: >-
            Who drives this run: `phonebase` (we do) or `self_hosted` (your
            worker does).
        state:
          type: string
          enum:
            - queued
            - running
            - needs_user_control
            - paused
            - succeeded
            - failed
            - canceled
          description: >-
            Where the run is now. `succeeded`, `failed` and `canceled` are
            terminal. `paused` means a person put the agent on hold: the
            worker's channels are shut until Resume. It is not
            `needs_user_control`, which can mean the agent is asking for help or
            an interrupted hosted run is waiting for explicit continuation. A
            non-null `recovery` distinguishes the latter.
        stepSeq:
          type: integer
          description: Metered steps issued so far. One step is one frame served.
        maxSteps:
          type: integer
          description: The run's step ceiling.
        deadlineAt:
          type:
            - string
            - 'null'
          format: date-time
          description: >-
            Absolute wall clock deadline, or null when the stored deadline is
            not a renderable instant. FROZEN while the run is `paused`: the time
            the agent spends on hold is not charged to the run, and resuming
            slides this forward by however long the hold lasted. It is null
            during recovery waiting; `recovery.remainingMs` reports the saved
            time budget.
        recovery:
          $ref: '#/components/schemas/RunRecovery'
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        workerFirstContactAt:
          type:
            - string
            - 'null'
          format: date-time
          description: >-
            When a self-hosted worker first reached this run, or null when none
            ever did. On a `self_hosted` run you supply the worker, and one that
            stays queued with null here is waiting for it. A `phonebase` run is
            driven in process and never sets this.
        initiator:
          $ref: '#/components/schemas/Actor'
        scheduleId:
          type:
            - string
            - 'null'
          description: >-
            The schedule that started this run, or null when a person or a key
            did. Present and null rather than absent, so you can tell those
            apart from an older server that does not say.
        pausedAt:
          type:
            - string
            - 'null'
          format: date-time
          description: >-
            When a person put the run on hold, or null when it is not on hold.
            Present and null rather than absent, so a consumer can tell "not on
            hold" from an older server that does not report it.
        manualControlUntil:
          type:
            - string
            - 'null'
          format: date-time
          description: >-
            When the hold ends, or null when the run is not paused. This is the
            clock that runs while the run is on hold, not `deadlineAt`. Past it,
            a run nobody resumed is `canceled` with an explanation in `detail`;
            no new failure reason is used. The name is kept from when a hold was
            a person's manual control window; a person needs no window to act on
            the device now.
        lastFrame:
          type:
            - object
            - 'null'
          description: >-
            The last frame this run was served, or null when it has been served
            none. On a finished run this is the frame the model's report was
            written from, because `finish_run` ends the turn and nothing is
            captured after it, so a reader can put what the model said next to
            the screen it read that off. `viewport` is the frame's own pixel
            geometry, which is the coordinate basis it was captured in and not
            necessarily the device's current size.
          properties:
            frameId:
              type: string
            viewport:
              type: object
              properties:
                width:
                  type: integer
                height:
                  type: integer
              required:
                - width
                - height
              additionalProperties: false
          required:
            - frameId
            - viewport
          additionalProperties: false
        reason:
          type: string
          enum:
            - worker_reported_failure
            - budget_exhausted
            - deadline_exceeded
            - canceled_by_caller
            - lease_lost
            - interrupted_by_restart
          description: Why the run ended, when it did not simply succeed.
        detail:
          type: string
          description: >-
            Free text about how the run ended. A worker's own report, or, when
            the run hit its deadline, the control plane's explanation of which
            kind of timeout it was.
      required:
        - runId
        - agentRunId
        - deviceId
        - goal
        - executor
        - state
        - stepSeq
        - maxSteps
        - deadlineAt
        - createdAt
        - updatedAt
        - workerFirstContactAt
        - initiator
        - pausedAt
        - manualControlUntil
        - scheduleId
        - lastFrame
      additionalProperties: false
      description: >-
        The public projection of a run, and who started it. The run token is
        deliberately absent: it is returned once, when the run is started, and
        never again.
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
                - backend_resolution_failed
                - backend_not_implemented
                - device_unavailable
                - device_disconnected
                - lease_mismatch
                - lease_required
                - capability_unsupported
                - invalid_argument
                - backend_command_failed
                - idempotency_conflict
                - action_outcome_unknown
                - stale_frame
                - internal_error
                - unauthorized
                - permission_denied
                - run_not_found
                - frame_not_found
                - device_busy
                - admission_unavailable
                - checkout_unavailable
                - checkout_customer_balance
                - checkout_in_progress
                - quote_changed
                - payment_in_progress
                - out_of_stock
                - wrong_state
                - instance_not_served
                - reverification_required
                - not_found
                - receipt_not_found
                - rate_limited
                - payload_too_large
                - origin_not_allowed
                - api_key_from_browser_origin
                - key_predates_instance_attribution
                - org_not_served_by_instance
                - tenant_binding_unavailable
                - tenant_binding_unsettled
                - fleet_at_capacity
                - forbidden
                - service_unavailable
                - invalid_signature
                - invalid_event
                - not_settled
              description: >-
                The closed set of codes this service answers with on purpose, on
                MCP tools and REST alike (D152). What each one means and when
                you get it is on the errors page.
            message:
              type: string
              description: A human readable summary. Do not branch on it, branch on `code`.
            retryable:
              type: boolean
              description: >-
                Whether re-issuing the same call unchanged is meaningful. It is
                a property of the occurrence, not of the code, so read the field
                rather than inferring it.
          required:
            - code
            - message
          additionalProperties: true
          description: >-
            Specific errors add their own fields, such as `deviceId`, `reason`
            or `capability`. Read the ones you branch on and ignore the rest.
      required:
        - error
      additionalProperties: false
      description: >-
        The control surface error envelope. Every non 2xx JSON body on this
        surface has this shape.
    UnauthorizedError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              const: unauthorized
              description: Always `unauthorized` on this response.
            reason:
              type: string
              enum:
                - missing_credentials
                - malformed_credentials
                - invalid_token
                - token_expired
                - wrong_audience
                - missing_org
                - missing_org_role
                - unknown_key
                - revoked_key
                - expired_key
              description: The closed set of ways authentication can fail.
            hint:
              type: string
              description: >-
                A next step in prose, on the reasons where there is one to give:
                `revoked_key`, `expired_key`, `missing_org_role`, and
                `invalid_token` when the account is not a member of the named
                organization. Absent otherwise, so branch on `reason` and show
                this if it is there.
          required:
            - code
            - reason
          additionalProperties: false
      required:
        - error
      additionalProperties: false
      description: >-
        A refused credential. `unknown_key` covers both a keyId nobody has and a
        secret that does not match, because telling those apart would enumerate
        keys for you. Revocation and expiry are distinguishable, since you owned
        that key.
    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.
    RunRecovery:
      type:
        - object
        - 'null'
      description: >-
        An interrupted hosted run waiting for explicit continuation, or null.
        Older servers may omit this field. Progress and remaining budget are
        preserved; no action is replayed automatically.
      properties:
        id:
          type: string
          format: uuid
          description: >-
            This interruption episode. Send it as expectedRecoveryId when
            continuing.
        since:
          type: string
          format: date-time
        remainingMs:
          type: integer
          minimum: 0
          maximum: 9007199254740991
        blockedReason:
          type:
            - string
            - 'null'
          enum:
            - null
            - action_unresolved
            - budget_exhausted
          description: >-
            A non-null reason prevents continuation. Reading this record does
            not settle an unresolved action.
      required:
        - id
        - since
        - remainingMs
        - blockedReason
      additionalProperties: false
    Actor:
      type: object
      properties:
        kind:
          type: string
          enum:
            - api_key
            - oauth
            - dev_token
            - system
            - unattributed
          description: >-
            Which credential this was. `system` is phonebase itself: a
            schedule's timer, or phonebase driving a run, and `onBehalfOf` then
            says for whom; it is never a person. `unattributed` means the record
            cannot say, which is NOT the same as a positive claim that no key
            was involved: it is what a row written before this field existed
            reads as.
        userId:
          type: string
          description: >-
            The authorization server's subject id of the PERSON, present only
            when `kind` is `oauth`. It is an id and never a name: resolve it
            through your own directory. Nothing here stores a name or an email
            address.
        apiKeyId:
          type: string
          description: >-
            The key's id, present only when `kind` is `api_key`. The same id
            `GET /v1/api-keys` lists.
        viaCopilot:
          type: boolean
          description: >-
            Whether a copilot did this on the named person's behalf. The person
            stays responsible: a label reads like "Mira Osei via copilot".
            ALWAYS PRESENT, and false today on every record, because there is no
            copilot yet. Present and false says so; an absent field would only
            mean an older server.
        onBehalfOf:
          type: object
          properties:
            kind:
              type: string
              enum:
                - schedule
                - user
                - api_key
                - dev_token
              description: >-
                Whose work it was: `schedule` (a timer fired), `user` or
                `api_key` (phonebase drove a run that person or key started), or
                `dev_token` (the local operator).
            scheduleId:
              type: string
              description: The schedule, when `kind` is `schedule`.
            userId:
              type: string
              description: >-
                The person's subject id, when `kind` is `user`. An id, never a
                name.
            apiKeyId:
              type: string
              description: The key's id, when `kind` is `api_key`.
          required:
            - kind
          additionalProperties: false
          description: >-
            Present exactly when `kind` is `system`: the control plane acted,
            and this says for whom. Never present on any other kind.
      required:
        - kind
        - viaCopilot
      additionalProperties: false
      description: >-
        WHO. The same shape answers three questions: who took an action
        (`Receipt.actor`), who is holding a device (`Lease.holder`), and who
        started a run (`RunView.initiator`). One shape on purpose, so the three
        can never disagree about what a given credential is called.
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >-
        The control surface credential. Send `Authorization: Bearer <token>`.


        Two kinds of token are accepted and they are told apart by shape, not by
        a separate header. A token beginning `pbk_` is an org scoped API key,
        whose public half and secret half are generated together and of which
        only a hash of the secret is ever stored; anything else is treated as an
        OAuth 2.1 access token and verified against the authorization server's
        keys.


        Both resolve to the same context: an org, a principal and a set of
        scopes. Nothing downstream branches on which channel you used, with one
        deliberate exception, key management, which requires a signed-in person
        so that a key can never mint another key.


        Scopes are enforced when MCP tools are REGISTERED rather than when they
        are called, so a tool your credential cannot use is absent from
        `tools/list` rather than refused mid gesture.

````

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