openapi: 3.1.0
info:
  title: Apkservice.mobi — Agent API
  description: |
    **Agent-facing subset** of the Apkservice.mobi API. Cherry-picked
    operations safe for autonomous AI agents (Claude, ChatGPT, Cursor) to
    use against an authenticated user's own team.

    ### Excluded from this subset

    - **Cloaking infrastructure** (`/service/*`, `CloakRoutes`) — internal
      operations on the traffic-filtering pipeline. Not for agents.
    - **User and team management** (`/auth/register`, `/auth/link-telegram`,
      `/admin/teams/*`, `/admin/users/*`) — sensitive role/membership
      operations gated behind explicit human approval.
    - **Crypto administration** (`/admin/crypto*`) — API-key management,
      webhooks, direct crediting, and status mutation stay internal. Agents
      receive only scoped balance-top-up create/list/get operations.
    - **Firebase admin, cache, training, postback callbacks** — service
      endpoints with no useful agent semantics.

    ### What is included

    - `auth/me` — agent context (who am I, which team, balance)
    - Bundle CRUD — create, update, delete APK bundles
    - Reference data — countries, timezones, white-page templates
    - Read-only inspection — pool info, rebuild queue, stats

    ### Hosting

    This document is **not** served from `api.apkservice.mobi`. It is
    mirrored to the public landing domain at
    `https://apkservice.mobi/api/openapi.yaml` so agents can discover it
    via the RFC 9727 API Catalog at
    `https://apkservice.mobi/.well-known/api-catalog`. The actual API
    endpoints, however, live on `api.apkservice.mobi` and are reached via
    the MCP server proxy at `mcp.apkservice.mobi` after OAuth.

    ### Authentication

    All endpoints (except `/health`) require a Bearer JWT obtained through
    the OAuth 2.1 Authorization Code flow (PKCE) advertised at
    `api.apkservice.mobi/.well-known/oauth-authorization-server`. The MCP
    server `mcp.apkservice.mobi` brokers OAuth on the agent's behalf.

    ### Versioning

    This subset evolves independently of the internal full API. Adding a
    new endpoint here requires a deliberate review of agent safety
    (destructive actions, cross-team impact, billing impact).
  version: 0.1.0
  contact:
    url: https://apkservice.mobi/AGENTS.md

servers:
  - url: https://api.apkservice.mobi
    description: Production (reached via mcp.apkservice.mobi proxy after OAuth)
  - url: http://localhost:8095
    description: Local development

tags:
  - name: identity
    description: Who is the calling agent
  - name: account
    description: One-time bootstrap of the team's dashboard administrator
  - name: billing
    description: Create and poll the calling team's USDT balance top-ups
  - name: bundles
    description: APK bundle CRUD — the primary agent workflow
  - name: reference
    description: Read-only reference data (countries, timezones, templates)
  - name: inspection
    description: Read-only inspection of internal queues and aggregates

security:
  - bearerAuth: []

