openapi: 3.1.0
info:
  title: Ghosty Studio API
  version: "2.0"
  summary: "Habla con tus agentes en nombre de una persona."
  description: |
    API por persona: tu aplicación actúa en nombre de quien le dio permiso por OAuth2
    (authorization_code + PKCE). Un agente corre en su propia máquina aislada; un turno se
    **encarga** (`202`) y se **mira** por el stream de eventos (`/events`, SSE).

    Guías: https://www.ghosty.studio/docs/api/autenticacion · Eventos:
    https://www.ghosty.studio/docs/api/eventos · Errores: https://www.ghosty.studio/docs/api/errores-y-limites
  contact:
    name: Ghosty Studio
    email: hola@ghosty.studio
    url: https://www.ghosty.studio/docs
servers:
  - url: https://www.ghosty.studio
security:
  - oauth2: []
tags:
  - name: Configuración
    description: "Configurar un agente con SU token (`gat_…`): identidad, modelo, archivos, skills y MCP. Es lo que usa la skill `ghosty-agent` de Claude Code/Cursor."
  - name: Cuenta
  - name: Agentes
  - name: Conversaciones
  - name: Turnos
  - name: Archivos
  - name: Conectores
  - name: Dispositivos

paths:
  /api/v2/agents/{id}:
    parameters: [{ $ref: "#/components/parameters/id" }]
    get:
      tags: [Configuración]
      operationId: getAgentConfig
      summary: Configuración del agente
      description: "Con `?full=1` abre la máquina (la despierta si duerme) y añade `files` y `skills`."
      security: [{ agentToken: [] }, { oauth2: ["agents:read"] }]
      parameters:
        - { name: full, in: query, schema: { type: string, enum: ["1"] } }
        - { name: fields, in: query, schema: { type: string }, description: "Sólo estos campos, separados por coma (`id` siempre va). P. ej. `prompt,model,hasMachine`." }
      responses:
        "200":
          description: Configuración
          content: { application/json: { schema: { $ref: "#/components/schemas/AgentConfig" } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/NoAcp" }
    patch:
      tags: [Configuración]
      operationId: patchAgentConfig
      summary: Cambiar identidad, modelo, nombre o canales
      description: "`prompt` se guarda (y en motores con máquina se escribe en ella); entra en la siguiente conversación. En motores con máquina `POST …/restart` la aplica ya; en los demás no existe restart. `model` reinicia el agente. La respuesta trae `nota` con lo que aplica a ese motor."
      security: [{ agentToken: [] }, { oauth2: ["agents:write"] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 64 }
                engine: { type: string, description: "Cambia el MOTOR (p.ej. `claude` → `ghosty-lite`). Conserva identidad, skills, conocimiento, canales y permisos; la memoria de las conversaciones se queda en el motor viejo. Con `model`, es un modelo del motor nuevo; sin él, su default." }
                model: { type: string, description: "Uno de `models[].id` del GET." }
                prompt: { type: string, maxLength: 20000 }
                promptMode: { type: string, enum: [append, replace], description: "`replace` = el agente es SÓLO su prompt (sin la persona Ghosty de casa); `append` (default) la conserva. Sólo motores con máquina." }
                webSearch: { type: boolean }
                channels: { type: object, properties: { teams: { type: boolean }, web: { type: boolean }, whatsapp: { type: boolean }, messenger: { type: boolean } } }
                starters: { type: array, maxItems: 5, items: { type: string, maxLength: 80 }, description: "Sugerencias de arranque (chips) — reemplaza la lista." }
                tools:
                  type: object
                  description: "Apagar (`false`) o volver a encender (`true`) tools por nombre. Se funde con lo guardado. `gs`: las que presta gs (ver `tools.gs` del GET). `extensions`: las del `config.yaml` de la máquina (`developer`, `todo`, `analyze`, `easybits`… ver `extensions` con `?full=1`); cambiarlas reinicia la máquina. El veto es real: la tool desaparece del `list` Y se rechaza al ejecutarse."
                  properties:
                    gs: { type: object, additionalProperties: { type: boolean } }
                    extensions: { type: object, additionalProperties: { type: boolean } }
                    canales:
                      type: object
                      description: "Vetos por canal sobre las tools de gs: `{ messenger: { actualizar_identidad: false } }`. Canales: chat (Studio/app de Mac), teams, whatsapp, messenger, programado, prueba. `true` vuelve al default de la tool."
                      additionalProperties: { type: object, additionalProperties: { type: boolean } }
      responses:
        "200":
          description: Aplicado
          content: { application/json: { schema: { $ref: "#/components/schemas/AgentConfig" } } }
        "400": { description: "Nada que cambiar o modelo inválido." }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/NoAcp" }
        "502": { description: "Guardado, pero la máquina no lo tomó. Reintenta `POST …/restart`." }

  /api/v2/agents/{id}/files/{path}:
    parameters:
      - { $ref: "#/components/parameters/id" }
      - { name: path, in: path, required: true, schema: { type: string }, description: "Relativa al workspace (`/data/work`), sin `..`." }
    get:
      tags: [Configuración]
      operationId: listAgentFiles
      summary: Listar archivos del workspace
      security: [{ agentToken: [] }, { oauth2: ["agents:read"] }]
      responses:
        "200":
          description: Lista
          content: { application/json: { schema: { type: object, properties: { dir: { type: string }, files: { type: array, items: { type: object, properties: { path: { type: string }, size: { type: integer } } } } } } } }
    put:
      tags: [Configuración]
      operationId: putAgentFile
      summary: Subir un archivo al workspace
      description: "Bytes crudos en el cuerpo (`--data-binary`). Máx 10 MB. No hace falta reiniciar."
      security: [{ agentToken: [] }, { oauth2: ["agents:write"] }]
      requestBody:
        required: true
        content: { application/octet-stream: { schema: { type: string, format: binary } } }
      responses:
        "200":
          description: Escrito
          content: { application/json: { schema: { type: object, properties: { path: { type: string }, bytes: { type: integer }, en: { type: string } } } } }
        "400": { description: "Ruta inválida o cuerpo vacío." }
        "413": { description: "Más de 10 MB." }
    delete:
      tags: [Configuración]
      operationId: deleteAgentFile
      summary: Borrar un archivo
      security: [{ agentToken: [] }, { oauth2: ["agents:write"] }]
      responses:
        "200": { description: Borrado }

  /api/v2/agents/{id}/skills:
    parameters: [{ $ref: "#/components/parameters/id" }]
    get:
      tags: [Configuración]
      operationId: listAgentSkills
      summary: Skills instaladas y catálogo de la comunidad
      description: "`skills` son las instaladas en el agente. `community` es el catálogo curado de skills open-source (grill-me, caveman, ponytail, frontend-design, test-driven-development…) con `installed` por slug; se instalan con `PUT …/skills/{slug}` y `{ \"fromCatalog\": true }`."
      security: [{ agentToken: [] }, { oauth2: ["agents:read"] }]
      responses:
        "200":
          description: Lista
          content:
            application/json:
              schema:
                type: object
                properties:
                  skills: { type: array, items: { $ref: "#/components/schemas/Skill" } }
                  community:
                    type: array
                    items:
                      type: object
                      properties:
                        slug: { type: string }
                        description: { type: string }
                        repo: { type: string, description: "owner/name en GitHub." }
                        url: { type: string, description: "Carpeta de la skill fijada al commit vendorizado." }
                        license: { type: string, example: MIT }
                        installed: { type: boolean }

  /api/v2/agents/{id}/skills/{slug}:
    parameters:
      - { $ref: "#/components/parameters/id" }
      - { name: slug, in: path, required: true, schema: { type: string, pattern: "^[a-z0-9][a-z0-9-]{0,63}$" } }
    put:
      tags: [Configuración]
      operationId: putAgentSkill
      summary: Instalar una skill (propia o del catálogo)
      description: "Formato Agent Skills: `SKILL.md` con frontmatter `name` y `description`, más assets. Entra al reiniciar (`POST …/restart`). Con `{ \"fromCatalog\": true }` y sin `markdown` instala la skill de ese slug desde el catálogo de la comunidad (`GET …/skills` → `community`)."
      security: [{ agentToken: [] }, { oauth2: ["agents:write"] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                markdown: { type: string, description: "Contenido de SKILL.md. Obligatorio salvo con `fromCatalog`." }
                fromCatalog: { type: boolean, description: "Instalar desde el catálogo de la comunidad; ignora `markdown` y `assets`." }
                assets:
                  type: array
                  items:
                    type: object
                    required: [name, contentBase64]
                    properties:
                      name: { type: string, description: "Relativo a la carpeta de la skill (`scripts/x.py`)." }
                      contentBase64: { type: string, contentEncoding: base64 }
      responses:
        "200":
          description: Instalada
          content: { application/json: { schema: { type: object, properties: { slug: { type: string }, files: { type: array, items: { type: string } }, bytes: { type: integer }, nota: { type: string } } } } }
        "400": { description: "Slug inválido o SKILL.md sin frontmatter." }
        "404": { description: "`fromCatalog` con un slug que no está en el catálogo." }
        "413": { description: "Más de 25 MB." }
    delete:
      tags: [Configuración]
      operationId: deleteAgentSkill
      summary: Quitar una skill
      security: [{ agentToken: [] }, { oauth2: ["agents:write"] }]
      responses:
        "200": { description: Quitada }

  /api/v2/agents/{id}/mcp:
    parameters: [{ $ref: "#/components/parameters/id" }]
    get:
      tags: [Configuración]
      operationId: getAgentMcp
      summary: Servidores MCP del agente
      security: [{ agentToken: [] }, { oauth2: ["agents:read"] }]
      responses:
        "200":
          description: Lista
          content: { application/json: { schema: { type: object, properties: { servers: { type: array, items: { $ref: "#/components/schemas/McpServer" } } } } } }
    put:
      tags: [Configuración]
      operationId: putAgentMcp
      summary: Reemplazar la lista de servidores MCP
      description: "Reemplaza la lista ENTERA (manda los existentes más el nuevo). Escribe la config de la máquina y reinicia el agente."
      security: [{ agentToken: [] }, { oauth2: ["agents:write"] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [servers]
              properties:
                servers: { type: array, maxItems: 20, items: { $ref: "#/components/schemas/McpServer" } }
      responses:
        "200":
          description: Aplicado y reiniciado
          content: { application/json: { schema: { type: object, properties: { servers: { type: array, items: { $ref: "#/components/schemas/McpServer" } }, reiniciado: { type: boolean } } } } }
        "400": { description: "Servidor inválido (el mensaje dice cuál)." }

  /api/v2/agents/{id}/restart:
    parameters: [{ $ref: "#/components/parameters/id" }]
    post:
      tags: [Configuración]
      operationId: restartAgent
      summary: Reiniciar el proceso del agente
      description: "Corta un turno en vuelo; el disco sobrevive. Aplica identidad y skills recién escritas."
      security: [{ agentToken: [] }, { oauth2: ["agents:write"] }]
      responses:
        "200":
          description: Reiniciado
          content: { application/json: { schema: { type: object, properties: { reiniciado: { type: boolean }, sandboxId: { type: string } } } } }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/agents/{id}/try:
    parameters: [{ $ref: "#/components/parameters/id" }]
    post:
      tags: [Configuración]
      operationId: tryAgent
      summary: Un turno de prueba, a texto
      description: "Manda un mensaje al agente y devuelve su respuesta completa (sin stream). Es el «Probar» de la ficha para agentes de código: configura y comprueba en el mismo turno. `session` separa memorias (default `default`); `reset: true` la olvida. Un turno a la vez por sesión; hasta 180 s. Funciona en todos los motores y consume saldo como cualquier turno."
      security: [{ agentToken: [] }, { oauth2: ["agents:write"] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text: { type: string, description: "El mensaje." }
                session: { type: string, maxLength: 32, description: "Clave de la sesión de prueba (`a-z A-Z 0-9 _ -`)." }
                reset: { type: boolean, description: "Olvida la sesión antes de mandar `text` (o sólo la olvida, si no hay `text`)." }
                as: { type: string, enum: [whatsapp], description: "Corre el turno como un cliente de WhatsApp Business (canal, rol cliente, estilo y CRM del agente) sin mandar nada a WhatsApp. `session` es entonces el teléfono (10-15 dígitos). Para probar un agente de ventas con conversaciones reales." }
      responses:
        "200":
          description: Respuesta del agente
          content: { application/json: { schema: { type: object, properties: { text: { type: string }, error: { type: [string, "null"] }, session: { type: string } } } } }
        "400": { description: "Falta `text`." }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "`turno_en_curso`: ya hay un turno en esa sesión." }
        "502": { description: "El turno falló sin texto (`error` dice por qué)." }

  /api/v2/agents/{id}/bundle:
    parameters: [{ $ref: "#/components/parameters/id" }]
    get:
      tags: [Configuración]
      operationId: getAgentBundle
      summary: El agente como directorio estilo Eve
      description: "`instructions.md`, `skills/<slug>/…`, `knowledge/…` y `mcp.json` en JSON con base64."
      security: [{ agentToken: [] }, { oauth2: ["agents:write"] }]
      responses:
        "200": { description: Bundle, content: { application/json: { schema: { $ref: "#/components/schemas/Bundle" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Configuración]
      operationId: importAgentBundle
      summary: Importar un directorio de agente (Eve)
      description: "Misma forma que el GET. `instructions.md` → identidad; `skills/x.md` o `skills/x/SKILL.md` → skills (se añade frontmatter si falta); `knowledge/…` → archivos. `tools/`, `channels/` y `schedules/` no se ejecutan y vuelven en `ignorado`."
      security: [{ agentToken: [] }, { oauth2: ["agents:write"] }]
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/Bundle" } } } }
      responses:
        "200": { description: Aplicado, content: { application/json: { schema: { type: object, properties: { aplicado: { type: array, items: { type: string } }, ignorado: { type: array, items: { type: string } }, nota: { type: string } } } } } }
        "413": { description: "Un archivo pasa de 10 MB." }

  /api/v2/agents/{id}/whatsapp:
    parameters: [{ $ref: "#/components/parameters/id" }]
    get:
      tags: [Configuración]
      operationId: getAgentWhatsapp
      summary: Qué números de WhatsApp contesta
      description: "Los números del workspace (se parean desde Sales, `salesUrl`) y cuál contesta este agente (`mine`) o lo contesta otro (`takenByOther`)."
      security: [{ agentToken: [] }, { oauth2: ["agents:write"] }]
      responses:
        "200":
          description: Números
          content: { application/json: { schema: { $ref: "#/components/schemas/WhatsappNumbers" } } }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Configuración]
      operationId: putAgentWhatsapp
      summary: Elegir qué números contesta
      description: "Deja al agente contestando exactamente esos números. Un número lo contesta un solo agente: marcarlo se lo quita al otro. Desmarcarlo no borra el pareo, sólo lo calla."
      security: [{ agentToken: [] }, { oauth2: ["agents:write"] }]
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [numbers], properties: { numbers: { type: array, items: { type: string }, description: "Ids de `numbers[].id` del GET." } } } } }
      responses:
        "200":
          description: Aplicado
          content: { application/json: { schema: { $ref: "#/components/schemas/WhatsappNumbers" } } }
        "400": { description: "`body_invalido`." }
        "404": { $ref: "#/components/responses/NotFound" }

  /oauth2/token:
    post:
      operationId: postOauth2Token
      tags: [Cuenta]
      security: []
      summary: Canjear un code o un refresh_token
      description: "Cliente público: sólo `client_id` (PKCE obligatorio). Confidencial: además `client_secret` (`client_secret_post` o `Authorization: Basic`)."
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [grant_type, client_id]
              properties:
                grant_type: { type: string, enum: [authorization_code, refresh_token] }
                client_id: { type: string }
                client_secret: { type: string }
                code: { type: string, description: "Con authorization_code. Vale 60 s." }
                redirect_uri: { type: string, description: "Exacto al usado en /oauth2/authorize." }
                code_verifier: { type: string, description: "PKCE (S256)." }
                refresh_token: { type: string, description: "Con refresh_token. Rota en cada uso." }
      responses:
        "200":
          description: Tokens
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token: { type: string }
                  refresh_token: { type: string }
                  token_type: { type: string, const: Bearer }
                  expires_in: { type: integer, example: 3600 }
                  scope: { type: string, example: profile agents:read agents:write files }
        "400":
          description: "`invalid_grant` (code caducado, verifier incorrecto, refresh rotado, redirect_uri distinto) o `unsupported_grant_type`"
          content: { application/json: { schema: { $ref: "#/components/schemas/OAuthError" } } }
        "401":
          description: "`invalid_client`"
          content: { application/json: { schema: { $ref: "#/components/schemas/OAuthError" } } }
  /oauth2/revoke:
    post:
      operationId: postOauth2Revoke
      tags: [Cuenta]
      security: []
      summary: Revocar un token (RFC 7009)
      requestBody:
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [token, client_id]
              properties:
                token: { type: string }
                client_id: { type: string }
                client_secret: { type: string }
      responses:
        "200": { description: "Siempre, aunque el token no exista." }

  /api/v2/me:
    delete:
      operationId: deleteMe
      tags: [Cuenta]
      summary: Borrar la cuenta
      description: "Elimina agentes, máquinas, conversaciones y archivos."
      security: [{ oauth2: ["agents:write"] }]
      responses:
        "200":
          description: Borrada
          content: { application/json: { schema: { type: object, properties: { borrada: { type: boolean, const: true } } } } }
        "409":
          description: "Hay un bloqueo (p. ej. es dueña de un espacio con miembros)."
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v2/me/agents:
    get:
      operationId: getMeAgents
      tags: [Agentes]
      summary: Los agentes de la persona
      description: "Con `profile` incluye el correo. Si la cuenta está vacía, enrola el primer agente antes de contestar."
      security: [{ oauth2: ["agents:read"] }]
      responses:
        "200":
          description: Lista
          content:
            application/json:
              schema:
                type: object
                properties:
                  agentes:
                    type: array
                    items: { $ref: "#/components/schemas/Agente" }
                  usuario:
                    type: object
                    properties: { id: { type: string }, email: { type: string } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v2/me/agents/{agentId}/conversations:
    parameters: [{ $ref: "#/components/parameters/agentId" }]
    get:
      operationId: getMeAgentsByagentIdConversations
      tags: [Conversaciones]
      summary: Listar conversaciones
      security: [{ oauth2: ["agents:read"] }]
      responses:
        "200":
          description: "Lista con el estado del último turno de cada una."
          content:
            application/json:
              schema:
                type: object
                properties:
                  conversaciones:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        titulo: { type: [string, "null"] }
                        actualizada: { type: [string, "null"], format: date-time }
                        ultimoTurno: { $ref: "#/components/schemas/TurnoResumen" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/NoAcp" }
    post:
      operationId: postMeAgentsByagentIdConversations
      tags: [Conversaciones]
      summary: Abrir una conversación
      security: [{ oauth2: ["agents:write"] }]
      responses:
        "201":
          description: Creada
          content: { application/json: { schema: { type: object, properties: { id: { type: string } } } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/NoAcp" }

  /api/v2/me/agents/{agentId}/conversations/{sessionId}:
    parameters:
      - { $ref: "#/components/parameters/agentId" }
      - { $ref: "#/components/parameters/sessionId" }
    get:
      operationId: getMeAgentsByagentIdConversationsBysessionId
      tags: [Conversaciones]
      summary: Mensajes de una conversación
      security: [{ oauth2: ["agents:read"] }]
      parameters:
        - name: limit
          in: query
          schema: { type: integer }
          description: "Sólo la cola por defecto; `saltados` dice cuántos quedaron fuera."
      responses:
        "200":
          description: Mensajes
          content:
            application/json:
              schema:
                type: object
                properties:
                  messages:
                    type: array
                    items: { $ref: "#/components/schemas/Mensaje" }
                  saltados: { type: integer }
                  ultimoTurno: { $ref: "#/components/schemas/TurnoResumen" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: patchMeAgentsByagentIdConversationsBysessionId
      tags: [Conversaciones]
      summary: Renombrar
      security: [{ oauth2: ["agents:write"] }]
      requestBody:
        content: { application/json: { schema: { type: object, required: [title], properties: { title: { type: string } } } } }
      responses:
        "200": { description: "Renombrada" }
    delete:
      operationId: deleteMeAgentsByagentIdConversationsBysessionId
      tags: [Conversaciones]
      summary: Archivar
      security: [{ oauth2: ["agents:write"] }]
      responses:
        "200": { description: "Archivada" }

  /api/v2/me/agents/{agentId}/conversations/{sessionId}/messages:
    parameters:
      - { $ref: "#/components/parameters/agentId" }
      - { $ref: "#/components/parameters/sessionId" }
    post:
      operationId: postMeAgentsByagentIdConversationsBysessionIdMessages
      tags: [Turnos]
      summary: Encargar un turno
      description: |
        Contesta `202` **al aceptar**, no al terminar. El progreso se mira en `/events`.
        Manda un `turnId` propio (UUID): repetirlo no duplica el turno (`repetido: true`).
        Un mensaje nuevo mientras hay un turno en curso en la misma conversación **lo detiene y lo
        reemplaza** (`reemplazoAnterior: true`).
      security: [{ oauth2: ["agents:write"] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content: { type: string }
                turnId: { type: string, format: uuid, description: "Idempotencia." }
                primerTurno: { type: boolean, description: "El hilo estaba vacío; se bautiza al cerrar." }
                permisos:
                  type: string
                  enum: [auto, preguntar]
                  default: auto
                  description: "`preguntar` sólo si tu cliente pinta y contesta el evento `permission`; si no, el turno espera 10 min y se deniega."
                images:
                  type: array
                  maxItems: 8
                  description: Adjuntos (cualquier tipo, no sólo imágenes) en base64.
                  items:
                    type: object
                    required: [mimeType, data]
                    properties:
                      name: { type: string }
                      mimeType: { type: string }
                      data: { type: string, contentEncoding: base64 }
                      uri: { type: string }
      responses:
        "202":
          description: Aceptado
          content:
            application/json:
              schema:
                type: object
                properties:
                  turnId: { type: string }
                  key: { type: string }
                  estado: { type: string, enum: [queued, running] }
                  enCola: { type: integer, description: "Turnos del agente esperando máquina." }
                  reemplazoAnterior: { type: boolean }
                  repetido: { type: boolean }
        "400": { description: "Falta `content`." }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/NoAcp" }
        "503": { $ref: "#/components/responses/Draining" }

  /api/v2/me/agents/{agentId}/conversations/{sessionId}/events:
    parameters:
      - { $ref: "#/components/parameters/agentId" }
      - { $ref: "#/components/parameters/sessionId" }
    get:
      operationId: getMeAgentsByagentIdConversationsBysessionIdEvents
      tags: [Turnos]
      summary: Stream de eventos (SSE)
      description: |
        `text/event-stream` con eventos con nombre. Re-suscribible: al conectar recibes lo que el
        turno ya emitió y después el directo. En reposo llega `done {reposo:true}`. Latido cada 25 s.

        Eventos: `started`, `caps {image}`, `status {phase}`, `chunk {text, turnId}`, `thought {text}`,
        `tool {id, title, kind, status, path, detalle}`, `title {title}`, `models {options, current}`,
        `usage {used, size, cost, input, output}`, `permission {id, title, tool, options[]}`,
        `permission-resolved {id}`, `artifact {...}`, `done {turnId} | {reposo:true}`, `error {message}`.
      security: [{ oauth2: ["agents:read"] }]
      responses:
        "200":
          description: Stream
          content:
            text/event-stream:
              schema: { type: string }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/me/agents/{agentId}/conversations/{sessionId}/cancel:
    parameters:
      - { $ref: "#/components/parameters/agentId" }
      - { $ref: "#/components/parameters/sessionId" }
    post:
      operationId: postMeAgentsByagentIdConversationsBysessionIdCancel
      tags: [Turnos]
      summary: Detener el turno en curso
      description: "Sólo quien lo pidió."
      security: [{ oauth2: ["agents:write"] }]
      responses:
        "200":
          description: Resultado
          content: { application/json: { schema: { type: object, properties: { detenido: { type: boolean } } } } }

  /api/v2/me/agents/{agentId}/conversations/{sessionId}/permission:
    parameters:
      - { $ref: "#/components/parameters/agentId" }
      - { $ref: "#/components/parameters/sessionId" }
    post:
      operationId: postMeAgentsByagentIdConversationsBysessionIdPermission
      tags: [Turnos]
      summary: Contestar un permiso pendiente
      security: [{ oauth2: ["agents:write"] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, optionId]
              properties:
                id: { type: string, description: "El `id` del evento `permission`." }
                optionId: { type: string, description: "Uno de `options[].optionId`." }
      responses:
        "200":
          description: Resuelto
          content: { application/json: { schema: { type: object, properties: { resuelto: { type: boolean } } } } }
        "404":
          description: "Ya no está abierto (contestado o vencido)."
          content: { application/json: { schema: { type: object, properties: { resuelto: { type: boolean, const: false }, motivo: { type: string, const: no_abierto } } } } }

  /api/v2/me/agents/{agentId}/conversations/{sessionId}/model:
    parameters:
      - { $ref: "#/components/parameters/agentId" }
      - { $ref: "#/components/parameters/sessionId" }
    post:
      operationId: postMeAgentsByagentIdConversationsBysessionIdModel
      tags: [Turnos]
      summary: Cambiar de modelo
      description: "Aplica desde el siguiente turno. Los valores vienen del evento `models`."
      security: [{ oauth2: ["agents:write"] }]
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [model], properties: { model: { type: string } } } } }
      responses:
        "200": { description: "Cambiado" }
        "400": { description: "Falta el modelo." }

  /api/v2/me/agents/{agentId}/conversations/{sessionId}/schedule:
    parameters:
      - { $ref: "#/components/parameters/agentId" }
      - { $ref: "#/components/parameters/sessionId" }
    get:
      operationId: getMeAgentsByagentIdConversationsBysessionIdSchedule
      tags: [Turnos]
      summary: Turnos programados
      security: [{ oauth2: ["agents:read"] }]
      responses:
        "200":
          description: Lista
          content:
            application/json:
              schema:
                type: object
                properties:
                  programados:
                    type: array
                    items: { $ref: "#/components/schemas/TurnoProgramado" }
    post:
      operationId: postMeAgentsByagentIdConversationsBysessionIdSchedule
      tags: [Turnos]
      summary: Programar un turno futuro
      description: "`inMinutes` («en 2 horas») o `dueAt` ISO («mañana a las 9»). `repeatMin` lo vuelve periódico; `until` lo acota."
      security: [{ oauth2: ["agents:write"] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [prompt]
              properties:
                prompt: { type: string }
                inMinutes: { type: number }
                dueAt: { type: string, format: date-time }
                repeatMin: { type: [number, "null"] }
                until: { type: [string, "null"], format: date-time }
      responses:
        "201":
          description: Programado
          content: { application/json: { schema: { type: object, properties: { programado: { $ref: "#/components/schemas/TurnoProgramado" } } } } }
        "400":
          description: "`body_invalido` o un error de validación con `detalle`."
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /api/v2/me/agents/{agentId}/conversations/{sessionId}/schedule/{id}:
    parameters:
      - { $ref: "#/components/parameters/agentId" }
      - { $ref: "#/components/parameters/sessionId" }
      - { name: id, in: path, required: true, schema: { type: string } }
    delete:
      operationId: deleteMeAgentsByagentIdConversationsBysessionIdScheduleByid
      tags: [Turnos]
      summary: Cancelar un turno programado
      security: [{ oauth2: ["agents:write"] }]
      responses:
        "200": { description: "Cancelado" }

  /api/v2/me/agents/{agentId}/conversations/{sessionId}/share:
    parameters:
      - { $ref: "#/components/parameters/agentId" }
      - { $ref: "#/components/parameters/sessionId" }
    get:
      operationId: getMeAgentsByagentIdConversationsBysessionIdShare
      tags: [Conversaciones]
      summary: ¿Está compartida?
      security: [{ oauth2: ["agents:read"] }]
      responses:
        "200":
          description: Estado
          content: { application/json: { schema: { $ref: "#/components/schemas/Compartida" } } }
    post:
      operationId: postMeAgentsByagentIdConversationsBysessionIdShare
      tags: [Conversaciones]
      summary: Compartir con enlace público
      description: "La página pública queda en `/s/{slug}`."
      security: [{ oauth2: ["agents:write"] }]
      responses:
        "200":
          description: Compartida
          content: { application/json: { schema: { $ref: "#/components/schemas/Compartida" } } }
    delete:
      operationId: deleteMeAgentsByagentIdConversationsBysessionIdShare
      tags: [Conversaciones]
      summary: Dejar de compartir
      security: [{ oauth2: ["agents:write"] }]
      responses:
        "200":
          description: Ya no
          content: { application/json: { schema: { $ref: "#/components/schemas/Compartida" } } }

  /api/v2/me/files:
    get:
      operationId: getMeFiles
      tags: [Archivos]
      summary: Biblioteca de la cuenta
      security: [{ oauth2: ["files"] }]
      responses:
        "200":
          description: Lista
          content:
            application/json:
              schema:
                type: object
                properties:
                  archivos:
                    type: array
                    items: { $ref: "#/components/schemas/Archivo" }
    post:
      operationId: postMeFiles
      tags: [Archivos]
      summary: Subir un archivo
      description: "El cuerpo son los **bytes crudos** (no multipart). El nombre va en `?nombre=`."
      security: [{ oauth2: ["files"] }]
      parameters:
        - { name: nombre, in: query, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema: { type: string, format: binary }
      responses:
        "200":
          description: "Subido, con URL firmada de 6 h."
          content: { application/json: { schema: { $ref: "#/components/schemas/Archivo" } } }
        "400": { description: "Falta `nombre`." }

  /api/v2/me/files/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getMeFilesByid
      tags: [Archivos]
      summary: Nueva URL firmada
      security: [{ oauth2: ["files"] }]
      responses:
        "200":
          description: URL de 6 h.
          content: { application/json: { schema: { type: object, properties: { url: { type: string } } } } }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: deleteMeFilesByid
      tags: [Archivos]
      summary: Borrar
      security: [{ oauth2: ["files"] }]
      responses:
        "200":
          description: Borrado
          content: { application/json: { schema: { type: object, properties: { deleted: { type: boolean } } } } }

  /api/v2/me/stt:
    post:
      operationId: postMeStt
      tags: [Archivos]
      summary: Transcribir audio
      description: "Bytes crudos del audio en el cuerpo."
      security: [{ oauth2: ["files"] }]
      requestBody:
        required: true
        content:
          audio/*:
            schema: { type: string, format: binary }
      responses:
        "200":
          description: Transcripción
          content: { application/json: { schema: { type: object, properties: { text: { type: string } } } } }
        "413": { description: "Audio demasiado grande." }

  /api/v2/me/connectors:
    get:
      operationId: getMeConnectors
      tags: [Conectores]
      summary: Catálogo de conectores
      responses:
        "200":
          description: "Cada conector con si está conectado."
          content:
            application/json:
              schema:
                type: object
                properties:
                  connectors:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        nombre: { type: string }
                        conectado: { type: boolean }
                        disponible: { type: boolean }
  /api/v2/me/connectors/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    delete:
      operationId: deleteMeConnectorsByid
      tags: [Conectores]
      summary: Desconectar (revoca en el proveedor)
      responses:
        "200": { description: "Desconectado" }
  /api/v2/me/connectors/{id}/start:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: postMeConnectorsByidStart
      tags: [Conectores]
      summary: Iniciar la autorización
      responses:
        "200":
          description: "URL para abrir en el navegador."
          content: { application/json: { schema: { type: object, properties: { url: { type: string } } } } }

  /api/v2/me/devices:
    post:
      operationId: postMeDevices
      tags: [Dispositivos]
      summary: Registrar un dispositivo para push
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, plataforma]
              properties:
                token: { type: string }
                plataforma: { type: string, enum: [ios, android] }
                entorno: { type: string, enum: [production, sandbox] }
      responses:
        "200": { description: "Registrado" }
        "400": { description: "`token_invalido`" }
    delete:
      operationId: deleteMeDevices
      tags: [Dispositivos]
      summary: Dar de baja el dispositivo
      responses:
        "204": { description: "Baja" }

components:
  securitySchemes:
    agentToken:
      type: http
      scheme: bearer
      description: "Token del agente (`gat_…`, de Agentes → tu agente → Generar token). Sólo alcanza a ese agente. Vale en `/api/v2/agents/{id}/*`."
    oauth2:
      type: oauth2
      description: "Authorization Code con PKCE (S256). Access 1 h; refresh 90 días, rota en cada uso."
      flows:
        authorizationCode:
          authorizationUrl: https://www.ghosty.studio/oauth2/authorize
          tokenUrl: https://www.ghosty.studio/oauth2/token
          refreshUrl: https://www.ghosty.studio/oauth2/token
          scopes:
            profile: Saber quién eres (id y correo)
            agents:read: Ver agentes y conversaciones, suscribirse a eventos
            agents:write: Mandar mensajes, cancelar, permisos, programar, compartir
            files: Guardar y leer archivos, transcribir audio
  parameters:
    id:
      name: id
      in: path
      required: true
      schema: { type: string }
      description: "Id del agente (en la URL de su ficha: /app/agents/<id>)."
    agentId:
      name: agentId
      in: path
      required: true
      schema: { type: string }
    sessionId:
      name: sessionId
      in: path
      required: true
      schema: { type: string }
  responses:
    Unauthorized: { description: "Sin token o caducado. Refresca y reintenta una vez." }
    Forbidden: { description: "Falta un scope." }
    NotFound: { description: "El agente no es tuyo o el recurso no existe (un agente ajeno responde 404, no 403)." }
    NoAcp:
      description: "El agente no tiene máquina propia (motor sin ACP): conversaciones, archivos, skills y MCP no aplican; identidad y modelo sí."
      content: { application/json: { schema: { type: object, properties: { error: { type: string, const: agente_no_acp }, motor: { type: string } } } } }
    PaymentRequired:
      description: Bolsa agotada, trial vencido o modelo que exige llave propia.
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string, enum: [quota_exhausted, trial_expired, own_key_required] }
              message: { type: string }
    Draining:
      description: "Deploy en curso. Reintenta con el mismo `turnId` tras `Retry-After`."
      headers:
        Retry-After: { schema: { type: integer }, description: "Segundos." }
      content: { application/json: { schema: { type: object, properties: { error: { type: string, const: reiniciando } } } } }
  schemas:
    AgentConfig:
      type: object
      properties:
        storage: { type: string, enum: [box, bundle], description: "Dónde viven skills y archivos: la máquina propia (ACP) o el bundle en Studio (pool), sembrado en el cwd de cada turno." }
        hasMachine: { type: boolean, description: "El motor tiene máquina propia (archivos, skills, MCP y restart disponibles)." }
        id: { type: string }
        name: { type: string }
        engine: { type: string, enum: [ghosty-lite, goose, claude, deepseek, codex] }
        model: { type: string }
        models: { type: array, items: { type: object, properties: { id: { type: string }, label: { type: string } } } }
        prompt: { type: string }
        promptMode: { type: string, enum: [append, replace] }
        channels: { type: object, properties: { teams: { type: boolean } } }
        webSearch: { type: boolean }
        mcp: { type: array, items: { $ref: "#/components/schemas/McpServer" } }
        starters: { type: array, items: { type: string }, description: "Sugerencias de arranque que enseñan el notch y el chat cuando el agente está quieto." }
        messengerPage: { type: boolean, description: "Este agente es el que contesta la página de Facebook conectada (Messenger)." }
        tools:
          type: object
          description: "`gs`: todas las tools que presta gs con su estado. `extensions`: sólo los vetos guardados (la lista real va en `extensions` con `?full=1`)."
          properties:
            gs: { type: object, additionalProperties: { type: boolean } }
            extensions: { type: object, additionalProperties: { type: boolean } }
            canales: { type: object, description: "Lo EFECTIVO por canal: `{ chat: { actualizar_identidad: true }, messenger: { actualizar_identidad: false }, … }`.", additionalProperties: { type: object, additionalProperties: { type: boolean } } }
        extensions: { type: array, description: "Sólo con `?full=1`: las extensiones del `config.yaml` de la máquina y si están encendidas.", items: { type: object, properties: { name: { type: string }, enabled: { type: boolean } } } }
        files: { type: array, description: "Sólo con `?full=1`.", items: { type: object, properties: { path: { type: string }, size: { type: integer } } } }
        skills: { type: array, description: "Sólo con `?full=1`.", items: { $ref: "#/components/schemas/Skill" } }
        aplicado: { type: array, items: { type: string } }
    Bundle:
      type: object
      properties:
        layout: { type: string, enum: [eve] }
        files: { type: array, items: { type: object, required: [path, contentBase64], properties: { path: { type: string }, contentBase64: { type: string } } } }
    WhatsappNumbers:
      type: object
      properties:
        numbers: { type: array, items: { type: object, properties: { id: { type: string }, label: { type: string }, mine: { type: boolean }, takenByOther: { type: boolean } } } }
        hasWorkspace: { type: boolean, description: "Sin workspace no hay números que vincular." }
        hasSales: { type: boolean }
        salesUrl: { type: [string, "null"], description: "Dónde parear un número nuevo." }
        cambiados: { type: integer, description: "Sólo en el PUT." }
    Skill:
      type: object
      properties:
        slug: { type: string }
        description: { type: string }
        files: { type: array, items: { type: string } }
    McpServer:
      oneOf:
        - type: object
          required: [name, url]
          properties:
            name: { type: string, pattern: "^[a-z0-9][a-z0-9_-]{0,40}$" }
            type: { type: string, enum: [http, sse], default: http }
            url: { type: string, description: "Sólo https." }
            headers: { type: object, additionalProperties: { type: string } }
        - type: object
          required: [name, command]
          properties:
            name: { type: string }
            command: { type: string }
            args: { type: array, items: { type: string } }
            env: { type: object, additionalProperties: { type: string } }
    Error:
      type: object
      properties:
        error: { type: string }
        detalle: { type: string }
    OAuthError:
      type: object
      properties:
        error: { type: string }
        error_description: { type: string }
    Agente:
      type: object
      properties:
        id: { type: string }
        nombre: { type: string }
        origen: { type: string, enum: [gs, easybits] }
        protocolo: { type: string, enum: [acp, sse, http] }
        conexion:
          oneOf:
            - type: object
              properties:
                tipo: { type: string, const: acp }
                host: { type: string }
                token: { type: string }
            - type: object
              properties:
                tipo: { type: string, const: easybits }
                agentId: { type: string }
                token: { type: string }
            - type: "null"
        necesitaToken: { type: boolean, description: "Es ACP pero aún no tiene token." }
    Mensaje:
      type: object
      properties:
        role: { type: string, enum: [user, assistant] }
        content: { type: string }
        at: { type: string, format: date-time }
    TurnoResumen:
      type: [object, "null"]
      properties:
        turnId: { type: string }
        estado: { type: string, enum: [queued, running, done, error, stopped] }
        at: { type: string, format: date-time }
    TurnoProgramado:
      type: object
      properties:
        id: { type: string }
        prompt: { type: string }
        dueAt: { type: string, format: date-time }
        repeatMin: { type: [number, "null"] }
        until: { type: [string, "null"], format: date-time }
    Compartida:
      type: object
      properties:
        compartida: { type: boolean }
        slug: { type: string }
    Archivo:
      type: object
      properties:
        id: { type: string }
        nombre: { type: string }
        mimeType: { type: string }
        size: { type: integer }
        url: { type: string, description: "Firmada, 6 h." }
