openapi: 3.1.0
info:
  title: EasyBits API
  version: "2"
  summary: La nube para agentes de IA, en MXN.
  description: |
    REST API v2 de EasyBits: sandboxes (microVMs Firecracker), web (buscar/leer/extraer),
    archivos con CDN, bases SQL, documentos, hosting de apps, agentes y flota multicanal.

    - Documentación completa: https://www.easybits.cloud/docs
    - Índice para agentes: https://www.easybits.cloud/llms.txt
    - Skills instalables: https://www.easybits.cloud/.well-known/agent-skills/index.json
    - MCP (misma superficie como tools): https://www.easybits.cloud/api/mcp

    Todas las rutas van bajo `/api/v2` salvo `/api/tools.json`, que es pública.
    Los errores siempre tienen la forma `{ error, code?, issues? }`.
  contact:
    name: EasyBits
    url: https://www.easybits.cloud
  license:
    name: Términos de servicio
    url: https://www.easybits.cloud/terminos
servers:
  - url: https://www.easybits.cloud
security:
  - apiKey: []
  - oauth2: []
tags:
  - name: Sandboxes
    description: MicroVMs efímeras o persistentes para ejecutar código y agentes.
  - name: Web
    description: Buscar, leer, extraer y rastrear internet. Se mide en consultas.
  - name: Files
    description: Archivos con CDN, presigned URLs y borrado suave de 7 días.
  - name: Databases
    description: SQLite como servicio (sqld), una base aislada por recurso.
  - name: Machines
    description: Hosting always-on con releases, backups y secretos.
  - name: Documents
    description: Documentos HTML por páginas, publicables y exportables a PDF.
  - name: Agents
    description: Agentes persistentes (ACP o Claude Code) con endpoint propio.
  - name: Fleet
    description: Flota elástica de agentes multicanal (WhatsApp, WABA, web).
  - name: Webhooks
    description: Avisos HTTP firmados con HMAC.
  - name: Account
    description: Identidad, uso y saldo de tokens LLM.
  - name: Docs
    description: Documentación y catálogo de tools para agentes.

