openapi: "3.1.0"
info:
  title: Lusor API
  version: "0.1.0"
  description: |
    Run AI agents on real phones, or drive a phone directly with
    deterministic actions.

    ## Authentication

    Every `/v1/*` endpoint except `GET /v1/health` requires an API key.
    Create one in the Lusor dashboard under **Settings → API Keys**. A key
    is shown once and is bound to one workspace. Revoking it there stops it
    working immediately.

    Send it in either header:

    - `X-Api-Key: <your API key>`
    - `Authorization: Bearer <your API key>`

    Because a key is bound to its workspace, `workspaceId` query and body
    parameters are optional for API-key callers. A request that names a
    different workspace returns an opaque `404`.

    ## Runs

    Use `POST /v1/agents/{id}/turns` to start or continue an agent run.
    Read the run under `/v1/sessions/{id}`; poll
    `GET /v1/sessions/{id}/status` or `GET /v1/sessions/{id}/events` for
    progress.

    ## Errors

    Errors are JSON: `{ "error": "<code>", "message": "<text>" }`. A
    request that fails validation returns `400` in one of two shapes,
    depending on the endpoint: `{ "error": "validation_error", "details":
    { ... } }` (direct phone control actions, for example), or
    `{ "error": "bad_request", "message": "<text>" }` (agent endpoints, for
    example). Branch on `error`.
  contact:
    name: Lusor
    url: https://lusor.ai
  license:
    name: Proprietary
    url: https://lusor.ai/terms

servers:
  - url: https://api.lusor.ai
    description: Production

security:
  - ApiKey: []
  - BearerAuth: []

tags:
  - name: Agents
    description: Persistent agents, the phone each one uses, and their turns (runs).
  - name: Secrets
    description: |
      Secrets saved in a workspace (passwords, sign-in details). A run uses
      them by name through `secrets`; values never travel in a request.
  - name: Sessions
    description: |
      Run records: list, inspect, stop, and read events and artifacts. Start
      a run with `POST /v1/agents/{id}/turns`, or without an agent through
      `POST /v1/workspaces/{workspaceId}/devices/{deviceId}/sessions`.
  - name: LiveView
    description: |
      View-only WebRTC live view of a session's phone: signaling, ICE
      servers, and stream control. API keys get live view only on the
      customer's own phone, not on a Lusor Phone.
  - name: Devices
    description: |
      The phones in your workspace, and reads of their current screen and
      UI tree.
  - name: DeviceVerbs
    description: |
      Direct phone control: deterministic actions (tap, swipe, type, key,
      clear, back, home, recent, screenshot, apps/start, apps/stop) and
      chains of them. On a Lusor Phone, API-key calls meter phone minutes.
  - name: Shell
    description: |
      Restricted shell escape hatch. Commands are checked against a
      denylist and audit-logged. No command output is returned.
  - name: Datasets
    description: Lists a run produced, saved as reusable Datasets.
  - name: Skills
    description: Reusable automation skills (Markdown documents).
  - name: PhoneNumbers
    description: |
      Workspace phone numbers and their stored SMS messages. Read-only for
      API keys.
  - name: Emails
    description: |
      Workspace Agent Mail addresses and stored inbound mail.
      Read-only: there is no compose, reply, or send endpoint, and message
      bodies are returned as raw plain text without HTML execution, remote
      asset fetches, or code extraction.
  - name: Workspaces
    description: The workspace an API key is bound to.
  - name: Catalog
    description: The catalog of apps that can be installed on a Lusor Phone.
  - name: Health
    description: Service health check.