paths:

  /agent-onboard/me:
    get:
      operationId: getCurrentAgent
      tags: [identity]
      x-ai: { effect: read, approval: never, idempotencyRequired: false, versionedResource: false, allowedRoles: [ADMIN, MANAGER, VIEWER] }
      x-i18n:
        ru: { summary: "Получить команду и лимиты авторизованного агента", result: "Текущая идентичность агента" }
        uk: { summary: "Отримати команду та ліміти авторизованого агента", result: "Поточна ідентичність агента" }
      summary: Get the authenticated agent's team identity and limits
      description: |
        Returns the agent's tier (shadow / real), sandbox subdomain, balance,
        and team metadata. Call this first at session start so the agent knows
        which host to pin bundles to and whether promotion is needed.

        Phase 5.3 split this away from the dashboard `/auth/me`, which expects
        a service_users row that agent OAuth tokens never have.
      responses:
        '200':
          description: Current agent identity
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AgentMeResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /agent/account/dashboard-setup:
    post:
      operationId: createDashboardSetupLink
      tags: [account]
      x-ai: { effect: write, approval: always, idempotencyRequired: true, versionedResource: false, allowedRoles: [ADMIN] }
      x-i18n:
        ru: { summary: "Создать одноразовую ссылку на настройку дашборда для незанятой команды", result: "Одноразовая ссылка создана" }
        uk: { summary: "Створити одноразове посилання на налаштування дашборда для незайнятої команди", result: "Одноразове посилання створено" }
      summary: Create a one-time dashboard setup link for an unclaimed team
      description: |
        Creates a 24-hour link for the human owner to choose the first dashboard
        ADMIN password. The password never passes through MCP. Issuing a new
        link revokes the previous active link. This operation is available only
        while the team has no dashboard users.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DashboardSetupLinkRequest' }
      responses:
        '201':
          description: One-time setup link created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DashboardSetupLinkResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '409':
          description: Team is already claimed or the email is unavailable
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /agent/billing/topups:
    post:
      operationId: createBalanceTopUp
      tags: [billing]
      x-ai: { effect: financial, approval: always, idempotencyRequired: true, versionedResource: false, allowedRoles: [ADMIN] }
      x-i18n:
        ru: { summary: "Создать идемпотентный заказ на пополнение баланса в USDT", result: "Заказ в статусе PROVISIONED готов либо возвращён существующий идемпотентный заказ" }
        uk: { summary: "Створити ідемпотентне замовлення на поповнення балансу в USDT", result: "Замовлення в статусі PROVISIONED готове або повернуто наявне ідемпотентне замовлення" }
      summary: Create an idempotent USDT balance top-up order
      description: |
        Creates a CryptoPro deposit address for the calling team. Currency and
        purpose are fixed server-side to USDT and BALANCE_TOPUP. Both shadow
        and real teams may use this operation. The Agent Service injects the
        hidden `runId:toolCallId` binding and Ktor derives a deterministic
        UUIDv5 server-side. CryptoPro does not accept an idempotency key,
        so order provisioning is first claimed durably in PostgreSQL. A 202
        means another request or replica owns that claim; retry this operation
        with the same tool-call binding after the Retry-After delay. A 503 means
        the provider outcome is ambiguous and a new key must not be used to
        create a replacement order automatically.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateBalanceTopUpRequest' }
      responses:
        '201':
          description: A PROVISIONED order is ready, or the existing PROVISIONED idempotent order was returned
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BalanceTopUp' }
        '202':
          description: Provisioning is still owned by another request; retry the same idempotency key after Retry-After
          headers:
            Retry-After:
              schema: { type: integer, minimum: 1 }
              description: Seconds to wait before polling with the same request and idempotency key.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '409':
          description: The idempotency key was already used with a different payload
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '502':
          description: CryptoPro definitively rejected the validated deposit-order request
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '503':
          description: CryptoPro may have created an order but the outcome cannot be proven; automatic replacement is unsafe
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
    get:
      operationId: listBalanceTopUps
      tags: [billing]
      x-ai: { effect: read, approval: never, idempotencyRequired: false, versionedResource: false, allowedRoles: [ADMIN, MANAGER, VIEWER] }
      x-i18n:
        ru: { summary: "Показать только пополнения баланса вызывающей команды", result: "Постраничный список пополнений; депозиты по промо никогда не включаются" }
        uk: { summary: "Показати лише поповнення балансу команди, що викликає", result: "Посторінковий список поповнень; депозити за промо ніколи не включаються" }
      summary: List only balance top-ups for the calling team
      parameters:
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: size
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
      responses:
        '200':
          description: Paginated balance top-ups; promotion deposits are never included
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BalanceTopUpsPage' }
        '400': { $ref: '#/components/responses/BadRequest' }

  /agent/billing/topups/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: getBalanceTopUp
      tags: [billing]
      x-ai: { effect: read, approval: never, idempotencyRequired: false, versionedResource: false, allowedRoles: [ADMIN, MANAGER, VIEWER] }
      x-i18n:
        ru: { summary: "Получить пополнение баланса, принадлежащее вызывающей команде", result: "Пополнение баланса" }
        uk: { summary: "Отримати поповнення балансу, що належить команді, яка викликає", result: "Поповнення балансу" }
      summary: Get a balance top-up owned by the calling team
      responses:
        '200':
          description: Balance top-up
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BalanceTopUp' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }

  /agent/bundles:
    get:
      operationId: listBundlesWithPoolInfo
      tags: [bundles]
      x-ai: { effect: read, approval: never, idempotencyRequired: false, versionedResource: true, allowedRoles: [ADMIN, MANAGER, VIEWER] }
      x-i18n:
        ru: { summary: "Показать бандлы команды с заданным размером пула APK", result: "Бандлы с информацией о пуле" }
        uk: { summary: "Показати бандли команди із заданим розміром пулу APK", result: "Бандли з інформацією про пул" }
      summary: List the team's bundles with their configured APK pool size
      description: |
        Returns every bundle owned by the calling team. Visible to ADMIN,
        MANAGER, VIEWER. Phase 5.3 redirected this to `/agent/bundles` because
        the dashboard `/admin/bundles/pool-info` path never existed in code.
      responses:
        '200':
          description: Bundles with pool info
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/BundleWithPoolInfo' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      operationId: createBundle
      tags: [bundles]
      x-ai: { effect: write, approval: never, idempotencyRequired: true, versionedResource: true, allowedRoles: [ADMIN, MANAGER] }
      x-i18n:
        ru: { summary: "Создать бандл с настройками по умолчанию для shadow-тарифа (ADMIN, MANAGER)", result: "Созданный бандл" }
        uk: { summary: "Створити бандл із типовими налаштуваннями для shadow-тарифу (ADMIN, MANAGER)", result: "Створений бандл" }
      summary: Create a bundle with shadow-tier-friendly defaults (ADMIN, MANAGER)
      description: |
        **Write operation.** May execute without an extra approval when the
        user's message explicitly requested the create.

        Only `name` is required. The handler derives `host` from the team's
        sandbox subdomain (shadow tier), or from `pool_domain_id` / a
        caller-provided host (real tier), then auto-generates
        `application_id` from the host, picks the
        accessible compatible black page with the lowest UUID, and creates a
        CUSTOM white page.

        `app_type` selects the bundle type and defaults to PWA, so no APK
        compile or Firebase setup is triggered. Real-tier teams may pass
        `app_type: APK` to get the dashboard's full pipeline instead.

        Phase 5.3 added this in place of the broken `POST /admin/bundles`,
        which required 9+ AppsFlyer / OneSignal / push fields agents do not
        carry.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AgentCreateBundleRequest' }
      responses:
        '201':
          description: Created bundle
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AgentBundle' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: The requested pool domain does not exist
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '409':
          description: The pool domain was taken by another team before the bundle was written
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '422': { $ref: '#/components/responses/WhitePageListingNotFound' }
        '502': { $ref: '#/components/responses/BadGateway' }

  /agent/domain-pool:
    get:
      operationId: listDomainPool
      tags: [reference]
      x-ai: { effect: read, approval: never, idempotencyRequired: false, versionedResource: false, allowedRoles: [ADMIN, MANAGER, VIEWER] }
      x-i18n:
        ru: { summary: "Домены, доступные к покупке, с ценой за каждый", result: "Доступные домены" }
        uk: { summary: "Домени, доступні до купівлі, з ціною за кожен", result: "Доступні домени" }
      summary: Domains available to buy, with the price charged for each
      description: |
        The domains any team can put a bundle on, read live — the pool is
        shared and names sell, so a list fetched earlier in the conversation
        may already be stale. Only what is buyable right now comes back;
        anything sold or still being set up is absent rather than flagged.

        To use one, pass its `pool_domain_id` to createBundle. **Putting the
        domain name in `createBundle.host` does not buy it** — `host` is for a
        domain already pointed at us, and createBundle rejects a host naming a
        domain that is still on sale, answering with the id to send instead.

        `price_cents` is charged to the team as the bundle is created, in the
        same units as `getCurrentAgent.balance_cents`. Shadow teams host on
        their sandbox subdomain and cannot buy: the list reads, but createBundle
        rejects the purchase until the team is promoted.
      parameters:
        - name: search
          in: query
          required: false
          schema: { type: string }
          description: Case-insensitive substring match on the domain name.
        - name: max_price_cents
          in: query
          required: false
          schema: { type: integer, minimum: 0 }
          description: Only domains at or below this price.
      responses:
        '200':
          description: Available domains
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/AgentPoolDomain' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /agent/push-packs:
    get:
      operationId: listPushPacks
      tags: [reference]
      x-ai: { effect: read, approval: never, idempotencyRequired: false, versionedResource: false, allowedRoles: [ADMIN, MANAGER, VIEWER] }
      x-i18n:
        ru: { summary: "Push-паки In-House, которые команда может привязать к бандлу", result: "Push-паки команды" }
        uk: { summary: "Push-паки In-House, які команда може прикріпити до бандла", result: "Push-паки команди" }
      summary: Push In-House packs this team can attach to a bundle
      description: |
        `pih_pack_ids` on createBundle and updateBundle is a list of these ids.
        Derive one pack from the offer using the production skill's mapping,
        then resolve it by name from this live result. Do not trust remembered
        ids: an id that is not the team's fails inside the push service rather
        than at the API, which reads as the pack simply not arriving.

        Answers 503 when Push In-House cannot be reached, so an empty list
        means the team has no packs rather than that the question failed.
      responses:
        '200':
          description: The team's packs
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/AgentPushPack' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '503':
          description: Push In-House is unreachable; the call is safe to retry
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /agent/images:
    get:
      operationId: listImages
      tags: [images]
      x-ai: { effect: read, approval: never, idempotencyRequired: false, versionedResource: false, allowedRoles: [ADMIN, MANAGER, VIEWER] }
      x-i18n:
        ru: { summary: "Библиотека изображений команды", result: "Страница изображений" }
        uk: { summary: "Бібліотека зображень команди", result: "Сторінка зображень" }
      summary: The team's image library
      description: |
        `app_icon_id` on createBundle and updateBundle takes an id from here.
        Until this existed the only id an agent could hold was one it had just
        uploaded, so anything the team already owned was unreachable and the
        field looked broken for every case but that one.
      parameters:
        - name: page
          in: query
          required: false
          schema: { type: integer, minimum: 1, default: 1 }
        - name: size
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: type
          in: query
          required: false
          schema: { type: string }
          description: Filter by image type.
        - name: query
          in: query
          required: false
          schema: { type: string }
          description: Substring match on the name.
        - name: tags
          in: query
          required: false
          schema: { type: string }
          description: Comma-separated tags.
      responses:
        '200':
          description: A page of images
          content:
            application/json:
              schema:
                type: object
                required: [items, total, page, size]
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/AgentImage' }
                  total: { type: integer }
                  page: { type: integer }
                  size: { type: integer }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /agent/images/{id}:
    delete:
      operationId: deleteImage
      tags: [images]
      x-ai: { effect: destructive, approval: always, idempotencyRequired: true, versionedResource: false, allowedRoles: [ADMIN, MANAGER] }
      x-i18n:
        ru: { summary: "Удалить одно из изображений команды (ADMIN, MANAGER)", result: "Удалено" }
        uk: { summary: "Видалити одне із зображень команди (ADMIN, MANAGER)", result: "Видалено" }
      summary: Delete one of the team's images (ADMIN, MANAGER)
      description: |
        **Destructive.** Always requires approval. Only the calling team's own
        images can be deleted; another team's answers 404 rather than
        confirming it exists.

        A bundle still using the image keeps its reference, so check before
        removing one that an icon points at.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '204': { description: Deleted }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /admin/stats/installs:
    get:
      operationId: getInstallStats
      tags: [inspection]
      x-ai: { effect: read, approval: never, idempotencyRequired: false, versionedResource: false, allowedRoles: [ADMIN, MANAGER, VIEWER] }
      x-i18n:
        ru: { summary: "Инсталлы, просмотры страниц, регистрации и депозиты за период", result: "Итоги и ряд значений за период" }
        uk: { summary: "Інсталли, перегляди сторінок, реєстрації та депозити за період", result: "Підсумки та ряд значень за період" }
      summary: Installs, page views, registrations and deposits over a period
      description: |
        The team's funnel over a time range: installs, page views,
        registrations, deposit attempts and deposits, plus the charge accrued.
        Scoped to the calling team, and to one bundle when `bundle_id` is given.

        An agent that can build a bundle but cannot see whether anyone
        installed it is working blind, which is why this is a read every
        role gets.
      parameters:
        - name: from
          in: query
          required: true
          schema: { type: integer, format: int64 }
          description: Start of the range, epoch seconds.
        - name: to
          in: query
          required: true
          schema: { type: integer, format: int64 }
          description: End of the range, epoch seconds.
        - name: granularity
          in: query
          required: false
          schema: { type: string, enum: [hour, day], default: hour }
        - name: bundle_id
          in: query
          required: false
          schema: { type: string, format: uuid }
          description: Narrow to one bundle. Must belong to the team.
        - name: include_shadow
          in: query
          required: false
          schema: { type: boolean, default: false }
      responses:
        '200':
          description: Totals and a series over the range
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InstallStatsResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /admin/referrals/stats:
    get:
      operationId: getReferralStats
      tags: [inspection]
      x-ai: { effect: read, approval: never, idempotencyRequired: false, versionedResource: false, allowedRoles: [ADMIN, MANAGER, VIEWER] }
      x-i18n:
        ru: { summary: "Реферальная программа команды за период", result: "Итоги по рефералам" }
        uk: { summary: "Реферальна програма команди за період", result: "Підсумки за рефералами" }
      summary: The team's referral programme over a period
      parameters:
        - name: from
          in: query
          required: false
          schema: { type: integer, format: int64 }
        - name: to
          in: query
          required: false
          schema: { type: integer, format: int64 }
      responses:
        '200':
          description: Referral totals
          content:
            application/json:
              schema: { type: object }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /admin/bundle-utils:
    get:
      operationId: listMacroKeys
      tags: [reference]
      x-ai: { effect: read, approval: never, idempotencyRequired: false, versionedResource: false, allowedRoles: [ADMIN, MANAGER, VIEWER] }
      x-i18n:
        ru: { summary: "Макро-ключи, которые принимает клоака команды", result: "Макро-ключи команды" }
        uk: { summary: "Макро-ключі, які приймає клоака команди", result: "Макро-ключі команди" }
      summary: The macro keys this team's cloaking accepts
      description: |
        `spy_chain_sequences` on createBundle and updateBundle takes keys from
        here. The field is Macro Key Cloaking in the dashboard; setting it to
        something the team does not have configured cloaks nothing, so read
        this before writing it.
      responses:
        '200':
          description: The team's macro keys
          content:
            application/json:
              schema:
                type: object
                properties:
                  spy_chain_sequences:
                    type: array
                    items: { type: string }
        '403': { $ref: '#/components/responses/Forbidden' }

  /admin/bundle-utils/spy-chain-sequences:
    put:
      operationId: setMacroKeys
      tags: [bundles]
      x-ai: { effect: write, approval: never, idempotencyRequired: true, versionedResource: false, allowedRoles: [ADMIN, MANAGER] }
      x-i18n:
        ru: { summary: "Заменить макро-ключи команды (ADMIN, MANAGER)", result: "Сохранённый набор" }
        uk: { summary: "Замінити макро-ключі команди (ADMIN, MANAGER)", result: "Збережений набір" }
      summary: Replace the team's macro keys (ADMIN, MANAGER)
      description: |
        **Write operation.** Replaces the whole set, so send every key the team
        should keep, not just the new one. Team-wide: every bundle using Macro
        Key Cloaking draws from this list.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [spy_chain_sequences]
              properties:
                spy_chain_sequences:
                  type: array
                  items: { type: string }
      responses:
        '200':
          description: The stored set
          content:
            application/json:
              schema:
                type: object
                properties:
                  spy_chain_sequences:
                    type: array
                    items: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /admin/black-pages/template-schema/{templateType}:
    get:
      operationId: getBlackPageTemplateSchema
      tags: [reference]
      x-ai: { effect: read, approval: never, idempotencyRequired: false, versionedResource: false, allowedRoles: [ADMIN, MANAGER] }
      x-i18n:
        ru: { summary: "Схема полей для типа шаблона блэк-пейджа", result: "Схема полей шаблона" }
        uk: { summary: "Схема полів для типу шаблона блек-пейджа", result: "Схема полів шаблона" }
      summary: The field schema for a black page template type
      description: |
        The whole shape up front — field names, types, whether they are
        required, their defaults and how the dashboard groups them — instead of
        discovering it from a 400 that names one missing field at a time.

        Only GOOGLE_PLAY has a schema; a CUSTOM page takes the small fixed
        shape described on `AgentCreateBlackPageRequest.data`, so CUSTOM
        answers 404 rather than an empty document.
      parameters:
        - name: templateType
          in: path
          required: true
          schema: { type: string, enum: [CUSTOM, GOOGLE_PLAY] }
      responses:
        '200':
          description: The template's field schema
          content:
            application/json:
              schema: { type: object }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /admin/bundles/stats:
    get:
      operationId: getBundleStats
      tags: [inspection]
      x-ai: { effect: read, approval: never, idempotencyRequired: false, versionedResource: false, allowedRoles: [ADMIN, MANAGER, VIEWER] }
      x-i18n:
        ru: { summary: "Сводные счётчики бандлов команды", result: "Сводные счётчики" }
        uk: { summary: "Зведені лічильники бандлів команди", result: "Зведені лічильники" }
      summary: Aggregate bundle counts for the team
      responses:
        '200':
          description: Aggregate counts
          content:
            application/json:
              schema:
                type: object
                properties:
                  total: { type: integer }
                  apk: { type: integer }
                  pwa: { type: integer }
                  apk_ready: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /agent/bundles/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: getBundle
      tags: [bundles]
      x-ai: { effect: read, approval: never, idempotencyRequired: false, versionedResource: true, allowedRoles: [ADMIN, MANAGER, VIEWER] }
      x-i18n:
        ru: { summary: "Получить бандл по идентификатору", result: "Бандл" }
        uk: { summary: "Отримати бандл за ідентифікатором", result: "Бандл" }
      summary: Get a bundle by id
      responses:
        '200':
          description: Bundle
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AgentBundle' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      operationId: updateBundle
      tags: [bundles]
      x-ai: { effect: write, approval: never, idempotencyRequired: true, versionedResource: true, allowedRoles: [ADMIN, MANAGER] }
      x-i18n:
        ru: { summary: "Изменить редактируемые настройки бандла (ADMIN, MANAGER)", result: "Обновлённый бандл" }
        uk: { summary: "Змінити редаговані налаштування бандла (ADMIN, MANAGER)", result: "Оновлений бандл" }
      summary: Update a bundle's editable settings (ADMIN, MANAGER)
      description: |
        **Write operation.** An explicitly requested rename does not require
        an extra confirmation.

        A PATCH, not a PUT: only the fields you send are written, so two agents
        touching different settings do not overwrite each other.

        Everything createBundle takes is editable here except what decides how
        the bundle was built and where it is served — `host`, `application_id`,
        `flavor_name`, `app_type`, `pool_domain_id`. Those stay create-time
        decisions because the APK pool and the Cloudflare zone were provisioned
        from them; sending one is refused by name rather than ignored.

        Sending `pih_pack_ids` attaches those packs, creating the bundle's
        Push In-House app first if it does not have one yet. If the packs
        cannot be attached the whole patch is refused, so a success here means
        the push setup exists.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AgentPatchBundleRequest' }
      responses:
        '200':
          description: Updated bundle
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AgentBundle' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/ResourceVersionConflict' }
    delete:
      operationId: deleteBundle
      tags: [bundles]
      x-ai: { effect: destructive, approval: always, idempotencyRequired: true, versionedResource: true, allowedRoles: [ADMIN, MANAGER] }
      x-i18n:
        ru: { summary: "Удалить бандл (ADMIN, MANAGER)", result: "Удалено" }
        uk: { summary: "Видалити бандл (ADMIN, MANAGER)", result: "Видалено" }
      summary: Delete a bundle (ADMIN, MANAGER)
      description: |
        **Destructive operation.** Requires explicit user confirmation.
        Cannot be undone — the APK pool for this bundle is also released.
      parameters:
        - name: expected_version
          in: query
          required: true
          description: Current resource_version observed by the caller.
          schema: { type: integer, format: int64, minimum: 1 }
      responses:
        '204': { description: Deleted }
        '409': { $ref: '#/components/responses/ResourceVersionConflict' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /agent/bundles/{id}/status:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    patch:
      operationId: setBundleEnabled
      tags: [bundles]
      x-ai: { effect: publish, approval: always, idempotencyRequired: true, versionedResource: true, allowedRoles: [ADMIN, MANAGER] }
      x-i18n:
        ru: { summary: "Включить или выключить бандл (ADMIN, MANAGER)", result: "Обновлённый бандл" }
        uk: { summary: "Увімкнути або вимкнути бандл (ADMIN, MANAGER)", result: "Оновлений бандл" }
      summary: Enable or disable a bundle (ADMIN, MANAGER)
      description: |
        **Publish operation.** Always requires explicit owner approval because
        it changes whether the bundle is live.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AgentSetBundleEnabledRequest' }
      responses:
        '200':
          description: Updated bundle
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AgentBundle' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/ResourceVersionConflict' }

  /admin/white-page-templates/summary:
    get:
      operationId: listWhitePageTemplates
      tags: [reference]
      x-ai: { effect: read, approval: never, idempotencyRequired: false, versionedResource: false, allowedRoles: [ADMIN, MANAGER, VIEWER] }
      x-i18n:
        ru: { summary: "Показать доступные шаблоны White Page с метаданными", result: "Краткие описания шаблонов" }
        uk: { summary: "Показати доступні шаблони White Page з метаданими", result: "Стислі описи шаблонів" }
      summary: List available white-page templates with metadata
      responses:
        '200':
          description: Template summaries
          content:
            application/json:
              schema: { type: array, items: { type: object } }
        '403': { $ref: '#/components/responses/Forbidden' }

  /agent/white-page-games/recommendation:
    get:
      operationId: recommendWhitePageGame
      tags: [reference]
      x-ai: { effect: read, approval: never, idempotencyRequired: false, versionedResource: false, allowedRoles: [ADMIN, MANAGER, VIEWER] }
      x-i18n:
        ru: { summary: "Подобрать проверенную игру из Google Play для White Page типа APP_LANDING", result: "Рекомендация проверенной игры" }
        uk: { summary: "Підібрати перевірену гру з Google Play для White Page типу APP_LANDING", result: "Рекомендація перевіреної гри" }
      summary: Find a verified Google Play game for an APP_LANDING white page
      description: |
        Searches Google Play with a fixed server-side set of neutral game
        queries and verifies every candidate against its live listing. Returns
        only a real game available in `geo` with 10,000-499,999 installs.

        Use the bundle name or source URL as `seed` so separate builds rotate
        through the candidate pool. This is a read operation: it does not create
        a white page or change a bundle. If no candidate is available, retry
        once with a fresh seed, then ask for a low-download Play game link.
      parameters:
        - name: geo
          in: query
          required: true
          description: Two-letter ISO country in which the game listing must be available.
          schema: { type: string, pattern: '^[A-Za-z]{2}$' }
        - name: seed
          in: query
          required: false
          description: Bundle-specific value used only to rotate candidate order.
          schema: { type: string, maxLength: 200 }
      responses:
        '200':
          description: Verified game recommendation
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WhitePageGameRecommendation' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '502': { $ref: '#/components/responses/BadGateway' }

  /admin/available-timezones:
    get:
      operationId: listTimezones
      tags: [reference]
      x-ai: { effect: read, approval: never, idempotencyRequired: false, versionedResource: false, allowedRoles: [ADMIN, MANAGER, VIEWER] }
      x-i18n:
        ru: { summary: "Показать часовые пояса, сгруппированные по странам", result: "Карта соответствия стран и часовых поясов" }
        uk: { summary: "Показати часові пояси, згруповані за країнами", result: "Карта відповідності країн і часових поясів" }
      summary: List timezones grouped by country
      responses:
        '200':
          description: Country to timezones map
          content:
            application/json:
              schema:
                type: object
                additionalProperties: { type: array, items: { type: string } }
        '403': { $ref: '#/components/responses/Forbidden' }

  /admin/available-countries:
    get:
      operationId: listCountries
      tags: [reference]
      x-ai: { effect: read, approval: never, idempotencyRequired: false, versionedResource: false, allowedRoles: [ADMIN, MANAGER, VIEWER] }
      x-i18n:
        ru: { summary: "Соответствие ISO-кодов и названий стран", result: "Карта стран" }
        uk: { summary: "Відповідність ISO-кодів і назв країн", result: "Карта країн" }
      summary: ISO code to country name map
      responses:
        '200':
          description: Country map
          content:
            application/json:
              schema: { type: object, additionalProperties: { type: string } }
        '403': { $ref: '#/components/responses/Forbidden' }

  /agent/black-page-templates:
    get:
      operationId: listBlackPageTemplates
      tags: [reference]
      x-ai: { effect: read, approval: never, idempotencyRequired: false, versionedResource: false, allowedRoles: [ADMIN, MANAGER, VIEWER] }
      x-i18n:
        ru: { summary: "Показать FTL-шаблоны блэк-пейджей, доступные при создании CUSTOM-страницы", result: "Каталог шаблонов" }
        uk: { summary: "Показати FTL-шаблони блек-пейджів, доступні при створенні CUSTOM-сторінки", result: "Каталог шаблонів" }
      summary: List black-page FTL templates the agent can pick when creating a CUSTOM black page
      description: |
        Returns the catalog of registered templates. `category` filters to a
        single category (e.g. `aviator`, `sweet_bonanza`, `google_play`); pass
        `popular=true` to surface only popularity≥7 entries. Templates without
        curated metadata are still listed with auto-derived category and
        popularity=1.
      parameters:
        - name: category
          in: query
          required: false
          schema: { type: string }
        - name: popular
          in: query
          required: false
          schema: { type: boolean }
      responses:
        '200':
          description: Template catalog
          content:
            application/json:
              schema:
                type: object
                properties:
                  templates:
                    type: array
                    items: { $ref: '#/components/schemas/BlackPageTemplateInfo' }
                  categories:
                    type: array
                    items: { type: string }
                  filtered_count: { type: integer }
        '403': { $ref: '#/components/responses/Forbidden' }

  /agent/build-plan:
    post:
      operationId: planBundleBuild
      tags: [reference]
      x-ai: { effect: read, approval: never, idempotencyRequired: false, versionedResource: false, allowedRoles: [ADMIN, MANAGER, VIEWER] }
      x-i18n:
        ru: { summary: "Превратить креатив листинга и настройки сборки в точные данные, нужные для сборки бандла", result: "План сборки" }
        uk: { summary: "Перетворити креатив лістингу та налаштування збірки на точні дані, потрібні для збірки бандла", result: "План збірки" }
      summary: Turn a listing creative and build settings into the exact payloads a bundle build needs
      description: |
        Pure calculation. Nothing is created, nothing is charged, and calling it
        twice costs nothing — so it is safe to re-plan while the operator is
        still deciding, and worth doing before every build.

        It answers with the `createBlackPage` payload for the Google Play page,
        an optional preland page, the `createBundle` arguments, a `geo_report`
        line, `notes`, and an ordered `execution` list. Thread the ids and
        versions between the steps yourself; the plan cannot contain ids that do
        not exist yet.

        The tier comes from your own team, not from an argument, so a real-tier
        plan cannot silently come back without its geo gate. Countries are
        expanded to every timezone row they have using the same table the
        server uses, which is what stops a multi-timezone country from being
        gated down to one zone.

        `notes` is the part worth reading aloud: it carries the timezone row
        count, whether a pixel will actually deliver events, and what the tier
        dropped.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AgentBuildPlanRequest' }
      responses:
        '200':
          description: The plan
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AgentBuildPlan' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /agent/import-listing:
    post:
      operationId: importListingFromUrl
      tags: [reference]
      x-ai: { effect: read, approval: never, idempotencyRequired: false, versionedResource: false, allowedRoles: [ADMIN, MANAGER, VIEWER] }
      x-i18n:
        ru: { summary: "Прочитать живой листинг, чтобы собрать по нему бандл", result: "Листинг в виде креатива" }
        uk: { summary: "Прочитати живий лістинг, щоб зібрати за ним бандл", result: "Лістинг у вигляді креативу" }
      summary: Read a live listing so a bundle can be built from it
      description: |
        Fetches the page and returns it as the `app_data` creative that
        `planBundleBuild` takes. Nothing is written and nothing is charged.

        The page is usually cloaked: it shows a decoy to anything it does not
        trust, and the real listing only to a visitor who looks like a person
        arriving from an ad. `geo` selects the visitor's language and clock.
        It does not move this server's network egress: the current deployment
        reaches India from India and other countries from India until an egress
        proxy is configured. For a non-India source, use the published local
        proxy fallback rather than retrying the same out-of-geo request.

        Read `diagnostic` before building on the result. `source` says whether
        the listing was read exactly, from the page's own data, or guessed from
        rendered HTML. `warnings` says what was missing, and a result with no
        reviews and no screenshots usually means the cloak stayed shut and what
        came back is the decoy — a real page, just not the one being copied.
        `preland_template` names the game page a funnel source runs in front of
        its listing, when there is one.

        At most 40 imports an hour per team.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AgentImportListingRequest' }
      responses:
        '200':
          description: The listing, as a creative
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AgentImportedListing' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429':
          description: Too many imports this hour
        '502':
          description: The source could not be fetched or did not answer with a page

  /agent/pwa-clones:
    post:
      operationId: clonePwa
      tags: [bundles]
      x-ai: { effect: publish, approval: always, idempotencyRequired: true, versionedResource: false, allowedRoles: [ADMIN, MANAGER] }
      x-i18n:
        ru: { summary: "Клонировать живую PWA в готовый проверенный бандл одной операцией", result: "Включённый и проверенный клонированный бандл" }
        uk: { summary: "Клонувати живу PWA в готовий перевірений бандл однією операцією", result: "Увімкнений і перевірений клонований бандл" }
      summary: Clone a live PWA into an enabled, verified bundle in one operation
      description: |
        **Publish operation. Always obtain one explicit confirmation.** This is
        the complete URL clone workflow: fetch the source through the managed
        GEO egress, normalise its Play-style creative, plan the build, create
        the page and icon, create and wire the bundle, enable it, then verify
        the final state.

        This soft-launch operation is available only to ADMIN/MANAGER callers
        from real-tier teams with the configured minimum positive balance.
        The balance is an access condition only: managed proxy use is free and
        is never reserved or debited. There are initially no per-team hourly,
        daily, concurrency or traffic quotas; limits may be tightened later as
        user volume and observed provider spend grow.

        `source_url` and the two-letter ISO `geo` are sufficient when the team
        already has a usable host configuration. The server derives the
        bundle/app name from the listing, uses the service tracker default,
        creates a PWA, applies the selected GEO, and enables it. Send `config`
        when the operator chose a tracker, pixels, domain, white page, funnel,
        push packs, or other build settings. A paid pool domain may still debit
        the balance through the ordinary bundle flow; the proxy never does.

        The source URL is SSRF-screened at entry and every redirect. Proxy
        credentials never appear in the response. Before fetching the source,
        unresolved double-brace ad macros inside query values stay unresolved.
        Literal braces are percent-encoded only for valid HTTP transport;
        already encoded macros stay unchanged. Query keys, ordering, duplicates,
        static values and fragments are preserved so the fetch retains the
        landing URL shape without inventing campaign or ad values.
        The service verifies the actual exit country through the same sticky
        proxy session. Missing egress, an unverifiable exit, or a country
        mismatch fails closed instead of silently fetching from the wrong GEO.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PwaCloneRequest' }
      responses:
        '201':
          description: Enabled and verified cloned bundle
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PwaCloneResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '402':
          description: Real-tier team does not have the configured minimum positive balance
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/BadRequest' }
        '502': { $ref: '#/components/responses/BadGateway' }
        '503': { $ref: '#/components/responses/BadGateway' }

  /agent/black-pages:
    get:
      operationId: listBlackPages
      tags: [black-pages]
      x-ai: { effect: read, approval: never, idempotencyRequired: false, versionedResource: true, allowedRoles: [ADMIN, MANAGER, VIEWER] }
      x-i18n:
        ru: { summary: "Краткие описания блэк-пейджей, доступных команде (свои и общие)", result: "Краткие описания блэк-пейджей" }
        uk: { summary: "Стислі описи блек-пейджів, доступних команді (власні та спільні)", result: "Стислі описи блек-пейджів" }
      summary: Summaries of black pages visible to the team (own + shared)
      responses:
        '200':
          description: Black page summaries
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/BlackPageSummary' }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      operationId: createBlackPage
      tags: [black-pages]
      x-ai: { effect: write, approval: never, idempotencyRequired: true, versionedResource: true, allowedRoles: [ADMIN, MANAGER] }
      x-i18n:
        ru: { summary: "Создать блэк-пейдж типа CUSTOM или GOOGLE_PLAY (ADMIN, MANAGER)", result: "Созданный блэк-пейдж" }
        uk: { summary: "Створити блек-пейдж типу CUSTOM або GOOGLE_PLAY (ADMIN, MANAGER)", result: "Створений блек-пейдж" }
      summary: Create a CUSTOM or GOOGLE_PLAY black page (ADMIN, MANAGER)
      description: |
        **Write operation.** May execute without an extra approval when the
        user's message explicitly requested the create.

        For `type=custom`: pick `template` from `GET /agent/black-page-templates`
        and supply `data` as `BlackPageData` (`claimButtonText`, `modalTextLines`,
        optional `appName`).

        For `type=google_play`: `template` is fixed to `g_play`; supply `data`
        as `GPlayAppData` (full Google Play app envelope — name, developer,
        icon URL, screenshots, ratings, comments).

        All pages created here are team-scoped (`team_id = call.getTeamId()`).
        Shadow teams hit a 429 `shadow_quota_exceeded` once they exceed
        `agentSandbox.shadowMaxBlackPages` (default 10).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AgentCreateBlackPageRequest' }
      responses:
        '201':
          description: Created black page
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BlackPage' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/QuotaExceeded' }

  /agent/black-pages/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: getBlackPage
      tags: [black-pages]
      x-ai: { effect: read, approval: never, idempotencyRequired: false, versionedResource: true, allowedRoles: [ADMIN, MANAGER, VIEWER] }
      x-i18n:
        ru: { summary: "Получить блэк-пейдж по идентификатору (свой или общий)", result: "Блэк-пейдж" }
        uk: { summary: "Отримати блек-пейдж за ідентифікатором (власний або спільний)", result: "Блек-пейдж" }
      summary: Fetch a black page by id (own or shared)
      responses:
        '200':
          description: Black page
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BlackPage' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      operationId: updateBlackPage
      tags: [black-pages]
      x-ai: { effect: write, approval: never, idempotencyRequired: true, versionedResource: true, allowedRoles: [ADMIN, MANAGER] }
      x-i18n:
        ru: { summary: "Переименовать блэк-пейдж или заменить его данные (ADMIN, MANAGER)", result: "Обновлённая страница" }
        uk: { summary: "Перейменувати блек-пейдж або замінити його дані (ADMIN, MANAGER)", result: "Оновлена сторінка" }
      summary: Rename a black page or replace its data (ADMIN, MANAGER)
      description: |
        **Write operation.** Send `name`, `data`, or both — only what you send
        is written. A page could previously be created and deleted but not
        changed, so fixing a typo meant deleting and recreating it, which
        changes the id every bundle points at.

        A GOOGLE_PLAY page has its `data` normalised the way creation
        normalises it: storefront codes and an ISO-8601 `lastUpdate` with an
        offset. Shared pages belong to every team and only their owner may edit
        them.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AgentUpdateBlackPageRequest' }
      responses:
        '200':
          description: The updated page
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BlackPage' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/ResourceVersionConflict' }
    delete:
      operationId: deleteBlackPage
      tags: [black-pages]
      x-ai: { effect: destructive, approval: always, idempotencyRequired: true, versionedResource: true, allowedRoles: [ADMIN, MANAGER] }
      x-i18n:
        ru: { summary: "Удалить свой блэк-пейдж (ADMIN, MANAGER)", result: "Удалено" }
        uk: { summary: "Видалити власний блек-пейдж (ADMIN, MANAGER)", result: "Видалено" }
      summary: Delete an own black page (ADMIN, MANAGER)
      description: |
        **Destructive operation.** Requires explicit user confirmation.
        Cannot delete shared (global) black pages — only pages the calling
        team owns.
      parameters:
        - name: expected_version
          in: query
          required: true
          description: Current resource_version observed by the caller.
          schema: { type: integer, format: int64, minimum: 1 }
      responses:
        '204': { description: Deleted }
        '409': { $ref: '#/components/responses/ResourceVersionConflict' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /agent/images/from-url:
    post:
      operationId: uploadImageFromUrl
      tags: [images]
      x-ai: { effect: write, approval: never, idempotencyRequired: true, versionedResource: true, allowedRoles: [ADMIN, MANAGER] }
      x-i18n:
        ru: { summary: "Загрузить изображение по HTTPS-ссылке в хранилище команды (ADMIN, MANAGER)", result: "Сохранённое изображение" }
        uk: { summary: "Завантажити зображення за HTTPS-посиланням у сховище команди (ADMIN, MANAGER)", result: "Збережене зображення" }
      summary: Download an image from an HTTPS URL into team storage (ADMIN, MANAGER)
      description: |
        **Write operation.** May execute without an extra approval when the
        user's message explicitly requested the upload.

        Lets agents add icons / screenshots / avatars to a bundle without
        uploading binary blobs. The server downloads the URL through an
        SSRF-safe path: HTTPS only, internal IP ranges blocked
        (RFC1918, loopback, link-local incl. cloud metadata at
        169.254.169.254), 5 MB cap, MIME allowlist (png/jpeg/gif/webp),
        magic-bytes check after download.

        Errors map to stable codes:
          - `invalid_url` — scheme not https, bad URL, userinfo, custom port
          - `url_resolves_to_internal_address` — host points at private IP
          - `image_too_large` — declared or actual size > 5 MB
          - `unsupported_media_type` — unsupported declared type or unrecognised body
          - `media_type_mismatch` — allow-listed declared MIME disagrees with body magic bytes
          - `download_failed` — network / status / redirect errors
          - `shadow_quota_exceeded` — see `agentSandbox.shadowMaxImages`

        Download rejections include only safe structured `reason` and
        `retryable` fields; upstream URLs and response bodies are never echoed.
        Missing Content-Type and `application/octet-stream` are accepted only
        when PNG/JPEG/GIF/WebP magic bytes identify the body.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ImageFromUrlRequest' }
      responses:
        '201':
          description: Saved image
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Image' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/QuotaExceeded' }
        '502': { $ref: '#/components/responses/BadGateway' }

  /agent/bundles/{id}/white-page:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    patch:
      operationId: updateBundleWhitePage
      tags: [bundles]
      x-ai: { effect: publish, approval: always, idempotencyRequired: true, versionedResource: true, allowedRoles: [ADMIN, MANAGER] }
      x-i18n:
        ru: { summary: "Заменить White Page у существующего бандла (ADMIN, MANAGER)", result: "Обновлённый бандл" }
        uk: { summary: "Замінити White Page в наявному бандлі (ADMIN, MANAGER)", result: "Оновлений бандл" }
      summary: Swap the white page of an existing bundle (ADMIN, MANAGER)
      description: |
        **Publish operation.** Requires approval because it changes the live
        page shown by an enabled bundle.

        Creates a new WhitePage row with the supplied template / data and
        re-points `bundle.white_page_id` at it. The previous WhitePage stays
        orphaned (matches dashboard PUT behaviour).

        For `white_page_template_type=APP_LANDING`: server fetches the Google
        Play page for `white_page_package_name` and stores the parsed envelope.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AgentUpdateWhitePageRequest' }
      responses:
        '200':
          description: Updated bundle
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AgentBundle' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/ResourceVersionConflict' }
        '422': { $ref: '#/components/responses/WhitePageListingNotFound' }
        '502': { $ref: '#/components/responses/BadGateway' }

  /agent/bundles/{id}/pages:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    patch:
      operationId: updateBundlePages
      tags: [bundles]
      x-ai: { effect: publish, approval: always, idempotencyRequired: true, versionedResource: true, allowedRoles: [ADMIN, MANAGER] }
      x-i18n:
        ru: { summary: "Заменить финальную страницу бандла и, при необходимости, прелендинг (ADMIN, MANAGER)", result: "Обновлённый бандл" }
        uk: { summary: "Замінити фінальну сторінку бандла та, за потреби, прелендінг (ADMIN, MANAGER)", result: "Оновлений бандл" }
      summary: Replace a bundle's final page and optional prelanding (ADMIN, MANAGER)
      description: |
        **Publish operation.** Requires approval because it changes the live
        final page or prelanding shown by an enabled bundle.

        `black_page_id` is the final page and is always required. Sending
        `prelanding_black_page_id: null` (or omitting it) clears the preland.
        Without a preland, the final page may be CUSTOM or GOOGLE_PLAY. With a
        preland, the preland must be CUSTOM and the final page GOOGLE_PLAY.
        Both ids must reference an own or shared page visible to the team.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AgentUpdateBundlePagesRequest' }
      responses:
        '200':
          description: Updated bundle
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AgentBundle' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/ResourceVersionConflict' }

  /admin/apk-rebuild-queue:
    get:
      operationId: getApkRebuildQueue
      tags: [inspection]
      x-ai: { effect: read, approval: never, idempotencyRequired: false, versionedResource: false, allowedRoles: [ADMIN, MANAGER, VIEWER] }
      x-i18n:
        ru: { summary: "Посмотреть текущую очередь пересборки APK", result: "Элементы очереди с данными бандлов" }
        uk: { summary: "Переглянути поточну чергу перезбірки APK", result: "Елементи черги з даними бандлів" }
      summary: Inspect the current APK rebuild queue
      responses:
        '200':
          description: Queue items with bundle metadata
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RebuildQueueResponse' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /bundle/{id}/tiktok/test-event:
    post:
      operationId: sendTikTokTestEvent
      tags: [bundles]
      # approval: always is deliberate. write/never is only safe for operations
      # listed in the agent's WRITE_INTENT_OPERATION_IDS allowlist; anything else
      # would need an approval click anyway, but by accident rather than by design.
      x-ai: { effect: write, approval: always, idempotencyRequired: true, versionedResource: false, allowedRoles: [ADMIN, MANAGER] }
      x-i18n:
        ru: { summary: "Поставить в очередь тестовое событие TikTok Purchase", result: "Все отправки уже поставлены в очередь или доставлены" }
        uk: { summary: "Поставити в чергу тестову подію TikTok Purchase", result: "Усі відправлення вже поставлені в чергу або доставлені" }
      summary: Queue a synthetic TikTok Purchase test event
      description: |
        Team-scoped diagnostic action. Durably queues one synthetic Purchase
        per configured TikTok pixel using the bundle's test event code. This
        calls an external provider; invoke only when the user asks to validate
        TikTok configuration. No token, PII, attribution, or payload is returned.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Every delivery was already queued or delivered
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TikTokTestEventResponse' }
        '202':
          description: At least one delivery was durably accepted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TikTokTestEventResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: TikTok test configuration is incomplete or disabled
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '503':
          description: Bundle lookup or durable persistence is unavailable
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /bundle/{id}/tiktok/deliveries:
    get:
      operationId: getTikTokDeliveries
      tags: [inspection]
      x-ai: { effect: read, approval: never, idempotencyRequired: false, versionedResource: false, allowedRoles: [ADMIN, MANAGER, VIEWER] }
      x-i18n:
        ru: { summary: "Посмотреть последние безопасные состояния доставок в TikTok", result: "Последние состояния, начиная с новых" }
        uk: { summary: "Переглянути останні безпечні стани доставок у TikTok", result: "Останні стани, починаючи з нових" }
      summary: Inspect recent safe TikTok delivery states
      description: |
        Team-scoped read combining delivered and current retry/permanent-failure
        states. Bodies are sanitized and capped; event IDs, tokens, PII,
        attribution, and request payloads are excluded.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: limit
          in: query
          schema: { type: integer, default: 10, minimum: 1, maximum: 50 }
      responses:
        '200':
          description: Recent states, newest first
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TikTokDeliveriesResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503':
          description: Bundle lookup or ClickHouse diagnostics are unavailable
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Bearer JWT obtained via OAuth 2.1 Authorization Code + PKCE.
        Discovery: https://api.apkservice.mobi/.well-known/oauth-authorization-server

  responses:
    BadRequest:
      description: Validation or input error
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
    Unauthorized:
      description: Missing or invalid token
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
    Forbidden:
      description: Caller does not have the required role
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
    NotFound:
      description: Resource does not exist
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
    PayloadTooLarge:
      description: Resource (e.g. downloaded image) exceeded the size cap
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
    UnsupportedMediaType:
      description: Declared Content-Type or detected magic bytes are not allowed
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
    QuotaExceeded:
      description: Shadow-tier per-team quota exhausted
      content:
        application/json:
          schema: { $ref: '#/components/schemas/QuotaExceededResponse' }
    BadGateway:
      description: |
        Upstream fetch or parsing failed. APP_LANDING uses the stable error
        codes `white_page_upstream_unavailable` and `white_page_parse_failed`.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
    WhitePageListingNotFound:
      description: Google Play has no listing for the requested package (`white_page_listing_not_found`)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
    ResourceVersionConflict:
      description: The resource changed since the caller last read it.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ResourceVersionConflict' }

  schemas:

    ErrorResponse:
      type: object
      properties:
        error: { type: string }
        message:
          type: string
          description: Safe, user-facing explanation when the error has a stable machine code.
        reason:
          type: string
          pattern: '^[a-z][a-z0-9_]{0,63}$'
          description: Optional safe machine-readable diagnostic narrower than error.
        retryable:
          type: boolean
          description: Whether one bounded retry with the same confirmed input and idempotency is safe.
        step:
          type: string
          pattern: '^[a-z][a-z0-9-]{0,63}$'
          description: Exact clone stage that failed, including asset role.
        partial_resources:
          type: object
          maxProperties: 32
          additionalProperties: { type: string, format: uuid }
          description: Resources already created before a clone failure; never implies that nothing was created.
      required: [error]

    StatsPeriod:
      type: object
      additionalProperties: false
      required: [from, to]
      properties:
        from: { type: integer, format: int64 }
        to: { type: integer, format: int64 }

    BundleStatsItem:
      type: object
      additionalProperties: false
      required:
        - bundle_id
        - bundle_name
        - app_type
        - page_views
        - installs
        - registrations
        - deposits
        - deposit_attempts
        - first_deposits
        - second_deposits
        - third_deposits
        - fourth_deposits
        - high_value_payers
        - d7_values
        - predicted_ltvs
        - charge_cents
      properties:
        bundle_id: { type: string, format: uuid }
        bundle_name: { type: string }
        app_type: { type: string, enum: [APK, PWA] }
        page_views: { type: integer, format: int64, minimum: 0 }
        installs: { type: integer, format: int64, minimum: 0 }
        registrations: { type: integer, format: int64, minimum: 0 }
        deposits: { type: integer, format: int64, minimum: 0 }
        deposit_attempts: { type: integer, format: int64, minimum: 0 }
        first_deposits: { type: integer, format: int64, minimum: 0 }
        second_deposits: { type: integer, format: int64, minimum: 0 }
        third_deposits: { type: integer, format: int64, minimum: 0 }
        fourth_deposits: { type: integer, format: int64, minimum: 0 }
        high_value_payers: { type: integer, format: int64, minimum: 0 }
        d7_values: { type: integer, format: int64, minimum: 0 }
        predicted_ltvs: { type: integer, format: int64, minimum: 0 }
        charge_cents: { type: integer, format: int64, minimum: 0 }

    StatsTimeSlot:
      type: object
      additionalProperties: false
      required:
        - time
        - page_views
        - installs
        - registrations
        - deposits
        - deposit_attempts
        - first_deposits
        - second_deposits
        - third_deposits
        - fourth_deposits
        - high_value_payers
        - d7_values
        - predicted_ltvs
        - charge_cents
        - by_bundle
      properties:
        time: { type: string, format: date-time }
        page_views: { type: integer, format: int64, minimum: 0 }
        installs: { type: integer, format: int64, minimum: 0 }
        registrations: { type: integer, format: int64, minimum: 0 }
        deposits: { type: integer, format: int64, minimum: 0 }
        deposit_attempts: { type: integer, format: int64, minimum: 0 }
        first_deposits: { type: integer, format: int64, minimum: 0 }
        second_deposits: { type: integer, format: int64, minimum: 0 }
        third_deposits: { type: integer, format: int64, minimum: 0 }
        fourth_deposits: { type: integer, format: int64, minimum: 0 }
        high_value_payers: { type: integer, format: int64, minimum: 0 }
        d7_values: { type: integer, format: int64, minimum: 0 }
        predicted_ltvs: { type: integer, format: int64, minimum: 0 }
        charge_cents: { type: integer, format: int64, minimum: 0 }
        by_bundle:
          type: array
          items: { $ref: '#/components/schemas/BundleStatsItem' }

    WhiteReasonCounts:
      type: object
      description: Aggregated reason keys and event counts; never contains IP addresses, User-Agent strings, or raw reason details.
      additionalProperties:
        type: integer
        format: int64
        minimum: 0

    BundleWhiteReasons:
      type: object
      additionalProperties: false
      required: [bundle_id, bundle_name, app_type, white_page_reasons, white_page_2_reasons]
      properties:
        bundle_id: { type: string, format: uuid }
        bundle_name: { type: string }
        app_type: { type: string, enum: [APK, PWA] }
        white_page_reasons: { $ref: '#/components/schemas/WhiteReasonCounts' }
        white_page_2_reasons: { $ref: '#/components/schemas/WhiteReasonCounts' }

    InstallStatsResponse:
      type: object
      additionalProperties: false
      required:
        - team_id
        - period
        - granularity
        - total_installs
        - total_page_views
        - total_registrations
        - total_deposits
        - total_deposit_attempts
        - total_first_deposits
        - total_second_deposits
        - total_third_deposits
        - total_fourth_deposits
        - total_high_value_payers
        - total_d7_values
        - total_predicted_ltvs
        - total_charge_cents
        - tariff_cents
        - balance_cents
        - items
        - white_reasons_by_bundle
      properties:
        team_id: { type: string, format: uuid }
        period: { $ref: '#/components/schemas/StatsPeriod' }
        granularity: { type: string, enum: [hour, day] }
        total_installs: { type: integer, format: int64, minimum: 0 }
        total_page_views: { type: integer, format: int64, minimum: 0 }
        total_registrations: { type: integer, format: int64, minimum: 0 }
        total_deposits: { type: integer, format: int64, minimum: 0 }
        total_deposit_attempts: { type: integer, format: int64, minimum: 0 }
        total_first_deposits: { type: integer, format: int64, minimum: 0 }
        total_second_deposits: { type: integer, format: int64, minimum: 0 }
        total_third_deposits: { type: integer, format: int64, minimum: 0 }
        total_fourth_deposits: { type: integer, format: int64, minimum: 0 }
        total_high_value_payers: { type: integer, format: int64, minimum: 0 }
        total_d7_values: { type: integer, format: int64, minimum: 0 }
        total_predicted_ltvs: { type: integer, format: int64, minimum: 0 }
        total_charge_cents: { type: integer, format: int64, minimum: 0 }
        tariff_cents: { type: integer }
        balance_cents: { type: integer, format: int64 }
        items:
          type: array
          description: Time-series slots with per-bundle funnel counters.
          items: { $ref: '#/components/schemas/StatsTimeSlot' }
        white_reasons_by_bundle:
          type: array
          description: Team-scoped aggregated cloak reason counts. No IP, User-Agent, or other raw request values are returned.
          items: { $ref: '#/components/schemas/BundleWhiteReasons' }

    AgentUpdateBlackPageRequest:
      type: object
      additionalProperties: false
      description: Only what you send is written; omit a field to leave it alone.
      properties:
        name: { type: string, maxLength: 255 }
        data:
          type: object
          description: |
            Replaces the page's data. A GOOGLE_PLAY page is normalised on the
            way in, so storefront codes and an offset-bearing lastUpdate are
            required.
        expected_version: { type: integer, format: int64, minimum: 1 }

    AgentImage:
      type: object
      required: [id, type, name, url, size_bytes, mime_type, resource_version]
      properties:
        id:
          type: string
          format: uuid
          description: Pass to createBundle or updateBundle as app_icon_id.
        type: { type: string }
        name: { type: string }
        description: { type: [string, 'null'] }
        tags:
          type: array
          items: { type: string }
        url: { type: string }
        size_bytes: { type: integer }
        mime_type: { type: string }
        resource_version: { type: integer, format: int64 }

    AgentPushPack:
      type: object
      required: [id, name, is_system]
      properties:
        id:
          type: integer
          description: Pass in pih_pack_ids.
        name: { type: string }
        is_system:
          type: boolean
          description: Service packs the push provider ships, not ones the team made.

    WhitePageGameRecommendation:
      type: object
      additionalProperties: false
      required: [package_name, app_name, downloads, installs, genre, rating, google_play_url, hl, gl, selection_seed]
      properties:
        package_name:
          type: string
          description: Pass to `white_page.package_name` with `mode=app_landing`.
        app_name: { type: string }
        downloads:
          type: string
          description: Display value read from the live Google Play listing.
        installs: { type: integer, format: int64, minimum: 10000, maximum: 499999 }
        genre: { type: string }
        rating: { type: number, format: double, minimum: 0, maximum: 5 }
        google_play_url: { type: string, format: uri }
        hl: { type: string, const: en }
        gl: { type: string }
        selection_seed: { type: string }

    AgentPoolDomain:
      type: object
      required: [domain, tld, currency, pool_domain_id]
      description: |
        A domain on offer. Deliberately narrower than the factory's own view,
        which also carries what we paid for the name and where it is hosted.
      properties:
        domain: { type: string, example: aviator-pro.click }
        tld: { type: string, example: click }
        price_cents:
          type: [integer, 'null']
          description: Charged to the team's balance when a bundle is created on it.
        currency:
          type: string
          example: USDT
          description: The unit of both price_cents and getCurrentAgent.balance_cents.
        pool_domain_id:
          type: string
          format: uuid
          description: Pass to createBundle as `pool_domain_id`.

    TikTokTestEventResponse:
      type: object
      required: [status, event_id, accepted_count, deduplicated_count, pixel_count]
      properties:
        status: { type: string, enum: [accepted, deduplicated] }
        event_id: { type: string, description: Synthetic identifier with no user data }
        accepted_count: { type: integer, minimum: 0 }
        deduplicated_count: { type: integer, minimum: 0 }
        pixel_count: { type: integer, minimum: 1 }

    TikTokDeliveryDiagnostic:
      type: object
      required: [status, pixel_code, event_name, event_time, retry_count, observed_at]
      properties:
        status: { type: string, enum: [DELIVERED, RETRYING, PERMANENT_FAILURE] }
        pixel_code: { type: string }
        event_name: { type: string }
        event_time: { type: integer, format: int64 }
        http_status: { type: integer, nullable: true }
        business_code: { type: integer, nullable: true }
        response_body: { type: string, nullable: true, maxLength: 4096 }
        retry_count: { type: integer, minimum: 0 }
        last_error: { type: string, nullable: true, maxLength: 4096 }
        observed_at: { type: string, format: date-time }

    TikTokDeliveriesResponse:
      type: object
      required: [bundle_id, limit, deliveries]
      properties:
        bundle_id: { type: string, format: uuid }
        limit: { type: integer, minimum: 1, maximum: 50 }
        deliveries:
          type: array
          maxItems: 50
          items: { $ref: '#/components/schemas/TikTokDeliveryDiagnostic' }
    ResourceVersionConflict:
      type: object
      required: [error, resource_type, resource_id, expected_version, current_version, current]
      properties:
        error:
          type: string
          const: RESOURCE_VERSION_CONFLICT
        resource_type:
          type: string
          enum: [bundle, black_page]
        resource_id: { type: string, format: uuid }
        expected_version: { type: integer, format: int64, minimum: 1 }
        current_version: { type: integer, format: int64, minimum: 1 }
        current:
          type: object
          description: Current authorized snapshot used by the UI to render a diff.

    DashboardSetupLinkRequest:
      type: object
      additionalProperties: false
      required: [email]
      properties:
        email:
          type: string
          format: email
          maxLength: 100

    DashboardSetupLinkResponse:
      type: object
      required: [setup_url, expires_at]
      properties:
        setup_url:
          type: string
          format: uri
          description: One-time URL whose raw token is carried only in the fragment.
        expires_at: { type: string, format: date-time }

    CreateBalanceTopUpRequest:
      type: object
      additionalProperties: false
      required: [amount_cents, network]
      properties:
        amount_cents:
          type: integer
          format: int64
          minimum: 500
          description: Requested top-up in USD/USDT cents.
        network:
          type: string
          enum: [trc20, erc20]

    BalanceTopUp:
      type: object
      description: |
        Sanitized balance top-up projection. Provider order identifiers,
        internal provisioning/reconciliation fields and idempotency bindings
        are never included.
      required:
        - id
        - amount_cents
        - crypto_amount
        - currency
        - network
        - status
        - confirmed
        - credited
        - expires_at
        - created_at
        - updated_at
        - resource_version
      properties:
        id: { type: string, format: uuid }
        amount_cents: { type: integer, format: int64, minimum: 500 }
        crypto_amount: { type: string }
        currency: { type: string, enum: [USDT] }
        network: { type: string, enum: [trc20, erc20] }
        deposit_address:
          type: [string, "null"]
          description: Payment address; null until an order is ready.
        status: { type: string, enum: [PENDING, DETECTED, CONFIRMED, EXPIRED, FAILED] }
        confirmed: { type: boolean }
        credited: { type: boolean }
        expires_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        resource_version: { type: integer, format: int64, minimum: 1 }

    BalanceTopUpsPage:
      type: object
      required: [items, total, page, size]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/BalanceTopUp' }
        total: { type: integer, format: int64, minimum: 0 }
        page: { type: integer, minimum: 1 }
        size: { type: integer, minimum: 1, maximum: 100 }

    AgentMeResponse:
      type: object
      required: [team_id, public_team_id, name, tier, balance_cents, principal_type, dashboard_status, limits]
      properties:
        team_id: { type: string, format: uuid }
        public_team_id: { type: string }
        name: { type: string }
        tier:
          type: string
          enum: [shadow, real]
        sandbox:
          type: object
          nullable: true
          properties:
            domain: { type: string }
            subdomain: { type: string }
        balance_cents: { type: integer, format: int64 }
        principal_type:
          type: string
          enum: [agent, user, delegated_agent]
        dashboard_status:
          type: string
          enum: [setup_required, configured]
        limits:
          type: object
          description: Shadow-tier limits. Omitted properties mean unlimited for real teams.
          properties:
            max_bundles: { type: integer, minimum: 1 }
            max_installs_per_bundle_per_24h: { type: integer, minimum: 1 }

    AgentBundle:
      type: object
      description: |
        Explicit MCP-safe projection. Provider credentials, access tokens,
        postback tokens, VAPID keys and private infrastructure fields are
        never included.
      required:
        [uuid, team_id, name, application_id, host, enabled, app_type,
         pool_size, allowed_platforms, max_screen_width_px, pixels, push_service_type, push_app_ready,
         resource_version, created_at, updated_at]
      properties:
        uuid: { type: string, format: uuid }
        team_id: { type: string, format: uuid }
        name: { type: string }
        application_id: { type: string }
        host: { type: string }
        enabled: { type: boolean }
        white_page_id: { type: string, format: uuid, nullable: true }
        black_page_id: { type: string, format: uuid, nullable: true }
        prelanding_black_page_id: { type: string, format: uuid, nullable: true }
        app_icon_id: { type: string, format: uuid, nullable: true }
        app_type: { type: string, enum: [APK, PWA] }
        pool_size: { type: integer, minimum: 0 }
        allowed_platforms:
          type: array
          minItems: 1
          uniqueItems: true
          items: { type: string, enum: [ANDROID, IOS, DESKTOP] }
        max_screen_width_px:
          type: [integer, 'null']
          minimum: 1
          maximum: 10000
        push_service_type: { type: string, enum: [FCM, PUSH_IN_HOUSE] }
        push_app_ready:
          type: boolean
          description: |
            Whether the bundle has a Push In-House app yet. False means packs
            sent now will provision one (PWA) or be refused until the Firebase
            setup finishes (APK).
        pixels:
          type: array
          items: { $ref: '#/components/schemas/AgentBundlePixel' }
        tiktok_enabled: { type: boolean }
        tiktok_pixels:
          type: array
          items: { $ref: '#/components/schemas/TikTokPixel' }
        tiktok_test_event_code:
          type: string
          nullable: true
        tiktok_event_mapping:
          type: object
          nullable: true
          additionalProperties:
            type: string
            nullable: true
        tiktok_send_client_pixel: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        resource_version: { type: integer, format: int64, minimum: 1 }

    BundleWithPoolInfo:
      type: object
      additionalProperties: false
      required: [uuid, name, app_type, enabled, pool_size, resource_version]
      properties:
        app_type:
          type: string
          enum: [APK, PWA]
        enabled:
          type: boolean
        uuid: { type: string, format: uuid }
        name: { type: string }
        pool_size: { type: integer, minimum: 0 }
        resource_version: { type: integer, format: int64, minimum: 1 }

    AgentCreateBundleRequest:
      type: object
      additionalProperties: false
      required: [name]
      description: |
        Minimal-required payload (just `name` + optional `host`) plus the
        Phase 5.4 enrichment fields agents can supply to ship a fully
        configured bundle in one call. Anything omitted falls back to the
        same defaults the dashboard uses.
      properties:
        name:
          type: string
          maxLength: 255
        host:
          type: string
          description: |
            A hostname already pointed at us. Real teams must send either this
            or `pool_domain_id`; shadow teams are pinned to their sandbox
            subdomain regardless, so both are optional for them.
        pool_domain_id:
          type: string
          format: uuid
          description: |
            Buy a domain from the pool and host the bundle on it, in one call.
            Take the id from listDomainPool. The team's balance is charged
            `price_cents` as the bundle is created — if either half fails,
            neither happens. Real tier only, and `host` must be omitted or
            match the pooled domain.
        application_id:
          type: string
          description: Optional Android applicationId. Generated from host if omitted.
        black_page_id:
          type: string
          format: uuid
          description: |
            Optional. When omitted, the server deterministically selects the
            accessible compatible page with the lowest UUID (GOOGLE_PLAY only
            when a preland is set). To author a custom page in the same flow,
            call POST /agent/black-pages first and pass the returned id here.
        prelanding_black_page_id:
          type: [string, 'null']
          format: uuid
          description: |
            Optional CUSTOM prelanding page. When set, black_page_id must be a
            GOOGLE_PLAY page. The runtime shows this page first and switches
            to the final page on `?step=install`.
        white_page_template:
          type: string
          enum: [WHITE_PAGE_1, WHITE_PAGE_2, WHITE_PAGE_3, WHITE_PAGE_4, WHITE_PAGE_5, WHITE_PAGE_TOUCH_BALL]
          description: Defaults to WHITE_PAGE_TOUCH_BALL for CUSTOM and WHITE_PAGE_1 for APP_LANDING.
        white_page_template_type:
          type: string
          enum: [CUSTOM, APP_LANDING]
          default: CUSTOM
          description: |
            CUSTOM is the safe default — no Google Play parse needed.
            APP_LANDING fetches the Play Store page for `white_page_package_name`.
        white_page_package_name:
          type: string
          description: Required iff white_page_template_type=APP_LANDING.
        white_page_hl: { type: string, default: en }
        white_page_gl: { type: string, default: US }
        app_icon_id:
          type: string
          format: uuid
          description: |
            Optional image id (from POST /agent/images/from-url or the dashboard
            uploader). The server verifies the image is owned by the calling
            team and rejects with 400 otherwise.
        tiktok_enabled:
          type: boolean
          description: Optional; defaults to false.
        tiktok_pixels:
          type: array
          description: Optional; null elements, blank or duplicate pixel codes are rejected. Enabling TikTok requires at least one pixel with an access token.
          items: { $ref: '#/components/schemas/TikTokPixel' }
        tiktok_test_event_code:
          type: [string, 'null']
          description: Optional; blank strings are normalized to null.
        tiktok_event_mapping:
          type: [object, 'null']
          description: Optional tracker-event overrides; each value is a string or explicit null.
          additionalProperties:
            type: [string, 'null']
        tiktok_send_client_pixel:
          type: boolean
          description: Optional; defaults to false.
        deposit_amount_default:
          type: number
          format: decimal
        deposit_amounts_by_country:
          type: object
          additionalProperties: { type: number, format: decimal }
        deposit_amount_type:
          type: string
          enum: [POSTBACK, FIXED]
          default: POSTBACK
        allowed_timezones:
          type: object
          additionalProperties: { type: string }
        allowed_platforms:
          type: array
          minItems: 1
          uniqueItems: true
          items: { type: string, enum: [ANDROID, IOS, DESKTOP] }
          description: Defaults to ANDROID for APK and ANDROID+IOS for PWA.
        max_screen_width_px:
          type: [integer, 'null']
          minimum: 1
          maximum: 10000
          description: Inclusive CSS-pixel maximum. Explicit null disables this check; omission uses the app-type default.
        tracker_url:
          type: string
          description: Must be http(s), no whitespace.
        app_type:
          type: string
          enum: [APK, PWA]
          default: PWA
          description: |
            PWA (default) publishes a web app: creation is immediate and no APK
            is compiled. APK opts into the full dashboard pipeline — a per-bundle
            Firebase project, Android flavor sync and pool compilation — and is
            therefore restricted to real-tier teams; shadow teams get 400 and
            must promote first (POST /agent-onboard/promote). APK bundles are
            attributed through the Meta Conversions API using `pixels`, since
            the agent surface does not collect an AppsFlyer dev key.
        optimization_type:
          type: string
          enum: [APPSFLYER, CONVERSIONS_API]
          description: |
            How installs are attributed. Defaults to CONVERSIONS_API when the
            bundle is an APK or carries `pixels`, and to APPSFLYER otherwise —
            an APPSFLYER bundle without an `appsflyer_dev_key` cannot post back.
        pixels:
          type: array
          description: Meta pixels. Works with either optimization type.
          items: { $ref: '#/components/schemas/AgentBundlePixel' }
        app_name:
          type: string
          description: Shown on the install prompt. Defaults to the bundle name.
        app_scheme:
          type: string
          description: Deep-link scheme. Defaults to `vapp`.
        flavor_name:
          type: string
          description: Android flavor. Derived from the name when omitted.
        enabled:
          type: boolean
          description: |
            Bundles are created disabled so someone can look before traffic
            arrives. Pass true to publish immediately, or call setBundleEnabled
            later.
        pool_size:
          type: integer
          minimum: 0
          maximum: 10000
          description: How many APKs to keep pre-compiled. APK bundles only.
        apk_type:
          type: string
          enum: [FALLBACK, DIRECT]
        apk_file_name:
          type: string
          description: File name offered on download. Defaults to `Kgame`.
        storage_type:
          type: string
          enum: [LOCAL_SERVER, R2]
        source_bundle_id:
          type: string
          format: uuid
          description: |
            Seed TikTok settings from a bundle the team already has. Not stored
            on the new bundle — the same meaning it has in the dashboard.
        onelink_url:
          type: string
        install_event_name:
          type: string
        pih_pack_ids:
          type: array
          items: { type: integer }
          description: |
            Push In-House packs to attach; ids come from listPushPacks. Sending
            any implies push_service_type PUSH_IN_HOUSE, since packs are a Push
            In-House concept — asking for both packs and FCM is refused rather
            than resolved one way.
        spy_chain_protect:
          type: boolean
          description: Macro Key Cloaking. Needs `spy_chain_sequences` to do anything.
        spy_chain_sequences:
          type: array
          items: { type: string }
          description: Macro keys the cloak accepts.
        webview_stealth_mode:
          type: boolean
        pass_through_mode:
          type: boolean
        pass_through_url:
          type: string
        exit_download:
          type: boolean
        force_intent_redirect:
          type: boolean
        bypass_timezone_cloak:
          type: boolean
        enhanced_bot_detection:
          type: boolean
        appsflyer_dev_key:
          type: string
          description: |
            Write-only. Set it here or through updateBundle; it is never
            returned, so keep your own copy if you need one.
        appsflyer_web_app_id:
          type: string
        appsflyer_bundle_id:
          type: string
        onesignal_app_id:
          type: string
        push_service_type:
          type: string
          enum: [FCM, PUSH_IN_HOUSE]
          description: |
            FCM requires the team to have FCM access; without it the call is
            refused rather than quietly downgraded.
        push_service_team_uuid:
          type: string
          format: uuid
        push_service_domain_uuid:
          type: string
          format: uuid
        push_service_domain_name:
          type: string
        push_service_pwa_uuid:
          type: string
          format: uuid

    AgentPatchBundleRequest:
      type: object
      additionalProperties: false
      required: [name, expected_version]
      description: |
        Diff-style patch: only the fields present are written, so two agents
        touching different settings do not overwrite each other. Everything
        createBundle takes is here except what decides how the bundle was built
        and where it is served — host, application_id, flavor_name, app_type,
        pool_domain_id — which stay create-time decisions because the APK pool
        and the Cloudflare zone were provisioned from them.
      properties:
        expected_version: { type: integer, format: int64, minimum: 1 }
        name:
          type: string
          maxLength: 255
        enabled:
          type: boolean
        tiktok_enabled:
          type: boolean
          description: Optional; omitted preserves the stored value.
        tiktok_pixels:
          type: array
          description: Optional; omitted preserves, while an empty array clears all TikTok pixels only when TikTok is disabled. Pixel codes must be unique and enabled configuration requires every access token.
          items: { $ref: '#/components/schemas/TikTokPixel' }
        tiktok_test_event_code:
          type: [string, 'null']
          description: Optional; omitted preserves, explicit null or blank clears the value.
        tiktok_event_mapping:
          type: [object, 'null']
          description: Optional; omitted preserves, explicit null clears all overrides.
          additionalProperties:
            type: [string, 'null']
        tiktok_send_client_pixel:
          type: boolean
          description: Optional; omitted preserves the stored value.
        optimization_type:
          type: string
          enum: [APPSFLYER, CONVERSIONS_API]
        pixels:
          type: array
          items: { $ref: '#/components/schemas/AgentBundlePixel' }
          description: Replaces the stored list.
        app_name: { type: string }
        app_scheme: { type: string }
        app_icon_id: { type: string, format: uuid }
        pool_size: { type: integer, minimum: 0, maximum: 10000 }
        apk_type: { type: string, enum: [FALLBACK, DIRECT] }
        apk_file_name: { type: string }
        storage_type: { type: string, enum: [LOCAL_SERVER, R2] }
        onelink_url: { type: string }
        install_event_name: { type: string }
        tracker_url: { type: string }
        allowed_timezones:
          type: object
          additionalProperties: { type: string }
        allowed_platforms:
          type: array
          minItems: 1
          uniqueItems: true
          items: { type: string, enum: [ANDROID, IOS, DESKTOP] }
          description: Replaces the stored platform allow-list.
        max_screen_width_px:
          type: [integer, 'null']
          minimum: 1
          maximum: 10000
          description: Omission preserves the stored value; explicit null disables the width check.
        deposit_amount_default: { type: number }
        deposit_amounts_by_country:
          type: object
          additionalProperties: { type: number }
        deposit_amount_type: { type: string, enum: [POSTBACK, FIXED] }
        pih_pack_ids:
          type: array
          items: { type: integer }
          description: |
            Replaces the packs attached to this bundle; ids come from
            listPushPacks. The bundle's Push In-House app is created first if
            it does not have one. If the packs cannot be attached — an APK
            still waiting on its Firebase setup, for instance — the whole patch
            is refused, so success here means the push setup exists.
        spy_chain_protect: { type: boolean, description: Macro Key Cloaking. }
        spy_chain_sequences:
          type: array
          items: { type: string }
        webview_stealth_mode: { type: boolean }
        pass_through_mode: { type: boolean }
        pass_through_url: { type: string }
        exit_download: { type: boolean }
        force_intent_redirect: { type: boolean }
        bypass_timezone_cloak: { type: boolean }
        enhanced_bot_detection: { type: boolean }
        appsflyer_dev_key:
          type: string
          description: Write-only; never returned.
        appsflyer_web_app_id: { type: string }
        appsflyer_bundle_id: { type: string }
        onesignal_app_id: { type: string }
        push_service_type: { type: string, enum: [FCM, PUSH_IN_HOUSE] }
        push_service_team_uuid: { type: string, format: uuid }
        push_service_domain_uuid: { type: string, format: uuid }
        push_service_domain_name: { type: string }
        push_service_pwa_uuid: { type: string, format: uuid }

    AgentSetBundleEnabledRequest:
      type: object
      additionalProperties: false
      required: [enabled, expected_version]
      properties:
        enabled: { type: boolean }
        expected_version: { type: integer, format: int64, minimum: 1 }

    AgentUpdateBundlePagesRequest:
      type: object
      additionalProperties: false
      required: [black_page_id, expected_version]
      properties:
        expected_version: { type: integer, format: int64, minimum: 1 }
        black_page_id:
          type: string
          format: uuid
          description: Final CUSTOM or GOOGLE_PLAY page; GOOGLE_PLAY is required when a preland is set.
        prelanding_black_page_id:
          type: [string, 'null']
          format: uuid
          description: Optional CUSTOM prelanding page. Null or omission clears the current preland.

    RebuildQueueItem:
      type: object
      required: [bundle_id, apk_type, status, created_at, waiting_duration]
      properties:
        bundle_id: { type: string, format: uuid }
        bundle_name: { type: string }
        bundle_host: { type: string }
        apk_type: { type: string, enum: [FALLBACK, MATCH] }
        status: { type: string, enum: [WAITING_IN_QUEUE, COMPILING] }
        created_at: { type: string, format: date-time }
        started_at: { type: string, format: date-time, nullable: true }
        waiting_duration: { type: string }
        compiling_duration: { type: string, nullable: true }

    RebuildQueueResponse:
      type: object
      required: [items, total_waiting, total_compiling, active_builds, max_concurrent_builds]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/RebuildQueueItem' }
        total_waiting: { type: integer, minimum: 0 }
        total_compiling: { type: integer, minimum: 0 }
        active_builds: { type: integer, minimum: 0 }
        max_concurrent_builds: { type: integer, minimum: 1 }

    BundlePixel:
      type: object
      additionalProperties: false
      required: [pixel_id]
      properties:
        pixel_id: { type: string }
        access_token: { type: string, nullable: true }

    AgentBundlePixel:
      type: object
      additionalProperties: false
      required: [pixel_id]
      properties:
        pixel_id: { type: string }

    TikTokPixel:
      type: object
      required: [pixel_code]
      properties:
        pixel_code:
          type: string
          minLength: 1
          pattern: '.*\S.*'
        access_token:
          type: string
          nullable: true
          description: Read responses return a fixed mask when a token is configured.

    AgentUpdateWhitePageRequest:
      type: object
      additionalProperties: false
      required: [expected_version]
      description: Same shape as the white-page subset of AgentCreateBundleRequest.
      properties:
        expected_version: { type: integer, format: int64, minimum: 1 }
        white_page_template:
          type: string
          enum: [WHITE_PAGE_1, WHITE_PAGE_2, WHITE_PAGE_3, WHITE_PAGE_4, WHITE_PAGE_5, WHITE_PAGE_TOUCH_BALL]
          description: Defaults to WHITE_PAGE_TOUCH_BALL for CUSTOM and WHITE_PAGE_1 for APP_LANDING.
        white_page_template_type:
          type: string
          enum: [CUSTOM, APP_LANDING]
          default: CUSTOM
        white_page_package_name:
          type: string
          description: Required iff white_page_template_type=APP_LANDING.
        white_page_hl: { type: string, default: en }
        white_page_gl: { type: string, default: US }

    BlackPageTemplateInfo:
      type: object
      required: [template, display_name, category, popularity]
      properties:
        template: { type: string, description: "FTL stem (e.g. aviator_red)" }
        display_name: { type: string }
        category: { type: string }
        popularity: { type: integer, minimum: 1, maximum: 10 }
        tags:
          type: array
          items: { type: string }
        preview_url: { type: string, nullable: true }

    BlackPageSummary:
      type: object
      required: [id, name, template, template_type, resource_version]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        template: { type: string }
        template_type:
          type: string
          enum: [CUSTOM, GOOGLE_PLAY]
        resource_version: { type: integer, format: int64, minimum: 1 }

    BlackPage:
      type: object
      required: [id, name, template, template_type]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        template: { type: string }
        template_type:
          type: string
          enum: [CUSTOM, GOOGLE_PLAY]
        team_id: { type: string, format: uuid, nullable: true }
        data:
          description: |
            Polymorphic per template_type — BlackPageData for CUSTOM,
            GPlayAppData for GOOGLE_PLAY.
          oneOf:
            - { $ref: '#/components/schemas/BlackPageData' }
            - { $ref: '#/components/schemas/GPlayAppData' }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        resource_version: { type: integer, format: int64, minimum: 1 }

    AgentCreateBlackPageRequest:
      type: object
      additionalProperties: false
      required: [type, name, data]
      properties:
        type:
          type: string
          enum: [custom, google_play]
        template:
          type: string
          description: |
            Required for type=custom (must exist in
            GET /agent/black-page-templates). Ignored for type=google_play
            (server pins to `g_play`).
        name:
          type: string
          maxLength: 255
        data:
          description: |
            For custom: BlackPageData {claimButtonText, modalTextLines, appName?}.
            For google_play: GPlayAppData. Only name, developer, icon, pictures,
            and descr are mandatory; dashboard defaults are applied to all
            omitted optional presentation fields. Set hl/gl (for example
            hi/IN) to pin the wrapper language and regional number/date format
            for this page instead of using geo detection.
          oneOf:
            - { $ref: '#/components/schemas/BlackPageData' }
            - { $ref: '#/components/schemas/GPlayAppData' }

    BlackPageData:
      type: object
      additionalProperties: false
      required: [claimButtonText, modalTextLines]
      properties:
        claimButtonText:
          type: string
          minLength: 1
        modalTextLines:
          type: array
          items: { type: string }
        appName:
          type: string
          nullable: true

    GPlayAppData:
      type: object
      additionalProperties: false
      required: [name, developer, icon, pictures, descr]
      properties:
        approved: { type: boolean, default: true }
        name: { type: string, minLength: 1 }
        developer: { type: string, minLength: 1 }
        hl:
          type: string
          minLength: 2
          maxLength: 3
          pattern: '^[A-Za-z]{2,3}$'
          example: hi
          description: ISO 639 storefront language. Overrides geo-detected Google Play wrapper language for this page.
        gl:
          type: string
          minLength: 2
          maxLength: 2
          pattern: '^[A-Za-z]{2}$'
          example: IN
          description: ISO 3166-1 alpha-2 storefront country used with hl for regional number and date formatting.
        icon: { type: string, minLength: 1, description: Public icon URL }
        pictures:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/Picture' }
        comments:
          type: array
          default: []
          items: { $ref: '#/components/schemas/Comment' }
        haveAds: { type: boolean, default: true }
        havePurchase: { type: boolean, default: true }
        editorChoice: { type: boolean, default: true }
        age: { type: integer, minimum: 0, maximum: 21, default: 18 }
        rating: { type: number, minimum: 0, maximum: 5, default: 4.9 }
        numberOfReviews: { type: integer, minimum: 0, default: 2340 }
        numberOfDownloads: { type: integer, minimum: 0, default: 1000000 }
        por1: { type: integer, minimum: 0, maximum: 100, default: 1, description: Together por1..por5 must total 99..101. }
        por2: { type: integer, minimum: 0, maximum: 100, default: 1, description: Together por1..por5 must total 99..101. }
        por3: { type: integer, minimum: 0, maximum: 100, default: 3, description: Together por1..por5 must total 99..101. }
        por4: { type: integer, minimum: 0, maximum: 100, default: 4, description: Together por1..por5 must total 99..101. }
        por5: { type: integer, minimum: 0, maximum: 100, default: 90, description: Together por1..por5 must total 99..101. }
        descr: { type: string, minLength: 1 }
        vers: { type: string, default: '6.4.5001' }
        lastUpdate: { type: string, format: date-time, default: '2025-11-10T17:00:07.588792Z' }
        rav: { type: string, default: '4.4' }
        ie: { type: string, default: Interactive elements }
        release: { type: string, format: date-time, default: '2025-11-10T17:00:07.588792Z' }
        whatsNew: { type: string, default: 'upd 4.75b' }

    Picture:
      type: object
      additionalProperties: false
      required: [url]
      properties:
        url: { type: string, minLength: 1 }
        priority: { type: integer, minimum: 0, default: 0 }

    Comment:
      type: object
      additionalProperties: false
      required: [avatar]
      properties:
        date:
          type: string
          format: date-time
          description: Generated by the server when omitted.
        userName: { type: string, minLength: 1, default: Md Shifan Khan }
        avatar: { type: string, minLength: 1 }
        content:
          type: string
          minLength: 1
          default: I don't usually write reviews, but Chicken Road has truly earned it. It's thrilling, and the wins are amazing. This app is definitely a must-try!
        stars: { type: integer, minimum: 1, maximum: 5, default: 5 }
        likes: { type: integer, minimum: 0, default: 98 }
        priority: { type: integer, minimum: 0, default: 0 }
        developerReply:
          allOf:
            - { $ref: '#/components/schemas/DeveloperReply' }
          nullable: true
          default:
            date: '2025-11-10T17:00:07.588792Z'
            content: Thank you

    DeveloperReply:
      type: object
      additionalProperties: false
      properties:
        date:
          type: string
          format: date-time
          description: Generated by the server when omitted.
        content: { type: string, minLength: 1, default: Thank you }

    ImageFromUrlRequest:
      type: object
      required: [url]
      properties:
        url:
          type: string
          format: uri
          description: Must be HTTPS. Internal IP ranges rejected.
        name:
          type: string
          description: Optional display name. Derived from URL path otherwise.
        type:
          type: string
          enum: [ICON, GALLERY, AVATAR, PUSH_IMAGE]
          default: ICON

    Image:
      type: object
      required: [id, type, name, url, mime_type]
      properties:
        id: { type: string, format: uuid }
        type:
          type: string
          enum: [ICON, GALLERY, AVATAR, PUSH_IMAGE]
        name: { type: string }
        url: { type: string, description: Public R2 URL }
        mime_type: { type: string }
        size_bytes: { type: integer, format: int64 }
        team_id: { type: string, format: uuid, nullable: true }
        created_at: { type: string, format: date-time }
        resource_version: { type: integer, format: int64, minimum: 1 }

    QuotaExceededResponse:
      type: object
      required: [error, resource, limit, message]
      properties:
        error:
          type: string
          enum: [shadow_quota_exceeded]
        resource:
          type: string
          enum: [black_pages, images]
        limit: { type: integer }
        message: { type: string }

    PwaCloneRequest:
      type: object
      required: [source_url, geo]
      additionalProperties: false
      properties:
        source_url:
          type: string
          description: Public HTTP(S) listing URL to copy; unresolved double-brace ad macros are allowed.
        geo:
          type: string
          pattern: '^[A-Za-z]{2}$'
          description: Two-letter ISO country used for egress, language and the default traffic gate.
        accept_language:
          type: string
          description: Optional explicit Accept-Language; normally derived from geo.
        config:
          $ref: '#/components/schemas/AgentBuildConfig'
        enable:
          type: boolean
          default: true
          description: Publish after creation. Leave true for the turnkey workflow.
        request_id:
          type: string
          format: uuid
          description: Stable retry key for direct HTTP clients; MCP supplies its own idempotency binding.

    PwaCloneResult:
      type: object
      required:
        - bundle_id
        - host
        - enabled
        - black_page_id
        - app_icon_id
        - resource_version
        - geo
        - egress
        - mirrored_asset_count
        - applied_defaults
        - warnings
      properties:
        bundle_id: { type: string, format: uuid }
        host: { type: string }
        enabled: { type: boolean }
        black_page_id: { type: string, format: uuid }
        prelanding_black_page_id: { type: [string, 'null'], format: uuid }
        app_icon_id: { type: string, format: uuid }
        resource_version: { type: integer, format: int64, minimum: 1 }
        geo: { type: string }
        egress:
          type: object
          required: [mode, cost_cents, source_bytes]
          properties:
            mode: { type: string, enum: [direct, managed_proxy] }
            provider: { type: [string, 'null'] }
            cost_cents:
              type: integer
              format: int64
              minimum: 0
              description: Internal provider-cost metadata only; never debited from team balance.
            source_bytes:
              type: integer
              format: int32
              minimum: 0
              description: Final source document bytes, used for approximate spend attribution.
            observed_geo:
              type: [string, 'null']
              pattern: '^[A-Z]{2}$'
              description: Exit country observed through the same proxy session before the source fetch.
        mirrored_asset_count:
          type: integer
          format: int32
          minimum: 1
          description: Distinct source icon, screenshot and explicit avatar URLs mirrored into team R2 storage.
        applied_defaults:
          type: array
          items: { type: string }
        warnings:
          type: array
          items: { type: string }

    AgentBuildPlanRequest:
      type: object
      required: [app_data, config]
      additionalProperties: false
      properties:
        app_data:
          $ref: '#/components/schemas/AgentBuildCreative'
        config:
          $ref: '#/components/schemas/AgentBuildConfig'

    AgentImportListingRequest:
      type: object
      required: [url]
      additionalProperties: false
      properties:
        url:
          type: string
          description: |
            The page to read, http or https on a standard port. For a funnel
            source, the base URL is the game preland — pass its `?step=install`
            URL to reach the store page behind it.
        geo:
          type: string
          description: |
            ISO country code of the audience. Decides both how the request
            looks to the source's cloak and which language the source renders.
        accept_language:
          type: string
          description: Override the language derived from `geo`. Rarely needed.

    AgentImportedListing:
      type: object
      properties:
        app_data:
          $ref: '#/components/schemas/AgentBuildCreative'
        diagnostic:
          type: object
          description: What the fetch actually got. Worth reading before building on the result.
          properties:
            source:
              type: string
              enum: [pwaInfo, gplay_dom]
              description: |
                `pwaInfo` — read exactly, from the page's own data.
                `gplay_dom` — read out of rendered HTML, so thinner and guessier.
            final_url: { type: string }
            bytes: { type: integer }
            redirects: { type: integer }
            loader_shell:
              type: boolean
              description: |
                The source answered with a JavaScript loader instead of a page.
                Its cloak needs a real browser; there is nothing here to copy.
            preland_template:
              type: string
              nullable: true
              description: The template a funnel source's preland was recognised as.
            warnings:
              type: array
              items: { type: string }

    AgentBuildCreative:
      type: object
      description: |
        What the listing says, normalised away from wherever it came from.
        `pictures` needs at least one entry; the black page will not create
        without a screenshot.
      required: [name, developer, descr, icon, pictures]
      additionalProperties: false
      properties:
        name: { type: string, description: The app title }
        developer: { type: string, description: The publisher line }
        descr: { type: string, description: The full "About this app" text }
        icon: { type: string, description: Public HTTPS URL, rendered by the visitor's browser }
        pictures:
          type: array
          minItems: 1
          description: Screenshots, as URLs or as objects with a url and priority
          items:
            oneOf:
              - type: string
              - type: object
                required: [url]
                properties:
                  url: { type: string }
                  priority: { type: integer }
        rating: { type: number, default: 4.9 }
        number_of_reviews: { type: integer }
        number_of_downloads: { type: integer }
        por:
          type: array
          description: The five star bars, ONE star first. Normalised to sum 100.
          items: { type: number }
        version: { type: string }
        whats_new: { type: string }
        updated: { type: string, description: A unix stamp, "Jan 10, 2025", or ISO }
        rav: { type: string }
        age: { type: integer, minimum: 0, maximum: 21 }
        editor_choice: { type: boolean }
        have_ads: { type: boolean }
        have_purchase: { type: boolean }
        lang_hint: { type: string, enum: [en, hi], description: Seeds hl/gl unless the config sets them }
        comments:
          type: array
          items:
            type: object
            additionalProperties: false
            properties:
              user_name: { type: string }
              content: { type: string }
              stars: { type: integer, minimum: 1, maximum: 5 }
              likes: { type: integer }
              priority: { type: integer }
              avatar: { type: string, description: URL; empty yields a generated letter avatar }
              date: { type: string }
              developer_reply:
                type: string
                description: |
                  Omitting this does not mean no reply. The render substitutes a
                  default "Thank you" dated 2025-11-10 on every comment, which
                  reads as an abandoned listing. Write a varied one for each.
              developer_reply_date: { type: string }

    AgentBuildConfig:
      type: object
      required: [bundle_name, tracker_url]
      additionalProperties: false
      properties:
        bundle_name: { type: string, description: Internal label; convention is to name it after the domain }
        tracker_url: { type: string, description: Bare http(s). A macro here fails the create }
        pool_domain_id:
          type: string
          format: uuid
          description: |
            Real tier. Buying a pool domain means passing its id here, which
            charges price_cents at create. Putting the domain NAME in `host`
            instead is rejected and buys nothing.
        host: { type: string, description: Only a domain already pointed at the team }
        app_type: { type: string, enum: [PWA, APK], description: APK is real tier only }
        app_name: { type: string, description: The offer name shown on the home screen, not the domain }
        application_id: { type: string }
        allowed_countries:
          type: array
          description: ISO codes or names. Each expands to every timezone row it has.
          items: { type: string }
        timezone_name: { type: string, description: Back-compat; prefer allowed_countries }
        timezone_offset: { type: string }
        pixels:
          type: array
          items:
            type: object
            required: [pixel_id]
            additionalProperties: false
            properties:
              pixel_id: { type: string }
              access_token: { type: string, description: Accepted and stored, though masked on read }
        install_event_name:
          type: string
          nullable: true
          description: Which Meta event an install fires. Null means installs are not sent at all.
        tiktok:
          type: object
          additionalProperties: true
        deposit:
          type: object
          additionalProperties: false
          properties:
            type: { type: string, enum: [POSTBACK, FIXED] }
            default: { type: number }
            by_country:
              type: object
              additionalProperties: { type: number }
        macro_key:
          type: object
          additionalProperties: false
          description: Macro Key Cloaking. Only enable it when the campaign really injects the key, or the black page never opens.
          properties:
            sequences:
              type: array
              items: { type: string }
        push:
          type: object
          additionalProperties: false
          properties:
            pack_ids:
              type: array
              items: { type: integer }
            service_type: { type: string, enum: [PUSH_IN_HOUSE, FCM] }
        hl: { type: string, description: Play chrome label language }
        gl: { type: string, description: Play chrome number format; US gives 990K, IN gives lakh }
        white_page:
          type: object
          additionalProperties: false
          properties:
            mode: { type: string, enum: [template, app_landing] }
            template: { type: string }
            package_name: { type: string, description: Required for app_landing }
            hl: { type: string }
            gl: { type: string }
        funnel:
          type: object
          required: [preland_template]
          additionalProperties: false
          properties:
            preland_template: { type: string }
            claim_text: { type: string }
            modal_lines:
              type: array
              items: { type: string }
        replace_existing: { type: boolean, default: false }

    AgentBuildPlan:
      type: object
      properties:
        tier: { type: string, enum: [shadow, real] }
        hl: { type: string }
        gl: { type: string }
        geo_report: { type: string, description: "countries N -> timezone rows M; say this back to the operator" }
        replace_existing: { type: boolean }
        pwa_icon_url: { type: string, description: Upload this to get app_icon_id }
        gplay_black_page: { type: object, additionalProperties: true }
        preland_black_page: { type: object, nullable: true, additionalProperties: true }
        bundle_args: { type: object, additionalProperties: true }
        notes:
          type: array
          description: What the plan wants said out loud before anything is created
          items: { type: string }
        execution:
          type: array
          items: { type: string }