paths:
  # ───────────────────────── Sandboxes ─────────────────────────
  /api/v2/sandboxes:
    get:
      tags: [Sandboxes]
      operationId: listSandboxes
      summary: Listar sandboxes
      description: Sandboxes vivas o suspendidas del dueño de la credencial.
      responses:
        "200":
          description: Lista.
          content:
            application/json:
              schema:
                type: object
                required: [sandboxes]
                properties:
                  sandboxes:
                    type: array
                    items: { $ref: "#/components/schemas/Sandbox" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Sandboxes]
      operationId: createSandbox
      summary: Crear sandbox
      description: |
        Levanta una microVM. ⚠️ Sin `suspendOnIdle`, al vencer `timeoutSeconds` la caja
        se **DESTRUYE**, no se duerme; y el default de `timeoutSeconds` son **300 s**.
        Para cualquier caja a la que vayas a escribir más tarde, manda `suspendOnIdle: true`.
        Rate limit: 10 creaciones por minuto.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SandboxCreate" }
      responses:
        "200":
          description: Sandbox creada (puede venir en `starting`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Sandbox" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v2/sandboxes/{id}:
    parameters:
      - $ref: "#/components/parameters/id"
    get:
      tags: [Sandboxes]
      operationId: getSandbox
      summary: Estado de una sandbox
      responses:
        "200":
          description: Sandbox.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Sandbox" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Sandboxes]
      operationId: destroySandbox
      summary: Destruir sandbox
      description: Libera la VM y su disco. Irreversible.
      responses:
        "200":
          description: Destruida.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/sandboxes/{id}/exec:
    parameters:
      - $ref: "#/components/parameters/id"
    post:
      tags: [Sandboxes]
      operationId: execSandbox
      summary: Ejecutar comando (síncrono)
      description: |
        Corre un comando y espera su salida (60 s por defecto, tope 600 s). Para un build,
        un dev server o cualquier cosa que sobreviva a la petición usa `/bg`: **no** intentes
        `nohup … &` aquí, el shell muere al responder y se lleva al hijo.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [command]
              properties:
                command: { type: string, minLength: 1 }
                cwd: { type: string }
                timeoutSeconds: { type: integer, minimum: 1, maximum: 600 }
                env:
                  type: object
                  additionalProperties: { type: string }
      responses:
        "200":
          description: Resultado.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExecResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v2/sandboxes/{id}/run-code:
    parameters:
      - $ref: "#/components/parameters/id"
    post:
      tags: [Sandboxes]
      operationId: runCodeSandbox
      summary: Ejecutar código inline
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string, minLength: 1 }
                lang: { type: string, enum: [python, node, bash] }
                timeoutSeconds: { type: integer, minimum: 1, maximum: 600 }
      responses:
        "200":
          description: Resultado.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExecResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/sandboxes/{id}/bg:
    parameters:
      - $ref: "#/components/parameters/id"
    get:
      tags: [Sandboxes]
      operationId: listBackgroundExecs
      summary: Listar procesos en background
      description: Sirve para recuperar un `execId` perdido.
      responses:
        "200":
          description: Procesos vivos.
          content:
            application/json:
              schema:
                type: object
                properties:
                  count: { type: integer }
                  processes:
                    type: array
                    items: { $ref: "#/components/schemas/BgExec" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Sandboxes]
      operationId: startBackgroundExec
      summary: Arrancar proceso en background
      description: |
        Para todo lo que dure más que la petición (builds, servidores). Escribe el comando
        como `exec <programa>` para que el shell sea reemplazado por tu proceso. Acuérdate de
        matarlo: un proceso colgado se come la caja hasta el TTL.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [command]
              properties:
                command: { type: string }
                cwd: { type: string }
                env:
                  type: object
                  additionalProperties: { type: string }
      responses:
        "200":
          description: Proceso lanzado.
          content:
            application/json:
              schema:
                type: object
                required: [execId]
                properties:
                  execId: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v2/sandboxes/{id}/bg/{execId}:
    parameters:
      - $ref: "#/components/parameters/id"
      - name: execId
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Sandboxes]
      operationId: getBackgroundExec
      summary: Estado y logs de un proceso
      responses:
        "200":
          description: Estado.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BgExec" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Sandboxes]
      operationId: killBackgroundExec
      summary: Matar proceso
      description: Mata al grupo entero (SIGTERM, luego SIGKILL tras `graceSeconds`).
      parameters:
        - name: graceSeconds
          in: query
          schema: { type: integer, minimum: 0, maximum: 30 }
      responses:
        "200":
          description: Matado.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/sandboxes/{id}/files/{op}:
    parameters:
      - $ref: "#/components/parameters/id"
      - name: op
        in: path
        required: true
        schema:
          type: string
          enum: [write, read, list, delete, move, mkdir]
    post:
      tags: [Sandboxes]
      operationId: sandboxFileOp
      summary: Operar archivos dentro de la caja
      description: |
        El body depende de `op`: `write` `{path, content, encoding?}` · `read` `{path}` ·
        `list` `{path}` · `delete` `{path, recursive?}` · `move` `{from, to}` · `mkdir` `{path}`.
        `read` y `list` también aceptan `GET …?path=`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                path: { type: string }
                content: { type: string }
                encoding: { type: string, enum: [utf8, base64] }
                recursive: { type: boolean }
                from: { type: string }
                to: { type: string }
      responses:
        "200":
          description: Resultado de la operación.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/sandboxes/{id}/{action}:
    parameters:
      - $ref: "#/components/parameters/id"
      - name: action
        in: path
        required: true
        schema:
          type: string
          enum: [extend, suspend, resume, snapshot, fork, expose, bootstrap]
    post:
      tags: [Sandboxes]
      operationId: sandboxAction
      summary: Acción sobre la caja
      description: |
        `extend` `{extendSeconds?}` · `suspend` (snapshot, pausa el TTL) · `resume` ·
        `snapshot` `{name?}` · `fork` `{count?, name?, metadata?, timeoutSeconds?}` ·
        `expose` `{port}` (URL pública HTTPS/WSS) · `bootstrap` `{script, mode?, timeoutSeconds?}`
        (script que el HOST corre en cada despertar; `script: ""` lo apaga; nunca metas
        credenciales, viaja en el metadata).
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                extendSeconds: { type: integer }
                name: { type: string }
                count: { type: integer }
                metadata:
                  type: object
                  additionalProperties: { type: string }
                timeoutSeconds: { type: integer }
                port: { type: integer }
                script: { type: string }
                mode: { type: string, enum: [async, blocking] }
      responses:
        "200":
          description: Resultado de la acción.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ───────────────────────── Web ─────────────────────────
  /api/v2/web/search:
    post:
      tags: [Web]
      operationId: webSearch
      summary: Buscar en la web
      description: Cuesta 1 consulta. Sin saldo responde 402 con `buy`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query]
              properties:
                query: { type: string }
                engine: { type: string }
                country: { type: string }
      responses:
        "200":
          description: Resultados.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v2/web/fetch:
    post:
      tags: [Web]
      operationId: webFetch
      summary: Leer una página
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri }
                country: { type: string }
                asMarkdown: { type: boolean }
      responses:
        "200":
          description: Contenido.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }

  /api/v2/web/extract:
    post:
      tags: [Web]
      operationId: webExtract
      summary: Extraer datos estructurados (asíncrono)
      description: Devuelve un `jobId`; consulta el estado en `GET /web/extract/{jobId}`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [input]
              properties:
                source: { type: string }
                datasetId: { type: string }
                input: {}
                limit: { type: integer }
      responses:
        "200":
          description: Job encolado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  jobId: { type: string }
                  status: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }

  /api/v2/web/extract/{jobId}:
    parameters:
      - name: jobId
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Web]
      operationId: webExtractStatus
      summary: Estado de una extracción
      responses:
        "200":
          description: Estado y datos si terminó.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/web/crawl:
    post:
      tags: [Web]
      operationId: webCrawl
      summary: Rastrear un sitio
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri }
                maxPages: { type: integer }
                country: { type: string }
      responses:
        "200":
          description: Páginas rastreadas.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }

  # ───────────────────────── Files ─────────────────────────
  /api/v2/files:
    get:
      tags: [Files]
      operationId: listFiles
      summary: Listar archivos
      parameters:
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/cursor"
        - name: assetId
          in: query
          schema: { type: string }
        - name: status
          in: query
          description: "`DELETED` lista los borrados (con `daysUntilPurge`)."
          schema: { type: string, enum: [DONE, DELETED] }
      responses:
        "200":
          description: Página de archivos.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Paginated"
                  - type: object
                    properties:
                      items:
                        type: array
                        items: { $ref: "#/components/schemas/File" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Files]
      operationId: createFile
      summary: Registrar archivo y obtener URL de subida
      description: Devuelve `putUrl`; sube los bytes con `PUT putUrl` y el archivo queda `DONE`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fileName, contentType, size]
              properties:
                fileName: { type: string }
                contentType: { type: string }
                size: { type: integer }
                access: { type: string, enum: [public, private] }
                region: { type: string, enum: [LATAM, US, EU] }
                assetId: { type: string }
      responses:
        "201":
          description: Archivo registrado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  file: { $ref: "#/components/schemas/File" }
                  putUrl: { type: string, format: uri }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v2/files/search:
    get:
      tags: [Files]
      operationId: searchFiles
      summary: Buscar archivos (IA)
      description: Requiere una llave de IA configurada. Hasta 20 coincidencias.
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Coincidencias.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/File" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v2/files/{fileId}:
    parameters:
      - $ref: "#/components/parameters/fileId"
    get:
      tags: [Files]
      operationId: getFile
      summary: Obtener archivo
      description: Incluye `readUrl` presignada (expira en 1 h).
      responses:
        "200":
          description: Archivo.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/File" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Files]
      operationId: updateFile
      summary: Actualizar archivo
      description: Cambiar `access` copia el objeto entre buckets público/privado.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                access: { type: string, enum: [public, private] }
                metadata: { type: object, additionalProperties: true }
                status: { type: string, enum: [DONE] }
      responses:
        "200":
          description: Archivo actualizado.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/File" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Files]
      operationId: deleteFile
      summary: Borrar archivo (suave)
      description: Recuperable 7 días con `/restore`.
      responses:
        "200":
          description: Borrado.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/files/{fileId}/restore:
    parameters:
      - $ref: "#/components/parameters/fileId"
    post:
      tags: [Files]
      operationId: restoreFile
      summary: Restaurar archivo borrado
      responses:
        "200":
          description: Archivo de vuelta en `DONE`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/File" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/files/{fileId}/duplicate:
    parameters:
      - $ref: "#/components/parameters/fileId"
    post:
      tags: [Files]
      operationId: duplicateFile
      summary: Duplicar archivo
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
      responses:
        "200":
          description: Copia nueva (objeto + registro).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/File" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ───────────────────────── Databases ─────────────────────────
  /api/v2/databases:
    get:
      tags: [Databases]
      operationId: listDatabases
      summary: Listar bases
      responses:
        "200":
          description: Bases del dueño.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/Database" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Databases]
      operationId: createDatabase
      summary: Crear base
      description: Nombre alfanumérico con guiones, máx 64. Límite por plan (Byte 3, Mega 10, Tera 20).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 64 }
                description: { type: string }
      responses:
        "201":
          description: Base creada.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Database" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }

  /api/v2/databases/{dbId}:
    parameters:
      - $ref: "#/components/parameters/dbId"
    get:
      tags: [Databases]
      operationId: getDatabase
      summary: Obtener base
      responses:
        "200":
          description: Base.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Database" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Databases]
      operationId: deleteDatabase
      summary: Borrar base
      description: Borra la base y TODOS sus datos. Irreversible.
      responses:
        "200":
          description: Borrada.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/databases/{dbId}/query:
    parameters:
      - $ref: "#/components/parameters/dbId"
    post:
      tags: [Databases]
      operationId: queryDatabase
      summary: Consultar, ejecutar en lote o importar
      description: |
        Un solo endpoint, tres cuerpos: `{sql, args?}` (query) · `{statements[]}` (lote, máx 20)
        · `{table, columns, rows, onConflict?}` (import, hasta 10,000 filas).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: "#/components/schemas/DbQueryBody"
                - $ref: "#/components/schemas/DbBatchBody"
                - $ref: "#/components/schemas/DbImportBody"
      responses:
        "200":
          description: Resultado según el cuerpo enviado.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/QueryResult"
                  - type: object
                    properties:
                      results:
                        type: array
                        items: { $ref: "#/components/schemas/QueryResult" }
                  - type: object
                    properties:
                      imported: { type: integer }
                      total: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ───────────────────────── Machines ─────────────────────────
  /api/v2/machines/tiers:
    get:
      tags: [Machines]
      operationId: listMachineTiers
      summary: Catálogo de tiers
      description: Para una app Node 24/7 el piso real es `micro`; `nano` no aguanta un build.
      responses:
        "200":
          description: Tiers con precio MXN/mes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tiers:
                    type: array
                    items: { $ref: "#/components/schemas/MachineTier" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v2/machines:
    get:
      tags: [Machines]
      operationId: listMachines
      summary: Listar máquinas
      responses:
        "200":
          description: Máquinas con `tier` y `monthlyMxn`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  machines:
                    type: array
                    items: { $ref: "#/components/schemas/Machine" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Machines]
      operationId: createMachine
      summary: Crear máquina permanente
      description: |
        Con plan activo, la máquina al instante. Sin plan, `{ checkoutUrl }`: la máquina
        nace cuando se confirma el pago. Con `fromSandboxId` promueve una efímera existente
        (conserva el mismo `sandboxId`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tier]
              properties:
                tier: { type: string }
                cpuMode: { type: string, enum: [shared, reserved] }
                diskAddonsGB: { type: integer, multipleOf: 100, maximum: 2000 }
                template: { type: string }
                name: { type: string, maxLength: 64 }
                fromSandboxId: { type: string }
      responses:
        "200":
          description: Máquina o link de pago.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/Machine"
                  - type: object
                    required: [checkoutUrl]
                    properties:
                      checkoutUrl: { type: string, format: uri }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v2/machines/launch:
    post:
      tags: [Machines]
      operationId: launchApp
      summary: Una app en producción en UNA llamada
      description: |
        Provisiona, mete el código, buildea, expone URL HTTPS, **publica el release de
        recuperación** y pega el dominio. Fuente: exactamente UNA de `repo` | `archiveUrl`;
        `sandboxId` es el DESTINO (puede ir solo o junto a una fuente para redesplegar).
        Buildea DENTRO de la caja: módulos nativos compilados en macOS revientan en Linux.
        Sin plan devuelve `{ checkoutUrl }`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LaunchInput" }
      responses:
        "200":
          description: App servida.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LaunchResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v2/machines/{id}:
    parameters:
      - $ref: "#/components/parameters/id"
    get:
      tags: [Machines]
      operationId: getMachine
      summary: Obtener máquina
      responses:
        "200":
          description: Máquina.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Machine" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Machines]
      operationId: releaseMachine
      summary: Liberar máquina
      description: Cancela el cobro (prorrateado) y destruye la VM. Destructiva, idempotente.
      responses:
        "200":
          description: Liberada.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/machines/{id}/releases:
    parameters:
      - $ref: "#/components/parameters/id"
    get:
      tags: [Machines]
      operationId: listMachineReleases
      summary: Listar releases
      description: Release = CÓDIGO. Backup = DATOS. Sin release, una caja muerta se lleva la app.
      responses:
        "200":
          description: Historial.
          content:
            application/json:
              schema:
                type: object
                properties:
                  releases:
                    type: array
                    items: { $ref: "#/components/schemas/Release" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Machines]
      operationId: publishMachineRelease
      summary: Publicar release
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                message: { type: string }
      responses:
        "200":
          description: Release publicado.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Release" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/machines/{id}/rollback:
    parameters:
      - $ref: "#/components/parameters/id"
    post:
      tags: [Machines]
      operationId: rollbackMachine
      summary: Volver a un release anterior
      description: En la MISMA caja, sin rebuild (los releases van `prebuilt`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [releaseId]
              properties:
                releaseId: { type: string }
      responses:
        "200":
          description: Aplicado.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/machines/{id}/restart:
    parameters:
      - $ref: "#/components/parameters/id"
    post:
      tags: [Machines]
      operationId: restartMachine
      summary: Reinicio en caliente
      description: Aplica secretos y runspec sin bajar ni construir nada.
      responses:
        "200":
          description: Reiniciada.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/machines/{id}/secrets:
    parameters:
      - $ref: "#/components/parameters/id"
    get:
      tags: [Machines]
      operationId: listMachineSecrets
      summary: Nombres de secretos
      description: Nombres, nunca valores. Un secreto guardado no se vuelve a leer por API.
      responses:
        "200":
          description: Nombres declarados y cuáles están en la bóveda.
          content:
            application/json:
              schema:
                type: object
                properties:
                  secretNames:
                    type: array
                    items: { type: string }
                  inVault:
                    type: array
                    items: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Machines]
      operationId: setMachineSecrets
      summary: Cargar secretos
      description: |
        Body `{ NOMBRE: valor }`. Van cifrados a la bóveda; en el runspec queda sólo la lista
        de nombres. Surten efecto al escribirlos (reinicia el proceso); con `?restart=false`
        se difieren hasta `/restart`. `PUT` es alias.
      parameters:
        - name: restart
          in: query
          schema: { type: boolean, default: true }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: { type: string }
      responses:
        "200":
          description: Guardados.
          content:
            application/json:
              schema:
                type: object
                properties:
                  secretNames:
                    type: array
                    items: { type: string }
                  pendingRestart: { type: boolean }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v2/machines/{id}/logs:
    parameters:
      - $ref: "#/components/parameters/id"
    get:
      tags: [Machines]
      operationId: getMachineLogs
      summary: Log de la app
      description: Primer lugar a mirar cuando el deploy dijo que arrancó y el sitio no contesta.
      parameters:
        - name: lines
          in: query
          schema: { type: integer, default: 200 }
        - name: grep
          in: query
          schema: { type: string }
      responses:
        "200":
          description: Últimas líneas.
          content:
            application/json:
              schema:
                type: object
                properties:
                  lines:
                    type: array
                    items: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/machines/{id}/backups:
    parameters:
      - $ref: "#/components/parameters/id"
    get:
      tags: [Machines]
      operationId: listMachineBackups
      summary: Listar backups
      description: Diarios, 7 días, incluidos. Copian `runspec.dataPaths`, no el rootfs. `consistency` dice si fue en caliente.
      responses:
        "200":
          description: Backups.
          content:
            application/json:
              schema:
                type: object
                properties:
                  backups:
                    type: array
                    items: { $ref: "#/components/schemas/Backup" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ───────────────────────── Documents ─────────────────────────
  /api/v2/documents:
    get:
      tags: [Documents]
      operationId: listDocuments
      summary: Listar documentos
      responses:
        "200":
          description: Documentos.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/Document" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Documents]
      operationId: createDocument
      summary: Crear documento
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                prompt: { type: string }
                theme: { type: string }
                customColors: { type: object, additionalProperties: true }
                sections:
                  type: array
                  items: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Documento creado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  document: { $ref: "#/components/schemas/Document" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v2/documents/{id}:
    parameters:
      - $ref: "#/components/parameters/id"
    get:
      tags: [Documents]
      operationId: getDocument
      summary: Obtener documento
      responses:
        "200":
          description: Documento con páginas.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Document" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Documents]
      operationId: updateDocument
      summary: Actualizar documento
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                prompt: { type: string }
                theme: { type: string }
                customColors: { type: object, additionalProperties: true }
                sections:
                  type: array
                  items: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Documento actualizado.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Document" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Documents]
      operationId: deleteDocument
      summary: Borrar documento
      responses:
        "200":
          description: Borrado.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/documents/{id}/deploy:
    parameters:
      - $ref: "#/components/parameters/id"
    post:
      tags: [Documents]
      operationId: deployDocument
      summary: Publicar como sitio
      description: Requiere al menos una página.
      responses:
        "200":
          description: Publicado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, format: uri }
                  websiteId: { type: string }
                  slug: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/documents/{id}/unpublish:
    parameters:
      - $ref: "#/components/parameters/id"
    post:
      tags: [Documents]
      operationId: unpublishDocument
      summary: Despublicar
      responses:
        "200":
          description: De vuelta a borrador.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/documents/{id}/pdf:
    parameters:
      - $ref: "#/components/parameters/id"
    get:
      tags: [Documents]
      operationId: getDocumentPdf
      summary: Exportar PDF
      description: Respeta `metadata.format`. `sections` filtra páginas; `inline=1` lo sirve sin descarga.
      parameters:
        - name: sections
          in: query
          description: IDs separados por coma.
          schema: { type: string }
        - name: inline
          in: query
          schema: { type: string, enum: ["1"] }
        - name: token
          in: query
          description: Share token (sustituye la credencial).
          schema: { type: string }
      responses:
        "200":
          description: PDF.
          content:
            application/pdf:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ───────────────────────── Agents ─────────────────────────
  /api/v2/agents:
    get:
      tags: [Agents]
      operationId: listAgents
      summary: Listar agentes
      responses:
        "200":
          description: Agentes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  agents:
                    type: array
                    items: { $ref: "#/components/schemas/Agent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Agents]
      operationId: createAgent
      summary: Crear agente persistente
      description: |
        `env` es obligatorio (`{}` si el template no pide nada). Responde de inmediato con
        `status: building`; consulta `GET /agents/{id}` hasta `running` antes del primer mensaje.
        Templates ACP: `ghosty-lite` (tu llave EasyBits como cerebro por default) y `goose`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [template, env]
              properties:
                template: { type: string }
                env:
                  type: object
                  additionalProperties: { type: string }
                name: { type: string }
                timeoutSeconds: { type: integer }
                seedFiles:
                  type: array
                  items:
                    type: object
                    required: [name, contentBase64]
                    properties:
                      name: { type: string }
                      contentBase64: { type: string }
                mcpServers:
                  type: array
                  description: Sólo templates ACP. stdio `{name, command, args?, env?}` o http `{name, type:"http", url, headers?}`.
                  items: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Agente en construcción.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Agent" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }

  /api/v2/agents/{id}:
    parameters:
      - $ref: "#/components/parameters/id"
    get:
      tags: [Agents]
      operationId: getAgent
      summary: Estado del agente
      responses:
        "200":
          description: Agente.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Agent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Agents]
      operationId: destroyAgent
      summary: Destruir agente
      responses:
        "200":
          description: Destruido.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/agents/{id}/message:
    parameters:
      - $ref: "#/components/parameters/id"
    post:
      tags: [Agents]
      operationId: messageAgent
      summary: Enviar mensaje (SSE)
      description: |
        Stream `text/event-stream`: `{type:"chunk",value}` … `{type:"usage",…}` opcional …
        `{type:"done"}`. Acepta la `eb_sk` del dueño o el `embedToken` del agente (CORS `*`).
        Si el agente está `lost`, lo revive solo.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content: { type: string }
      responses:
        "200":
          description: Stream de eventos.
          content:
            text/event-stream:
              schema: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/agents/{id}/try:
    parameters:
      - $ref: "#/components/parameters/id"
    post:
      tags: [Agents]
      operationId: tryAgent
      summary: Un turno completo a texto, sin stream (para verificar)
      description: |
        Manda un turno y espera la respuesta completa como texto (máximo 180 s). Pensado para
        comprobar que el agente responde antes de cablear el stream. Auth: apiKey o el embed
        token del agente (`Bearer agt_…`). `reset: true` abre sesión nueva.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text: { type: string }
                session: { type: string }
                reset: { type: boolean }
      responses:
        "200":
          description: Respuesta del turno.
          content:
            application/json:
              schema:
                type: object
                required: [text, error, session, ms]
                properties:
                  text: { type: string }
                  error: { type: [string, "null"] }
                  session: { type: [string, "null"] }
                  ms: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "Ya hay un turno en curso en esa sesión (`error: \"turno_en_curso\"`)."
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Error"
                  - type: object
                    properties:
                      session: { type: string }
        "502":
          description: El agente terminó sin producir texto.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v2/agents/{id}/revive:
    parameters:
      - $ref: "#/components/parameters/id"
    post:
      tags: [Agents]
      operationId: reviveAgent
      summary: Revivir agente sobre la misma URL
      description: |
        Vuelve a levantar la caja del agente conservando `agentId` y URL. Tarda lo que tarda el
        boot (~10-60 s): espera la respuesta, no reintentes. Se pierde el disco anterior.
      responses:
        "200":
          description: Revivido (o ya existía).
          content:
            application/json:
              schema:
                type: object
                properties:
                  agentId: { type: string }
                  sandboxId: { type: string }
                  status: { type: string }
                  wsUrl: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ───────────────────────── Fleet ─────────────────────────
  /api/v2/fleet-agents:
    get:
      tags: [Fleet]
      operationId: listFleetAgents
      summary: Listar fleet agents
      description: Credencial de cliente. Cada agente trae su `token` por-agente para configurarlo y mensajearlo.
      responses:
        "200":
          description: Fleet agents (clave histórica `pools`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  pools:
                    type: array
                    items: { $ref: "#/components/schemas/FleetAgent" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v2/fleet-agents/{fleetAgentId}/message:
    parameters:
      - $ref: "#/components/parameters/fleetAgentId"
    post:
      tags: [Fleet]
      operationId: fleetMessage
      summary: Enviar turno (síncrono)
      description: |
        Auth = token del agente (scope MESSAGE). 🚨 Manda siempre `configGroupId`: es la unidad
        de configuración (prompt, MCPs). Sin él el agente arranca **sin conectores**, sin error
        visible. `groupId` identifica la conversación y lo eliges tú (`web-<uuid>`).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/FleetMessageBody" }
      responses:
        "200":
          description: Respuesta del agente.
          content:
            application/json:
              schema:
                type: object
                properties:
                  reply: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "503":
          description: Flota llena en este instante; reintenta tras `retryAfter`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v2/fleet-agents/{fleetAgentId}/message-stream:
    parameters:
      - $ref: "#/components/parameters/fleetAgentId"
    post:
      tags: [Fleet]
      operationId: fleetMessageStream
      summary: Enviar turno (SSE)
      description: |
        Eventos `chunk`, `tool`, `usage`, `capacity`, `done`. `done.value` es la respuesta
        **autoritativa**; no concatenes los chunks. `capacity` no es un fallo: reintenta tras `retryAfter`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/FleetMessageBody" }
      responses:
        "200":
          description: Stream de eventos.
          content:
            text/event-stream:
              schema: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v2/fleet-agents/{fleetAgentId}/messages:
    parameters:
      - $ref: "#/components/parameters/fleetAgentId"
    get:
      tags: [Fleet]
      operationId: fleetMessages
      summary: Leer conversación con cursor
      description: |
        Persiste el `cursor` y mándalo como `since`. **Revisa `gap` antes de confiar en el
        delta**: `true` = recarga el hilo (`cursor_too_old` o `context_reset`).
      parameters:
        - name: groupId
          in: query
          required: true
          schema: { type: string }
        - name: since
          in: query
          schema: { type: string }
        - $ref: "#/components/parameters/limit"
      responses:
        "200":
          description: Página de mensajes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { type: object, additionalProperties: true }
                  cursor: { type: string }
                  hasMore: { type: boolean }
                  gap: { type: boolean }
                  gapReason: { type: string, enum: [cursor_too_old, context_reset] }
                  resetAt: { type: string, format: date-time }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ───────────────────────── Webhooks ─────────────────────────
  /api/v2/webhooks:
    get:
      tags: [Webhooks]
      operationId: listWebhooks
      summary: Listar webhooks
      responses:
        "200":
          description: Webhooks.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/Webhook" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Webhooks]
      operationId: createWebhook
      summary: Crear webhook
      description: "El `secret` se muestra UNA vez. Firma `X-Easybits-Signature: sha256=<hmac>`. Se pausa tras 5 fallos."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url: { type: string, format: uri }
                events:
                  type: array
                  items: { type: string }
      responses:
        "201":
          description: Webhook con `secret`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v2/webhooks/{webhookId}:
    parameters:
      - $ref: "#/components/parameters/webhookId"
    get:
      tags: [Webhooks]
      operationId: getWebhook
      summary: Obtener webhook
      responses:
        "200":
          description: Webhook.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Webhooks]
      operationId: updateWebhook
      summary: Actualizar webhook
      description: "Reactivar (`status: ACTIVE`) reinicia el contador de fallos."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string, format: uri }
                events:
                  type: array
                  items: { type: string }
                status: { type: string, enum: [ACTIVE, PAUSED] }
      responses:
        "200":
          description: Actualizado.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Webhooks]
      operationId: deleteWebhook
      summary: Borrar webhook
      responses:
        "200":
          description: Borrado.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ───────────────────────── Account ─────────────────────────
  /api/v2/me:
    get:
      tags: [Account]
      operationId: getMe
      summary: Quién soy
      responses:
        "200":
          description: Usuario de la credencial.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  email: { type: string, format: email }
                  verified_email: { type: boolean }
                  confirmed: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v2/usage:
    get:
      tags: [Account]
      operationId: getUsage
      summary: Uso de almacenamiento y archivos
      responses:
        "200":
          description: Estadísticas.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v2/llm/balance:
    get:
      tags: [Account]
      operationId: getLlmBalance
      summary: Saldo de tokens LLM
      description: Forma compatible con DeepSeek `/user/balance`.
      responses:
        "200":
          description: Saldo.
          content:
            application/json:
              schema:
                type: object
                properties:
                  is_available: { type: boolean }
                  plan: { type: string }
                  balance_infos:
                    type: array
                    items:
                      type: object
                      properties:
                        currency: { type: string }
                        total_balance: { type: string }
                        total_balance_human: { type: string }
                        granted_balance: { type: string }
                        topped_up_balance: { type: string }
                        used: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ───────────────────────── Docs ─────────────────────────
  /api/v2/docs:
    get:
      tags: [Docs]
      operationId: getDocs
      summary: Documentación en markdown
      parameters:
        - name: section
          in: query
          description: Una sección (`files`, `agents`, `machines`, …); sin ella, todo.
          schema: { type: string }
      responses:
        "200":
          description: Markdown.
          content:
            text/markdown:
              schema: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/tools.json:
    get:
      tags: [Docs]
      operationId: getToolCatalog
      summary: Catálogo público de tools MCP
      description: Sin auth. Nombre, descripción y grupo de cada tool; sin schemas de entrada.
      security: []
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Catálogo.
          content:
            application/json:
              schema:
                type: object
                properties:
                  product: { type: string }
                  mcp: { type: string, format: uri }
                  docs: { type: string, format: uri }
                  total: { type: integer }
                  groups:
                    type: array
                    items:
                      type: object
                      properties:
                        key: { type: string }
                        label: { type: string }
                        description: { type: string }
                        tools: { type: integer }
                  tools:
                    type: array
                    items:
                      type: object
                      properties:
                        name: { type: string }
                        description: { type: string }
                        group: { type: string }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: eb_sk_live_…
      description: "API key de EasyBits (`Authorization: Bearer eb_sk_live_…`). Scopes READ/WRITE/DELETE/ADMIN según la llave."
    oauth2:
      type: oauth2
      description: OAuth 2.1 con PKCE (flujo de conectores MCP).
      flows:
        authorizationCode:
          authorizationUrl: https://www.easybits.cloud/oauth/authorize
          tokenUrl: https://www.easybits.cloud/oauth/token
          scopes:
            READ: Leer recursos.
            WRITE: Crear y modificar.
            DELETE: Borrar.
            ADMIN: Administración de la cuenta.

  parameters:
    id:
      name: id
      in: path
      required: true
      schema: { type: string }
    fileId:
      name: fileId
      in: path
      required: true
      schema: { type: string }
    dbId:
      name: dbId
      in: path
      required: true
      schema: { type: string }
    webhookId:
      name: webhookId
      in: path
      required: true
      schema: { type: string }
    fleetAgentId:
      name: fleetAgentId
      in: path
      required: true
      schema: { type: string }
    cursor:
      name: cursor
      in: query
      description: Cursor opaco de la página anterior (`nextCursor`).
      schema: { type: string }
    limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 50 }

  responses:
    BadRequest:
      description: Body o parámetros inválidos (`issues` trae el detalle de zod).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Falta o no vale la credencial.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    PaymentRequired:
      description: Sin saldo o sin plan. `buy` es la URL absoluta donde recargar.
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/Error"
              - type: object
                properties:
                  buy: { type: string, format: uri }
                  requiredCost: { type: number }
                  available: { type: number }
    Forbidden:
      description: La credencial no tiene el scope o no es dueña del recurso.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: No existe o no es tuyo.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Conflict:
      description: Estado incompatible con la operación.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Demasiadas peticiones (sandboxes 10 creaciones/min, 120 ops/min). Respeta `Retry-After`.
      headers:
        Retry-After:
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string }
        code: { type: string }
        message: { type: string }
        issues:
          type: array
          items: { type: object, additionalProperties: true }
    Ok:
      type: object
      properties:
        ok: { type: boolean }
      additionalProperties: true
    Paginated:
      type: object
      description: Envelope único de toda lista.
      required: [items]
      properties:
        items:
          type: array
          items: {}
        nextCursor: { type: string }
        hasMore: { type: boolean }
        total: { type: integer }
    File:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        contentType: { type: string }
        size: { type: integer }
        status: { type: string, enum: [PENDING, DONE, DELETED] }
        access: { type: string, enum: [public, private] }
        url: { type: string, format: uri }
        readUrl: { type: string, format: uri, description: "Presignada, expira en 1 h." }
        metadata: { type: object, additionalProperties: true }
        daysUntilPurge: { type: integer }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    SandboxCreate:
      type: object
      required: [template]
      properties:
        template:
          type: string
          description: "`ubuntu`, `python`, `node`, `bun`, `code-interpreter`, `claude-code`, `ghosty-lite`, `goose`, …"
        timeoutSeconds:
          type: integer
          minimum: 30
          default: 300
          description: TTL de inactividad. Máximo según plan (Byte 1h · Mega 4h · Tera 24h).
        name: { type: string, maxLength: 64 }
        metadata:
          type: object
          additionalProperties: { type: string }
        persistent: { type: boolean, description: "Salta el reaper por antigüedad." }
        size: { type: string, enum: [s, m, l, xl] }
        suspendOnIdle:
          type: boolean
          description: Al idlear, SUSPENDE (snapshot, despierta en ~0.2 s) en vez de destruir.
        hardTtlSeconds: { type: integer, minimum: 60, description: "Cuándo destruirla de verdad." }
    Sandbox:
      type: object
      properties:
        sandboxId: { type: string }
        name: { type: string }
        template: { type: string }
        status: { type: string, enum: [starting, running, suspended, stopped, error, lost] }
        createdAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time }
        hardExpiresAt: { type: string, format: date-time }
        suspendOnIdle: { type: boolean }
        ownerId: { type: string }
        metadata:
          type: object
          additionalProperties: { type: string }
    ExecResult:
      type: object
      properties:
        exitCode: { type: integer }
        stdout: { type: string }
        stderr: { type: string }
        timedOut: { type: boolean }
        durationMs: { type: integer }
    BgExec:
      type: object
      properties:
        execId: { type: string }
        command: { type: string }
        status: { type: string, enum: [running, exited, killed] }
        pid: { type: integer }
        exitCode: { type: integer }
        stdout: { type: string }
        stderr: { type: string }
        startedAt: { type: string, format: date-time }
    Database:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        namespace: { type: string }
        description: { type: string }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    DbQueryBody:
      type: object
      required: [sql]
      properties:
        sql: { type: string }
        args:
          type: array
          items: {}
    DbBatchBody:
      type: object
      required: [statements]
      properties:
        statements:
          type: array
          maxItems: 20
          items:
            type: object
            required: [sql]
            properties:
              sql: { type: string }
              args:
                type: array
                items: {}
    DbImportBody:
      type: object
      required: [table, columns, rows]
      properties:
        table: { type: string }
        columns:
          type: array
          items: { type: string }
        rows:
          type: array
          maxItems: 10000
          items:
            type: array
            items: {}
        onConflict: { type: string, enum: [ignore, replace] }
    QueryResult:
      type: object
      properties:
        cols:
          type: array
          items: { type: string }
        rows:
          type: array
          items:
            type: array
            items: {}
        affected_row_count: { type: integer }
        last_insert_rowid:
          type: [string, "null"]
    MachineTier:
      type: object
      properties:
        tier: { type: string }
        vcpus: { type: integer }
        memoryMb: { type: integer }
        diskGb: { type: integer }
        monthlyMxn: { type: number }
    Machine:
      type: object
      properties:
        sandboxId: { type: string }
        name: { type: string }
        tier: { type: string }
        cpuMode: { type: string, enum: [shared, reserved] }
        monthlyMxn: { type: number }
        status: { type: string }
        template: { type: string }
        createdAt: { type: string, format: date-time }
    LaunchInput:
      type: object
      properties:
        repo: { type: string, description: "URL git. Privado con `https://x-access-token:TOKEN@github.com/u/r.git` o `repoToken`." }
        branch: { type: string }
        repoToken: { type: string, description: "Acepta `$secret:NOMBRE`. No se guarda." }
        repoUsername: { type: string }
        archiveUrl: { type: string, format: uri, description: "URL a un `.tar.gz`/`.zip` de la app." }
        sandboxId: { type: string, description: "DESTINO. Redespliega sobre una máquina existente." }
        tier: { type: string, default: micro }
        name: { type: string }
        template: { type: string, default: node }
        appDir: { type: string, default: /app }
        buildCommand: { type: string, default: "(npm ci || npm install) && npm run build" }
        startCommand: { type: string, default: npm start }
        unit: { type: string }
        port: { type: integer, default: 3000 }
        dataPaths:
          type: array
          items: { type: string }
          description: Lo que respalda el backup diario. Sin esto NO hay backup.
        prebuilt: { type: boolean, description: "El artefacto ya trae build; no se ejecuta `buildCommand`." }
        env:
          type: object
          additionalProperties: { type: string }
          description: Variables NO secretas. Los secretos van a `/machines/{id}/secrets`.
        secretNames:
          type: array
          items: { type: string }
        domain: { type: string }
        message: { type: string }
    LaunchResult:
      type: object
      properties:
        checkoutUrl: { type: string, format: uri, description: "Sólo sin plan. Nada se desplegó todavía." }
        monthlyMxn: { type: number }
        sandboxId: { type: string }
        url: { type: string, format: uri }
        releaseId: { type: string }
        version: { type: integer }
        exitCode: { type: integer }
        buildOutput: { type: string }
        domain:
          type: object
          properties:
            domain: { type: string }
            url: { type: string, format: uri }
            dns: { description: "El registro exacto que el cliente debe crear." }
    Release:
      type: object
      properties:
        id: { type: string }
        sandboxId: { type: string }
        version: { type: integer }
        message: { type: string }
        prebuilt: { type: boolean }
        sizeBytes: { type: integer }
        createdAt: { type: string, format: date-time }
    Backup:
      type: object
      properties:
        id: { type: string }
        sandboxId: { type: string }
        consistency: { type: string, enum: [crash, clean] }
        sizeBytes: { type: integer }
        createdAt: { type: string, format: date-time }
    Document:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        slug: { type: string }
        status: { type: string }
        theme: { type: string }
        shareUrl: { type: string, format: uri }
        pdfUrl: { type: string, format: uri }
        sections:
          type: array
          items: { type: object, additionalProperties: true }
        metadata: { type: object, additionalProperties: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    Agent:
      type: object
      properties:
        agentId: { type: string }
        sandboxId: { type: string }
        name: { type: string }
        template: { type: string }
        status: { type: string, enum: [building, running, suspended, lost, error] }
        agentUrl: { type: string, description: "URL estable del agente (lleva el agentId, no la máquina)." }
        embedToken: { type: string }
        createdAt: { type: string, format: date-time }
    FleetAgent:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        token: { type: string, description: "Autoriza TODO; reparte credenciales con alcance en su lugar." }
        engine: { type: string }
        model: { type: string }
        workerTemplate: { type: string }
        createdAt: { type: string, format: date-time }
    FleetMessageBody:
      type: object
      required: [groupId]
      properties:
        groupId: { type: string, description: "Id estable de la conversación, lo eliges tú." }
        configGroupId: { type: string, description: "Unidad de configuración. Mándalo SIEMPRE." }
        text: { type: string }
        sender: { type: string }
        image:
          type: object
          properties:
            base64: { type: string }
            ext: { type: string }
            url: { type: string }
        audio:
          type: object
          properties:
            base64: { type: string }
            mimeType: { type: string }
        mediaUrl: { type: string }
        denikApiKey: { type: string }
    Webhook:
      type: object
      properties:
        id: { type: string }
        url: { type: string, format: uri }
        events:
          type: array
          items: { type: string }
        secret: { type: string, description: "Sólo en la creación." }
        status: { type: string, enum: [ACTIVE, PAUSED] }
        failCount: { type: integer }
        createdAt: { type: string, format: date-time }