paths:
  # ─── Sessions ───────────────────────────────────────────────────────────────

  /v1/sessions/{id}:
    get:
      operationId: getSession
      summary: Get session detail
      tags: [Sessions]
      parameters:
        - $ref: "#/components/parameters/SessionId"
      responses:
        "200":
          description: Session detail
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SessionDetail"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateSession
      summary: Rename a session
      description: |
        Sets the session's display name. Send `name: null` to clear it, so
        the name is derived from the prompt again. Returns the updated
        session.

        Workspace scope is resolved from `workspaceId` in the JSON body, or
        the workspace bound to the API key, so API-key callers may omit it.
      tags: [Sessions]
      parameters:
        - $ref: "#/components/parameters/SessionId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateSessionInput"
      responses:
        "200":
          description: Updated session
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SessionDetail"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/sessions/{id}/events:
    get:
      operationId: getSessionEvents
      summary: Get session events
      tags: [Sessions]
      parameters:
        - $ref: "#/components/parameters/SessionId"
        - name: since
          in: query
          description: Only return events after this ISO 8601 timestamp.
          schema:
            type: string
            format: date-time
      responses:
        "200":
          description: Event list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/SessionEvent"
                required: [data]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/sessions/{id}/breakdown:
    get:
      operationId: getSessionBreakdown
      summary: Get the detailed step breakdown (session JSON)
      description: |
        The replay view's JSON breakdown: the session detail and its full event
        list as one `lusor.session.v1` document. Included only on App tiers with
        the detailed step breakdown (Hobby and above); other tiers get 403
        `detailed_breakdown_not_in_plan` and no session data. The same
        retention window as `GET /v1/sessions/{id}` and `/events` applies.
      tags: [Sessions]
      parameters:
        - $ref: "#/components/parameters/SessionId"
      responses:
        "200":
          description: Session breakdown document
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      schema:
                        type: string
                        enum: [lusor.session.v1]
                      exportedAt:
                        type: string
                        format: date-time
                      session:
                        $ref: "#/components/schemas/SessionDetail"
                      events:
                        type: array
                        items:
                          $ref: "#/components/schemas/SessionEvent"
                    required: [schema, exportedAt, session, events]
                required: [data]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: "`detailed_breakdown_not_in_plan`: the workspace's App tier does not include the detailed step breakdown."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          $ref: "#/components/responses/NotFound"
        "503":
          description: "`history_policy_unresolved`: the workspace's plan cannot be resolved right now; nothing is returned."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # ─── Skills ─────────────────────────────────────────────────────────────────

  /v1/skills:
    get:
      operationId: listSkills
      summary: List skills
      tags: [Skills]
      parameters:
        - name: scope
          in: query
          required: false
          schema:
            type: string
            enum: [own, public]
            default: own
          description: >-
            `own` lists the caller's skills; `public` lists the public
            catalog (read-only for non-owners). Any other value behaves as
            `own`.
      responses:
        "200":
          description: Skill list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Skill"
                required: [data]
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createSkill
      summary: Create a skill
      description: |
        Creates a private skill owned by the caller. The slug is derived from
        `title`; a title that slugs to the same value as another of your
        skills returns `409 skill_exists`.
      tags: [Skills]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateSkillInput"
      responses:
        "201":
          description: Skill created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Skill"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          description: Title slug conflicts with another owned skill (`skill_exists`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/skills/{id}:
    get:
      operationId: getSkill
      summary: Get a skill
      tags: [Skills]
      parameters:
        - $ref: "#/components/parameters/SkillId"
      responses:
        "200":
          description: Skill detail
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Skill"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateSkill
      summary: Update a skill
      description: >-
        Partial update. When `clientRevision` is supplied the update is
        applied only if it is newer than the stored revision — this orders
        concurrent PATCHes (e.g. an in-flight autosave racing the Skills
        panel's unload flush) so an older request landing last cannot
        revert newer values. A stale revision returns the current row.
      tags: [Skills]
      parameters:
        - $ref: "#/components/parameters/SkillId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                title:
                  type: string
                  minLength: 1
                  maxLength: 200
                content:
                  type: string
                  maxLength: 20000
                tags:
                  type: array
                  items:
                    type: string
                    minLength: 1
                    maxLength: 64
                  maxItems: 20
                appPackage:
                  type: [string, "null"]
                  minLength: 1
                  maxLength: 256
                  description: Send `null` to clear the package association.
                resultSchema:
                  type: [object, "null"]
                folder:
                  type: [string, "null"]
                  minLength: 1
                  maxLength: 120
                  description: Folder grouping label; send `null` to clear.
                clientRevision:
                  type: integer
                  format: int64
                  minimum: 0
                  maximum: 9007199254740991
                  description: Monotonic client revision for update ordering.
      responses:
        "200":
          description: Updated skill (or current row when the revision was stale)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Skill"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Title slug conflicts with another owned skill (`skill_exists`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    delete:
      operationId: deleteSkill
      summary: Delete a skill
      tags: [Skills]
      parameters:
        - $ref: "#/components/parameters/SkillId"
      responses:
        "204":
          description: Skill deleted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  # ─── Phone numbers ──────────────────────────────────────────────────────────

  /v1/workspaces/{workspaceId}/vault:
    get:
      operationId: listWorkspaceSecrets
      summary: List the secrets saved in a workspace
      description: |
        Lists the secrets saved in the workspace, names only. Reference them
        by `<slug>.<field>` in a session's `secrets`. Secret values are
        never returned. With an API key, no field values are returned at
        all. Secrets are added, changed and deleted in the Lusor app's
        Vault, not with an API key.
      tags: [Secrets]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
      responses:
        "200":
          description: The workspace's saved secrets.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WorkspaceSecretListResponse"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/workspaces/{workspaceId}/numbers:
    get:
      operationId: listPhoneNumbers
      summary: List workspace phone numbers
      description: |
        Returns numbers shared within the authorized workspace, including
        country, paid coverage and lifecycle information. Numbers have no
        manual assignments or default agent/device/app bindings.
      tags: [PhoneNumbers]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
      responses:
        "200":
          description: Workspace phone-number inventory
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PhoneNumberListResponse"
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/workspaces/{workspaceId}/numbers/{phoneNumberId}:
    get:
      operationId: getPhoneNumber
      summary: Get one workspace phone number
      description: |
        Returns workspace-owned number details without manual associations.
        Unknown or foreign numbers return an opaque 404.
      tags: [PhoneNumbers]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/PhoneNumberIdPath"
      responses:
        "200":
          description: Phone-number detail
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PhoneNumberResponse"
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/workspaces/{workspaceId}/numbers/{phoneNumberId}/messages:
    get:
      operationId: listPhoneNumberMessages
      summary: Page stored SMS messages for a workspace number
      description: |
        Read-only human inbox. Returns raw stored text and codes, never parsed
        OTPs or inferred sender identities. Requires an API key bound to this
        exact workspace.
        Unknown, foreign, or malformed workspace/number IDs return opaque 404s.
        No provider polling, writes, or outbound SMS occurs.

        Ordered by received_at DESC, id DESC, including equal timestamps.
        Pass nextCursor back unchanged to load older retained history; null
        means no further rows were observed. This is not a snapshot: newly
        received messages require refreshing the first page and retention may
        remove older messages. Reset pagination when workspace, number, or
        sender changes. No total, unread count, or complete sender list is implied.
        Unknown/repeated query parameters and malformed values return 400.
        All responses use Cache-Control: no-store.
      tags: [PhoneNumbers]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/PhoneNumberIdPath"
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
          description: Decimal integer without signs, whitespace, or leading zeroes.
        - name: cursor
          in: query
          schema: { type: string, minLength: 1, maxLength: 512, pattern: '^[A-Za-z0-9_-]+$' }
          description: Opaque validated pagination cursor from the previous page.
        - name: sender
          in: query
          schema: { type: string, minLength: 1, maxLength: 4096 }
          description: Exact case-sensitive stored sender string; no trimming or brand inference. Omit to include null senders.
      responses:
        "200":
          description: Bounded page of stored messages (may be empty)
          headers:
            Cache-Control:
              schema: { type: string, const: no-store }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PhoneNumberMessageListResponse"
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ─── Agent Mail ─────────────────────────────────────────────────────────────

  /v1/emails/addresses:
    get:
      operationId: listEmailAddresses
      summary: List the workspace's Agent Mail addresses
      description: |
        Read-only Agent Mail identity list. Returns only addresses owned by the
        caller's workspace.

        `workspaceId` is optional for an API key, which is bound to its
        workspace. A request that resolves
        no workspace at all returns 400; a workspace UUID that does not exist,
        or that the caller cannot access, returns an opaque 404. An empty list
        is the expected state until mail transport is live — nothing is
        concealed or synthesized. All responses use Cache-Control: no-store.
      tags: [Emails]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdQuery"
      responses:
        "200":
          description: Workspace Agent Mail addresses (may be empty)
          headers:
            Cache-Control:
              schema: { type: string, const: no-store }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EmailAddressListResponse"
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/emails/messages:
    get:
      operationId: listEmailMessages
      summary: Page stored inbound mail for a workspace address
      description: |
        Read-only human inbox. Returns stored sender fields, subject, and the
        raw plain-text body — never parsed codes, executed HTML, or remotely
        fetched assets. The address is resolved inside the caller's workspace:
        an absent, repeated, foreign, or malformed addressId returns the same
        opaque 404 and never another workspace's mail.

        Ordered by received_at DESC, id DESC, including equal timestamps.
        Pass nextCursor back unchanged to load older retained mail; null means
        no further rows were observed. This is not a snapshot: newly received
        mail requires refreshing the first page, and stored retention — not
        this API — is what removes older messages. Expiry is enforced against
        the database clock, so a message past its stored retention window is
        never returned. Reset pagination when the
        workspace or address changes. No total, unread count, or complete
        sender list is implied.

        `workspaceId` is resolved before this endpoint's own validation and
        is optional for an API key, which is bound to its workspace. A request that
        resolves no workspace at all returns 400; a workspace UUID that does
        not exist, or that the caller cannot access, returns an opaque 404.
        Unknown or repeated `limit`/`cursor` values, and any other unknown
        query parameter, return 400 — `addressId` is governed by the 404 rule
        above, not this one. All responses use Cache-Control: no-store.
      tags: [Emails]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdQuery"
        - name: addressId
          in: query
          required: true
          schema: { type: string, format: uuid }
          description: Agent Mail address UUID, resolved inside the caller's workspace. Absent, repeated, foreign, and malformed ids all return an opaque 404.
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
          description: Decimal integer without signs, whitespace, or leading zeroes.
        - name: cursor
          in: query
          schema: { type: string, minLength: 1, maxLength: 512, pattern: '^[A-Za-z0-9_-]+$' }
          description: Opaque validated pagination cursor from the previous page.
      responses:
        "200":
          description: Bounded page of stored mail (may be empty)
          headers:
            Cache-Control:
              schema: { type: string, const: no-store }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EmailMessageListResponse"
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ─── Devices ────────────────────────────────────────────────────────────────

  /v1/devices:
    get:
      operationId: listDevices
      summary: List devices in the key's workspace
      description: |
        Returns every phone in the API key's workspace, including offline
        ones. The same list as `GET /v1/workspaces/{workspaceId}/devices`,
        without needing the workspace id in the path.
      tags: [Devices]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdQuery"
      responses:
        "200":
          description: Device list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Device"
                required: [data]
        "401":
          $ref: "#/components/responses/Unauthorized"

  # ─── Workspace-scoped devices and direct control ────────────────────────────
  #
  # A device outside the caller's workspace returns an opaque 404.

  /v1/workspaces/{workspaceId}/devices:
    get:
      operationId: listWorkspaceDevices
      summary: List devices in a workspace
      tags: [Devices]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
      responses:
        "200":
          description: Device list (workspace-scoped)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Device"
                required: [data]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  # ─── Deploy and remove a Lusor Phone (STL-2754) ─────────────────────────────
  #
  # Deploying a phone raises the workspace's Phone plan quantity by one;
  # removing one lowers it by one. Each is a preview (the price, nothing
  # charged) followed by a confirmation that names the preview. Callers: an
  # API key for the workspace, or a tenant administrator.

  /v1/workspaces/{workspaceId}/devices/deploy/preview:
    post:
      operationId: previewDeviceDeploy
      summary: Preview deploying Lusor Phones
      description: |
        Quotes `quantity` more Lusor Phones (default 1) on the workspace's
        Phone plan, as one change: the
        prorated amount charged today for the rest of the current period,
        and the next full invoice. Nothing is charged. The quote expires
        after 10 minutes; confirm it with `POST /devices/deploy`.

        Phones are deployed into the tenant's Default workspace only; any
        other workspace answers 409 `default_workspace_only`.
      tags: [Devices]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeviceDeployPreviewInput"
      responses:
        "200":
          description: The quote
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/DeviceChangePreview"
                required: [data]
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PhoneLimit"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/DeviceChangeConflict"
        "503":
          $ref: "#/components/responses/BillingUnavailable"

  /v1/workspaces/{workspaceId}/devices/deploy:
    post:
      operationId: deployDevice
      summary: Deploy the quoted Lusor Phones
      description: |
        Confirms a deploy quote: the Phone plan quantity rises by the quoted
        number of phones and the quoted amount is charged to the saved card
        now. If the payment does not go through, the quantity stays
        unchanged, `paymentPending` is true and no phone is deployed;
        complete the payment in billing management.

        Each new phone appears in `GET /devices` once the payment is
        confirmed (seconds), and is ready to use once it has deployed
        (minutes).
      tags: [Devices]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeviceChangeConfirmInput"
      responses:
        "200":
          description: The plan after the change
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/DeviceChangeResult"
                required: [data]
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PhoneLimit"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/DeviceChangeConflict"
        "503":
          $ref: "#/components/responses/BillingUnavailable"

  /v1/workspaces/{workspaceId}/devices/{deviceId}/remove/preview:
    post:
      operationId: previewDeviceRemove
      summary: Preview removing a Lusor Phone
      description: |
        Quotes removing this Lusor Phone from the Phone plan: the plan
        quantity falls by one and the unused rest of the current period is
        credited to the account balance (applied to the next invoice).
        Nothing changes yet. The quote expires after 10 minutes; confirm it
        with `POST /devices/{deviceId}/remove`.

        Refused with 409 `device_busy` while a session is running on the
        phone, and 409 `last_phone` for the plan's last phone (cancel the
        plan in billing management instead).
      tags: [Devices]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
      responses:
        "200":
          description: The quote
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/DeviceChangePreview"
                required: [data]
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/DeviceNotFound"
        "409":
          $ref: "#/components/responses/DeviceChangeConflict"
        "503":
          $ref: "#/components/responses/BillingUnavailable"

  /v1/workspaces/{workspaceId}/devices/{deviceId}/remove:
    post:
      operationId: removeDevice
      summary: Remove a Lusor Phone
      description: |
        Confirms a removal quote. This phone, and no other, stops accepting
        runs at once; the plan quantity falls by one, and the phone is then
        wiped and released. Its data cannot be recovered. A session started
        on it since the quote refuses the removal with 409 `device_busy`.
      tags: [Devices]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeviceChangeConfirmInput"
      responses:
        "200":
          description: The plan after the change
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/DeviceRemoveResult"
                required: [data]
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/DeviceNotFound"
        "409":
          $ref: "#/components/responses/DeviceChangeConflict"
        "503":
          $ref: "#/components/responses/BillingUnavailable"

  /v1/workspaces/{workspaceId}/devices/{deviceId}/geo:
    get:
      operationId: getDeviceGeo
      summary: Get a Lusor Phone's location and timezone
      description: |
        The GPS location and timezone the phone reports to apps. In `auto`
        mode both follow the phone's Internet exit IP and are re-applied
        after every IP change. In `override` mode they are the values set
        with `PUT /geo`, and `apply.state` says whether the phone has them
        yet (`pending`, `applied`, `held` while the phone restarts or
        resets, or `failed`).
      tags: [Devices]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      responses:
        "200":
          description: The phone's location settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/DeviceGeo"
                required: [data]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/DeviceGeoConflict"
        "502":
          $ref: "#/components/responses/UpstreamError"
    put:
      operationId: setDeviceGeo
      summary: Set a Lusor Phone's location and timezone
      description: |
        Sets the GPS location and timezone the phone reports, replacing the
        automatic match to its Internet exit IP. The setting persists across
        restarts and IP changes until it is cleared with `DELETE /geo`, or
        until the phone is wiped (Wipe & reset) or removed. It can
        disagree with the exit IP's country or timezone; that is allowed.

        The change is stored at once and given to the phone in the
        background (usually within a minute); read `apply.state` with
        `GET /geo`. Allowed for an API key on its own workspace and for
        workspace admins and members.
      tags: [Devices]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeviceGeoInput"
      responses:
        "200":
          description: The stored setting
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/DeviceGeo"
                required: [data]
        "400":
          description: |
            `geo_override_invalid`: latitude, longitude or timezone is out
            of range or not an IANA zone name.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/DeviceGeoConflict"
        "502":
          $ref: "#/components/responses/UpstreamError"
        "504":
          $ref: "#/components/responses/DeviceGeoQueued"
    delete:
      operationId: clearDeviceGeo
      summary: Restore a Lusor Phone's automatic location
      description: |
        Clears a location set with `PUT /geo`. The phone's GPS location and
        timezone follow its Internet exit IP again and are re-applied now.
        Clearing when nothing is set changes nothing.
      tags: [Devices]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      responses:
        "200":
          description: The setting after the change (`mode` is `auto`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/DeviceGeo"
                required: [data]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/DeviceGeoConflict"
        "502":
          $ref: "#/components/responses/UpstreamError"
        "504":
          $ref: "#/components/responses/DeviceGeoQueued"

  /v1/workspaces/{workspaceId}/devices/{deviceId}/occupancy:
    get:
      operationId: getDeviceOccupancy
      summary: Get which agents are using, queued for, or attached to a phone
      description: |
        `active` is the run currently holding the phone (null when idle;
        `agentId` is null for a run started without an agent). `queue` lists
        agent turns waiting for the phone, oldest first. `assigned` lists the
        agents attached to the phone.
      tags: [Devices]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      responses:
        "200":
          description: The phone's occupancy
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/DeviceOccupancy"
                required: [data]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/workspaces/{workspaceId}/internet/locations:
    get:
      operationId: listInternetLocations
      summary: List the countries a Lusor Phone's Internet can exit from
      description: |
        `rotating`: the countries a phone can be bought in or moved to on the
        residential route. `persistent`: the countries a static IP is sold in.
        ISO 3166-1 alpha-2 codes, lowercase. Any other country answers 400
        `unsupported_country` at purchase and on a location change.
      tags: [Devices]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
      responses:
        "200":
          description: The countries, by connection type
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      persistent:
                        type: array
                        items: { type: string, pattern: "^[a-z]{2}$" }
                      rotating:
                        type: array
                        items: { type: string, pattern: "^[a-z]{2}$" }
                    required: [persistent, rotating]
                required: [data]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/workspaces/{workspaceId}/devices/{deviceId}/internet-location:
    get:
      operationId: getDeviceInternetLocation
      summary: Get a Lusor Phone's Internet exit country and route
      description: |
        The country the phone's Internet traffic exits from, and whether the
        phone is on its static IP (`route: "static"`) or the shared
        residential route (`route: "residential"`). `applied` is `pending`
        until the phone's route is recorded.
      tags: [Devices]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      responses:
        "200":
          description: The phone's Internet location
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/DeviceInternetLocation"
                required: [data]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: "`not_managed_phone`: the device is not a Lusor Phone."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "503":
          description: "`internet_location_unavailable`: not available yet. Retry later."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/workspaces/{workspaceId}/devices/{deviceId}/static-ip:
    get:
      operationId: getDeviceStaticIp
      summary: Get a Lusor Phone's static IP status
      description: |
        Without `operation`, returns the phone's static IP state. With the
        `operation` id returned when a static IP was requested, returns that
        operation's outcome: `200` once it settled, `202` while it is still
        in progress (poll again).
      tags: [Devices]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
        - name: operation
          in: query
          required: false
          schema:
            type: string
          description: An operation id returned when the static IP was requested.
      responses:
        "200":
          description: The static IP state
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/DeviceStaticIpStatus"
                required: [data]
        "202":
          description: The operation is still in progress; poll again.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/DeviceStaticIpStatus"
                required: [data]
        "400":
          description: |
            `bad_request`: the `operation` id is not one of this phone's, or
            no workspace could be resolved. `unsupported_country`: the
            operation failed because a static IP is not available in the
            phone's country.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: "`not_entitled`: the operation failed because this phone is not covered by the workspace's current Lusor Phone quantity."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: "`needs_review`: setting up this phone's static IP needs support; the phone stays on its current connection."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          description: "`cap_busy`: static IPs can't be set up right now. Try again later."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Busy"
        "503":
          description: "`static_ip_unavailable`: the static IP service is not available right now. Retry later."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/workspaces/{workspaceId}/devices/{deviceId}/reset:
    get:
      operationId: getDeviceReset
      summary: Get a Lusor Phone's wipe-and-reset progress
      description: |
        The state of the phone's most recent wipe and reset: `idle` (none
        has run), `running`, `succeeded`, `refused` (it did not start; see
        `reason`), or `failed`.
      tags: [Devices]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      responses:
        "200":
          description: The reset status
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/DeviceReset"
                required: [data]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: "`not_managed_phone`: the device is not a Lusor Phone."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "502":
          $ref: "#/components/responses/UpstreamError"

  /v1/workspaces/{workspaceId}/devices/{deviceId}/state:
    get:
      operationId: getDeviceState
      summary: Get device UI state
      description: |
        Captures the current UI tree and screen dimensions. The `a11yTree`
        field carries the formatted element tree (with 1-based interactive
        indices) used by `POST /tap { index }`.

        On a Lusor Phone, an API-key read meters phone minutes like any
        other direct action.
      tags: [Devices]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      responses:
        "200":
          description: Device state
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeviceState"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "404":
          $ref: "#/components/responses/NotFound"
        "502":
          $ref: "#/components/responses/UpstreamError"
        "503":
          $ref: "#/components/responses/MeterUnavailable"

  /v1/workspaces/{workspaceId}/devices/{deviceId}/screenshot:
    get:
      operationId: getDeviceScreenshot
      summary: Get fresh device screenshot (binary PNG)
      description: |
        Triggers a fresh screenshot on the device and returns the resulting
        PNG bytes. Response is binary `image/png`.

        On a Lusor Phone, an API-key read meters phone minutes like any
        other direct action.
      tags: [Devices]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      responses:
        "200":
          description: PNG bytes
          content:
            image/png:
              schema:
                type: string
                format: binary
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "404":
          $ref: "#/components/responses/NotFound"
        "502":
          $ref: "#/components/responses/UpstreamError"
        "503":
          $ref: "#/components/responses/MeterUnavailable"
    post:
      operationId: deviceScreenshotTrigger
      summary: Trigger screenshot (capture-aware verb)
      description: |
        Triggers a fresh screenshot on the device and returns
        the standard verb envelope. Pass `capture: true` (or
        `capture: { screenshot: true, ui: true }`) to receive the
        formatted element tree + `screenshot_url` in the same response.

        Unlike `GET /screenshot` (which returns binary PNG), this verb
        is the chain-compatible variant — the same shape as `tap`,
        `swipe`, etc. so it slots into a `POST /chain` step list.
      tags: [DeviceVerbs]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EmptyVerbInput"
      responses:
        "200":
          description: Screenshot triggered (capture optional)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VerbResultWithOptionalCapture"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/InternetRouteInactive"
        "503":
          $ref: "#/components/responses/MeterUnavailable"

  /v1/workspaces/{workspaceId}/devices/{deviceId}/tap:
    post:
      operationId: deviceTap
      summary: Tap on device
      description: |
        Tap an interactive element by `{ index }` (1-based, from
        `/state.a11yTree`) OR an explicit `{ x, y }` coordinate. Supplying
        both is accepted and `index` wins. At least one of the two forms is
        required: supplying neither is a 400, and `x` without `y` is also a
        400 when no `index` is supplied (with `index`, any coordinates are
        accepted and ignored).

        Optionally include `capture: true | CaptureOptions` to
        return a fresh element tree + screen dimensions in the same
        round trip.
      tags: [DeviceVerbs]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TapInput"
      responses:
        "200":
          description: Tap dispatched (capture optional)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TapResult"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/InternetRouteInactive"
        "503":
          $ref: "#/components/responses/MeterUnavailable"

  /v1/workspaces/{workspaceId}/devices/{deviceId}/swipe:
    post:
      operationId: deviceSwipe
      summary: Swipe gesture on device
      description: |
        Optionally include `capture: true | CaptureOptions` to
        return a fresh element tree + screen dimensions in the same
        round trip.
      tags: [DeviceVerbs]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SwipeInput"
      responses:
        "200":
          description: Swipe dispatched (capture optional)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VerbResultWithOptionalCapture"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/InternetRouteInactive"
        "503":
          $ref: "#/components/responses/MeterUnavailable"

  /v1/workspaces/{workspaceId}/devices/{deviceId}/type:
    post:
      operationId: deviceType
      summary: Type text into focused field
      description: |
        Optionally include `capture: true | CaptureOptions` to
        return a fresh element tree + screen dimensions in the same
        round trip.
      tags: [DeviceVerbs]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TypeTextInput"
      responses:
        "200":
          description: Text typed (capture optional)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VerbResultWithOptionalCapture"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/InternetRouteInactive"
        "503":
          $ref: "#/components/responses/MeterUnavailable"

  /v1/workspaces/{workspaceId}/devices/{deviceId}/key:
    post:
      operationId: deviceKey
      summary: Press a key on device
      description: |
        Send a single Android KeyEvent. Common codes: 66=Enter, 67=Del,
        61=Tab, 111=Esc, 19/20/21/22=DPad.

        Optionally include `capture: true | CaptureOptions` to
        return a fresh element tree + screen dimensions in the same
        round trip.
      tags: [DeviceVerbs]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/KeyInput"
      responses:
        "200":
          description: Key dispatched (capture optional)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VerbResultWithOptionalCapture"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/InternetRouteInactive"
        "503":
          $ref: "#/components/responses/MeterUnavailable"

  /v1/workspaces/{workspaceId}/devices/{deviceId}/clear:
    post:
      operationId: deviceClear
      summary: Clear focused field
      description: |
        Accepts an optional `capture` field identical in shape to the
        parameterised verbs.
      tags: [DeviceVerbs]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EmptyVerbInput"
      responses:
        "200":
          description: Clear dispatched (capture optional)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VerbResultWithOptionalCapture"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/InternetRouteInactive"
        "503":
          $ref: "#/components/responses/MeterUnavailable"

  /v1/workspaces/{workspaceId}/devices/{deviceId}/back:
    post:
      operationId: deviceBack
      summary: Press Back key
      description: |
        Accepts an optional `capture` field.
      tags: [DeviceVerbs]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EmptyVerbInput"
      responses:
        "200":
          description: Back dispatched (capture optional)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VerbResultWithOptionalCapture"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/InternetRouteInactive"
        "503":
          $ref: "#/components/responses/MeterUnavailable"

  /v1/workspaces/{workspaceId}/devices/{deviceId}/home:
    post:
      operationId: deviceHome
      summary: Press Home key
      description: |
        Accepts an optional `capture` field.
      tags: [DeviceVerbs]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EmptyVerbInput"
      responses:
        "200":
          description: Home dispatched (capture optional)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VerbResultWithOptionalCapture"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/InternetRouteInactive"
        "503":
          $ref: "#/components/responses/MeterUnavailable"

  /v1/workspaces/{workspaceId}/devices/{deviceId}/recent:
    post:
      operationId: deviceRecent
      summary: Press Recent Apps key
      description: |
        Accepts an optional `capture` field.
      tags: [DeviceVerbs]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EmptyVerbInput"
      responses:
        "200":
          description: Recent dispatched (capture optional)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VerbResultWithOptionalCapture"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/InternetRouteInactive"
        "503":
          $ref: "#/components/responses/MeterUnavailable"

  /v1/workspaces/{workspaceId}/devices/{deviceId}/apps/start:
    post:
      operationId: deviceStartApp
      summary: Launch an app
      description: |
        Launch an Android app by package name. The `package` and optional
        `activity` must be dotted identifiers (for example
        `com.android.settings`); anything else is a 400.

        Optionally include `capture: true | CaptureOptions` to
        return a fresh element tree + screen dimensions in the same
        round trip.
      tags: [DeviceVerbs]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/StartAppInput"
      responses:
        "200":
          description: App launch dispatched (capture optional)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VerbResultWithOptionalCapture"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/InternetRouteInactive"
        "503":
          $ref: "#/components/responses/MeterUnavailable"

  /v1/workspaces/{workspaceId}/devices/{deviceId}/apps/stop:
    post:
      operationId: deviceStopApp
      summary: Force-stop an app
      description: |
        Force-stop an Android app by package name. The `package` is
        validated the same way as `apps/start`.

        Optionally include `capture: true | CaptureOptions` to
        return a fresh element tree + screen dimensions in the same
        round trip.
      tags: [DeviceVerbs]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/StopAppInput"
      responses:
        "200":
          description: App stop dispatched (capture optional)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VerbResultWithOptionalCapture"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/InternetRouteInactive"
        "503":
          $ref: "#/components/responses/MeterUnavailable"

  /v1/workspaces/{workspaceId}/devices/{deviceId}/chain:
    post:
      operationId: deviceChain
      summary: Run a chain of actions
      description: |
        Execute a typed sequence of up to 20 deterministic actions in
        order, optionally collecting a UI capture per step. On a Lusor
        Phone, an API-key chain meters phone minutes before every step; a
        `402` or `503` stops the chain before that step reaches the phone.

        ## Capture resolution rules

        Per-step `capture` always wins. Top-level `capture` supplies
        the default policy:

          - `'none'`  (default) — no capture on any step.
          - `'each'`            — capture on every step.
          - `'final'`           — capture only on the last step.

        ## Step verbs

        `tap`, `swipe`, `type`, `key`, `clear`, `back`, `home`, `recent`,
        `start_app`, `stop_app`, `screenshot`, `wait`. Each step has the
        same params as the corresponding direct-verb route plus an
        optional `capture` override.

        ## Settling

        Steps execute back-to-back with no implicit inter-step delay.
        Insert `wait` steps (`{ verb: "wait", params: { duration_ms: N } }`,
        0–60000 ms) where the UI needs time to settle — e.g. after a tap
        that opens a screen, or before typing into a freshly focused
        field. Cumulative wait duration per chain is capped at 120s so
        the worst case stays inside the chain timeout.

        A step that completes with `ok: false` does not stop the chain:
        the next step still runs, the top-level `ok` is true only when every
        step succeeded, and each step's `ok` shows which one failed.

        A step that errors instead aborts the chain. This happens when the
        phone cannot be reached, when a step's capture fails, or when
        metering refuses a later step (`402`/`503`). The response is then
        that error alone, with no per-step results, and the steps before it
        have already run on the phone. Read the screen before you retry a
        chain.
      tags: [DeviceVerbs]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ChainRequest"
      responses:
        "200":
          description: Chain executed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChainResponse"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: |
            Billing refused a step (`credits_exhausted` or
            `billing_required`). That step and the ones after it were not
            sent; steps before it may already have run.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/InternetRouteInactive"
        "503":
          description: |
            `meter_unavailable`: metering could not continue, so that step
            and the ones after it were not sent. Steps before it may already
            have run; read the screen before retrying.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/workspaces/{workspaceId}/devices/{deviceId}/shell:
    post:
      operationId: deviceShell
      summary: Run a restricted shell command
      description: |
        Last-resort shell access. The command is checked against a
        server-side denylist (shell metacharacters such as `|`, `;`, `&`
        and backticks are refused anywhere in the string) and every
        attempt, allowed or denied, is audit-logged. The response reports
        whether the command ran; command output is not returned.
      tags: [Shell]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ShellInput"
      responses:
        "200":
          description: Command dispatched
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ShellResult"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          description: Command denied by denylist policy.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/InternetRouteInactive"
        "503":
          $ref: "#/components/responses/MeterUnavailable"

  /v1/workspaces/{workspaceId}/devices/{deviceId}/language:
    get:
      operationId: getDeviceLanguage
      summary: Get a Lusor Phone's language
      description: |
        The language last set through `PUT .../language` and how applying
        it ended. `null` fields mean none has been set. `inProgress` names a
        change that is still being applied.
      tags: [Devices]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      responses:
        "200":
          description: The language setting
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/DeviceLanguage"
                required: [data]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/DeviceControlConflict"
        "502":
          $ref: "#/components/responses/UpstreamError"
    put:
      operationId: setDeviceLanguage
      summary: Set a Lusor Phone's language
      description: |
        Sets the phone's system language (and optional region). Answers 202
        at once with the change `pending`; read `GET .../language` until
        `state` is `applied` or `failed`. The phone may restart to apply it.
        Refused with 409 `phone_busy` while a session or live viewer holds
        the phone or another change is being applied.
      tags: [Devices]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeviceLanguageInput"
      responses:
        "202":
          description: The change was accepted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/DeviceLanguage"
                required: [data]
        "400":
          $ref: "#/components/responses/DeviceControlInvalid"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/DeviceControlConflict"
        "502":
          $ref: "#/components/responses/DeviceControlUpstream"

  /v1/workspaces/{workspaceId}/devices/{deviceId}/identity:
    get:
      operationId: getDeviceIdentity
      summary: Get a Lusor Phone's identity
      description: |
        The identity fields set so far (latest value of each) and how the
        last change ended. Fields never set keep the phone's own values and
        are absent here.
      tags: [Devices]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      responses:
        "200":
          description: The identity setting
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/DeviceIdentity"
                required: [data]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/DeviceControlConflict"
        "502":
          $ref: "#/components/responses/UpstreamError"
    put:
      operationId: setDeviceIdentity
      summary: Set a Lusor Phone's identity
      description: |
        Sets the device identity apps read: brand, manufacturer, model,
        device codename, product name, build fingerprint and Android ID.
        Only the fields sent change. Raw Android properties are refused
        (400 `identity_prop_refused`). Answers 202 with the change
        `pending`; once written (`state` `applied`, `restartQueued` true)
        **the phone restarts** to apply it, which takes a few minutes. The
        values persist across restarts and resets; set the old values again
        to undo a change.
      tags: [Devices]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeviceIdentityFields"
      responses:
        "202":
          description: The change was accepted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/DeviceIdentity"
                required: [data]
        "400":
          $ref: "#/components/responses/DeviceControlInvalid"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/DeviceControlConflict"
        "502":
          $ref: "#/components/responses/DeviceControlUpstream"

  /v1/workspaces/{workspaceId}/devices/{deviceId}/advertising-id:
    get:
      operationId: getDeviceAdvertisingId
      summary: Get a Lusor Phone's last advertising-ID reset
      description: How the last advertising-ID reset ended. The ID itself is not returned.
      tags: [Devices]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      responses:
        "200":
          description: The last reset
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/DeviceAdvertisingIdReset"
                required: [data]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/DeviceControlConflict"
        "502":
          $ref: "#/components/responses/UpstreamError"

  /v1/workspaces/{workspaceId}/devices/{deviceId}/advertising-id/reset:
    post:
      operationId: resetDeviceAdvertisingId
      summary: Reset a Lusor Phone's advertising ID
      description: |
        Gives the phone a new Google advertising ID. **Irreversible**: the
        previous ID cannot be restored. Answers 202 with the reset
        `pending`; read `GET .../advertising-id` until it is `applied` or
        `failed`.
      tags: [Devices]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
      responses:
        "202":
          description: The reset was accepted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/DeviceAdvertisingIdReset"
                required: [data]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/DeviceControlConflict"
        "502":
          $ref: "#/components/responses/DeviceControlUpstream"

  /v1/workspaces/{workspaceId}/devices/{deviceId}/sessions:
    post:
      operationId: createDeviceSession
      summary: Create a new session on this device
      description: |
        Starts an agent run on this phone without a persistent agent.
        Returns `202 Accepted { id, status: "queued" }` immediately; poll
        `GET /v1/sessions/{id}/status` or `GET /v1/sessions/{id}/events`
        for progress.

        Concurrent same-device session create returns
        `429 { error: 'device_busy', currentSessionId }`.
      tags: [Sessions]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateSessionInput"
      responses:
        "202":
          description: Session queued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreateSessionResult"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: |
            The session cannot start on this phone. `internet_route_inactive`:
            the Lusor Phone's Internet connection is not active yet; retry
            once it is. `device_releasing`: the phone is being removed from
            the Phone plan; start the session on another phone.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "422":
          description: |
            `unknown_secret`: a name in `secrets` is not saved in this
            workspace. The response's `missing` lists them. No session was
            created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "412":
          description: |
            `unsupported_stealth_input`: `stealthInput` was requested for a
            phone whose agent does not advertise human-like input. No session
            was created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          description: Device busy with another session.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Busy"
    get:
      operationId: listDeviceSessions
      summary: List sessions for device
      tags: [Sessions]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
        - $ref: "#/components/parameters/DeviceIdPath"
        - name: status
          in: query
          schema:
            type: string
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        "200":
          description: Session list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/SessionV2"
                required: [data]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  # ─── Sessions: list / status / artifacts / stop ─────────────────────────────

  /v1/sessions:
    get:
      operationId: listSessions
      summary: List sessions
      description: |
        Server-side filtering over the workspace's session history, newest
        first. `deviceId` and `status` are exact single-value matches; the
        filter parameters (`statuses`, `statusMode`,
        `nameContains`, `deviceContains`, `createdBefore`, `createdAfter`)
        are applied server-side so the result stays correct across
        `limit`/`offset` pages.
      tags: [Sessions]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdQuery"
        - name: deviceId
          in: query
          description: Exact device id match.
          schema:
            type: string
            minLength: 1
        - name: status
          in: query
          description: |
            Exact single-status match, applied verbatim (not filtered to
            the known vocabulary). Use `statuses` for a multi-status
            include/exclude filter.
          schema:
            type: string
            minLength: 1
        - name: statuses
          in: query
          description: |
            Comma-separated list of DB statuses, included or excluded per
            `statusMode`. Entries outside the known vocabulary (`queued`,
            `running`, `executing`, `awaiting_human`, `completed`,
            `complete`, `cancelled`, `failed`) are dropped silently — an
            unrecognised spelling filters nothing rather than failing the
            request.
          schema:
            type: string
            minLength: 1
        - name: statusMode
          in: query
          description: |
            Applies to `statuses` only. `include` = "is" / "is any of";
            `exclude` = "is not" / "is not any of".
          schema:
            type: string
            enum: [include, exclude]
            default: include
        - name: nameContains
          in: query
          description: |
            Case-insensitive substring matched against the user-set name OR
            the first prompt. Both columns are tested on every request — the
            prompt is a second match source, not only a fallback for a
            session with no name set. The value is sanitized before it is
            matched: `,`, `(`, `)` and `"` are replaced with spaces, because
            those characters are reserved by the filter expression the term
            is embedded in — so `foo,bar` matches `foo bar`, not a stored
            `foo,bar`. A term consisting only of substituted characters
            sanitizes to empty, and the filter is then skipped: the listing
            is returned unfiltered rather than as an empty result.
          schema:
            type: string
            minLength: 1
            maxLength: 200
        - name: deviceContains
          in: query
          description: |
            Case-insensitive substring over the device name OR its id.
          schema:
            type: string
            minLength: 1
            maxLength: 200
        - name: createdBefore
          in: query
          description: |
            Exclusive upper bound on `created_at`, as a UTC instant with an
            uppercase `Z` suffix. Seconds and fractional seconds are
            optional, matching the handler (`2026-09-13T12:00Z`,
            `2026-09-13T12:00:00Z`, `2026-09-13T12:00:00.5Z`). A numeric
            offset such as `+02:00` — valid RFC 3339, but rejected by the
            handler — and any other malformed value are a 400. The `pattern`
            cannot express month lengths, so a nonexistent calendar date such
            as `2026-02-29T12:00:00Z` still matches it even though the handler
            rejects that request; no other keyword on this parameter rejects
            it either.
          schema:
            type: string
            pattern: '^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?Z$'
        - name: createdAfter
          in: query
          description: |
            Inclusive lower bound on `created_at`, as a UTC instant with an
            uppercase `Z` suffix. Seconds and fractional seconds are
            optional, matching the handler (`2026-09-13T12:00Z`,
            `2026-09-13T12:00:00Z`, `2026-09-13T12:00:00.5Z`). A numeric
            offset such as `+02:00` — valid RFC 3339, but rejected by the
            handler — and any other malformed value are a 400. The `pattern`
            cannot express month lengths, so a nonexistent calendar date such
            as `2026-02-29T12:00:00Z` still matches it even though the handler
            rejects that request; no other keyword on this parameter rejects
            it either.
          schema:
            type: string
            pattern: '^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?Z$'
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        "200":
          description: Session list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/SessionV2"
                required: [data]
        "400":
          description: |
            Either no workspace context could be resolved from the query
            string or the API key binding — returned as `{ error, message }`;
            or a query parameter failed validation — returned as
            `{ error, details }`.
          content:
            application/json:
              schema:
                anyOf:
                  - $ref: "#/components/schemas/ErrorResponse"
                  - $ref: "#/components/schemas/ValidationErrorResponse"

  /v1/sessions/{id}/status:
    get:
      operationId: getSessionStatus
      summary: Get session status (lightweight)
      tags: [Sessions]
      parameters:
        - $ref: "#/components/parameters/SessionId"
        - $ref: "#/components/parameters/WorkspaceIdQuery"
      responses:
        "200":
          description: Session status view
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SessionStatusView"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/sessions/{id}/artifacts:
    get:
      operationId: getSessionArtifacts
      summary: Get session artifacts (screenshots + ui-states + output)
      tags: [Sessions]
      parameters:
        - $ref: "#/components/parameters/SessionId"
        - $ref: "#/components/parameters/WorkspaceIdQuery"
      responses:
        "200":
          description: Artifacts bundle
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SessionArtifacts"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/sessions/{id}/stop:
    post:
      operationId: stopSession
      summary: Cancel a running session
      description: |
        Idempotent cancel. Repeated calls after a successful cancel return
        `status: "noop"`.

        Workspace scope is resolved in this order (first non-empty wins):
        `workspaceId` in the JSON body, then `?workspaceId=`, then the
        workspace bound to the API key, so API-key callers may omit it.
      tags: [Sessions]
      parameters:
        - $ref: "#/components/parameters/SessionId"
        - $ref: "#/components/parameters/WorkspaceIdQuery"
      requestBody:
        description: |
          Optional workspace scope. Takes precedence over `?workspaceId=`
          when present and non-empty.
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WorkspaceScopeInput"
      responses:
        "200":
          description: Stop request acknowledged
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StopSessionResult"
        "400":
          description: |
            Either no workspace context could be resolved from the body, the
            query string, or the API key binding; or the named session
            exists in scope but is not bound to a device
            (`Session <id> is not bound to a device`), so there is nothing
            to cancel.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/sessions/{id}/ui-states/{file}:
    get:
      operationId: getSessionUiState
      summary: Get one captured UI state of a session
      description: |
        The UI tree the agent saw at one step of the run, as the JSON array
        of nodes it was captured as. `file` is the step index followed by
        `.json`, for example `0.json`. Content follows the workspace's
        history window: a state older than the window returns `404`.
      tags: [Sessions]
      parameters:
        - $ref: "#/components/parameters/SessionId"
        - name: file
          in: path
          required: true
          schema:
            type: string
            pattern: '^[0-9]+\.json$'
          description: Step index plus `.json`, for example `3.json`.
        - $ref: "#/components/parameters/WorkspaceIdQuery"
      responses:
        "200":
          description: The UI nodes captured at this step
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  additionalProperties: true
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "502":
          $ref: "#/components/responses/UpstreamError"
        "503":
          $ref: "#/components/responses/HistoryPolicyUnresolved"

  /v1/sessions/{id}/queue/{invocationId}:
    delete:
      operationId: cancelQueuedFollowup
      summary: Cancel a queued follow-up or queued agent turn
      description: |
        Removes a turn that is waiting behind a running one, before it
        starts. `invocationId` is the `invocationId` of the `202`
        `{ queued: true }` response of `POST /v1/sessions/{id}/continue` or
        `POST /v1/agents/{id}/turns`. Idempotent: cancelling an
        already-cancelled entry returns `alreadyCancelled: true`. An unknown
        `invocationId` returns `404`.

        Workspace scope is resolved from `?workspaceId=` or the workspace
        bound to the API key.
      tags: [Sessions]
      parameters:
        - $ref: "#/components/parameters/SessionId"
        - name: invocationId
          in: path
          required: true
          schema:
            type: string
        - $ref: "#/components/parameters/WorkspaceIdQuery"
      responses:
        "200":
          description: The queued entry was cancelled
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CancelQueuedFollowupResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "502":
          $ref: "#/components/responses/UpstreamError"

  /v1/sessions/{id}/continue:
    post:
      operationId: continueSession
      summary: Send a follow-up message to a session
      description: |
        Continues the session with a new prompt on the same transcript. The
        follow-up runs on the session's phone (for an agent's session, the
        phone the agent has now). If the session is still running, the
        follow-up is queued behind it (`queued: true`) and runs when the
        current run ends. Continuing a finished session is allowed.

        Send an `Idempotency-Key` header to make retries safe: the same key
        attaches to the original follow-up instead of sending a second one.

        Workspace scope: `workspaceId` in the body, otherwise the workspace
        bound to the API key.
      tags: [Sessions]
      parameters:
        - $ref: "#/components/parameters/SessionId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ContinueSessionInput"
      responses:
        "202":
          description: Follow-up accepted, dispatched or queued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ContinueSessionResult"
        "400":
          description: |
            `bad_request`: `prompt` is missing or empty, `maxDurationSecs` is
            not a positive integer, no workspace could be resolved, or the
            session is not bound to a device.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: |
            `credits_exhausted`: the workspace is out of credits.
            `billing_required`: the session's Lusor Phone is not covered by
            an active paid plan.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: |
            The follow-up cannot run now. `agent_needs_device`: the
            session's agent has no phone attached. `recording_required`: the
            agent session started without recording; start a new turn
            instead. `device_busy`: the phone is being reset or updated.
            `trial_phone_unavailable`: the trial phone this session ran on
            is no longer available. `internet_route_inactive`: the Lusor
            Phone's Internet connection is not active yet. `device_releasing`:
            the phone is being removed from the Phone plan.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          description: "`device_busy`: the phone is running a different session; stop it first."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Busy"
        "502":
          description: |
            `upstream_error` or `dispatch_outcome_unknown`. With
            `dispatch_outcome_unknown` the follow-up may have been accepted;
            retry with the same `Idempotency-Key`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/sessions/{id}/hitl:
    post:
      operationId: answerSessionHitl
      summary: Answer the agent's question (request_human)
      description: |
        When the agent asks a question, the run pauses: the session's status
        becomes `awaiting_human` and its events include a `hitl_requested`
        event whose `data.correlation_id` identifies the question
        (`data.question`, and `data.options` when the agent offered
        answer labels). Post the answer here; the run resumes with your text
        as the reply. A question stays answerable until the session ends or
        a newer message supersedes it.

        Only plain questions can be answered here. A request for a secret
        (a `hitl_requested` event whose `data.kind` is `credential` or
        `2fa`) is answered only in the Lusor dashboard: any reply to it
        made with an API key returns `403` `secure_ask_dashboard_only` and
        stores nothing.

        A run created with an API key never reaches that state: when the
        agent asks for a secret, the run ends as `failed` with `error`
        `secret_required:<name>` (see `SessionV2.error`) instead of
        pausing, so there is nothing to answer. Save the secret in the
        workspace, then retry the run naming it in `secrets`.

        Retrying with the same text is idempotent (`idempotent: true`). A
        different answer to a question that was already answered returns
        `409`; the first answer stands.

        Workspace scope: `workspaceId` in the body, otherwise the workspace
        bound to the API key.
      tags: [Sessions]
      parameters:
        - $ref: "#/components/parameters/SessionId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AnswerHitlInput"
      responses:
        "202":
          description: Answer accepted; the run resumes
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AnswerHitlResult"
        "400":
          description: |
            `bad_request`: `correlation_id` or `response` is missing, or the
            question is a secret request. `card_details_not_allowed`: the
            question is a secret request and `response` is a payment card
            number.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `secure_ask_dashboard_only`: the question is a secret request
            (`data.kind` `credential` or `2fa`), which only the Lusor
            dashboard can answer. Nothing is stored and the run stays paused.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "402":
          description: "`credits_exhausted` or `billing_required`, as for `POST /v1/sessions/{id}/continue`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: The session, or a question with this `correlation_id` in it, was not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: |
            `conflict`: the question was already answered with different
            text; this answer was discarded. The other `409` codes of
            `POST /v1/sessions/{id}/continue` also apply.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "410":
          description: "`stale_hitl_request`: the session has ended, or a newer message superseded the question."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          description: "`device_busy`: the phone is running a different session."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Busy"
        "500":
          description: "`db_error`: the answer was not accepted and could not be rolled back; retry shortly."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "502":
          $ref: "#/components/responses/UpstreamError"

  /v1/sessions/{id}/recording:
    get:
      operationId: getSessionRecording
      summary: Get the session's video recording
      description: |
        Returns signed URLs for the recording's MP4 segments, in order.
        URLs expire at each segment's `expires_at` (Unix seconds); fetch
        this endpoint again for fresh ones.

        `recording_status` is always present:

        - `absent`: the session has no recording.
        - `pending`: the recording is still being finalized. Poll again.
        - `complete`: finished. When `data` is present it is always
          `complete`.
        - `failed`: finalization failed; `recording_status_reason` says
          why.

        `data` is null until there is playable media. A recording follows
        the workspace's history window: past it, `402` with
        `reason: "history_window"`, unless the plan includes video replay
        or the recording was made on a paid Lusor Phone plan.
      tags: [Sessions]
      parameters:
        - $ref: "#/components/parameters/SessionId"
        - $ref: "#/components/parameters/WorkspaceIdQuery"
      responses:
        "200":
          description: Recording state, and its segments when playable
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SessionRecordingResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/HistoryWindow"
        "404":
          $ref: "#/components/responses/NotFound"
        "502":
          $ref: "#/components/responses/UpstreamError"
        "503":
          $ref: "#/components/responses/HistoryPolicyUnresolved"

  /v1/sessions/{id}/replay:
    get:
      operationId: getSessionReplay
      summary: Get the session's step-by-step replay
      description: |
        Each completed action paired with the screenshot taken after it, in
        time order, with a signed `frame_url` that expires at `expires_at`
        (Unix seconds, about five minutes). Frames still uploading are left
        out until they are ready. Past the workspace's history window this
        returns `402` with `reason: "history_window"`.
      tags: [Sessions]
      parameters:
        - $ref: "#/components/parameters/SessionId"
        - $ref: "#/components/parameters/WorkspaceIdQuery"
      responses:
        "200":
          description: Replay frames
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SessionReplayResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/HistoryWindow"
        "404":
          $ref: "#/components/responses/NotFound"
        "502":
          $ref: "#/components/responses/UpstreamError"
        "503":
          $ref: "#/components/responses/HistoryPolicyUnresolved"

  /v1/sessions/{id}/replay/frame:
    get:
      operationId: getSessionReplayFrame
      summary: Download one replay frame (PNG)
      description: |
        The bytes of one replay screenshot, served by the API instead of a
        signed URL. `index` selects into the list `GET
        /v1/sessions/{id}/replay` returns, counting from 0.
      tags: [Sessions]
      parameters:
        - $ref: "#/components/parameters/SessionId"
        - $ref: "#/components/parameters/WorkspaceIdQuery"
        - name: index
          in: query
          required: true
          description: Zero-based position in the replay list. A missing or out-of-range index returns `404`.
          schema:
            type: integer
            minimum: 0
      responses:
        "200":
          description: PNG bytes
          content:
            image/png:
              schema:
                type: string
                format: binary
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/HistoryWindow"
        "404":
          $ref: "#/components/responses/NotFound"
        "502":
          $ref: "#/components/responses/UpstreamError"
        "503":
          $ref: "#/components/responses/HistoryPolicyUnresolved"

  # ─── Session live view (WebRTC, view-only) ──────────────────────────────────
  #
  # Signaling for a WebRTC view of the session's phone. The phone sends the
  # offer and its ICE candidates over `GET /stream/events`; the client answers
  # through `POST /stream/signal`.

  /v1/sessions/{id}/stream/start:
    post:
      operationId: startSessionStream
      summary: Start a live view of the session's phone
      description: |
        Asks the phone to start a WebRTC stream for this session. Open
        `GET /v1/sessions/{id}/stream/events` first to receive the phone's
        offer. TURN servers are added automatically unless you pass
        `iceServers`.

        API keys get live view only on the customer's own phone; on a Lusor
        Phone every `/stream/*` route returns `403
        live_view_not_available_for_api_keys`.
      tags: [LiveView]
      parameters:
        - $ref: "#/components/parameters/SessionId"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/StreamStartInput"
      responses:
        "200":
          $ref: "#/components/responses/StreamCommand"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/LiveViewRefused"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/SessionHasNoDevice"
        "502":
          $ref: "#/components/responses/UpstreamError"

  /v1/sessions/{id}/stream/connect:
    post:
      operationId: connectSessionStream
      summary: Re-arm a live view
      description: |
        Reconnects the WebRTC stream without starting over: the phone reuses
        its running screen capture when it can. Same body and response as
        `POST /v1/sessions/{id}/stream/start`.
      tags: [LiveView]
      parameters:
        - $ref: "#/components/parameters/SessionId"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/StreamStartInput"
      responses:
        "200":
          $ref: "#/components/responses/StreamCommand"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/LiveViewRefused"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/SessionHasNoDevice"
        "502":
          $ref: "#/components/responses/UpstreamError"

  /v1/sessions/{id}/stream/keepalive:
    post:
      operationId: keepaliveSessionStream
      summary: Extend a live view's liveness
      description: |
        Sends one extra keep-alive to the phone. The server already sends
        one every 25 seconds while a stream is active, so this is optional.
      tags: [LiveView]
      parameters:
        - $ref: "#/components/parameters/SessionId"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/StreamViewerInput"
      responses:
        "200":
          $ref: "#/components/responses/StreamCommand"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/LiveViewRefused"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/SessionHasNoDevice"
        "502":
          $ref: "#/components/responses/UpstreamError"

  /v1/sessions/{id}/stream/request_frame:
    post:
      operationId: requestSessionStreamFrame
      summary: Ask the phone for a fresh keyframe
      description: |
        Makes the phone's encoder send a keyframe, which recovers a frozen
        picture without reconnecting.
      tags: [LiveView]
      parameters:
        - $ref: "#/components/parameters/SessionId"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/StreamViewerInput"
      responses:
        "200":
          $ref: "#/components/responses/StreamCommand"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/LiveViewRefused"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/SessionHasNoDevice"
        "502":
          $ref: "#/components/responses/UpstreamError"

  /v1/sessions/{id}/stream/signal:
    post:
      operationId: signalSessionStream
      summary: Send a WebRTC answer or ICE candidate to the phone
      description: |
        Relays the client's side of the WebRTC handshake to the phone.
        `message.type` must be `webrtc_answer` or `webrtc_ice`; the rest of
        `message` is passed to the phone unchanged.
      tags: [LiveView]
      parameters:
        - $ref: "#/components/parameters/SessionId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/StreamSignalInput"
      responses:
        "200":
          $ref: "#/components/responses/StreamCommand"
        "400":
          description: "`bad_request`: `message` is missing or its `type` is not supported."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/LiveViewRefused"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/SessionHasNoDevice"
        "502":
          $ref: "#/components/responses/UpstreamError"

  /v1/sessions/{id}/stream/stop:
    post:
      operationId: stopSessionStream
      summary: Stop a live view
      tags: [LiveView]
      parameters:
        - $ref: "#/components/parameters/SessionId"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/StreamStopInput"
      responses:
        "200":
          $ref: "#/components/responses/StreamCommand"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/LiveViewRefused"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/SessionHasNoDevice"
        "502":
          $ref: "#/components/responses/UpstreamError"

  /v1/sessions/{id}/stream/ice-servers:
    get:
      operationId: getSessionStreamIceServers
      summary: Get the ICE servers for a live view
      description: |
        The STUN/TURN configuration for the client's `RTCPeerConnection`.
        The phone gets the same TURN allocation, so pass these to the
        browser unchanged. `iceServers` is null when TURN is not available;
        fall back to STUN. Rotating the credential during a live view is
        not available through the API yet: after `expiresAt`, start a new
        live view.
      tags: [LiveView]
      parameters:
        - $ref: "#/components/parameters/SessionId"
        - $ref: "#/components/parameters/WorkspaceIdQuery"
      responses:
        "200":
          description: ICE server configuration
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/IceServerIssuance"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/LiveViewRefused"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/SessionHasNoDevice"

  /v1/sessions/{id}/stream/events:
    get:
      operationId: getSessionStreamEvents
      summary: Receive the phone's WebRTC signaling (Server-Sent Events)
      description: |
        A Server-Sent Events stream. Each event is named `signal` and its
        `data` is one JSON signaling message from the phone, identified by
        its `action` (for example `webrtc_offer`, `webrtc_ice`,
        `stream_ready`). A `keep-alive` comment arrives every 15 seconds.
        The stream stays open until the client disconnects.
      tags: [LiveView]
      parameters:
        - $ref: "#/components/parameters/SessionId"
        - $ref: "#/components/parameters/WorkspaceIdQuery"
      responses:
        "200":
          description: Event stream
          content:
            text/event-stream:
              schema:
                $ref: "#/components/schemas/StreamSignalEvent"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/LiveViewRefused"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/SessionHasNoDevice"
        "502":
          $ref: "#/components/responses/UpstreamError"

  # ─── Datasets ───────────────────────────────────────────────────────────────

  /v1/sessions/{id}/datasets:
    post:
      operationId: promoteSessionDataset
      summary: Promote one session run's list to a Dataset
      description: |
        Resolves the specified run's terminal list and saves it as a Dataset.
        Promotion is idempotent on `(session_id, run_id)`: the first request
        creates the Dataset; re-promotion updates it without duplicating it.
      tags: [Datasets]
      parameters:
        - $ref: "#/components/parameters/SessionId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PromoteDatasetInput"
      responses:
        "200":
          description: Existing Dataset updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DatasetResponse"
        "201":
          description: Dataset created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DatasetResponse"
        "400":
          description: Invalid request or the run has no promotable list payload.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/datasets:
    get:
      operationId: listDatasets
      summary: List Datasets visible to the caller
      description: |
        Returns Datasets whose source sessions belong to workspaces the caller
        can access, newest first.
      tags: [Datasets]
      responses:
        "200":
          description: Dataset list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DatasetListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/datasets/{id}:
    get:
      operationId: getDataset
      summary: Get a Dataset
      tags: [Datasets]
      parameters:
        - $ref: "#/components/parameters/DatasetId"
      responses:
        "200":
          description: Dataset detail
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DatasetResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateDataset
      summary: Update a Dataset (rename and/or replace output)
      description: >
        Provide `name`, `output`, or both. `output` replaces the stored list
        (validated as `{ schema, rows }` with at least one row of plain
        objects); `row_count` is recomputed server-side from the accepted
        rows. Typed schema entries are validated strictly: an object entry
        whose `name` is missing/empty or whose `type` is not one of
        `text | number | boolean` is a 400. Declared column types apply
        lossless value coercions server-side (e.g. "4.3" → 4.3 in a `number`
        column); unconvertible values are stored unchanged — a type change
        never destroys data.
      tags: [Datasets]
      parameters:
        - $ref: "#/components/parameters/DatasetId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateDatasetInput"
      responses:
        "200":
          description: Dataset updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DatasetResponse"
        "400":
          description: Invalid update payload (neither field provided, bad name, or malformed output).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: deleteDataset
      summary: Delete a Dataset
      tags: [Datasets]
      parameters:
        - $ref: "#/components/parameters/DatasetId"
      responses:
        "200":
          description: Dataset deleted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeleteDatasetResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  # ─── Agents ────────────────────────────────────────────────────────────────

  /v1/agents:
    get:
      operationId: listAgents
      summary: List active agents
      description: |
        Without `limit` or `cursor`, returns every active agent, newest created
        first; only the 50 agents with the newest sessions carry a latest-turn
        preview. With either parameter, returns one page ordered by
        `activityAt` (newest first), every row with its own preview, plus
        `nextCursor`/`hasMore`. `activityAt` only increases, so walking the
        cursor never repeats a row. Order is most-recent activity first; an
        agent that becomes active during a walk moves to the head and is
        returned by a fresh first-page read, not by later pages.
      tags: [Agents]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdQuery"
        - name: cursor
          in: query
          required: false
          description: >-
            Opaque anchor from the previous page's `nextCursor`; a malformed
            cursor is a 400. Order is most-recent activity first; an agent that
            becomes active during a walk moves to the head and is returned by a
            fresh first-page read, not by later pages.
          schema:
            type: string
            maxLength: 512
        - name: limit
          in: query
          required: false
          description: >-
            Agents per page. Defaults to 50 when only `cursor` is given. Order
            is most-recent activity first; an agent that becomes active during
            a walk moves to the head and is returned by a fresh first-page
            read, not by later pages.
          schema:
            type: integer
            minimum: 1
            maximum: 100
      responses:
        "200":
          description: Active agent roster with latest-turn previews
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentListResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createAgent
      summary: Create an agent
      tags: [Agents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateAgentInput"
      responses:
        "201":
          description: Agent created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentDetailResponse"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/agents/primary:
    post:
      operationId: ensurePrimaryAgent
      summary: Get or create the workspace's primary agent
      description: |
        Returns the workspace's primary agent, restoring it if it was
        archived or creating it if the workspace has none. Idempotent:
        repeated calls return the same agent.
      tags: [Agents]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WorkspaceScopeInput"
      responses:
        "200":
          description: The primary agent
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentDetailResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/agents/trash:
    get:
      operationId: listArchivedAgents
      summary: List agents recoverable within the 30-day restore window
      description: |
        Lists archived agents in the authenticated tenant and selected workspace
        with a non-null `purgeAfter` strictly later than the query time. Expired
        and already-purged agents are excluded. This read does not purge rows.
        Ordered by `purgeAfter` ascending (nearest deadline first), then `id`.
        Each entry includes identity/profile fields, `archivedAt`, `purgeAfter`,
        and minimal thread preview (`lastTurnAt`, `lastTurnSnippet`); no full
        turn history is returned. Pass an entry's `id` to
        `POST /v1/agents/{id}/restore` in the same workspace before its deadline.
        Active roster and detail endpoints continue to hide archived agents.
        API keys default to their bound workspace and cannot select another
        workspace.
      tags: [Agents]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdQuery"
      responses:
        "200":
          description: Recoverable archived agent roster (empty data array when none)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentListResponse"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/agents/{id}:
    parameters:
      - $ref: "#/components/parameters/AgentId"
    get:
      operationId: getAgent
      summary: Get an agent, attachments, and the newest page of turns
      description: |
        `turns` is the NEWEST page of history only (20 turns), oldest-first.
        Walk backwards with `GET /v1/agents/{id}/turns` using `turnsCursor`.
      tags: [Agents]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdQuery"
      responses:
        "200":
          description: Agent detail with the newest history page
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentDetailResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateAgent
      summary: Update an agent and atomically replace its attachment set
      tags: [Agents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateAgentInput"
      responses:
        "200":
          description: Agent updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentDetailResponse"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: archiveAgent
      summary: Archive an agent for 30 days
      tags: [Agents]
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WorkspaceScopeInput"
      responses:
        "200":
          description: Agent archived
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ArchiveAgentResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/agents/{id}/restore:
    post:
      operationId: restoreAgent
      summary: Restore an archived agent within its 30-day window
      tags: [Agents]
      parameters:
        - $ref: "#/components/parameters/AgentId"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WorkspaceScopeInput"
      responses:
        "200":
          description: Agent restored
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentDetailResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "410":
          description: Restore window expired

  /v1/agents/{id}/turns:
    get:
      operationId: listAgentTurns
      summary: Read one older page of an agent's turn history
      description: |
        Cursor (keyset) pagination over the stable `(created_at, id)` session
        ordering, walking BACKWARDS from newest to oldest. Offsets are not
        supported on purpose: a turn arriving mid-scroll shifts every offset
        by one, so consecutive offset pages both duplicate and drop rows.

        The page unit is the session row, not the turn. A session bundles its
        markers and events, so a turn is never split across a page boundary;
        the turn count per page is therefore normally `limit` but can exceed
        it for older rows carrying several markers.

        Omitting `cursor` returns the newest page. `turns` is oldest-first,
        so a client prepends the array verbatim. `nextCursor` is null once
        history is exhausted.
      tags: [Agents]
      parameters:
        - $ref: "#/components/parameters/AgentId"
        - $ref: "#/components/parameters/WorkspaceIdQuery"
        - name: cursor
          in: query
          required: false
          description: Opaque anchor from the previous page, or `turnsCursor` from the agent detail.
          schema:
            type: string
            maxLength: 512
        - name: limit
          in: query
          required: false
          description: Sessions per page. Defaults to 20.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
      responses:
        "200":
          description: One page of history, oldest-first
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentTurnPageResponse"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      operationId: createAgentTurn
      summary: Submit a turn to an agent
      description: |
        Starts a run on the agent's phone, or queues it if the phone is busy
        (`queued: true`). An agent with no phone gets a turn that waits for
        one (`needsDevice: true`). Track the run with the returned
        `sessionId` under `/v1/sessions/{id}`.

        Send an `Idempotency-Key` header to make retries safe: the same key
        returns the original turn.
      tags: [Agents]
      parameters:
        - $ref: "#/components/parameters/AgentId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateAgentTurnInput"
      responses:
        "202":
          description: Turn accepted or durably queued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentTurnDispatchResponse"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "402":
          description: "`credits_exhausted`: the workspace is out of credits."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: |
            No phone is available for this turn right now, or
            (`device_releasing`) the agent's phone is being removed from the
            Phone plan.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/agents/{id}/turns/{sessionId}/cancel:
    post:
      operationId: cancelAgentTurn
      summary: Cancel a turn that is still waiting for its device
      description: |
        Retracts a queued turn before it starts. Idempotent: a turn that has
        already finished answers `200` with `cancelled: false`. A turn that
        has started returns `400`; stop it with
        `POST /v1/sessions/{id}/stop` instead. `409` means the cancel's
        outcome is not known yet; retry it.
      tags: [Agents]
      parameters:
        - $ref: "#/components/parameters/AgentId"
        - name: sessionId
          in: path
          required: true
          schema:
            type: string
            minLength: 1
            maxLength: 512
          description: The turn's `sessionId`, as returned by `POST /v1/agents/{id}/turns`.
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WorkspaceScopeInput"
      responses:
        "200":
          description: Turn cancelled, or already finished
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [sessionId, status, cancelled, invocationId, invocationCancelled]
                    properties:
                      sessionId:
                        type: string
                      status:
                        type: string
                        description: The turn's status after the call.
                      cancelled:
                        type: boolean
                        description: "`false` when the turn had already finished."
                      invocationId:
                        type: [string, "null"]
                      invocationCancelled:
                        type: boolean
        "400":
          description: The turn has already started; use `POST /v1/sessions/{id}/stop`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The cancel is still settling; retry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # ─── Health ─────────────────────────────────────────────────────────────────

  # ─── Workspaces ─────────────────────────────────────────────────────────────

  /v1/workspaces/{workspaceId}:
    get:
      operationId: getWorkspace
      summary: Get a workspace
      description: An API key can read only the workspace it is bound to; any other id returns an opaque `404`.
      tags: [Workspaces]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdPath"
      responses:
        "200":
          description: Workspace detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/Workspace"
                required: [data]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  # ─── Catalog ────────────────────────────────────────────────────────────────

  /v1/catalog/apps:
    get:
      operationId: listCatalogApps
      summary: List installable catalog apps
      description: Up to 500 apps, ordered by name.
      tags: [Catalog]
      parameters:
        - $ref: "#/components/parameters/WorkspaceIdQuery"
      responses:
        "200":
          description: Catalog apps
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/CatalogApp"
                required: [data]
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "503":
          description: "`catalog_unavailable`: the catalog is not available right now. Retry later."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/catalog/apps/{package}/icon:
    get:
      operationId: getCatalogAppIcon
      summary: Get a catalog app's icon (binary image)
      description: Returns the icon bytes, as `image/png`, `image/jpeg` or `image/webp`.
      tags: [Catalog]
      parameters:
        - name: package
          in: path
          required: true
          schema:
            type: string
            maxLength: 255
            pattern: '^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$'
          description: Android package name, for example `com.example.app`.
        - $ref: "#/components/parameters/WorkspaceIdQuery"
      responses:
        "200":
          description: Icon bytes
          content:
            image/png:
              schema:
                type: string
                format: binary
            image/jpeg:
              schema:
                type: string
                format: binary
            image/webp:
              schema:
                type: string
                format: binary
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "503":
          description: "`catalog_unavailable`: the icon is not available right now. Retry later."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/health:
    get:
      operationId: healthCheck
      summary: Health check
      description: Unauthenticated health probe for uptime monitoring.
      tags: [Health]
      security: []
      responses:
        "200":
          description: Healthy
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok
                  version:
                    type: string
                    example: "0.1.0"
                required: [status, version]

# ─── Components ─────────────────────────────────────────────────────────────────

components:
  securitySchemes:
    ApiKey:
      type: apiKey
      in: header
      name: X-Api-Key
      description: Workspace API key, created in the dashboard under Settings → API Keys.
    BearerAuth:
      type: http
      scheme: bearer
      description: "The same workspace API key, sent as `Authorization: Bearer <your API key>`."

  parameters:
    SessionId:
      name: id
      in: path
      required: true
      schema:
        type: string
      description: Session ID.
    AgentId:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
      description: Agent UUID. Missing, inaccessible, and malformed IDs return an opaque 404.
    DatasetId:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
      description: Dataset UUID. Missing and inaccessible IDs both return an opaque 404.
    SkillId:
      name: id
      in: path
      required: true
      schema:
        type: string
      description: Skill UUID.
    WorkspaceIdPath:
      name: workspaceId
      in: path
      required: true
      schema:
        type: string
        format: uuid
      description: Workspace UUID. Caller must have access.
    PhoneNumberIdPath:
      name: phoneNumberId
      in: path
      required: true
      schema:
        type: string
        format: uuid
      description: Phone-number inventory UUID. Foreign and missing ids return an opaque 404.
    DeviceIdPath:
      name: deviceId
      in: path
      required: true
      schema:
        type: string
      description: Device id (paired into the workspace).
    WorkspaceIdQuery:
      name: workspaceId
      in: query
      required: false
      schema:
        type: string
        format: uuid
      description: |
        Workspace the request is scoped to. Optional for API keys: a key is
        bound to one workspace when it is created, and naming any other
        workspace returns an opaque 404.
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      schema:
        type: string
        maxLength: 256
      description: |
        Caller-provided key for safe retries. The same key in the same
        workspace yields the same run id, and a duplicate POST within the
        retention window returns the original run instead of starting a
        second one.

  schemas:
    PhoneNumber:
      type: object
      required:
        - id
        - workspaceId
        - phoneNumber
        - country
        - numberType
        - status
        - autoRenew
        - expiresAt
        - lastSyncedAt
        - createdAt
        - updatedAt
        - paidThrough
      properties:
        id: { type: string, format: uuid }
        workspaceId: { type: string, format: uuid }
        phoneNumber:
          type: string
          pattern: '^\+[1-9][0-9]{7,14}$'
        country:
          type: [string, "null"]
          enum: [US, GB, AU, HK, IT, NL, CA, null]
          description: ISO country from the original purchase order; null for unknown legacy inventory.
        numberType: { type: string, enum: [voip, real_sim] }
        status: { type: string }
        autoRenew: { type: boolean }
        expiresAt: { type: [string, "null"], format: date-time }
        lastSyncedAt: { type: [string, "null"], format: date-time }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        paidThrough:
          type: [string, "null"]
          format: date-time
          description: Paid customer coverage, independent of expiresAt; null for legacy inventory.

    PhoneNumberMessage:
      type: object
      additionalProperties: false
      required: [id, sender, body, receivedAt]
      properties:
        id: { type: string, format: uuid }
        sender:
          type: [string, "null"]
          description: Stored sender, or null when unknown. Not a verified brand identity.
        body:
          type: string
          description: Raw stored SMS text, including codes. Render as plain text, never HTML.
        receivedAt:
          type: string
          format: date-time
          description: Stored receipt timestamp with original database precision.

    PhoneNumberMessageListResponse:
      type: object
      additionalProperties: false
      required: [data, nextCursor]
      properties:
        data:
          type: array
          maxItems: 100
          items: { $ref: "#/components/schemas/PhoneNumberMessage" }
        nextCursor:
          type: [string, "null"]
          description: Pass unchanged as cursor; null when no older retained rows were observed.

    WorkspaceSecretListResponse:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/WorkspaceSecret"
    WorkspaceSecret:
      type: object
      required: [id, slug, label, kind, fields, created_at, updated_at]
      properties:
        id:
          type: string
        slug:
          type: string
          description: The name used before the dot in `secrets`, for example `gmail`.
        label:
          type: string
        kind:
          type: string
        fields:
          type: object
          description: |
            The item's fields by name: `gmail.password` is the field
            `password` of the item `gmail`. `secret: true` fields never
            carry a value.
          additionalProperties:
            type: object
            required: [secret]
            properties:
              secret:
                type: boolean
              value:
                type: string
                description: Present only for a non-secret field, and never for an API-key request.
        allowed_packages:
          type: [array, "null"]
          items:
            type: string
          description: Apps this secret may be typed into. Null or empty means any app.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    PhoneNumberListResponse:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/PhoneNumber" }

    PhoneNumberResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: "#/components/schemas/PhoneNumber" }

    EmailAddress:
      type: object
      additionalProperties: false
      required: [id, address, included]
      properties:
        id: { type: string, format: uuid }
        address:
          type: string
          description: Canonical lowercase agent mailbox address, unique across workspaces.
        included:
          type: [boolean, "null"]
          description: |
            True when the address is included free with the tenant's paid phones,
            false when it is a paid Inbox address, null when billing could not
            be read (the list is still returned).

    EmailAddressListResponse:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/EmailAddress" }

    EmailMessage:
      type: object
      additionalProperties: false
      required: [id, addressId, senderName, senderAddress, receivedAt, subject, body]
      properties:
        id: { type: string, format: uuid }
        addressId:
          type: string
          format: uuid
          description: Recipient Agent Mail address the message was delivered to.
        senderName:
          type: [string, "null"]
          description: Stored display name, or null when the message carried none. Not a verified identity.
        senderAddress:
          type: [string, "null"]
          description: Stored sender mailbox, or null when the message carried none. Not a verified identity.
        receivedAt:
          type: string
          format: date-time
          description: Stored receipt timestamp with original database precision.
        subject:
          type: [string, "null"]
          description: Stored subject line, or null when absent. Never rendered as markup.
        body:
          type: string
          description: Raw stored plain-text body. Render as text, never HTML; no remote assets are fetched.

    EmailMessageListResponse:
      type: object
      additionalProperties: false
      required: [data, nextCursor]
      properties:
        data:
          type: array
          maxItems: 100
          items: { $ref: "#/components/schemas/EmailMessage" }
        nextCursor:
          type: [string, "null"]
          description: Pass unchanged as cursor; null when no older retained rows were observed.

    WorkspaceScopeInput:
      type: object
      properties:
        workspaceId:
          type: string
          format: uuid
          description: Optional for workspace-scoped API keys.

    AgentDeviceInput:
      type: object
      required: [device_id]
      properties:
        device_id:
          type: string
          minLength: 1
          maxLength: 512
        label:
          type: [string, "null"]
          maxLength: 255
        description:
          type: [string, "null"]
          maxLength: 4096
        position:
          type: integer
          minimum: 0
          maximum: 10000

    AgentDevice:
      type: object
      required: [deviceId, label, description, position, createdAt]
      properties:
        deviceId:
          type: string
        label:
          type: [string, "null"]
        description:
          type: [string, "null"]
        position:
          type: integer
        createdAt:
          type: string
          format: date-time

    AgentTurn:
      type: object
      required:
        [id, sessionId, prompt, status, deviceId, summary, recording, queued,
         createdAt, startedAt, completedAt]
      properties:
        id:
          type: string
          description: Turn id. For older turns this is the session id.
        sessionId:
          type: string
        prompt:
          type: string
        status:
          type: string
        deviceId:
          type: [string, "null"]
        summary:
          type: [string, "null"]
        recording:
          type: boolean
        queued:
          type: boolean
        createdAt:
          type: string
          format: date-time
        startedAt:
          type: [string, "null"]
          format: date-time
        completedAt:
          type: [string, "null"]
          format: date-time

    Agent:
      type: object
      required:
        [id, workspaceId, name, color, avatar, description, model, archivedAt,
         purgeAfter, createdAt, updatedAt, lastTurnAt, lastTurnSnippet]
      properties:
        id:
          type: string
          format: uuid
        workspaceId:
          type: string
          format: uuid
        name:
          type: string
        color:
          type: string
        avatar:
          type: [string, "null"]
          description: Avatar id, `<group>/<name>` (for example `emojis/rocket`).
        description:
          type: [string, "null"]
        model:
          type: string
          description: The agent's provider-qualified model id, for example `deepseek/deepseek-flash`.
        archivedAt:
          type: [string, "null"]
          format: date-time
        purgeAfter:
          type: [string, "null"]
          format: date-time
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        lastTurnAt:
          type: [string, "null"]
          format: date-time
        lastTurnSnippet:
          type: [string, "null"]
        activityAt:
          type: string
          format: date-time
          description: Roster sort key (newest session, else creation time). Roster reads only.

    AgentDetail:
      allOf:
        - $ref: "#/components/schemas/Agent"
        - type: object
          required: [devices, turns, turnsCursor, turnsHasMore]
          properties:
            devices:
              type: array
              items:
                $ref: "#/components/schemas/AgentDevice"
            turns:
              description: Newest page of history, oldest-first.
              type: array
              items:
                $ref: "#/components/schemas/AgentTurn"
            turnsCursor:
              type: [string, "null"]
              description: Opaque cursor for the next OLDER page; null when history is exhausted.
            turnsHasMore:
              type: boolean

    AgentTurnPage:
      type: object
      required: [turns, nextCursor, hasMore]
      properties:
        turns:
          description: Oldest-first, so the client prepends the array verbatim.
          type: array
          items:
            $ref: "#/components/schemas/AgentTurn"
        nextCursor:
          type: [string, "null"]
        hasMore:
          type: boolean

    AgentTurnPageResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/AgentTurnPage"

    CreateAgentInput:
      allOf:
        - $ref: "#/components/schemas/WorkspaceScopeInput"
        - type: object
          required: [name]
          properties:
            name:
              type: string
              minLength: 1
              maxLength: 255
            color:
              type: string
              minLength: 1
              maxLength: 64
            avatar:
              type: string
              pattern: '^(emojis|apps|other)/[a-z0-9-]{1,64}$'
              description: Avatar id. Omit on create for a random emoji; `null` is rejected.
            description:
              type: [string, "null"]
              maxLength: 4096
            model:
              type: string
              minLength: 1
              maxLength: 255
              description: |
                Provider-qualified model id, for example `deepseek/deepseek-flash`
                (the default), `openai/gpt-6-luna` or `anthropic/claude-opus-5-5`.
                `auto` is accepted as an alias for the default model and is
                stored as `deepseek/deepseek-flash`.
            devices:
              type: array
              maxItems: 1
              description: |
                An agent uses at most one device. The array wire shape
                is retained for client compatibility, but at most one entry
                is accepted — a second is rejected with a 400.
              items:
                $ref: "#/components/schemas/AgentDeviceInput"

    UpdateAgentInput:
      allOf:
        - $ref: "#/components/schemas/WorkspaceScopeInput"
        - type: object
          properties:
            name:
              type: string
              minLength: 1
              maxLength: 255
            color:
              type: string
              minLength: 1
              maxLength: 64
            avatar:
              type: string
              pattern: '^(emojis|apps|other)/[a-z0-9-]{1,64}$'
              description: Avatar id. Omit on create for a random emoji; `null` is rejected.
            description:
              type: [string, "null"]
              maxLength: 4096
            model:
              type: string
              minLength: 1
              maxLength: 255
              description: |
                Provider-qualified model id, for example `deepseek/deepseek-flash`
                (the default), `openai/gpt-6-luna` or `anthropic/claude-opus-5-5`.
                `auto` is accepted as an alias for the default model and is
                stored as `deepseek/deepseek-flash`.
            devices:
              type: array
              maxItems: 1
              description: |
                An agent uses at most one device. Supplying the array
                replaces the current attachment atomically (single RPC) —
                this is the "move my agent to another phone" flow. At most
                one entry is accepted; a second is rejected with a 400.
              items:
                $ref: "#/components/schemas/AgentDeviceInput"

    CreateAgentTurnInput:
      allOf:
        - $ref: "#/components/schemas/WorkspaceScopeInput"
        - type: object
          required: [prompt]
          properties:
            prompt:
              type: string
              minLength: 1
              maxLength: 8192
            device_id:
              type: string
              minLength: 1
              maxLength: 512
            idempotencyKey:
              type: string
              minLength: 1
              maxLength: 256
              description: Body fallback; Idempotency-Key header takes precedence.

    AgentListResponse:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Agent"
        nextCursor:
          type: [string, "null"]
          description: Paged reads only. Pass as `cursor` for the next page; null on the last page.
        hasMore:
          type: boolean
          description: Paged reads only.

    AgentDetailResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/AgentDetail"

    AgentTurnDispatchResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [id, sessionId, status, queued, needsDevice, deviceId, recording]
          properties:
            id:
              type: string
              format: uuid
            sessionId:
              type: string
            status:
              type: string
            queued:
              type: boolean
            needsDevice:
              type: boolean
            deviceId:
              type: [string, "null"]
            recording:
              type: boolean
            recordingState:
              type: object
              properties:
                status:
                  type: string
                  enum: [starting, not_started]
                reason:
                  type: string
            invocationId:
              type: string

    ArchiveAgentResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [id, archivedAt, purgeAfter]
          properties:
            id:
              type: string
              format: uuid
            archivedAt:
              type: string
              format: date-time
            purgeAfter:
              type: string
              format: date-time

    SessionEvent:
      type: object
      required: [id, sessionId, type, timestamp]
      properties:
        id:
          type: string
        sessionId:
          type: string
        type:
          type: string
          description: "Event type (e.g. session_created, action_performed, screenshot_captured)."
        data:
          type: [object, "null"]
          additionalProperties: true
        screenshotId:
          type: [string, "null"]
        stepIndex:
          type: [integer, "null"]
        timestamp:
          type: string
          format: date-time

    Skill:
      type: object
      required: [id, userId, title, content, tags, folder, isPublic, clientRevision, createdAt, updatedAt]
      properties:
        id:
          type: string
        userId:
          type: string
        orgId:
          type: [string, "null"]
        title:
          type: string
        content:
          type: string
          description: Markdown skill document.
        tags:
          type: array
          items:
            type: string
        folder:
          type: [string, "null"]
          description: Optional folder grouping label shown in the skills panel tree.
        clientRevision:
          type: integer
          format: int64
          description: >-
            Persisted autosave revision. Autosave clients should seed their
            next `clientRevision` above this value (see PATCH).
        appPackage:
          type: [string, "null"]
        isPublic:
          type: boolean
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    Device:
      type: object
      required: [id, name, tier, planId, pairedAt, connected]
      properties:
        id:
          type: string
        name:
          type: [string, "null"]
          description: Live name when connected; last-known name or device id when offline.
        tier:
          type: [string, "null"]
          enum: [cloud_managed, byod, null]
          description: Pairing/provisioning class. Null only for forward compatibility.
        planId:
          type: string
          enum: [personal, virtual, glass, unknown]
          description: >-
            The device's plan. `unknown` marks older rows without a recorded
            plan.
        pairedAt:
          type: string
          format: date-time
          description: Durable pairing timestamp.
        connected:
          type: boolean
          description: Whether the device is connected to Lusor right now.
        platform:
          type: string
          description: Live platform, or validated last-known platform while offline.
        androidVersion:
          type: [string, "null"]
          description: Live-only; omitted while the device is offline.
        apiLevel:
          type: [integer, "null"]
          description: Live-only; omitted while the device is offline.
        appVersion:
          type: [string, "null"]
          description: Live-only; omitted while the device is offline.
        connectedAt:
          type: integer
          description: Unix timestamp (seconds), live-only.
        lastSeen:
          type: integer
          description: Unix timestamp (seconds), live-only.

    DeviceControlState:
      type: [string, "null"]
      enum: [pending, applied, failed, unknown, null]
      description: |
        `pending` while the change is applied, then `applied`, `failed` or
        `unknown`. `null` when no change was ever made.

        The reported value is the one the phone was actually given: a `failed`
        or `unknown` change never changes it.

        `unknown` means the phone may or may not have taken the change. Further
        changes are refused (`phone_busy`) until support resolves it, so an
        irreversible advertising-ID reset is never repeated on its own.
    DeviceControlError:
      type: [string, "null"]
      enum: [phone_not_ready, phone_offline, task_failed, task_unconfirmed, dispatch_unconfirmed, device_busy, null]
      description: |
        Why a change did not apply; `null` otherwise. `phone_not_ready`,
        `phone_offline`, `task_failed` and `device_busy` are `failed` states and
        nothing was changed — set the value again to retry. `task_unconfirmed`
        and `dispatch_unconfirmed` are `unknown` states.
    DeviceControlInProgress:
      type: [string, "null"]
      enum: [language, identity, ad_id_reset, null]
      description: The change still being applied, if any. Another change is refused until it settles.
    DeviceLanguageInput:
      type: object
      additionalProperties: false
      required: [language]
      properties:
        language:
          type: string
          pattern: "^[A-Za-z]{2,3}$"
          description: ISO 639 language code, e.g. `de`.
          example: de
        country:
          type: string
          pattern: "^[A-Za-z]{2}$"
          description: Optional ISO 3166-1 alpha-2 region, e.g. `DE`.
          example: DE
    DeviceLanguage:
      type: object
      required: [language, country, state, error, inProgress]
      properties:
        language:
          type: [string, "null"]
        country:
          type: [string, "null"]
        state:
          $ref: "#/components/schemas/DeviceControlState"
        error:
          $ref: "#/components/schemas/DeviceControlError"
        inProgress:
          $ref: "#/components/schemas/DeviceControlInProgress"
    DeviceIdentityFields:
      type: object
      additionalProperties: false
      description: At least one field. Text fields are 1-64 letters, digits, spaces or `. _ + -`.
      properties:
        brand:
          type: string
          example: google
        manufacturer:
          type: string
          example: Google
        model:
          type: string
          example: Pixel 8
        device:
          type: string
          description: Device codename (`ro.product.device`).
          example: shiba
        name:
          type: string
          description: Product name (`ro.product.name`).
          example: shiba
        fingerprint:
          type: string
          maxLength: 200
          description: "Build fingerprint: `brand/name/device:release/id/incremental:type/tags`."
        androidId:
          type: string
          pattern: "^[0-9A-Fa-f]{16}$"
          description: The Android ID, 16 hex digits.
    DeviceIdentity:
      type: object
      required: [fields, state, restartQueued, error, inProgress]
      properties:
        fields:
          $ref: "#/components/schemas/DeviceIdentityFields"
        state:
          $ref: "#/components/schemas/DeviceControlState"
        restartQueued:
          type: boolean
          description: The values were written and the phone's restart was queued; they take effect when it completes.
        error:
          $ref: "#/components/schemas/DeviceControlError"
        inProgress:
          $ref: "#/components/schemas/DeviceControlInProgress"
    DeviceAdvertisingIdReset:
      type: object
      required: [state, error, inProgress]
      properties:
        state:
          $ref: "#/components/schemas/DeviceControlState"
        error:
          $ref: "#/components/schemas/DeviceControlError"
        inProgress:
          $ref: "#/components/schemas/DeviceControlInProgress"

    # ─── Device and session schemas ─────────────────────────────────────────────

    DeviceState:
      type: object
      required: [deviceId, phoneState, deviceContext, a11yTree]
      properties:
        deviceId:
          type: string
        phoneState:
          type: object
          additionalProperties: true
        deviceContext:
          type: object
          required: [screenWidth, screenHeight, interactiveCount, elementCount]
          properties:
            screenWidth:
              type: integer
            screenHeight:
              type: integer
            interactiveCount:
              type: integer
            elementCount:
              type: integer
        a11yTree:
          type: string
          description: |
            Formatted UI element tree with 1-based interactive indices.
            Use `[N]` in `POST /tap { index: N }` to tap the Nth
            interactive element. On an iPhone it is the iOS
            accessibility tree, each frame `{{x, y}, {width, height}}`
            in points, and `[N]` taps the centre of that frame.

    # ─── Capture shapes ─────────────────────────────────────────────────────────

    CaptureVerbosity:
      type: string
      enum: [compact, default, full]
      description: |
        Presentation tier for `capture.elements`. `default` is the same
        element tree Lusor's own agent reads. `compact` drops indentation +
        non-interactive nodes for 60-70% fewer tokens. `full` includes every
        flag.

    CaptureOnlyFilter:
      type: string
      enum: [all, clickable, focused, editable]
      description: |
        Subset filter applied before index assignment.

    CaptureOptions:
      type: object
      additionalProperties: false
      properties:
        ui:
          type: boolean
          description: Capture UI element tree (default true when `capture` is set).
        screenshot:
          type: boolean
          description: |
            When true, the response includes `screenshot_url`, a link to
            the `GET /screenshot` endpoint. That endpoint takes a new
            screenshot each time it is fetched, so the image shows the
            screen at fetch time, not the moment after the action.
        verbosity:
          $ref: "#/components/schemas/CaptureVerbosity"
        only:
          $ref: "#/components/schemas/CaptureOnlyFilter"
        min_size_px:
          type: integer
          minimum: 0
          description: Drop elements smaller than N pixels.
        include_keyboard:
          type: boolean
          description: Include the soft-input/keyboard subtree (default false).
        max_depth:
          type: integer
          minimum: 1
          maximum: 50
          description: Cap on the depth of the element tree.

    CaptureField:
      description: |
        `capture: boolean | CaptureOptions`. `true` captures with
        defaults; an object lets the caller override individual knobs.
        `false` or omitted = no capture.
      oneOf:
        - type: boolean
        - $ref: "#/components/schemas/CaptureOptions"

    CaptureResponse:
      type: object
      description: |
        Capture payload. All inner fields are optional and present iff
        the corresponding source was captured:
          - `elements` / `element_count` / `screen_dimensions` — set iff
            `capture.ui` was true.
          - `screenshot_url` — set iff `capture.screenshot` was true.

        A screenshot-only capture (no UI) returns just `screenshot_url`;
        a UI-only capture returns the tree + count + dimensions; a full
        capture returns everything.

        The action has already run when the capture is taken. On a single
        action endpoint, a failed capture still returns `200` with
        `capture: { failed: true, error }`, so a retry does not repeat a
        non-idempotent action. Inside a chain, a failed capture aborts the
        chain with an error instead (see `POST …/chain`).
      properties:
        failed:
          type: boolean
          description: Present and `true` only when the capture failed on a single action endpoint.
        error:
          type: string
          description: Why the capture failed. Present only with `failed`.
        elements:
          type: string
          description: |
            Formatted element tree. Present iff `capture.ui` was true.
            The exact shape depends on `capture.verbosity`.
        element_count:
          type: integer
          minimum: 0
          description: |
            Number of `[N]`-indexed interactive elements. Present iff
            `capture.ui` was true.
        screen_dimensions:
          type: object
          description: |
            Device screen size derived from the UI walk's bounding box.
            Present iff `capture.ui` was true.
          required: [width, height]
          properties:
            width:
              type: integer
              minimum: 0
            height:
              type: integer
              minimum: 0
        screenshot_url:
          type: string
          description: |
            Present iff `capture.screenshot` was true. Links to the
            `GET /screenshot` endpoint, which needs the same API key and
            takes a new screenshot when fetched (metered on a Lusor Phone
            like any read). Fetch it before the next action if you want the
            screen this action left behind.

    VerbResultWithOptionalCapture:
      allOf:
        - $ref: "#/components/schemas/VerbResult"
        - type: object
          properties:
            capture:
              $ref: "#/components/schemas/CaptureResponse"

    # ─── Action input bodies ────────────────────────────────────────────────────

    TapInput:
      type: object
      description: |
        Provide `index`, or `x`+`y`, or both. At least one form is required:
        supplying neither is a 400, and `x` without `y` is a 400 only when
        no `index` is supplied. When both forms are supplied `index` wins
        and the coordinates are ignored.
      anyOf:
        - required: [index]
        - required: [x, y]
      properties:
        index:
          type: integer
          minimum: 1
          description: 1-based interactive element index from `/state.a11yTree`.
        x:
          type: number
          description: Pixel x-coordinate (alternative to `index`).
        y:
          type: number
          description: Pixel y-coordinate (alternative to `index`).
        stealth:
          type: boolean
          description: |
            Inject this tap the way a person would: the press held 45–110 ms
            instead of a single frame, and the point nudged by up to 2 px.
            Returns `412 unsupported_stealth_input` when the phone's agent
            cannot do it; it is never silently downgraded.
        capture:
          $ref: "#/components/schemas/CaptureField"

    TapResult:
      allOf:
        - $ref: "#/components/schemas/VerbResult"
        - type: object
          properties:
            resolved:
              type: object
              required: [x, y]
              properties:
                x:
                  type: integer
                y:
                  type: integer
                index:
                  type: integer
            capture:
              $ref: "#/components/schemas/CaptureResponse"

    SwipeInput:
      type: object
      required: [startX, startY, endX, endY]
      properties:
        startX:
          type: number
        startY:
          type: number
        endX:
          type: number
        endY:
          type: number
        durationMs:
          type: integer
          minimum: 1
          maximum: 60000
        stealth:
          type: boolean
          description: |
            Follow a curved, eased path between the two points instead of a
            straight line at constant speed. The duration is unchanged.
            Returns `412 unsupported_stealth_input` when the phone's agent
            cannot do it.
        capture:
          $ref: "#/components/schemas/CaptureField"

    TypeTextInput:
      type: object
      required: [text]
      properties:
        text:
          type: string
          minLength: 1
          maxLength: 8192
        clear:
          type: boolean
        stealth:
          type: boolean
          description: |
            Type with a human per-key delay (45–120 ms, with the occasional
            longer pause), bounded at 6 seconds in total: the delays are
            scaled to fit that budget as the text gets longer.
            Because every key still waits at least 8 ms, stealth typing is
            limited to **750 characters** (UTF-16 code units: an emoji
            counts as two) — longer text returns
            `400 invalid_stealth_input`; split it, or send it without
            `stealth`. Returns `412 unsupported_stealth_input` when the
            phone's agent cannot do human-like input at all.
        capture:
          $ref: "#/components/schemas/CaptureField"

    KeyInput:
      type: object
      required: [keyCode]
      properties:
        keyCode:
          type: integer
          minimum: 0
          maximum: 2000
        capture:
          $ref: "#/components/schemas/CaptureField"

    EmptyVerbInput:
      type: object
      description: |
        Body shape for empty-body verbs (`clear`, `back`, `home`,
        `recent`, `screenshot/trigger`). The body itself is optional;
        when supplied the only meaningful field is `capture`.
      properties:
        capture:
          $ref: "#/components/schemas/CaptureField"

    StartAppInput:
      type: object
      required: [package]
      properties:
        package:
          type: string
          minLength: 1
          maxLength: 255
          pattern: '^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$'
          description: |
            Dotted Java identifier, first segment letter-led. Case-insensitive
            (the runtime regex carries the `i` flag). A value such as
            `com.x; reboot` is rejected with a 400.
        activity:
          type: string
          minLength: 1
          maxLength: 255
          pattern: '^\.?[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)*$'
          description: |
            Relative-or-fully-qualified Android activity name; a leading `.`
            (`.MainActivity`) is accepted. Validated against the same regex
            before dispatch.
        capture:
          $ref: "#/components/schemas/CaptureField"

    StopAppInput:
      type: object
      required: [package]
      properties:
        package:
          type: string
          minLength: 1
          maxLength: 255
          pattern: '^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$'
          description: |
            Dotted Java identifier, first segment letter-led. Case-insensitive;
            validated against the same regex as `apps/start`.
        capture:
          $ref: "#/components/schemas/CaptureField"

    ShellInput:
      type: object
      required: [command]
      properties:
        command:
          type: string
          minLength: 1
          maxLength: 4096

    VerbResult:
      type: object
      required: [ok, exit_code, status]
      properties:
        ok:
          type: boolean
        exit_code:
          type: integer
          description: |
            `0` on success, non-zero on failure. Currently derived from
            `ok`; it does not carry the command's own exit code.
        status:
          type: string
        commandId:
          type: string
        durationMs:
          type: [integer, "null"]
        message:
          type: string

    ShellResult:
      type: object
      required: [ok, status, commandId]
      properties:
        ok:
          type: boolean
        status:
          type: string
        commandId:
          type: string
        durationMs:
          type: [integer, "null"]

    DatasetColumnType:
      type: string
      enum: [text, number, boolean]

    DatasetColumn:
      type: object
      required: [name, type]
      properties:
        name:
          type: string
          minLength: 1
        type:
          $ref: "#/components/schemas/DatasetColumnType"

    DatasetOutput:
      type: object
      required: [schema, rows]
      properties:
        schema:
          type: array
          description: >
            Column list. Entries are typed `{ name, type }` objects; bare
            strings are the older untyped form. The server
            infers a type for every untyped entry on any write that carries
            `output` (promote, cell edit, type override), so such a write
            migrates an untyped schema to typed form. A name-only update is
            metadata-only and deliberately leaves `output` untouched —
            rewriting it from the row snapshot read earlier in the request
            would revert a concurrent cell edit. Readers must therefore
            accept both forms.
          items:
            oneOf:
              - type: string
              - $ref: "#/components/schemas/DatasetColumn"
        rows:
          type: array
          minItems: 1
          items:
            type: object
            additionalProperties: true

    Dataset:
      type: object
      required:
        [id, tenant_id, session_id, run_id, name, output, row_count, source, created_at]
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        session_id:
          type: string
        run_id:
          type: string
        name:
          type: string
          minLength: 1
          maxLength: 120
        output:
          $ref: "#/components/schemas/DatasetOutput"
        row_count:
          type: integer
          minimum: 1
        source:
          type: string
          enum: [session]
        created_at:
          type: string
          format: date-time

    DatasetResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/Dataset"

    DatasetListResponse:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Dataset"

    PromoteDatasetInput:
      type: object
      required: [run_id]
      properties:
        run_id:
          type: string
          minLength: 1
          maxLength: 200
        name:
          type: string
          minLength: 1
          maxLength: 120

    UpdateDatasetInput:
      type: object
      # At least one of `name` / `output` must be present — unknown-only
      # bodies are rejected by the handler with 400.
      anyOf:
        - required: [name]
        - required: [output]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 120
          # The handler trims and rejects whitespace-only names.
          pattern: "\\S"
        output:
          $ref: "#/components/schemas/DatasetOutput"

    DeleteDatasetResponse:
      type: object
      required: [ok]
      properties:
        ok:
          type: boolean
          enum: [true]

    SessionStatusV2:
      type: string
      # awaiting_human: the run is waiting on a human answer and resumes when
      # it arrives.
      enum: [queued, running, executing, awaiting_human, complete, completed, failed, cancelled]

    SessionV2:
      type: object
      required: [id, prompt, status, createdAt]
      properties:
        id:
          type: string
        prompt:
          type: string
        status:
          $ref: "#/components/schemas/SessionStatusV2"
        deviceId:
          type: [string, "null"]
        workspaceId:
          type: [string, "null"]
          format: uuid
        app:
          type: [string, "null"]
        outputSchema:
          type: [object, "null"]
          additionalProperties: true
        output:
          oneOf:
            - type: object
              additionalProperties: true
            - type: array
              items: {}
            - type: "null"
          description: |
            Structured output. Populated when `status == completed`. Shape
            is caller-defined via `outputSchema` on session create.
        error:
          type: [string, "null"]
          description: |
            Machine-readable failure code when `status == failed`. Known
            code: `secret_required:<name>` - a run created with an API key
            needed a credential or 2FA code (`<name>` is the agent's slug for
            it, for example `gmail_password`) that it was not given. The run
            ends immediately rather than waiting for a human. Save the secret
            in the workspace, then retry the run naming it in `secrets`
            (`<item_slug>.<field>`). Dashboard runs never
            fail this way: they pause in `awaiting_human` and are answered in
            the dashboard. Known code: `credits_exhausted` - the run's model
            cost reached the workspace's credit balance at the time the run
            started, so it stopped; upgrade the plan or buy a credit pack,
            then continue the session.
        createdAt:
          type: string
          format: date-time
        startedAt:
          type: [string, "null"]
          format: date-time
        completedAt:
          type: [string, "null"]
          format: date-time
        maxDurationSecs:
          type: [integer, "null"]
          format: int64
          description: Effective per-session duration cap in seconds as persisted by the API at session creation. Null means a historical row has no persisted cap; consumers must not infer the current default from null.

    SessionDetail:
      description: |
        One run. Content is kept for the workspace's history window; each
        `*ExpiresAt` field says when the matching field expires.
      allOf:
        - $ref: "#/components/schemas/SessionV2"
        - type: object
          properties:
            agentId:
              type: [string, "null"]
              format: uuid
              description: The agent that ran this turn; null for runs started without an agent.
            name:
              type: [string, "null"]
              description: Display name; null means derive it from the prompt.
            promptExpiresAt:
              type: [string, "null"]
              format: date-time
            outputExpiresAt:
              type: [string, "null"]
              format: date-time
            nameExpiresAt:
              type: [string, "null"]
              format: date-time
            errorExpiresAt:
              type: [string, "null"]
              format: date-time

    SessionStatusView:
      type: object
      required: [id, status]
      properties:
        id:
          type: string
        status:
          $ref: "#/components/schemas/SessionStatusV2"
        deviceId:
          type: [string, "null"]
        startedAt:
          type: [string, "null"]
          format: date-time
        completedAt:
          type: [string, "null"]
          format: date-time
        error:
          type: [string, "null"]

    SessionArtifacts:
      type: object
      required: [sessionId, output, screenshots, uiStates]
      properties:
        sessionId:
          type: string
        output:
          oneOf:
            - type: object
              additionalProperties: true
            - type: array
              items: {}
            - type: "null"
        screenshots:
          type: array
          items:
            type: object
            required: [id, r2Key, timestamp]
            properties:
              id:
                type: string
              r2Key:
                type: string
                description: Storage key of the screenshot.
              stepIndex:
                type: [integer, "null"]
              timestamp:
                type: string
                format: date-time
        uiStates:
          type: array
          items:
            type: object
            required: [id, type, timestamp]
            properties:
              id:
                type: string
              type:
                type: string
              data:
                type: [object, "null"]
                additionalProperties: true
              stepIndex:
                type: [integer, "null"]
              timestamp:
                type: string
                format: date-time

    CreateSessionInput:
      type: object
      required: [prompt]
      properties:
        prompt:
          type: string
          minLength: 1
          maxLength: 8192
        maxDurationSecs:
          type: integer
          minimum: 1
          description: Optional cap on the run's duration, in seconds. The server applies its own upper bound.
        recording:
          type: boolean
          default: false
          description: Record a video of the run.
        stealthInput:
          type: boolean
          description: |
            Run every device action of this session in human-like (stealth)
            mode: jittered tap timing, curved swipe paths and a per-key
            typing delay. Refused at create with
            `412 unsupported_stealth_input` when the phone's agent does not
            support it, so a run never starts only to fail on its first tap.
        schema:
          type: [object, "null"]
          additionalProperties: true
        app:
          type: [string, "null"]
        secrets:
          type: array
          maxItems: 50
          items:
            type: string
            pattern: "^[a-z0-9_-]+\\.[a-z0-9_-]+$"
            maxLength: 256
          description: |
            Saved workspace secrets this run may use, by reference:
            `<item_slug>.<field>`, for example `gmail.password`. Values are
            never sent in the request; the agent types them on the phone
            without the model ever seeing them. Every name must already be
            saved in the workspace (`GET /v1/workspaces/{workspaceId}/vault`
            lists them), otherwise the request fails with `422`
            `unknown_secret`. Omitted: the run may use every secret saved in
            the workspace. `[]`: none.
        model:
          type: [string, "null"]
          description: |
            Provider-qualified model id for this run. Omitted, `null` or
            `auto` runs the default model, `deepseek/deepseek-flash`.
        idempotencyKey:
          type: string
          maxLength: 256
          description: |
            Key for retry safety. The `Idempotency-Key`
            header takes precedence over this body field.

    CreateSessionResult:
      type: object
      required: [id, status, deviceId, workspaceId]
      properties:
        id:
          type: string
        status:
          type: string
          enum: [queued]
        deviceId:
          type: string
        workspaceId:
          type: string
          format: uuid
        invocationId:
          type: string

    StopSessionResult:
      type: object
      required: [ok, status, sessionId]
      properties:
        ok:
          type: boolean
        status:
          type: string
          description: "`stopping` | `noop`"
        reason:
          type: string
        sessionId:
          type: string

    ContinueSessionInput:
      type: object
      required: [prompt]
      properties:
        prompt:
          type: string
          minLength: 1
          description: The follow-up message.
        maxDurationSecs:
          type: integer
          minimum: 1
          description: Optional cap on the follow-up run's duration, in seconds. The server applies its own upper bound.
        workspaceId:
          type: string
          format: uuid
          description: Optional for workspace-scoped API keys.

    ContinueSessionResult:
      type: object
      required: [id, status, deviceId, workspaceId]
      properties:
        id:
          type: string
          description: The session id (unchanged).
        status:
          type: string
          enum: [queued]
        deviceId:
          type: string
        workspaceId:
          type: string
          format: uuid
        invocationId:
          type: string
          description: |
            The follow-up's invocation. Absent when an `Idempotency-Key`
            matched a follow-up that has already run.
        queued:
          type: boolean
          description: |
            `true` when the follow-up waits behind the session's current run.
            Cancel it with `DELETE /v1/sessions/{id}/queue/{invocationId}`.
        queuedEventId:
          type: string
          description: Id of the `followup_queued` event, when `queued` is true.

    AnswerHitlInput:
      type: object
      required: [correlation_id, response]
      properties:
        correlation_id:
          type: string
          format: uuid
          description: "`data.correlation_id` of the `hitl_requested` event being answered."
        response:
          type: string
          minLength: 1
          description: The answer. For a question with `options`, the chosen label or any other text.
        workspaceId:
          type: string
          format: uuid
          description: Optional for workspace-scoped API keys.

    AnswerHitlResult:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [resolved, idempotent, continuation]
          properties:
            resolved:
              type: boolean
              description: Always `true`.
            idempotent:
              type: boolean
              description: "`true` when this repeats an answer that was already accepted."
            continuation:
              type: object
              required: [status, invocationId, queued]
              properties:
                status:
                  type: string
                  enum: [queued]
                invocationId:
                  type: [string, "null"]
                queued:
                  type: boolean
                  description: "`true` when the resumed run waits behind a run still on the phone."

    RecordingFinalizationStatus:
      type: string
      enum: [absent, pending, complete, failed]
      description: |
        `absent`: no recording. `pending`: still being finalized; poll again.
        `complete`: finished. `failed`: finalization failed.

    SessionRecordingSegment:
      type: object
      required: [url, start_offset_ms, duration_ms, width, height, expires_at]
      properties:
        url:
          type: string
          format: uri
          description: Signed URL of the MP4 segment.
        start_offset_ms:
          type: number
          description: Offset of the segment from the start of the recording.
        duration_ms:
          type: number
        width:
          type: [integer, "null"]
        height:
          type: [integer, "null"]
        expires_at:
          type: integer
          description: When `url` stops working, in Unix seconds.

    SessionRecording:
      type: object
      required: [status, stop_reason, started_at_ms, duration_ms, segments]
      properties:
        status:
          type: string
          enum: [complete, partial]
          description: "`partial` when the recorder did not mark the recording complete."
        stop_reason:
          type: string
          description: Why recording stopped, or `unknown`.
        started_at_ms:
          type: number
          description: Recording start, in Unix milliseconds.
        duration_ms:
          type: [number, "null"]
          description: Null when the recorder did not record a duration.
        segments:
          type: array
          items:
            $ref: "#/components/schemas/SessionRecordingSegment"

    SessionRecordingResponse:
      type: object
      required: [data, recording_status]
      properties:
        data:
          oneOf:
            - $ref: "#/components/schemas/SessionRecording"
            - type: "null"
          description: The playable recording, or null when there is none to play yet.
        recording_status:
          $ref: "#/components/schemas/RecordingFinalizationStatus"
        recording_status_reason:
          type: [string, "null"]
          description: Why finalization failed. Present only when `data` is null.

    SessionReplayFrame:
      type: object
      required: [event, timestamp, frame_url, expires_at]
      properties:
        event:
          type: object
          required: [id, type, data, screenshotId, stepIndex]
          properties:
            id:
              type: string
            type:
              type: string
              enum: [action_completed]
            data:
              type: [object, "null"]
              additionalProperties: true
            screenshotId:
              type: string
            stepIndex:
              type: [integer, "null"]
        timestamp:
          type: string
          format: date-time
          description: When the action completed.
        frame_url:
          type: string
          format: uri
          description: Signed URL of the PNG screenshot.
        expires_at:
          type: integer
          description: When `frame_url` stops working, in Unix seconds.

    SessionReplayResponse:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/SessionReplayFrame"

    StreamStartInput:
      type: object
      properties:
        width:
          type: number
        height:
          type: number
        fps:
          type: number
        iceServers:
          type: array
          items:
            $ref: "#/components/schemas/IceServer"
          description: Overrides the server-issued ICE servers for the phone. Omit to use them.
        viewer_id:
          type: string
          description: Identifies this viewer when several watch one phone. Defaults to the session id.
        workspaceId:
          type: string
          format: uuid
          description: Optional for workspace-scoped API keys.

    StreamViewerInput:
      type: object
      properties:
        viewer_id:
          type: string
          description: The viewer, as passed to `stream/start`. Defaults to the session id.
        workspaceId:
          type: string
          format: uuid
          description: Optional for workspace-scoped API keys.

    StreamStopInput:
      type: object
      properties:
        reason:
          type: string
        viewer_id:
          type: string
          description: The viewer to stop. Defaults to the session id.
        workspaceId:
          type: string
          format: uuid
          description: Optional for workspace-scoped API keys.

    StreamSignalInput:
      type: object
      required: [message]
      properties:
        message:
          type: object
          required: [type]
          additionalProperties: true
          properties:
            type:
              type: string
              enum: [webrtc_answer, webrtc_ice]
        workspaceId:
          type: string
          format: uuid
          description: Optional for workspace-scoped API keys.

    StreamSignalEvent:
      type: object
      required: [action]
      additionalProperties: true
      description: |
        One signaling message from the phone, relayed as the phone sent it.
        `action` says what it is; the other fields depend on it (for
        example the SDP of a `webrtc_offer`, or the candidate of a
        `webrtc_ice`).
      properties:
        action:
          type: string
          enum: [stream_ready, stream_error, stream_stopped, webrtc_offer, webrtc_ice, ice_update_applied]
        session_id:
          type: string

    StreamCommandResult:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [deviceId, status, message]
          properties:
            deviceId:
              type: string
            status:
              type: string
            message:
              type: string

    IceServer:
      type: object
      required: [urls]
      properties:
        urls:
          oneOf:
            - type: string
            - type: array
              items:
                type: string
        username:
          type: string
        credential:
          type: string

    IceServerIssuance:
      type: object
      required: [iceServers, generation, issuedAt, expiresAt, refreshAfter]
      properties:
        iceServers:
          type: [array, "null"]
          items:
            $ref: "#/components/schemas/IceServer"
          description: Null when TURN is not available.
        generation:
          type: integer
          description: Credential generation; increases with each rotation.
        issuedAt:
          type: integer
          description: Unix seconds.
        expiresAt:
          type: integer
          description: When the credential stops working, in Unix seconds.
        refreshAfter:
          type: integer
          description: When the dashboard starts rotating the credential, in Unix seconds. API callers cannot rotate yet.

    Busy:
      type: object
      description: |
        A `429` refusal. `device_busy` carries `currentSessionId` (the run
        holding the phone); `cap_busy` does not. Any authenticated request
        can also get `rate_limit_exceeded` when the API's request rate limit
        is exceeded; it comes with a `Retry-After` header in seconds.
      required: [error]
      properties:
        error:
          type: string
          enum: [device_busy, cap_busy, rate_limit_exceeded]
        currentSessionId:
          type: [string, "null"]
        message:
          type: string

    DeviceDeployPreviewInput:
      type: object
      additionalProperties: false
      properties:
        internetExitCountry:
          type: string
          pattern: "^[a-z]{2}$"
          description: |
            Where the new phone's Internet exits (ISO 3166-1 alpha-2, lowercase).
            Defaults to `us`. One of `rotating` from
            `GET /v1/workspaces/{workspaceId}/internet/locations`; any other
            code answers 400 `unsupported_country`.
        internetConnection:
          type: string
          enum: [rotating, persistent]
          description: |
            The connection type the phone is bought with. `rotating` births the
            phone without a Static IP; `persistent` (and absent) gives it one
            where the country is covered.
        quantity:
          type: integer
          minimum: 1
          maximum: 100
          default: 1
          description: |
            How many Lusor Phones to add. More than the App tier's free phone
            slots answers 402 `tier_phone_limit`; more than 100 phones on the
            plan answers 409.

    DeviceChangeConfirmInput:
      type: object
      additionalProperties: false
      required: [previewId]
      properties:
        previewId:
          type: string
          format: uuid
          description: The `previewId` of an unexpired quote.

    DeviceChangePreview:
      type: object
      required: [previewId, quantity, currency, dueToday, creditToday, totalAtRenewal, expiresAt, internetExitCountry]
      properties:
        previewId:
          type: string
          format: uuid
        quantity:
          type: integer
          description: Lusor Phones on the plan after the change.
        currency:
          type: string
          enum: [usd]
        dueToday:
          type: integer
          description: US cents charged when the change is confirmed (0 for a removal).
        creditToday:
          type: integer
          description: US cents credited to the account balance when the change is confirmed (0 for a deploy).
        totalAtRenewal:
          type: integer
          description: US cents of the next full invoice after the change.
        expiresAt:
          type: integer
          description: Unix seconds after which the quote cannot be confirmed.
        internetExitCountry:
          type: [string, "null"]
          description: The new phone's Internet exit country; null for a removal.
        inboxQuantityChange:
          type: integer
          description: Paid email addresses ($2/mo each) the same change adds or removes. Each Lusor Phone includes one address, so a removal that leaves more addresses than phones is +1 and added phones that cover paid addresses are negative; 0 when no address changes billing. Absent on older previews.
        subtotal:
          type: integer
          description: US cents, the invoice subtotal before invoice-level discounts and tax, with item-level discounts already applied (negative for a removal's credit).
        tax:
          type: integer
          description: US cents of tax added on top of that subtotal (exclusive tax; inclusive tax is already inside the subtotal), so that subtotal - discountToday + tax is the invoice total.
        discountToday:
          type: integer
          description: US cents of discounts itemized on the invoice billed now. Discounts already netted into prorated amounts are not counted. Absent on older previews.
        discountAtRenewal:
          type: integer
          description: US cents of discounts itemized on the next full invoice. Absent on older previews.
        subtotalAtRenewal:
          type: integer
          description: US cents, the next full invoice's subtotal before invoice-level discounts and added tax, so that subtotalAtRenewal - discountAtRenewal + taxAtRenewal = totalAtRenewal. Absent on older previews.
        taxAtRenewal:
          type: integer
          description: US cents of tax added on top of that subtotal (exclusive tax; inclusive tax is already inside the subtotal). Absent on older previews.

    DeviceChangeResult:
      type: object
      required: [status, quantity, paymentPending, workspaceId]
      properties:
        status:
          type: string
          description: Billing status after the change (`active` when it applied).
        quantity:
          type: integer
          description: Lusor Phones on the plan, as paid. Unchanged while `paymentPending`.
        paymentPending:
          type: boolean
          description: The charge did not go through yet; nothing changed. Complete it in billing management.
        workspaceId:
          type: string

    DeviceGeoInput:
      type: object
      additionalProperties: false
      properties:
        latitude:
          type: number
          minimum: -90
          maximum: 90
          description: Degrees, WGS84. Stored to six decimals.
        longitude:
          type: number
          minimum: -180
          maximum: 180
          description: Degrees, WGS84. Stored to six decimals.
        timezone:
          type: string
          maxLength: 64
          description: IANA zone name, for example `Europe/Paris`.
      required: [latitude, longitude, timezone]
    DeviceGeo:
      type: object
      properties:
        mode:
          type: string
          enum: [auto, override]
          description: "`auto` follows the Internet exit IP; `override` is a value set with `PUT /geo`."
        latitude:
          type: [number, "null"]
          description: The latitude the phone was last given; null when none is known yet.
        longitude:
          type: [number, "null"]
        timezone:
          type: [string, "null"]
        apply:
          type: [object, "null"]
          description: Override mode only. Whether the phone has been given the override.
          properties:
            state:
              type: string
              enum: [pending, applied, held, failed]
            reason:
              type: string
              description: "`held` only: `lifecycle` (restarting or resetting; applied when it finishes) or `no_pad`."
            error:
              type: string
              description: "`failed` only: `geo_apply_failed`. Send the PUT again to retry."
          required: [state]
      required: [mode, latitude, longitude, timezone, apply]
    DeviceRemoveResult:
      type: object
      required: [status, quantity, paymentPending, deviceId]
      properties:
        status:
          type: string
        quantity:
          type: integer
          description: Lusor Phones on the plan after the removal.
        paymentPending:
          type: boolean
        deviceId:
          type: string
          description: The phone being wiped and released.

    UpdateSessionInput:
      allOf:
        - $ref: "#/components/schemas/WorkspaceScopeInput"
        - type: object
          required: [name]
          properties:
            name:
              type: [string, "null"]
              minLength: 1
              maxLength: 120
              description: New display name (trimmed); `null` clears it.

    CancelQueuedFollowupResult:
      type: object
      required: [ok, sessionId, invocationId, queuedEventId]
      properties:
        ok:
          type: boolean
        sessionId:
          type: string
        invocationId:
          type: string
        queuedEventId:
          type: string
        alreadyCancelled:
          type: boolean
          description: Present and `true` when the entry had already been cancelled.

    CreateSkillInput:
      type: object
      required: [title, content]
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 200
        content:
          type: string
          maxLength: 20000
          description: Markdown skill document. May be empty for a draft.
        tags:
          type: array
          items:
            type: string
            minLength: 1
            maxLength: 64
          maxItems: 20
        appPackage:
          type: [string, "null"]
          minLength: 1
          maxLength: 256
        resultSchema:
          type: [object, "null"]
        folder:
          type: [string, "null"]
          minLength: 1
          maxLength: 120
          description: Folder grouping label.
        clientRevision:
          type: integer
          format: int64
          minimum: 0
          maximum: 9007199254740991

    DeviceOccupancy:
      type: object
      required: [active, queue, assigned]
      properties:
        active:
          description: The run holding the phone; null when it is idle.
          type: [object, "null"]
          properties:
            agentId:
              type: [string, "null"]
              format: uuid
            agentName:
              type: [string, "null"]
            agentColor:
              type: [string, "null"]
            sessionId:
              type: string
            startedAt:
              type: [string, "null"]
              format: date-time
          required: [agentId, agentName, agentColor, sessionId, startedAt]
        queue:
          type: array
          description: Agent turns waiting for the phone, oldest first.
          items:
            type: object
            properties:
              agentId:
                type: string
                format: uuid
              agentName:
                type: string
              agentColor:
                type: string
              sessionId:
                type: string
              queuedAt:
                type: string
                format: date-time
            required: [agentId, agentName, agentColor, sessionId, queuedAt]
        assigned:
          type: array
          description: Agents attached to the phone.
          items:
            type: object
            properties:
              agentId:
                type: string
                format: uuid
              agentName:
                type: string
              agentColor:
                type: string
              label:
                type: [string, "null"]
            required: [agentId, agentName, agentColor, label]

    DeviceStaticIp:
      type: object
      required: [state, country, publicIp, expiresAt]
      properties:
        state:
          type: string
          enum: [active, provisioning, needs_review, disabled]
        country:
          type: [string, "null"]
          description: ISO 3166-1 alpha-2, lowercase.
        publicIp:
          type: [string, "null"]
          description: The address the phone's traffic exits from.
        expiresAt:
          type: [string, "null"]
          format: date-time
          description: End of the paid term.

    DeviceStaticIpStatus:
      type: object
      required: [state]
      properties:
        state:
          type: string
          enum: [active, provisioning, needs_review, disabled]
        static:
          oneOf:
            - $ref: "#/components/schemas/DeviceStaticIp"
            - type: "null"
          description: Absent while an operation is in progress.
        operationId:
          type: string
          description: Present while an operation is in progress. Pass it as `operation` to poll.

    DeviceInternetLocation:
      type: object
      required: [country, generation, applied, network, route, static]
      properties:
        country:
          type: string
          description: ISO 3166-1 alpha-2, lowercase.
        generation:
          type: [integer, "null"]
          description: Changes each time the phone's route changes; null until it has one.
        applied:
          type: string
          enum: [route, pending]
        network:
          type: string
          enum: [home, mobile]
        route:
          type: string
          enum: [static, residential]
        static:
          oneOf:
            - $ref: "#/components/schemas/DeviceStaticIp"
            - type: "null"

    DeviceReset:
      type: object
      required: [device_id, attempt, state, reason, device_status, internet, requested_at, finished_at]
      properties:
        device_id:
          type: string
        attempt:
          type: integer
        state:
          type: string
          enum: [idle, running, succeeded, refused, failed]
        reason:
          type: [string, "null"]
        device_status:
          type: [string, "null"]
        internet:
          description: How the phone's Internet connection was restored after the reset; null before it ran.
          oneOf:
            - type: "null"
            - type: object
              required: [status]
              properties:
                status:
                  type: string
                  enum: [bound, needs_proxy_reassign, held, failed]
                reason:
                  type: string
                  description: Present when `status` is `held`.
        requested_at:
          type: [string, "null"]
          format: date-time
        finished_at:
          type: [string, "null"]
          format: date-time

    Workspace:
      type: object
      required: [workspaceId, tenantId, name, slug, createdAt]
      properties:
        workspaceId:
          type: string
          format: uuid
        tenantId:
          type: string
          format: uuid
        name:
          type: string
        slug:
          type: string
        createdAt:
          type: string
          format: date-time

    CatalogApp:
      type: object
      required: [package, display_name, developer, category, version_code, versionName, icon_url]
      properties:
        package:
          type: string
        display_name:
          type: string
        developer:
          type: [string, "null"]
        category:
          type: [string, "null"]
        version_code:
          type: string
          description: Android version code, as a decimal string.
        versionName:
          type: [string, "null"]
        icon_url:
          type: [string, "null"]
          description: >-
            Public, unauthenticated, content-addressed icon path
            (`/v1/public/catalog-icons/{package}/{sha256}.{png|jpg|webp}`),
            relative to the API origin and cacheable for a year. Null when the
            icon is momentarily unavailable; the authenticated
            `GET /v1/catalog/apps/{package}/icon` still serves the current icon.

    ErrorResponse:
      type: object
      required: [error, message]
      properties:
        error:
          type: string
        message:
          type: string

    # ─── Chain shapes ───────────────────────────────────────────────────────────

    ChainVerb:
      type: string
      enum:
        [tap, swipe, type, key, clear, back, home, recent, start_app, stop_app, screenshot, wait]
      description: Verb discriminator for a chain step.

    ChainCapturePolicy:
      type: string
      enum: [each, final, none]
      description: |
        Top-level capture policy for a chain request. Per-step `capture`
        always overrides this default. `'each'` captures every step;
        `'final'` captures only the last; `'none'` (default) captures
        nothing.

    # ─── Per-verb chain-step subschemas (OpenAPI 3.1 discriminator pattern) ─────
    #
    # Each variant has its correct `params` correlated with the `verb`
    # literal. The composite `ChainStep` schema below uses
    # `discriminator: { propertyName: verb, mapping: ... }` to route a
    # given payload to the right variant.

    TapChainStepParams:
      type: object
      description: |
        Step `params` for a `tap` step — the `TapInput` shape without
        `capture`, which is a sibling field of `params` on the step. Provide
        `index`, or `x`+`y`, or both; when both are supplied `index` wins.
        Supplying neither is a 400, and `x` without `y` is a 400 only when
        no `index` is supplied.
      anyOf:
        - required: [index]
        - required: [x, y]
      properties:
        index: { type: integer, minimum: 1 }
        x: { type: number }
        y: { type: number }
        stealth: { type: boolean }

    TapChainStep:
      type: object
      required: [verb, params]
      properties:
        verb: { type: string, enum: [tap] }
        params: { $ref: "#/components/schemas/TapChainStepParams" }
        capture: { $ref: "#/components/schemas/CaptureField" }

    SwipeChainStepParams:
      type: object
      required: [startX, startY, endX, endY]
      properties:
        startX: { type: number }
        startY: { type: number }
        endX: { type: number }
        endY: { type: number }
        durationMs: { type: integer, minimum: 1, maximum: 60000 }
        stealth: { type: boolean }

    SwipeChainStep:
      type: object
      required: [verb, params]
      properties:
        verb: { type: string, enum: [swipe] }
        params: { $ref: "#/components/schemas/SwipeChainStepParams" }
        capture: { $ref: "#/components/schemas/CaptureField" }

    TypeChainStepParams:
      type: object
      required: [text]
      properties:
        text: { type: string, minLength: 1, maxLength: 8192 }
        clear: { type: boolean }
        stealth: { type: boolean }

    TypeChainStep:
      type: object
      required: [verb, params]
      properties:
        verb: { type: string, enum: [type] }
        params: { $ref: "#/components/schemas/TypeChainStepParams" }
        capture: { $ref: "#/components/schemas/CaptureField" }

    KeyChainStepParams:
      type: object
      required: [keyCode]
      properties:
        keyCode: { type: integer, minimum: 0, maximum: 2000 }

    KeyChainStep:
      type: object
      required: [verb, params]
      properties:
        verb: { type: string, enum: [key] }
        params: { $ref: "#/components/schemas/KeyChainStepParams" }
        capture: { $ref: "#/components/schemas/CaptureField" }

    EmptyChainStepParams:
      type: object
      description: |
        Empty params object for verbs without inputs (`clear`, `back`,
        `home`, `recent`, `screenshot`). Server treats both `params: {}`
        and an omitted `params` as equivalent.

    ClearChainStep:
      type: object
      required: [verb]
      properties:
        verb: { type: string, enum: [clear] }
        params: { $ref: "#/components/schemas/EmptyChainStepParams" }
        capture: { $ref: "#/components/schemas/CaptureField" }

    BackChainStep:
      type: object
      required: [verb]
      properties:
        verb: { type: string, enum: [back] }
        params: { $ref: "#/components/schemas/EmptyChainStepParams" }
        capture: { $ref: "#/components/schemas/CaptureField" }

    HomeChainStep:
      type: object
      required: [verb]
      properties:
        verb: { type: string, enum: [home] }
        params: { $ref: "#/components/schemas/EmptyChainStepParams" }
        capture: { $ref: "#/components/schemas/CaptureField" }

    RecentChainStep:
      type: object
      required: [verb]
      properties:
        verb: { type: string, enum: [recent] }
        params: { $ref: "#/components/schemas/EmptyChainStepParams" }
        capture: { $ref: "#/components/schemas/CaptureField" }

    ScreenshotChainStep:
      type: object
      required: [verb]
      properties:
        verb: { type: string, enum: [screenshot] }
        params: { $ref: "#/components/schemas/EmptyChainStepParams" }
        capture: { $ref: "#/components/schemas/CaptureField" }

    StartAppChainStepParams:
      type: object
      required: [package]
      properties:
        package:
          type: string
          minLength: 1
          maxLength: 255
          pattern: '^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$'
          description: |
            Same constraint as `StartAppInput.package` — a case-insensitive
            dotted Java identifier, validated before dispatch.
        activity:
          type: string
          minLength: 1
          maxLength: 255
          pattern: '^\.?[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)*$'
          description: |
            Relative-or-fully-qualified Android activity name; a leading `.`
            is accepted. Same regex as `StartAppInput.activity`.

    StartAppChainStep:
      type: object
      required: [verb, params]
      properties:
        verb: { type: string, enum: [start_app] }
        params: { $ref: "#/components/schemas/StartAppChainStepParams" }
        capture: { $ref: "#/components/schemas/CaptureField" }

    StopAppChainStepParams:
      type: object
      required: [package]
      properties:
        package:
          type: string
          minLength: 1
          maxLength: 255
          pattern: '^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$'
          description: |
            Same constraint as `StopAppInput.package`; validated against the
            same regex as the `start_app` step.

    StopAppChainStep:
      type: object
      required: [verb, params]
      properties:
        verb: { type: string, enum: [stop_app] }
        params: { $ref: "#/components/schemas/StopAppChainStepParams" }
        capture: { $ref: "#/components/schemas/CaptureField" }

    WaitChainStepParams:
      type: object
      required: [duration_ms]
      properties:
        duration_ms:
          type: integer
          minimum: 0
          maximum: 60000
          description: |
            Milliseconds to pause before the next step runs. Bounded to
            60s per wait step; cumulative wait duration across all wait
            steps in one chain must be ≤ 120s. Chains have no
            implicit inter-step delay — insert `wait` steps where UI
            settling is needed (after taps that trigger animations,
            before taps that follow screen transitions).

    WaitChainStep:
      type: object
      required: [verb, params]
      properties:
        verb: { type: string, enum: [wait] }
        params: { $ref: "#/components/schemas/WaitChainStepParams" }
        capture: { $ref: "#/components/schemas/CaptureField" }

    ChainStep:
      description: |
        One step in a chain. The `params` shape is correlated with the
        `verb` discriminator via OpenAPI 3.1 `discriminator.mapping` —
        each variant carries the exact params shape its verb accepts.
      oneOf:
        - $ref: "#/components/schemas/TapChainStep"
        - $ref: "#/components/schemas/SwipeChainStep"
        - $ref: "#/components/schemas/TypeChainStep"
        - $ref: "#/components/schemas/KeyChainStep"
        - $ref: "#/components/schemas/ClearChainStep"
        - $ref: "#/components/schemas/BackChainStep"
        - $ref: "#/components/schemas/HomeChainStep"
        - $ref: "#/components/schemas/RecentChainStep"
        - $ref: "#/components/schemas/StartAppChainStep"
        - $ref: "#/components/schemas/StopAppChainStep"
        - $ref: "#/components/schemas/ScreenshotChainStep"
        - $ref: "#/components/schemas/WaitChainStep"
      discriminator:
        propertyName: verb
        mapping:
          tap: "#/components/schemas/TapChainStep"
          swipe: "#/components/schemas/SwipeChainStep"
          type: "#/components/schemas/TypeChainStep"
          key: "#/components/schemas/KeyChainStep"
          clear: "#/components/schemas/ClearChainStep"
          back: "#/components/schemas/BackChainStep"
          home: "#/components/schemas/HomeChainStep"
          recent: "#/components/schemas/RecentChainStep"
          start_app: "#/components/schemas/StartAppChainStep"
          stop_app: "#/components/schemas/StopAppChainStep"
          screenshot: "#/components/schemas/ScreenshotChainStep"
          wait: "#/components/schemas/WaitChainStep"

    ChainRequest:
      type: object
      required: [steps]
      properties:
        steps:
          type: array
          minItems: 1
          maxItems: 20
          items:
            $ref: "#/components/schemas/ChainStep"
        capture:
          $ref: "#/components/schemas/ChainCapturePolicy"

    ChainStepResponse:
      allOf:
        - $ref: "#/components/schemas/VerbResult"
        - type: object
          required: [verb]
          properties:
            verb:
              $ref: "#/components/schemas/ChainVerb"
            resolved:
              type: object
              properties:
                x: { type: integer }
                y: { type: integer }
                index: { type: integer }
            cleared:
              type: boolean
            capture:
              $ref: "#/components/schemas/CaptureResponse"

    ChainResponse:
      type: object
      required: [ok, steps]
      properties:
        ok:
          type: boolean
          description: |
            True iff every step's `ok` was true.
        steps:
          type: array
          items:
            $ref: "#/components/schemas/ChainStepResponse"

    ValidationErrorResponse:
      type: object
      required: [error, details]
      properties:
        error:
          type: string
          example: validation_error
        details:
          type: object
          additionalProperties: true

  responses:
    HistoryWindow:
      description: |
        `upgrade_required`: the session is older than the workspace plan's
        history window. Nothing is returned. The body also carries
        `reason: "history_window"`.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    HistoryPolicyUnresolved:
      description: "`history_policy_unresolved`: the workspace's plan cannot be resolved right now; nothing is returned."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    BadRequest:
      description: "`bad_request`: no workspace could be resolved from the body, the query string, or the API key."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    StreamCommand:
      description: The phone accepted the command
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/StreamCommandResult"
    LiveViewRefused:
      description: "`live_view_not_available_for_api_keys`: API keys cannot open a live view of a Lusor Phone. Use the device action routes instead."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    SessionHasNoDevice:
      description: "`no_device`: the session has no phone."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    PaymentRequired:
      description: |
        The action was refused for billing and was not sent to the phone.
        `credits_exhausted` (`resource: "phone_minutes"`): the workspace is
        out of credits for Lusor Phone minutes, or reached its plan's
        allowance with overages off. `billing_required`: this Lusor Phone is
        not covered by an active paid plan.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    PhoneLimit:
      description: |
        `tier_phone_limit`: the plan's phone limit would be exceeded; the
        body names the limit and the tier to upgrade to.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    Forbidden:
      description: The caller may not perform this action.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    DeviceNotFound:
      description: |
        `device_not_found`: no Lusor Phone of this workspace's plan has this
        id (absent, in another workspace, or not a Lusor Phone).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    DeviceChangeConflict:
      description: |
        The change was refused; nothing was charged or removed by this call. `error` is one of:
        `default_workspace_only` (deploy outside the Default workspace),
        `no_subscription` (no active Phone plan),
        `checkout_open` (a checkout is still open),
        `payment_pending` (a payment on the plan is unpaid),
        `preview_expired` (the quote expired, is unknown, or the plan changed since: preview again),
        `device_busy` (a session is running on the phone),
        `device_releasing` (the phone is already being released),
        `last_phone` (the plan's last phone: cancel the plan in billing management instead),
        or `billing_conflict` (the plan needs support reconciliation).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    DeviceControlConflict:
      description: |
        Refused; nothing changed. `error` is one of `not_managed_phone`
        (not a Lusor cloud phone), `phone_busy` (a session, a live viewer, a
        restart or reset, or another change holds the phone), or
        `phone_not_ready` (not deployed yet, or removed).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    DeviceControlInvalid:
      description: |
        `language_invalid`, `identity_invalid`, or `identity_prop_refused`
        (a key that is not one of the identity fields, such as a raw Android
        property).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    DeviceControlUpstream:
      description: |
        `upstream_error`, or `change_outcome_unknown`: the request timed out
        and the change may still be applied. Read the setting before
        retrying.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    BillingUnavailable:
      description: |
        `billing_unavailable`: billing is temporarily unavailable. On a
        preview nothing changed. On a confirmation the outcome is unknown:
        retry the confirmation with the SAME `previewId`, which is never
        applied twice, and do not request a new quote (it could add a second
        phone).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    MeterUnavailable:
      description: |
        `meter_unavailable`: metering for this Lusor Phone could not start,
        so the action was not sent. Safe to retry shortly.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    InternetRouteInactive:
      description: |
        `internet_route_inactive`: this Lusor Phone's Internet connection is
        not active yet. Retry once it is.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    Unauthorized:
      description: Authentication required or invalid credentials.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    DeviceGeoConflict:
      description: |
        `error` is one of:
        `not_managed_phone` (the device is not a Lusor Phone on the Phone plan; trial phones included),
        `phone_busy` (the phone is restarting or resetting: try again when it is ready),
        or `phone_not_ready` (the phone is not set up yet, or is removed).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    DeviceGeoQueued:
      description: |
        `geo_change_queued`: another operation on the phone is running. The
        change is queued and still applies; read it back with `GET /geo`.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    UpstreamError:
      description: The phone or a Lusor service could not be reached.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    ValidationError:
      description: |
        Request validation failed. Either `validation_error` with per-field
        `details`, or `bad_request` with a `message`, depending on the
        endpoint.
      content:
        application/json:
          schema:
            anyOf:
              - $ref: "#/components/schemas/ValidationErrorResponse"
              - $ref: "#/components/schemas/ErrorResponse"
