openapi: 3.1.0
info:
  title: Dukk Server — API REST
  version: "2.2.0"
  summary: |
    API completa do dukk-server (Axum) — 5 tenants de autenticação:
    público, SaaS (Zitadel JWT), admin (staff), webhooks e headless (bearer token).
    Identidade e organizações via Zitadel.
  description: |
    O servidor expõe mais de 200 endpoints REST + WebSocket, organizados em 5
    routers com gates de autenticação distintos. Páginas estáticas (landing, hub
    de docs, Swagger UI, ReDoc, login standalone) são servidas sem autenticação.

    ## Tenants (routers)

    | Router     | Auth                                    | Quem usa                     |
    |------------|-----------------------------------------|------------------------------|
    | `public`   | Nenhuma                                 | Health, landing page, OAuth  |
    | `saas`     | Zitadel JWT **ou** Staff Key (`intKey`) | App + equipe interna         |
    | `admin`    | Zitadel JWT + `is_staff=true`           | Painel admin (`/admin/*`)    |
    | `webhooks` | Assinatura verificada por origem        | Mensageria, GitHub, CRM      |
    | `headless` | Bearer token (`require_bearer`)         | CLI/TUI local, extensão IDE  |

    ## Autenticação

    O provedor de identidade é o **Zitadel**. Os claims do token trazem a
    organização e o papel do usuário — é deles que saem o isolamento entre
    contas e o acesso de equipe interna, não de configuração local.

    - **SaaS / Admin**: header `Authorization: Bearer <zitadel_jwt>`.
      WebSockets aceitam `?token=<jwt>` na query string.
    - **Headless**: header `Authorization: Bearer <server_token>`.
      Token gerado em `~/.dukk/server-token` (migrado do legado `~/.elai/server-token`).
    - **Webhooks**: cada origem tem seu próprio esquema, verificado em tempo
      constante e com janela de validade:
      Telegram usa um token secreto no header, Slack assina o corpo com HMAC
      SHA-256 sobre `v0:{timestamp}:{body}`, WhatsApp/Meta usa
      `X-Hub-Signature-256`, e o GitHub App tem a própria assinatura.
      Requisição com assinatura inválida é indistinguível de canal inexistente,
      por escolha — o token na URL não vira oráculo de existência.
    - **Staff Key (`dukk-int-*`)**: API key interna com acesso universal a
      todos os endpoints SaaS como staff (`is_staff = true`). Autenticação extra
      — não substitui o Zitadel JWT, funciona em paralelo.

    ### Staff Key passo a passo

    **Geração via CLI (recomendada)** — qualquer staff gera a própria key automaticamente:

    ```bash
    # Gera uma key apontando pra integração (int):
    cd dukk-core
    cargo run --quiet -p xtask -- staff-key --server https://int.dukk.com.br

    # ╔══════════════════════════════════════════════════╗
    # ║  1. Abra o navegador no link que aparecer       ║
    # ║  2. Faça login com Google/GitHub (Zitadel)         ║
    # ║  3. Pronto! Key salva em ~/.dukk/staff-key ✅   ║
    # ╚══════════════════════════════════════════════════╝

    # Teste na int:
    export DUKK_STAFF_KEY=$(cat ~/.dukk/staff-key)
    curl -H "Authorization: Bearer $DUKK_STAFF_KEY" \
      https://int.dukk.com.br/v1/me/billing
    ```

    **Ou via API (low-level) — direto na int:**

    ```bash
    curl -X POST https://int.dukk.com.br/v1/cli/auth
    # → { "device_code": "...", "user_code": "...",
    #     "verification_url": "https://int.dukk.com.br/cli/verify/..." }
    # Abra a URL no navegador e autorize.
    ```

    **Geração manual offline (admin):**

    ```bash
    cd dukk-core
    cargo xtask gen-int-key --name "william"
    ```

    **Uso — a key funciona nos dois ambientes:**

    ```bash
    curl -H "Authorization: Bearer dukk-int-<sua-key>" \
      https://int.dukk.com.br/v1/me/billing    # integração (uso interno)

    curl -H "Authorization: Bearer dukk-int-<sua-key>" \
      https://api.dukk.com.br/v1/me/api-usage   # produção
    ```

    A chave vale para **todos os ambientes** (produção, integração, local).

    ## Streaming

    - **SSE** (`text/event-stream`): `GET /v1/sessions/{id}/events`
    - **WebSocket**:
      - `GET /v1/sessions/{id}/browser/preview/ws` — frames JPEG do browser
      - `GET /v1/sessions/{id}/permissions/ws` — permissões em tempo real

    ## Erros

    JSON `{"error": "<mensagem>"}` com HTTP status padrão
    (400, 401, 403, 404, 409, 422, 500, 503).
  contact:
    name: Nexcode
    url: https://nexcode.live
  license:
    name: Proprietary

servers:
  - url: https://api.dukk.com.br
    description: |
      Produção — uso geral (aplicação, SDK, clientes). Dados reais.
      Autenticação: Zitadel JWT (usuários) ou Staff Key (equipe interna).
  - url: https://int.dukk.com.br
    description: |
      Integração (homologação) — uso INTERNO da equipe Dukk.
      Testes, validações, experimentos. Dados não-reais.
      Staff Key é o método de autenticação recomendado aqui.
      **Não use para tráfego de clientes.**
  - url: http://127.0.0.1:8080
    description: Loopback local (desenvolvimento).

security: []

tags:
  # ── Público ──
  - name: Health
    description: "Tenant: public. Sem auth. Saúde do servidor."
  - name: Static Pages
    description: "Tenant: public. Sem auth. Landing, hub de docs, Swagger UI, ReDoc, login standalone."
  - name: Invites (público)
    description: "Tenant: public. Sem auth. Validação de código de convite."
  - name: OpenCode
    description: "Tenant: public. Sem auth. Catálogo de modelos no formato OpenCode."
  # ── SaaS ──
  - name: Me / Perfil
    description: "Tenant: saas. Zitadel JWT. Dados e quotas do usuário."
  - name: Conversations
    description: "Tenant: saas. Zitadel JWT. CRUD de conversas com IA."
  - name: Artifacts
    description: "Tenant: saas. Zitadel JWT. Arquivos gerados pelo agente."
  - name: PDF Generation
    description: "Tenant: saas. Zitadel JWT. Geração de PDF sem modelo."
  - name: Uploads
    description: "Tenant: saas. Zitadel JWT. Extração de conteúdo de uploads."
  - name: Sessions
    description: "Tenant: saas. Zitadel JWT. Execução de chat + SSE + WebSocket."
  - name: Browser Preview
    description: "Tenant: saas. Zitadel JWT. Preview do navegador Dukk Browser."
  - name: Sandboxes
    description: "Tenant: saas. Zitadel JWT. Containers Docker por usuário."
  - name: Filesystem
    description: "Tenant: saas. Zitadel JWT. File picker local (host FS)."
  - name: Learning
    description: "Tenant: saas. Zitadel JWT. Memórias + skills + reviews."
  - name: Routines
    description: "Tenant: saas. Zitadel JWT. Rotinas automatizadas (cron + webhooks)."
  - name: Kanban
    description: "Tenant: saas. Zitadel JWT. Boards, cards, comentários, stage area de changes e SSE."
  - name: Connectors
    description: "Tenant: saas (+ callback público). Integrações OAuth."
  - name: Blocks
    description: "Tenant: saas. Zitadel JWT. Blocos compartilhados (shared blocks), user-scoped."
  - name: Skill Store
    description: "Tenant: saas. Zitadel JWT. Catálogo unificado de skills."
  - name: Welcome Shortcuts
    description: "Tenant: saas. Zitadel JWT. Atalhos da tela de boas-vindas."
  - name: SSH Targets
    description: "Tenant: saas. Zitadel JWT. Hosts remotos (SSH) por usuário — Dukkify."
  - name: CLI Auth
    description: "Tenant: saas + public. Geração de staff key via fluxo OAuth device-code."
  # ── Webhooks ──
  - name: Webhooks
    description: "Tenant: webhooks. Assinatura verificada por origem. Inbound de mensageria, GitHub e CRM."
  # ── Admin ──
  - name: Admin Ping
    description: "Tenant: admin. Zitadel JWT + is_staff. Smoke test."
  - name: Admin Invites
    description: "Tenant: admin. Zitadel JWT + is_staff. Gestão de convites."
  - name: Admin Users
    description: "Tenant: admin. Zitadel JWT + is_staff. Listagem/edição de usuários."
  - name: Admin Logs
    description: "Tenant: admin. Zitadel JWT + is_staff. Logs do sistema."
  - name: Admin Deep Research
    description: "Tenant: admin. Zitadel JWT + is_staff. Configuração de modelo para deep research."
  - name: Admin VPS Env
    description: "Tenant: admin. Zitadel JWT + is_staff. Variáveis de ambiente do serviço VPS."
  - name: Admin App
    description: "Tenant: admin. Zitadel JWT + is_staff. Download do app desktop Dukk (arquivos locais da VPS)."
  - name: Admin Skill Store
    description: "Tenant: admin. Zitadel JWT + is_staff. Fila de aprovação + resync."
  - name: Admin Welcome Shortcuts
    description: "Tenant: admin. Zitadel JWT + is_staff. CRUD de atalhos."
  # ── Headless ──
  - name: Version
    description: "Tenant: headless. Bearer token. Versão do servidor."
  - name: Workspace (Headless)
    description: "Tenant: headless. Bearer token. Operações de arquivo por sessão."
  - name: Git (Headless)
    description: "Tenant: headless. Bearer token. Comandos Git no workspace."
  - name: Session Ops (Headless)
    description: "Tenant: headless. Bearer token. Clone, compact, export, resume."
  - name: Tasks (Headless)
    description: "Tenant: headless. Bearer token. Tarefas em background."
  - name: Models & Providers
    description: "Tenant: headless. Bearer token. Catálogo de modelos e providers."
  - name: Commands (Headless)
    description: "Tenant: headless. Bearer token. Slash-commands + sessão."
  - name: Tools (Headless)
    description: "Tenant: headless. Bearer token. Tools + allow/deny + rate-limit."
  - name: Telemetry (Headless)
    description: "Tenant: headless. Bearer token. Eventos e uso."
  - name: Cache (Headless)
    description: "Tenant: headless. Bearer token. Estatísticas e limpeza."
  - name: MCP (Headless)
    description: "Tenant: headless. Bearer token. Servidores MCP."
  - name: Plugins (Headless)
    description: "Tenant: headless. Bearer token. Plugins + skills + agents + hooks."
  - name: User Commands (Headless)
    description: "Tenant: headless. Bearer token. Slash-commands customizadas."
  - name: Auth (Headless)
    description: "Tenant: headless. Bearer token. API keys + OAuth + import."
  - name: Config (Headless)
    description: "Tenant: headless. Bearer token. Config + budget + tema."

components:
  securitySchemes:
    zitadelJwt:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        JWT de sessão do Zitadel — provedor de identidade do Dukk.

        Os claims do token carregam a organização e o papel do usuário, que
        determinam o isolamento entre contas e o acesso de equipe interna.
    intKey:
      type: http
      scheme: bearer
      bearerFormat: opaque
      description: |
        Staff key interna (`dukk-int-<hex>`). Dá acesso universal a TODOS os
        endpoints SaaS como staff (is_staff = true). Geração offline:

          1. cd dukk-core
          2. cargo xtask gen-int-key --name "william"
          3. A chave aparece uma vez no terminal — guarde-a.

        Ou manualmente: python3 -c "import secrets,hashlib; k='dukk-int-'+secrets.token_hex(32); print('Key:',k); print('Hash:',hashlib.sha256(k.encode()).hexdigest())"

        O hash é registrado em crates/server/assets/staff_keys.json e vale
        para todos os ambientes (produção, integração, local).
    serverToken:
      type: http
      scheme: bearer
      bearerFormat: opaque
      description: Server token em ~/.dukk/server-token — CLI/TUI

  parameters:
    SessionId:
      in: path
      name: id
      required: true
      schema: { type: string }
      description: UUID da sessão
    WorkspaceSessionId:
      in: path
      name: session_id
      required: true
      schema: { type: string }
      description: UUID da sessão dona do workspace
    TurnId:
      in: path
      name: turn_id
      required: true
      schema: { type: string }
      description: UUID do turno
    ConversationId:
      in: path
      name: id
      required: true
      schema: { type: string }
      description: UUID da conversa
    ArtifactId:
      in: path
      name: id
      required: true
      schema: { type: string }
      description: UUID do artifact
    SandboxId:
      in: path
      name: id
      required: true
      schema: { type: string }
      description: UUID do sandbox
    RoutineId:
      in: path
      name: id
      required: true
      schema: { type: string }
      description: UUID da rotina
    AppId:
      in: path
      name: app_id
      required: true
      schema: { type: string }
      description: "ID do conector (ex: github, slack)"
    ConnectorAction:
      in: path
      name: action
      required: true
      schema: { type: string }
      description: Nome da ação do conector
    SkillId:
      in: path
      name: id
      required: true
      schema: { type: string }
      description: UUID da skill
    SkillName:
      in: path
      name: name
      required: true
      schema: { type: string }
      description: Nome da skill
    MemoryIndex:
      in: path
      name: index
      required: true
      schema: { type: integer }
      description: Índice da memória (0-based)
    ProviderId:
      in: path
      name: id
      required: true
      schema: { type: string }
      description: ID do provider LLM
    McpServerName:
      in: path
      name: name
      required: true
      schema: { type: string }
      description: Nome do servidor MCP
    McpToolName:
      in: path
      name: tool
      required: true
      schema: { type: string }
      description: Nome da tool MCP
    PluginName:
      in: path
      name: name
      required: true
      schema: { type: string }
      description: Nome do plugin
    AgentName:
      in: path
      name: name
      required: true
      schema: { type: string }
      description: Nome do agente
    UserCommandName:
      in: path
      name: name
      required: true
      schema: { type: string }
      description: Nome do comando
    AuthProvider:
      in: path
      name: provider
      required: true
      schema: { type: string }
      description: "Provider (ex: anthropic, openai)"
    TaskId:
      in: path
      name: id
      required: true
      schema: { type: string }
      description: UUID da task
    PermissionRequestId:
      in: path
      name: request_id
      required: true
      schema: { type: string }
      description: UUID da request de permissão
    AdminUserId:
      in: path
      name: id
      required: true
      schema: { type: string, format: uuid }
      description: UUID do usuário
    InviteCode:
      in: path
      name: code
      required: true
      schema: { type: string }
      description: Código do convite
    WelcomeShortcutId:
      in: path
      name: id
      required: true
      schema: { type: string, format: uuid }
      description: UUID do atalho
    KanbanBoardId:
      in: path
      name: id
      required: true
      schema: { type: string, format: uuid }
      description: UUID do board do Kanban
    KanbanCardId:
      in: path
      name: id
      required: true
      schema: { type: string, format: uuid }
      description: UUID do card do Kanban

  responses:
    Ok:
      description: OK
    Created:
      description: Criado
    NoContent:
      description: Sem conteúdo (204)
    BadRequest:
      description: Parâmetros inválidos
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string }
    Unauthorized:
      description: Não autenticado
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string }
    Forbidden:
      description: Sem permissão (não-staff)
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string }
    NotFound:
      description: Recurso não encontrado
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string }
    Conflict:
      description: Conflito (recurso já existe)
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string }
    InternalError:
      description: Erro interno
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string }
    ServiceUnavailable:
      description: Feature desabilitada
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string }

  schemas:
    # ─── Conversations ────────────────────────────────────────────────────
    Conversation:
      type: object
      description: |
        Struct minimalista de conversation (sem agregações).
        Retornado por `POST /v1/conversations` e `GET /v1/conversations/{id}`.
      required: [id, user_id, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        title: { type: string, nullable: true }
        model: { type: string, nullable: true }
        environment_type: { type: string, nullable: true }
        cwd: { type: string, nullable: true }
        sandbox_id: { type: string, format: uuid, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ConversationMetadata:
      type: object
      description: |
        Shape rico devolvido por `GET /v1/conversations`. Estende `Conversation`
        com tudo que o desktop Dukk precisa para popular `ServerAIConversationMetadata`
        num único round-trip.
      required:
        [id, user_id, created_at, updated_at, creator, usage, message_count, artifact_ids, harness, permissions]
      properties:
        id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        title: { type: string, nullable: true }
        model: { type: string, nullable: true }
        environment_type: { type: string, nullable: true }
        cwd: { type: string, nullable: true }
        sandbox_id: { type: string, format: uuid, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        creator:
          type: object
          required: [subject, email]
          properties:
            subject: { type: string }
            email: { type: string }
            name: { type: string, nullable: true }
            avatar_url: { type: string, nullable: true }
        sandbox:
          type: object
          nullable: true
          required: [id, name, image, running, is_default]
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
            image: { type: string }
            running: { type: boolean }
            is_default: { type: boolean }
        usage:
          type: object
          description: Soma dos campos de usage das messages JSONB.
          required: [input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens]
          properties:
            input_tokens: { type: integer }
            output_tokens: { type: integer }
            cache_creation_input_tokens: { type: integer }
            cache_read_input_tokens: { type: integer }
        message_count: { type: integer }
        artifact_ids:
          type: array
          items: { type: string, format: uuid }
        harness:
          type: string
          enum: [dukk-cloud, dukk-local]
          description: Engine que rodou a conversation (cloud com sandbox vs local host FS).
        permissions:
          type: object
          description: Stub enquanto sharing real não existe.
          properties:
            visibility: { type: string }

    # ─── SSH Targets ──────────────────────────────────────────────────────
    SshTarget:
      type: object
      description: |
        Host remoto (SSH) configurado por usuário. Só metadados do destino — a
        chave privada não fica aqui (a microVM reusa a chave já autorizada da VPS).
        Cada escrita regenera o `ssh_config` no diretório de secrets do usuário.
      required: [id, user_id, name, host, ssh_user, port, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        name: { type: string, description: "Slug do host (`[a-zA-Z0-9._-]`, 1-64 chars). Chave única com user_id." }
        host: { type: string, description: "HostName (IP ou DNS)." }
        ssh_user: { type: string, description: "Usuário SSH (`User` no ssh_config)." }
        port: { type: integer, description: "Porta SSH. Default 22 quando omitido na criação." }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CreateSshTargetRequest:
      type: object
      description: Payload de criação/atualização (upsert por `(user_id, name)`).
      required: [name, host, ssh_user]
      properties:
        name: { type: string, description: "1-64 chars de `[a-zA-Z0-9._-]`." }
        host: { type: string }
        ssh_user: { type: string }
        port: { type: integer, nullable: true, description: "Opcional; default 22." }

    # ─── Admin App (download do desktop) ──────────────────────────────────
    AppRelease:
      type: object
      description: |
        Metadados da versão mais recente do app desktop encontrada na VPS
        (varredura de `<DUKK_DESKTOP_RELEASES_DIR>/<DUKK_DESKTOP_VARIANT>/`).
      required: [version, variant, platforms]
      properties:
        version:
          type: string
          nullable: true
          description: "Maior versão encontrada (ex.: `1.0.1`), ou `null` se não houver artefato."
        variant: { type: string, description: "Variante do build (ex.: `embedded`)." }
        platforms:
          type: array
          items:
            type: object
            required: [platform, filename, size]
            properties:
              platform: { type: string, enum: [macos, linux], description: "`macos` (.dmg) ou `linux` (.AppImage)." }
              filename: { type: string }
              size: { type: integer, format: int64, description: "Tamanho do arquivo em bytes." }

    # ─── Kanban ───────────────────────────────────────────────────────────
    KanbanBoard:
      type: object
      required: [id, user_id, name, created_at]
      properties:
        id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        name: { type: string }
        created_at: { type: string, format: date-time }

    KanbanCard:
      type: object
      required: [id, board_id, title, body, status, priority, payload, consecutive_failures, max_retries, max_runtime_seconds, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        board_id: { type: string, format: uuid }
        parent_card_id: { type: string, format: uuid, nullable: true }
        title: { type: string }
        body: { type: string }
        status:
          type: string
          enum: [triage, todo, doing, blocked, done, archived]
        priority: { type: integer }
        subagent_type: { type: string, nullable: true }
        model: { type: string, nullable: true }
        assignee: { type: string, nullable: true }
        payload: { type: object }
        consecutive_failures: { type: integer }
        max_retries: { type: integer }
        max_runtime_seconds: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        started_at: { type: string, format: date-time, nullable: true }
        completed_at: { type: string, format: date-time, nullable: true }

    KanbanComment:
      type: object
      required: [id, card_id, author, body, created_at]
      properties:
        id: { type: string, format: uuid }
        card_id: { type: string, format: uuid }
        author: { type: string }
        body: { type: string }
        created_at: { type: string, format: date-time }

    # ─── Me / Billing ──────────────────────────────────────────────────
    UsageBar:
      type: object
      description: Barra de uso de quota do usuário (texto + usado + limite).
      required: [label, used, limit]
      properties:
        label: { type: string }
        used: { type: integer }
        limit: { type: integer }

    UsageResponse:
      type: object
      description: Resposta de GET /v1/me/usage.
      properties:
        bars:
          type: array
          items: { $ref: "#/components/schemas/UsageBar" }
        plan: { type: string }

    ApiUsageWindow:
      type: object
      description: Janela de consumo financeiro da Platform API.
      required: [label, cost_usd, events]
      properties:
        label: { type: string }
        cost_usd: { type: number, format: double }
        events: { type: integer }

    ApiUsageEvent:
      type: object
      description: Evento billável individual da Platform API.
      properties:
        id: { type: string, format: uuid }
        api_key_id: { type: string, format: uuid }
        endpoint: { type: string }
        conversation_id: { type: string, format: uuid, nullable: true }
        input_tokens: { type: integer }
        output_tokens: { type: integer }
        cache_creation_tokens: { type: integer }
        cache_read_tokens: { type: integer }
        cost_usd: { type: number, format: double }
        created_at: { type: string, format: date-time }

    ApiUsageResponse:
      type: object
      description: Consumo financeiro da Dukk Platform API (painel de faturamento).
      required: [currency, windows, recent]
      properties:
        currency: { type: string }
        windows:
          type: array
          items: { $ref: "#/components/schemas/ApiUsageWindow" }
        recent:
          type: array
          items: { $ref: "#/components/schemas/ApiUsageEvent" }

    CostBreakdown:
      type: object
      description: Custo de venda de um turno, quebrado por tier. No response em `dukk.cost`.
      required: [currency, total_usd, input_usd, output_usd, cache_read_usd, cache_creation_usd]
      properties:
        currency:
          type: string
          description: Moeda (sempre USD no MVP).
        total_usd: { type: number, format: double }
        input_usd: { type: number, format: double }
        output_usd: { type: number, format: double }
        cache_read_usd: { type: number, format: double }
        cache_creation_usd: { type: number, format: double }

    BillingSnapshot:
      type: object
      description: Snapshot do plano/assinatura do usuário (GET /v1/me/billing).
      properties:
        plan: { type: string }
        effective_plan: { type: string }
        subscription_status: { type: string, nullable: true }
        subscription_period_end: { type: string, format: date-time, nullable: true }
        pending_plan: { type: string, nullable: true }
        is_staff: { type: boolean }
        provider: { type: string }
        stripe_publishable_key: { type: string, nullable: true }

    BillingRedirectResponse:
      type: object
      description: Resposta de checkout/portal — URL de redirect ou handled_by_frontend.
      properties:
        redirect_url: { type: string, nullable: true }
        handled_by_frontend: { type: boolean }

paths:
  # ═══════════════════════════════════════════════════════════════════════════
  # TENANT: PUBLIC (sem auth)
  # ═══════════════════════════════════════════════════════════════════════════

  /v1/health:
    get:
      tags: [Health]
      summary: Healthcheck público
      operationId: health
      security: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }

  /v1/opencode/models:
    get:
      tags: [OpenCode]
      summary: Catálogo de modelos no formato OpenCode
      description: |
        Catálogo público de modelos LLM disponíveis no Dukk, formatado
        no shape esperado por integrações OpenCode (sem auth).
      operationId: listOpencodeModels
      security: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object

  /v1/invites/validate:
    post:
      tags: [Invites (público)]
      summary: Validar código de convite (landing page)
      operationId: validateInvite
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string }
      responses:
        "200":
          description: Válido
          content:
            application/json:
              schema:
                type: object
                properties:
                  valid: { type: boolean }
                  description: { type: string }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/routines/webhooks/github:
    post:
      tags: [Webhooks]
      summary: Webhook do GitHub para triggers de rotinas
      operationId: githubRoutineWebhook
      security: []
      responses:
        "200": { $ref: "#/components/responses/Ok" }

  /v1/connectors/{app_id}/callback:
    parameters: [{ $ref: "#/components/parameters/AppId" }]
    get:
      tags: [Connectors]
      summary: Callback OAuth de conector (provider externo redireciona sem JWT)
      description: |
        O `state` assinado por HMAC autentica o fluxo.
        Redireciona para o frontend após OAuth.
      operationId: connectorCallback
      security: []
      responses:
        "302":
          description: Redirecionamento para o frontend

  # ── CLI Auth (Staff Key Generation) ──
  /cli/verify/{user_code}:
    get:
      tags: [CLI Auth]
      summary: Página de autorização de staff key (HTML + login Zitadel)
      description: |
        Página HTML servida sem autenticação. O usuário faz login com
        Google/GitHub via Zitadel e autoriza a geração da staff key.
        Após autorizar, a key aparece na tela.
      operationId: cliAuthVerifyPage
      security: []
      parameters:
        - in: path
          name: user_code
          required: true
          schema: { type: string }
          description: Código de 8 caracteres exibido no terminal.
      responses:
        "200":
          description: Página HTML de autorização
          content:
            text/html:
              schema: { type: string }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/cli/auth:
    post:
      tags: [CLI Auth]
      summary: Iniciar fluxo de geração de staff key (device code)
      description: |
        Cria uma requisição de CLI auth. O terminal recebe um
        `device_code` (para polling), `user_code` (curto, pra página)
        e `verification_url` (link que o usuário abre no navegador).
      operationId: cliAuthStart
      security: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [device_code, user_code, verification_url]
                properties:
                  device_code:
                    type: string
                    description: Código longo para o CLI fazer polling.
                  user_code:
                    type: string
                    description: Código curto exibido na página de verificação.
                  verification_url:
                    type: string
                    format: uri
                    description: URL que o usuário abre no navegador.
        "500": { $ref: "#/components/responses/InternalError" }

  /v1/cli/auth/poll:
    post:
      tags: [CLI Auth]
      summary: Polling do CLI — verifica se o usuário autorizou
      description: |
        O CLI chama este endpoint a cada 2 segundos. Quando o usuário
        autoriza no navegador, o status muda para `authorized` e a
        `key` em claro é retornada (mostrada uma única vez).
      operationId: cliAuthPoll
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [device_code]
              properties:
                device_code:
                  type: string
                  description: Código recebido no start_auth.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    enum: [pending, authorized, expired]
                    description: Estado atual da requisição.
                  key:
                    type: string
                    nullable: true
                    description: Key em claro (só quando authorized).
                  name:
                    type: string
                    nullable: true
                    description: Nome do dono (só quando authorized).
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ── Páginas estáticas (Static Pages) ──
  /:
    get:
      tags: [Static Pages]
      summary: Landing page do servidor Dukk
      description: Página inicial em HTML servida sem autenticação.
      operationId: staticIndex
      security: []
      responses:
        "200":
          description: HTML da landing
          content:
            text/html:
              schema: { type: string }

  /docs:
    get:
      tags: [Static Pages]
      summary: Hub de documentação da API
      description: |
        Página com cards para Swagger UI, ReDoc, OpenAPI YAML e categorias da API.
        Usa iframes apontando para `/swagger` e `/redoc`.
      operationId: staticDocsHub
      security: []
      responses:
        "200":
          description: HTML do hub
          content:
            text/html:
              schema: { type: string }

  /swagger:
    get:
      tags: [Static Pages]
      summary: Swagger UI (try-it interativo)
      operationId: staticSwagger
      security: []
      responses:
        "200":
          description: Página Swagger UI
          content:
            text/html:
              schema: { type: string }

  /redoc:
    get:
      tags: [Static Pages]
      summary: ReDoc (referência três colunas)
      operationId: staticRedoc
      security: []
      responses:
        "200":
          description: Página ReDoc
          content:
            text/html:
              schema: { type: string }

  /openapi.yaml:
    get:
      tags: [Static Pages]
      summary: Spec OpenAPI 3.1 em YAML
      description: Arquivo-fonte servido pelo próprio servidor — consumido por Swagger/ReDoc.
      operationId: staticOpenapiYaml
      security: []
      responses:
        "200":
          description: YAML
          content:
            application/yaml:
              schema: { type: string }
            text/yaml:
              schema: { type: string }

  /login:
    get:
      tags: [Static Pages]
      summary: Página de login standalone (desktop OAuth)
      description: |
        Fluxo OAuth standalone para o app desktop — isolado do front-web Vue
        (que tem race conditions com o global guard + o widget de login).
      operationId: staticLogin
      security: []
      responses:
        "200":
          description: HTML da página de login
          content:
            text/html:
              schema: { type: string }

  /empresas/login:
    get:
      tags: [Static Pages]
      summary: Página de login enterprise standalone (desktop OAuth)
      description: |
        Variante visual enterprise da página de login standalone do desktop.
        Compartilha CSS + JS em `/assets/login/` com `/login`.
      operationId: staticEmpresasLogin
      security: []
      responses:
        "200":
          description: HTML da página de login enterprise
          content:
            text/html:
              schema: { type: string }

  # ═══════════════════════════════════════════════════════════════════════════
  # TENANT: SAAS (Zitadel JWT — require_auth)
  # ═══════════════════════════════════════════════════════════════════════════

  # ── Me / Perfil ──
  /v1/me:
    get:
      tags: [Me / Perfil]
      summary: Dados do usuário logado
      operationId: getMe
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  email: { type: string }
                  is_staff: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
    patch:
      tags: [Me / Perfil]
      summary: Atualizar perfil
      operationId: patchMe
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/me/usage:
    get:
      tags: [Me / Perfil]
      summary: Uso de quotas do usuário (H5 / diário / mensal)
      operationId: getMyUsage
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  h5_used: { type: integer }
                  h5_limit: { type: integer }
                  daily_used: { type: integer }
                  daily_limit: { type: integer }
                  monthly_used: { type: integer }
                  monthly_limit: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/me/api-usage:
    get:
      tags: [Me / Perfil]
      summary: Consumo financeiro da Platform API (USD por janela)
      description: |
        Retorna o custo de venda acumulado do usuário nas janelas 5h/hoje/mês,
        mais os eventos recentes detalhados. O preço é fixo e único
        (independente do modelo): input $0.26/Mtok, output $0.48/Mtok,
        cache-read $0.028/Mtok, cache-creation $0.26/Mtok.
      operationId: getMyApiUsage
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiUsageResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/me/billing:
    get:
      tags: [Me / Perfil]
      summary: Snapshot do plano e assinatura do usuário
      description: |
        Retorna os dados de plano/assinatura para o front renderizar
        badge de plano, banner de upgrade e botões de gerenciar assinatura.
      operationId: getMyBilling
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BillingSnapshot" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/invites/redeem:
    post:
      tags: [Me / Perfil]
      summary: Resgatar código de convite
      operationId: redeemInvite
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string }
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ── Billing ──
  /v1/billing/checkout:
    post:
      tags: [Me / Perfil]
      summary: Iniciar compra/upgrade de plano
      description: |
        Inicia o fluxo de checkout do billing. Com o provedor de billing, retorna
        `handled_by_frontend: true` (o front renderiza o <PricingTable>).
        Com Stripe direto, retorna a `redirect_url` para o checkout hosted.
      operationId: startCheckout
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan]
              properties:
                plan:
                  type: string
                  description: "Slug interno (ex.: dukk_code_plus) ou price_xxx do Stripe."
                success_url:
                  type: string
                  description: URL de redirect pós-checkout bem-sucedido.
                cancel_url:
                  type: string
                  description: URL de redirect se o usuário cancelar.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BillingRedirectResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/billing/portal:
    post:
      tags: [Me / Perfil]
      summary: Abrir gestão da assinatura (portal)
      description: |
        Abre o portal de gestão da assinatura. Com o provedor de billing, retorna
        `handled_by_frontend: true` (o front redireciona internamente).
        Com Stripe direto, retorna a URL do Customer Portal.
      operationId: openBillingPortal
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BillingRedirectResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ── Conversations ──
  /v1/conversations:
    post:
      tags: [Conversations]
      summary: Criar conversa
      description: |
        Cria nova conversa. O modelo ativo é resolvido server-side
        (via `db::config::get_active_model`); o cliente apenas passa o título opcional.
      operationId: createConversation
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string, nullable: true }
      responses:
        "201":
          description: Conversa criada (shape `Conversation` minimalista)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Conversation" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    get:
      tags: [Conversations]
      summary: Listar conversas (shape rico ConversationMetadata)
      description: |
        Retorna um array de `ConversationMetadata` — Conversation crua + creator
        (JOIN users), sandbox (LEFT JOIN sandboxes), usage agregado (SUM messages.usage),
        artifact_ids (array_agg), message_count e harness derivado (`dukk-cloud` / `dukk-local`).

        Aceita `?ids=<csv-uuid>` opcional para filtrar por lista de conversation_ids.
      operationId: listConversations
      security: [{ zitadelJwt: [] }]
      parameters:
        - in: query
          name: ids
          schema: { type: string }
          description: Lista CSV de UUIDs de conversation para filtrar.
      responses:
        "200":
          description: Lista de conversations com metadata rico
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ConversationMetadata" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/conversations/{id}:
    parameters: [{ $ref: "#/components/parameters/ConversationId" }]
    get:
      tags: [Conversations]
      summary: Detalhes da conversa (shape minimalista)
      description: |
        Retorna o struct `Conversation` cru (sem metadata enriquecido).
        Use `GET /v1/conversations` para o shape rico.
      operationId: getConversation
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Conversation" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Conversations]
      summary: Deletar conversa
      operationId: deleteConversation
      security: [{ zitadelJwt: [] }]
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/conversations/{id}/fork:
    parameters: [{ $ref: "#/components/parameters/ConversationId" }]
    post:
      tags: [Conversations]
      summary: Fork de conversa (duplica conversa + mensagens)
      description: |
        Duplica a conversa (com suas mensagens) num novo id do mesmo usuário.
        Usado pelo handoff local→cloud do desktop Dukk.
      operationId: forkConversation
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string, description: "Título da conversa forkada (opcional)." }
      responses:
        "201":
          description: Conversa forkada
          content:
            application/json:
              schema:
                type: object
                required: [forked_conversation_id]
                properties:
                  forked_conversation_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/agent-runs:
    get:
      tags: [Conversations]
      summary: Listar agent runs / cloud agents do usuário
      description: |
        Substitui o legado `GET /api/v1/agent/runs` da Warp cloud. Fonte
        híbrida: conversas cloud (sandbox) + runs de kanban, deduplicadas
        por `conversation_id`, ordenadas por `updated_at DESC`.
      operationId: listAgentRuns
      security: [{ zitadelJwt: [] }]
      parameters:
        - { name: limit, in: query, required: false, schema: { type: integer }, description: "Limite de runs (default 100)." }
        - { name: state, in: query, required: false, schema: { type: string }, description: "CSV de estados wire (QUEUED,INPROGRESS,…). Vazio = todos." }
        - { name: source, in: query, required: false, schema: { type: string }, description: "Filtra por source (ex.: CLOUD_MODE, SCHEDULED_AGENT)." }
        - { name: created_after, in: query, required: false, schema: { type: string, format: date-time }, description: "Mantém runs com created_at após este instante (RFC3339)." }
        - { name: environment_id, in: query, required: false, schema: { type: string }, description: "Filtra por id do environment (sandbox)." }
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/conversations/{id}/messages:
    parameters: [{ $ref: "#/components/parameters/ConversationId" }]
    get:
      tags: [Conversations]
      summary: Mensagens da conversa
      operationId: getMessages
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/conversations/{id}/artifacts:
    parameters: [{ $ref: "#/components/parameters/ConversationId" }]
    get:
      tags: [Artifacts]
      summary: Artifacts vinculados à conversa
      operationId: listConversationArtifacts
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ── Artifacts ──
  /v1/artifacts:
    get:
      tags: [Artifacts]
      summary: Listar todos os artifacts do usuário
      operationId: listArtifacts
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/artifacts/{id}:
    parameters: [{ $ref: "#/components/parameters/ArtifactId" }]
    get:
      tags: [Artifacts]
      summary: Conteúdo do artifact (binário ou texto)
      operationId: getArtifactContent
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: Conteúdo do artifact
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Artifacts]
      summary: Deletar artifact
      operationId: deleteArtifact
      security: [{ zitadelJwt: [] }]
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/artifacts/{id}/skill:
    parameters: [{ $ref: "#/components/parameters/ArtifactId" }]
    get:
      tags: [Artifacts]
      summary: Skill associada ao artifact
      operationId: getArtifactSkill
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/artifacts/{id}/metadata:
    parameters: [{ $ref: "#/components/parameters/ArtifactId" }]
    get:
      tags: [Artifacts]
      summary: Metadata do artifact
      operationId: getArtifactMetadata
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/artifacts/{id}/preview:
    parameters: [{ $ref: "#/components/parameters/ArtifactId" }]
    get:
      tags: [Artifacts]
      summary: Preview do artifact (thumbnail/render)
      operationId: getArtifactPreview
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: Preview (imagem ou HTML)
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── PDF Generation ──
  /v1/tools/pdf_generate:
    post:
      tags: [PDF Generation]
      summary: Gerar PDF via HTTP (acionado pelo botão "Exportar PDF" do front)
      description: |
        Não passa pelo loop do modelo. Persiste como artifact
        com preview_status='skipped' (PDF é nativo).
      operationId: generatePdf
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                markdown: { type: string }
                title: { type: string }
      responses:
        "201":
          description: PDF gerado como artifact
          content:
            application/json:
              schema: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ── Uploads ──
  /v1/uploads/extract:
    post:
      tags: [Uploads]
      summary: Extrair conteúdo de upload (PDF, imagem, DOCX, etc.)
      operationId: extractUpload
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
      responses:
        "200":
          description: Conteúdo extraído
          content:
            application/json:
              schema: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/sessions/{id}/uploads/extract:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    post:
      tags: [Uploads]
      summary: Extrair upload no contexto da sessão (anexa ao histórico)
      description: |
        Igual a `/v1/uploads/extract`, porém associa o resultado à sessão
        para que o agente tenha acesso ao conteúdo no próximo turno.
      operationId: extractUploadForSession
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
      responses:
        "200":
          description: Conteúdo extraído e associado à sessão
          content:
            application/json:
              schema: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Sessions ──
  /v1/sessions:
    post:
      tags: [Sessions]
      summary: Criar sessão de chat
      description: |
        Cria a sessão de agente. O campo `environment` escolhe o modo de execução:
        - `local` — FS do host, enraizado no `cwd` da sessão. As fs/bash tools
          rodam client-side (thin-client) quando o desktop anuncia capacidade via
          header `X-Dukk-Client-Exec` (ver `POST /v1/sessions/{id}/messages`).
        - `sandbox` — microVM via `sandbox_id` (resolve o sandbox default do
          usuário quando `sandbox_id` é omitido).

        Quando `environment` é omitido, infere a partir de `sandbox_id` (compat
        com clientes antigos: com `sandbox_id` → `sandbox`; sem → `local`).

        **Local mode é bloqueado em produção**: com `RUST_ENV=production`,
        `environment=local` retorna `403 local_mode_forbidden` — use `sandbox`.
      operationId: createSession
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                conversation_id: { type: string, format: uuid }
                model: { type: string }
                permission_mode: { type: string }
                cwd:
                  type: string
                  description: "Root do FS exposto ao agente em modo `local`; informativo em `sandbox`."
                sandbox_id:
                  type: string
                  format: uuid
                  description: "Vincula a sessão a uma microVM (modo `sandbox`)."
                environment:
                  type: string
                  enum: [local, sandbox]
                  description: "Modo de execução. Omitido → inferido a partir de `sandbox_id`."
      responses:
        "201":
          description: Sessão criada
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: |
            `local_mode_forbidden` — `environment=local` está desabilitado quando
            `RUST_ENV=production`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
    get:
      tags: [Sessions]
      summary: Listar sessões do usuário
      operationId: listSessions
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/sessions/{id}:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    get:
      tags: [Sessions]
      summary: Obter sessão com mensagens
      operationId: getSession
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Sessions]
      summary: Deletar sessão
      operationId: deleteSession
      security: [{ zitadelJwt: [] }]
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Sessions]
      summary: Atualizar modelo / permission_mode
      operationId: patchSession
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                model: { type: string }
                permission_mode: { type: string }
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sessions/{id}/messages:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    post:
      tags: [Sessions]
      summary: Enviar mensagem e iniciar execução do agente
      description: |
        Resultados chegam via SSE em `/events`.

        **Thin-client (delegação de fs tools)**: o desktop em modo `local` anuncia,
        via header `X-Dukk-Client-Exec`, quais tools de filesystem vai executar
        localmente (CSV, ex.: `bash,read_file,write_file,edit_file,grep,glob`).
        Para essas tools o server delega a execução ao cliente em vez de rodar
        server-side. Sem o header (ou vazio) a execução segue server-side; o
        conjunto só é sobrescrito quando o header vem preenchido (para um drain
        de mensagem enfileirada não zerar a capacidade já anunciada).
      operationId: sendMessage
      security: [{ zitadelJwt: [] }]
      parameters:
        - in: header
          name: X-Dukk-Client-Exec
          required: false
          schema: { type: string }
          description: |
            CSV das fs tools que o cliente executa localmente (ex.:
            `bash,read_file,write_file,edit_file,grep,glob`). Habilita a
            delegação de tools no modo thin-client (`environment=local`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content: { type: string }
      responses:
        "200":
          description: Mensagem aceita (stream via SSE)
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sessions/{id}/turns/{turn_id}/cancel:
    parameters:
      - { $ref: "#/components/parameters/SessionId" }
      - { $ref: "#/components/parameters/TurnId" }
    post:
      tags: [Sessions]
      summary: Cancelar turno em execução
      operationId: cancelTurn
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sessions/{id}/events:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    get:
      tags: [Sessions]
      summary: Stream SSE de eventos da sessão (text/event-stream)
      description: |
        Cada evento SSE tem um `event:` (nome em snake_case) e um `data:`
        com JSON serializado do enum `ServerEvent` (`tag = "type"`,
        `rename_all = "snake_case"`). Todo evento carrega `seq` (monotônico
        por sessão; use `?since=N` para reconectar) e `session_id`.

        Aceita `?since=<seq>` na query para reenviar apenas eventos com
        `seq > since` (reconexão sem perda).

        Eventos emitidos (35 variantes do `ServerEvent`):

        - `snapshot` — estado inicial da sessão (`session: SessionSnapshot`).
        - `turn_started` / `turn_completed` / `turn_error` / `turn_cancelled`
          — ciclo de vida do turno (`turn_id`).
        - `text_delta` (`text`) — chunk de texto do assistente.
        - `thinking_delta` (`thinking`) — chunk de raciocínio (extended thinking).
        - `tool_use_started` (`tool_call_id`, `tool_name`).
        - `tool_use_input_delta` (`tool_call_id`, `partial_json`).
        - `tool_result` (`tool_call_id`, `output`, `is_error`) — terminal.
        - `tool_progress` (`tool_call_id`, `message`) — progresso incremental
          de tools longas (ex.: DeepResearch); pode repetir.
        - `tool_delegated_to_client` (`tool_call_id`, `tool_name`, `input`)
          — pede ao cliente local executar a tool e devolver via
          `POST /v1/sessions/{id}/tools/{tool_call_id}/result`. Bloqueia o turno.
        - `permission_request` (`request_id`, `tool_name`, `input`,
          `required_mode`) — responda em `POST /v1/permissions/{request_id}/decide`.
        - `usage_delta` (`input_tokens`, `output_tokens`).
        - `message_appended` (`role`, `text_summary`).
        - `message_queued` (`queue_position`) — mensagem enfileirada (FIFO).
        - `sandbox_boot_started` (`sandbox_id`, `image`),
          `sandbox_boot_progress` (`elapsed_seconds`, `message`),
          `sandbox_ready` (`sandbox_id`),
          `sandbox_boot_failed` (`sandbox_id`, `error`).
        - `file_created` (`path`, `size`, `mime?`, `tool_call_id?`,
          `message_id?`, `message_file_id?`).
        - `artifact_created` (`artifact_id`, `name`, `mime`, `size`,
          `preview_status`), `artifact_preview_ready` (`artifact_id`),
          `artifact_preview_failed` (`artifact_id`, `error`),
          `artifact_open_requested` (`source: PreviewSource`).
        - `questions_emitted` (`tool_call_id`, `questions[]`) — bloqueia o turno;
          responda em `POST /v1/sessions/{id}/questions/answer`.
        - `questions_answered` (`tool_call_id`, `answers`, `skipped[]`).
        - `plan_suggested` (`tool_call_id`, `summary`, `proposed_tasks[]`) —
          bloqueia o turno; decida em `POST /v1/sessions/{id}/plan/decide`.
        - `plan_decided` (`tool_call_id`, `decision`).
        - `todo_updated` (`tool_call_id`, `todos[]`) — fire-and-forget.
        - `background_review_started` (`task_id`, `label`, `review_memory`,
          `review_skills`), `background_review_completed` (`task_id`,
          `status`, `summary?`).
        - `browser_setup_progress` (`stage`, `message`, `percent`),
          `browser_setup_failed` (`stage`, `error`).
      operationId: streamEvents
      security: [{ zitadelJwt: [] }]
      parameters:
        - name: since
          in: query
          required: false
          schema: { type: integer, format: int64 }
          description: Reenvia apenas eventos com `seq` maior que este valor.
      responses:
        "200":
          description: Stream SSE
          content:
            text/event-stream:
              schema:
                type: string
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sessions/{id}/cost:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    get:
      tags: [Sessions]
      summary: Custo total da sessão (USD + tokens)
      operationId: getSessionCost
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/sessions/{id}/context:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    get:
      tags: [Sessions]
      summary: Contexto atual da sessão (system prompt + histórico)
      operationId: getSessionContext
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/sessions/{id}/permissions/pending:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    get:
      tags: [Sessions]
      summary: Permissões pendentes de aprovação
      operationId: listPendingPermissions
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/permissions/{request_id}/decide:
    parameters: [{ $ref: "#/components/parameters/PermissionRequestId" }]
    post:
      tags: [Sessions]
      summary: Aprovar ou rejeitar permissão
      operationId: decidePermission
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [decision]
              properties:
                decision:
                  type: string
                  enum: [allow, deny]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sessions/{id}/questions/answer:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    post:
      tags: [Sessions]
      summary: Responder perguntas do agente
      description: |
        Frontend POSTa as respostas das perguntas emitidas pela tool
        `ask_user_questions`; o questions_bridge libera o `oneshot` e o
        turno do LLM retoma sem reiniciar.
      operationId: answerQuestions
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/sessions/{id}/plan/decide:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    post:
      tags: [Sessions]
      summary: Aprovar ou ajustar plano proposto pelo agente
      description: |
        Decide um plano emitido pelo agente em plan mode (plan_bridge).
        Análogo a `/permissions/{id}/decide` mas para planos estruturados.
      operationId: decidePlan
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [decision]
              properties:
                decision:
                  type: string
                  enum: [approve, reject, edit]
                edits: { type: object }
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sessions/{id}/tools/{tool_call_id}/result:
    parameters:
      - { $ref: "#/components/parameters/SessionId" }
      - name: tool_call_id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: [Sessions]
      summary: Devolver resultado de tool delegada ao cliente
      description: |
        Responde ao evento SSE `tool_delegated_to_client`: o cliente local
        (desktop, modo local) executa a tool de filesystem/cwd no ambiente
        dele e devolve o resultado aqui. O turno permanece bloqueado até este
        POST chegar.
      operationId: reportToolResult
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                output: { type: string }
                is_error: { type: boolean }
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Browser Preview (Dukk Browser) ──
  /v1/sessions/{id}/browser/preview/start:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    post:
      tags: [Browser Preview]
      summary: Ativar streaming de preview do navegador
      description: |
        Habilita captura automática de screenshots após
        ferramentas DOM-mutantes (browser_navigate, browser_click, etc.).
        Idempotente.
      operationId: startBrowserPreview
      security: [{ zitadelJwt: [] }]
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/sessions/{id}/browser/preview/stop:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    post:
      tags: [Browser Preview]
      summary: Desativar streaming de preview
      description: |
        Limpa o subscriber set. WebSocket aberto fecha no próximo recv.
        Idempotente.
      operationId: stopBrowserPreview
      security: [{ zitadelJwt: [] }]
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/sessions/{id}/browser/preview/ws:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    get:
      tags: [Browser Preview]
      summary: WebSocket — stream de frames JPEG do navegador
      description: |
        Aceita `?token=<jwt>` na query (browser WebSocket API não suporta
        headers customizados). Envia mensagens Text (meta JSON com URL)
        e Binary (JPEG, ~30-50 KB, q=50, 800px).
      operationId: browserPreviewWs
      security: [{ zitadelJwt: [] }]
      responses:
        "101":
          description: WebSocket upgrade
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/sessions/{id}/browser/preview/frames:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    get:
      tags: [Browser Preview]
      summary: Polling fallback de frames (alternativa ao WebSocket)
      description: |
        Devolve frames recentes do preview do navegador para clientes
        que não conseguem usar WebSocket. Aceita `?since=<timestamp>`
        opcional para incremental.
      operationId: browserPreviewFramesSince
      security: [{ zitadelJwt: [] }]
      parameters:
        - in: query
          name: since
          schema: { type: integer, format: int64 }
          description: Cursor (ms ou seq) para devolver só frames novos.
      responses:
        "200":
          description: Lista de frames disponíveis
          content:
            application/json:
              schema: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  # ── Sandboxes ──
  /v1/sandboxes:
    post:
      tags: [Sandboxes]
      summary: Criar sandbox (container Docker)
      operationId: createSandbox
      security: [{ zitadelJwt: [] }]
      responses:
        "201":
          description: Sandbox criado
          content:
            application/json:
              schema: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }
    get:
      tags: [Sandboxes]
      summary: Listar sandboxes do usuário
      operationId: listSandboxes
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/sandboxes/default:
    get:
      tags: [Sandboxes]
      summary: Sandbox padrão do usuário (idempotente)
      description: |
        Chamado no primeiro login (lazy_create_user) e no mount do frontend.
        Garante que sempre há um ambiente pronto antes de renderizar a UI.
      operationId: getDefaultSandbox
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/sandboxes/default/reset:
    post:
      tags: [Sandboxes]
      summary: Reset destrutivo do sandbox padrão
      description: |
        Apaga sandbox + volume + todas as conversations vinculadas,
        recria do zero. Front deve confirmar com modal antes de chamar.
      operationId: resetDefaultSandbox
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/sandboxes/{id}:
    parameters: [{ $ref: "#/components/parameters/SandboxId" }]
    get:
      tags: [Sandboxes]
      summary: Detalhes do sandbox
      operationId: getSandbox
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Sandboxes]
      summary: Atualizar sandbox
      operationId: updateSandbox
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Sandboxes]
      summary: Deletar sandbox
      operationId: deleteSandbox
      security: [{ zitadelJwt: [] }]
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sandboxes/{id}/start:
    parameters: [{ $ref: "#/components/parameters/SandboxId" }]
    post:
      tags: [Sandboxes]
      summary: Iniciar sandbox
      operationId: startSandbox
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sandboxes/{id}/stop:
    parameters: [{ $ref: "#/components/parameters/SandboxId" }]
    post:
      tags: [Sandboxes]
      summary: Parar sandbox
      operationId: stopSandbox
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sandboxes/{id}/scratch/clear:
    parameters: [{ $ref: "#/components/parameters/SandboxId" }]
    post:
      tags: [Sandboxes]
      summary: Limpar estado transitório do sandbox (best-effort)
      description: |
        Stub no-op por enquanto (valida ownership e devolve 204).
        Front chama quando o usuário troca de ambiente.
      operationId: clearSandboxScratch
      security: [{ zitadelJwt: [] }]
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sandboxes/{id}/status:
    parameters: [{ $ref: "#/components/parameters/SandboxId" }]
    get:
      tags: [Sandboxes]
      summary: Status do sandbox
      operationId: sandboxStatus
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sandboxes/{id}/files:
    parameters: [{ $ref: "#/components/parameters/SandboxId" }]
    get:
      tags: [Sandboxes]
      summary: Listar arquivos do sandbox
      operationId: listSandboxFiles
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sandboxes/{id}/files/content:
    parameters: [{ $ref: "#/components/parameters/SandboxId" }]
    get:
      tags: [Sandboxes]
      summary: Conteúdo de arquivo do sandbox
      operationId: readSandboxFileContent
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sandboxes/{id}/files/raw:
    parameters: [{ $ref: "#/components/parameters/SandboxId" }]
    get:
      tags: [Sandboxes]
      summary: Arquivo raw do sandbox (binário)
      operationId: readSandboxFileRaw
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: Conteúdo binário
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Filesystem ──
  /v1/fs/list:
    get:
      tags: [Filesystem]
      summary: Listar diretório do host (file picker local)
      operationId: fsList
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/fs/read:
    get:
      tags: [Filesystem]
      summary: Ler arquivo do host
      operationId: fsRead
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/fs/home:
    get:
      tags: [Filesystem]
      summary: Caminho do home do host
      operationId: fsHome
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ── Learning ──
  /v1/learning/memory:
    get:
      tags: [Learning]
      summary: Listar memórias do usuário
      operationId: listMemory
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  general:
                    type: array
                    items: { type: string }
                  user:
                    type: array
                    items: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Learning]
      summary: Adicionar memória
      operationId: addMemory
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind, content]
              properties:
                kind:
                  type: string
                  enum: [general, user]
                content: { type: string }
      responses:
        "201": { $ref: "#/components/responses/Created" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/learning/memory/{index}:
    parameters: [{ $ref: "#/components/parameters/MemoryIndex" }]
    put:
      tags: [Learning]
      summary: Substituir memória
      operationId: replaceMemory
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind, content]
              properties:
                kind:
                  type: string
                  enum: [general, user]
                content: { type: string }
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    delete:
      tags: [Learning]
      summary: Deletar memória
      operationId: deleteMemory
      security: [{ zitadelJwt: [] }]
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/learning/skills:
    get:
      tags: [Learning]
      summary: Listar skills de aprendizado
      operationId: listLearningSkills
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/learning/skills/{name}:
    parameters: [{ $ref: "#/components/parameters/SkillName" }]
    get:
      tags: [Learning]
      summary: Detalhe da skill de aprendizado
      operationId: getLearningSkill
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/learning/skills/{name}/archive:
    parameters: [{ $ref: "#/components/parameters/SkillName" }]
    post:
      tags: [Learning]
      summary: Arquivar skill
      operationId: archiveSkill
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/learning/skills/{name}/pin:
    parameters: [{ $ref: "#/components/parameters/SkillName" }]
    post:
      tags: [Learning]
      summary: Pinar skill
      operationId: pinSkill
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/learning/skills/{name}/mark-stale:
    parameters: [{ $ref: "#/components/parameters/SkillName" }]
    post:
      tags: [Learning]
      summary: Marcar skill como stale
      operationId: markStaleSkill
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/learning/background-reviews:
    get:
      tags: [Learning]
      summary: Background reviews do usuário
      operationId: listBackgroundReviews
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ── Routines ──
  /v1/routines:
    get:
      tags: [Routines]
      summary: Listar rotinas do usuário
      operationId: listRoutines
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Routines]
      summary: Criar rotina automatizada
      operationId: createRoutine
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        "201": { $ref: "#/components/responses/Created" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/routines/{id}:
    parameters: [{ $ref: "#/components/parameters/RoutineId" }]
    get:
      tags: [Routines]
      summary: Detalhes da rotina
      operationId: getRoutine
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Routines]
      summary: Atualizar rotina
      operationId: updateRoutine
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Routines]
      summary: Deletar rotina
      operationId: deleteRoutine
      security: [{ zitadelJwt: [] }]
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/routines/{id}/run-now:
    parameters: [{ $ref: "#/components/parameters/RoutineId" }]
    post:
      tags: [Routines]
      summary: Executar rotina agora
      operationId: runRoutineNow
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/routines/{id}/toggle:
    parameters: [{ $ref: "#/components/parameters/RoutineId" }]
    post:
      tags: [Routines]
      summary: Ligar/desligar rotina
      operationId: toggleRoutine
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/routines/{id}/api-trigger:
    parameters: [{ $ref: "#/components/parameters/RoutineId" }]
    post:
      tags: [Routines]
      summary: Disparar rotina via API
      operationId: apiTriggerRoutine
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Kanban ──
  /v1/kanban/boards:
    get:
      tags: [Kanban]
      summary: Listar boards do usuário
      operationId: listKanbanBoards
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  boards:
                    type: array
                    items: { $ref: "#/components/schemas/KanbanBoard" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Kanban]
      summary: Criar board
      operationId: createKanbanBoard
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
      responses:
        "201":
          description: Board criado
          content:
            application/json:
              schema: { $ref: "#/components/schemas/KanbanBoard" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/kanban/boards/{id}:
    parameters: [{ $ref: "#/components/parameters/KanbanBoardId" }]
    delete:
      tags: [Kanban]
      summary: Deletar board (cascade nos cards)
      operationId: deleteKanbanBoard
      security: [{ zitadelJwt: [] }]
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/kanban/boards/{id}/cards:
    parameters: [{ $ref: "#/components/parameters/KanbanBoardId" }]
    get:
      tags: [Kanban]
      summary: Listar cards do board (com filtro de status opcional)
      operationId: listKanbanCards
      security: [{ zitadelJwt: [] }]
      parameters:
        - in: query
          name: status
          schema:
            type: string
            enum: [triage, todo, doing, blocked, done, archived]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  cards:
                    type: array
                    items: { $ref: "#/components/schemas/KanbanCard" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Kanban]
      summary: Enfileirar card no board
      description: |
        Cria card. `status` default é `triage`; `todo` envia direto pro pool
        de promoção. Suporta `idempotency_key` para evitar duplicação no retry.
      operationId: enqueueKanbanCard
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title: { type: string }
                body: { type: string, default: "" }
                subagent_type: { type: string, nullable: true }
                model: { type: string, nullable: true }
                priority: { type: integer, default: 0 }
                parent_card_id: { type: string, format: uuid, nullable: true }
                payload: { type: object }
                idempotency_key: { type: string, nullable: true }
                max_retries: { type: integer, nullable: true }
                max_runtime_seconds: { type: integer, nullable: true }
                status:
                  type: string
                  enum: [triage, todo]
                  default: triage
      responses:
        "201":
          description: Card criado
          content:
            application/json:
              schema: { $ref: "#/components/schemas/KanbanCard" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/kanban/cards/{id}:
    parameters: [{ $ref: "#/components/parameters/KanbanCardId" }]
    get:
      tags: [Kanban]
      summary: Detalhe do card
      operationId: getKanbanCard
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/KanbanCard" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/kanban/cards/{id}/move:
    parameters: [{ $ref: "#/components/parameters/KanbanCardId" }]
    post:
      tags: [Kanban]
      summary: Mover card para outro status
      operationId: moveKanbanCard
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  enum: [triage, todo, doing, blocked, done, archived]
      responses:
        "200":
          description: Card movido
          content:
            application/json:
              schema: { $ref: "#/components/schemas/KanbanCard" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/kanban/cards/{id}/cancel:
    parameters: [{ $ref: "#/components/parameters/KanbanCardId" }]
    post:
      tags: [Kanban]
      summary: Cancelar execução do card
      operationId: cancelKanbanCard
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/kanban/cards/{id}/comments:
    parameters: [{ $ref: "#/components/parameters/KanbanCardId" }]
    post:
      tags: [Kanban]
      summary: Adicionar comentário ao card
      operationId: createKanbanComment
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string }
      responses:
        "201":
          description: Comentário criado
          content:
            application/json:
              schema: { $ref: "#/components/schemas/KanbanComment" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/kanban/cards/{id}/changes:
    parameters: [{ $ref: "#/components/parameters/KanbanCardId" }]
    get:
      tags: [Kanban]
      summary: Listar mudanças propostas (stage area do sub-agente)
      description: |
        F4 do Kanban — devolve a stage area das mudanças que o sub-agente
        Kanban propôs no card antes do usuário aplicar ou descartar.
      operationId: listKanbanCardChanges
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: Stage area de mudanças
          content:
            application/json:
              schema: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/kanban/cards/{id}/changes/apply:
    parameters: [{ $ref: "#/components/parameters/KanbanCardId" }]
    post:
      tags: [Kanban]
      summary: Aplicar (parcialmente) mudanças propostas
      description: |
        Aceita listas separadas de change IDs a aceitar e rejeitar.
        Espelha a tool LLM `kanban_apply_card_changes`.
      operationId: applyKanbanCardChanges
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                accept:
                  type: array
                  items: { type: string }
                reject:
                  type: array
                  items: { type: string }
      responses:
        "200":
          description: Resultado da aplicação
          content:
            application/json:
              schema: { type: object }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/kanban/cards/{id}/changes/discard:
    parameters: [{ $ref: "#/components/parameters/KanbanCardId" }]
    post:
      tags: [Kanban]
      summary: Descartar todas as mudanças propostas do card
      operationId: discardKanbanCardChanges
      security: [{ zitadelJwt: [] }]
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/kanban/boards/{id}/events:
    parameters: [{ $ref: "#/components/parameters/KanbanBoardId" }]
    get:
      tags: [Kanban]
      summary: SSE de eventos do board (catch-up + live)
      description: |
        Stream `text/event-stream`. Aceita `?since=<event_id>` (BIGSERIAL)
        para replay incremental — reconnect sem perder eventos.
      operationId: streamKanbanBoardEvents
      security: [{ zitadelJwt: [] }]
      parameters:
        - in: query
          name: since
          schema: { type: integer, format: int64, default: 0 }
          description: Devolve eventos com id > since.
      responses:
        "200":
          description: Stream SSE
          content:
            text/event-stream:
              schema: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/kanban/sync:
    post:
      tags: [Kanban]
      summary: Sincroniza cards do Kanban local com a nuvem
      description: |
        Sincronizacao bidirecional entre SQLite local (KANBAN_LOCAL=1)
        e PostgreSQL cloud. Push (cliente->servidor) + pull (servidor->cliente)
        num unico round-trip. LWW baseado em updated_at.
      operationId: syncKanban
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [last_synced_at, boards, cards, cards_deleted]
              properties:
                last_synced_at:
                  type: string
                  format: date-time
                  description: Timestamp do ultimo sync (ISO 8601).
                boards:
                  type: array
                  description: Boards modificados desde last_synced_at.
                  items:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      user_id: { type: string, format: uuid }
                      name: { type: string }
                      created_at: { type: string, format: date-time }
                      updated_at: { type: string, format: date-time }
                cards:
                  type: array
                  description: Cards modificados desde last_synced_at.
                  items:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      board_id: { type: string, format: uuid }
                      user_id: { type: string, format: uuid }
                      parent_card_id: { type: string, format: uuid, nullable: true }
                      title: { type: string }
                      body: { type: string }
                      status: { type: string, enum: [triage, todo, ready, running, blocked, done, archived, failed] }
                      priority: { type: integer }
                      subagent_type: { type: string, nullable: true }
                      model: { type: string, nullable: true }
                      payload: { type: object }
                      claim_owner: { type: string, nullable: true }
                      consecutive_failures: { type: integer }
                      max_retries: { type: integer }
                      max_runtime_seconds: { type: integer }
                      cancel_requested: { type: boolean }
                      idempotency_key: { type: string, nullable: true }
                      tenant: { type: string, nullable: true }
                      current_run_id: { type: string, format: uuid, nullable: true }
                      created_at: { type: string, format: date-time }
                      updated_at: { type: string, format: date-time }
                      started_at: { type: string, format: date-time, nullable: true }
                      completed_at: { type: string, format: date-time, nullable: true }
                      deleted: { type: boolean }
                cards_deleted:
                  type: array
                  description: UUIDs de cards deletados pelo cliente.
                  items: { type: string, format: uuid }
      responses:
        "200":
          description: Sync concluido.
          content:
            application/json:
              schema:
                type: object
                properties:
                  synced_at: { type: string, format: date-time }
                  remote_boards: { type: array, items: { type: object } }
                  remote_cards: { type: array, items: { type: object } }
                  remote_cards_deleted: { type: array, items: { type: string, format: uuid } }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ── Connectors ──
  /v1/messaging/platforms:
    get:
      tags: [Messaging]
      summary: Catálogo de plataformas de mensagem
      description: |
        Declara, por plataforma, os campos de credencial que o cliente deve
        pedir (`key`, `label`, `help`, `secret`, `required`), os transportes
        suportados e se o modo webhook exige URL pública.

        O formulário do cliente é montado a partir desta resposta — uma
        plataforma nova no servidor aparece sem release do app.
      operationId: listMessagingPlatforms
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Conta sem a capability `messaging` (hoje, staff-only)

  /v1/messaging/channels:
    get:
      tags: [Messaging]
      summary: Listar canais conectados
      operationId: listMessagingChannels
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Messaging]
      summary: Conectar uma plataforma
      description: |
        Valida as credenciais **contra a API da plataforma** antes de gravar
        (`getMe` no Telegram, `auth.test` no Slack, Graph no WhatsApp), então um
        erro aqui já é resposta do provedor.

        Em `ingress_mode: webhook`, registra o endpoint na plataforma quando ela
        oferece API para isso (Telegram) e devolve `webhook_url` para colar no
        painel quando não oferece (Slack, WhatsApp). **A URL só aparece nesta
        resposta.**

        Exige `DUKK_PUBLIC_BASE_URL` no servidor; sem ela responde `412`.
      operationId: createMessagingChannel
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [platform, name, credentials]
              properties:
                platform:
                  type: string
                  enum: [telegram, slack, whatsapp]
                name: { type: string, description: "Rótulo do canal" }
                credentials:
                  type: object
                  additionalProperties: { type: string }
                  description: "Campos declarados em /v1/messaging/platforms"
                ingress_mode:
                  type: string
                  enum: [webhook, polling]
                  default: webhook
                  description: "`polling` só no Telegram — é a saída para desenvolvimento atrás de NAT"
            examples:
              telegram:
                summary: Telegram
                value:
                  platform: telegram
                  name: Suporte
                  credentials: { bot_token: "123456:ABC-DEF..." }
              slack:
                summary: Slack
                value:
                  platform: slack
                  name: Time de Engenharia
                  credentials:
                    bot_token: "xoxb-..."
                    signing_secret: "8f742231b10e..."
      responses:
        "201": { $ref: "#/components/responses/Ok" }
        "400":
          description: Credencial inválida ou recusada pela plataforma
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Conta sem a capability `messaging`
        "412":
          description: Servidor sem `DUKK_PUBLIC_BASE_URL` — não há como registrar o webhook

  /v1/messaging/channels/{id}:
    delete:
      tags: [Messaging]
      summary: Desconectar canal
      description: Desregistra o webhook na plataforma antes de apagar o canal.
      operationId: deleteMessagingChannel
      security: [{ zitadelJwt: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "204": { description: Canal removido }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: Canal não encontrado }

  /v1/messaging/channels/{id}/agent:
    put:
      tags: [Messaging]
      summary: Ligar o canal a um agente Agno
      description: |
        A partir do vínculo, a mensagem que chega no canal é respondida pelo
        endpoint do agente Agno, e não por um turno da própria Dukk. Fila,
        deduplicação, anti-loop, aprovação de chat novo e limites do canal
        continuam valendo igual.

        O agente precisa ser do mesmo dono, estar `active` e ter sido publicado
        depois de a Dukk passar a guardar a chave de acesso cifrada — agentes
        anteriores devolvem `409 agent_not_linkable` e precisam ser
        republicados.
      operationId: linkMessagingChannelAgent
      security: [{ zitadelJwt: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [agno_agent_id]
              properties:
                agno_agent_id: { type: string, format: uuid }
      responses:
        "200": { description: Canal com o vínculo aplicado }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: Canal ou agente não encontrado }
        "409": { description: Agente inativo ou sem chave de acesso guardada }
    delete:
      tags: [Messaging]
      summary: Desligar o agente do canal
      description: |
        O canal **não** para de responder: volta a rodar um turno da própria
        Dukk.
      operationId: unlinkMessagingChannelAgent
      security: [{ zitadelJwt: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200": { description: Canal sem vínculo }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: Canal não encontrado }

  /v1/messaging/channels/{id}/chats:
    get:
      tags: [Messaging]
      summary: Listar chats vistos por um canal
      description: |
        Todo chat novo nasce `pending`: qualquer pessoa pode escrever para um
        bot, e o turno sai da cota do dono do canal.
      operationId: listMessagingChats
      security: [{ zitadelJwt: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/messaging/chats/{id}/authorize:
    post:
      tags: [Messaging]
      summary: Aprovar ou bloquear um chat
      description: Sem aprovação o chat não roda turno nenhum.
      operationId: authorizeMessagingChat
      security: [{ zitadelJwt: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [auth_status]
              properties:
                auth_status:
                  type: string
                  enum: [approved, blocked, pending]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: Chat não encontrado }

  /v1/messaging/webhook/{platform}/{token}:
    parameters:
      - { name: platform, in: path, required: true, schema: { type: string, enum: [telegram, slack, whatsapp] } }
      - { name: token, in: path, required: true, schema: { type: string }, description: "Token do canal, gerado na criação" }
    get:
      tags: [Messaging]
      summary: "Handshake de verificação (Meta)"
      description: |
        A Meta valida a URL com `hub.mode=subscribe` e espera o `hub.challenge`
        de volta em texto puro. O `hub.verify_token` é comparado em tempo
        constante.
      operationId: verifyMessagingWebhook
      security: []
      responses:
        "200": { description: Devolve o `hub.challenge` }
        "403": { description: Verify token não confere }
        "404": { description: Canal ou plataforma desconhecidos }
    post:
      tags: [Messaging]
      summary: Receber eventos da plataforma
      description: |
        **Público, sem JWT.** A autenticidade vem da assinatura da própria
        plataforma, verificada em tempo constante contra as credenciais do
        canal: `X-Telegram-Bot-Api-Secret-Token`, HMAC v0 do Slack sobre
        `v0:{timestamp}:{body}` (janela de 5 min) ou `X-Hub-Signature-256` da
        Meta.

        Responde `202` sem rodar o turno: o Slack considera falha qualquer
        resposta acima de três segundos e reentrega, o que viraria turno
        duplicado. O processamento acontece num worker.

        Canal inexistente e assinatura inválida devolvem **o mesmo status**,
        para o token da URL não virar oráculo de existência.

        Corpo limitado a 256 KiB.
      operationId: receiveMessagingWebhook
      security: []
      responses:
        "202": { description: Evento enfileirado (ou já conhecido, e ignorado) }
        "401": { description: Assinatura inválida, canal desconhecido ou desativado }

  /v1/search/messages:
    get:
      tags: [Search]
      summary: Buscar no histórico de conversa
      description: |
        Busca full-text com `tsvector` (configuração `portuguese`, com stemming)
        e `websearch_to_tsquery` — aceita `"frase exata"` e `-excluir`, e nunca
        falha por sintaxe malformada.

        Restrita às conversas do próprio usuário, **inclusive para staff**: a
        resposta traz trechos de conteúdo.

        Mensagens de tool ficam fora do índice.
      operationId: searchMessages
      security: [{ zitadelJwt: [] }]
      parameters:
        - { name: q, in: query, required: true, schema: { type: string }, description: "Termo de busca" }
        - { name: conversation_id, in: query, schema: { type: string, format: uuid } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
        - { name: offset, in: query, schema: { type: integer, default: 0 } }
      responses:
        "200":
          description: |
            `hits[]` com `snippet` (termos marcados com `<mark>`), `rank`,
            `conversation_id` e `created_at`.
        "400": { description: "`q` vazio" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/connectors/catalog:
    get:
      tags: [Connectors]
      summary: Catálogo de conectores disponíveis
      operationId: listConnectorCatalog
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/connectors:
    get:
      tags: [Connectors]
      summary: Listar integrações do usuário
      operationId: listIntegrations
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/connectors/{app_id}/authorize:
    parameters: [{ $ref: "#/components/parameters/AppId" }]
    get:
      tags: [Connectors]
      summary: Iniciar fluxo OAuth do conector
      operationId: authorizeConnector
      security: [{ zitadelJwt: [] }]
      responses:
        "302":
          description: Redireciona para o provider OAuth
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/connectors/{app_id}/device/start:
    parameters: [{ $ref: "#/components/parameters/AppId" }]
    get:
      tags: [Connectors]
      summary: "Iniciar device flow (ex.: GitHub)"
      description: |
        Device flow sem callback HTTP: o cliente exibe o `user_code` e polla
        em `/device/poll` até o usuário autorizar.
      operationId: connectorDeviceStart
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/connectors/{app_id}/device/poll:
    parameters: [{ $ref: "#/components/parameters/AppId" }]
    get:
      tags: [Connectors]
      summary: Polling do device flow
      description: |
        Verifica se o device flow já foi autorizado. O `state` (assinado por
        HMAC) carrega `device_code` + `user_id` + expiração.
      operationId: connectorDevicePoll
      security: [{ zitadelJwt: [] }]
      parameters:
        - { name: state, in: query, required: true, schema: { type: string }, description: "State assinado retornado por /device/start." }
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/connectors/{app_id}/connect:
    parameters: [{ $ref: "#/components/parameters/AppId" }]
    post:
      tags: [Connectors]
      summary: Conectar conector interno (identidade compartilhada)
      description: |
        Conector interno sem OAuth: valida o vínculo identidade→usuário no serviço
        de destino e grava o opt-in.
      operationId: connectInternalConnector
      security: [{ zitadelJwt: [] }]
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/connectors/{app_id}:
    parameters: [{ $ref: "#/components/parameters/AppId" }]
    delete:
      tags: [Connectors]
      summary: Remover integração
      operationId: deleteIntegration
      security: [{ zitadelJwt: [] }]
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/connectors/{app_id}/action/{action}:
    parameters:
      - { $ref: "#/components/parameters/AppId" }
      - { $ref: "#/components/parameters/ConnectorAction" }
    post:
      tags: [Connectors]
      summary: Chamar ação do conector
      operationId: callConnectorAction
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Blocks ──
  /v1/blocks:
    get:
      tags: [Blocks]
      summary: Listar blocos compartilhados do usuário
      operationId: listBlocks
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Blocks]
      summary: Salvar bloco compartilhado e retornar URL pública
      operationId: saveBlock
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [show_prompt, display_setting, time_started_term]
              properties:
                command: { type: string }
                output: { type: string }
                stylized_command: { type: string }
                stylized_output: { type: string }
                stylized_prompt: { type: string }
                stylized_prompt_and_command: { type: string }
                pwd: { type: string }
                show_prompt: { type: boolean }
                display_setting: { type: string }
                title: { type: string }
                time_started_term: { type: string, format: date-time }
      responses:
        "200":
          description: Bloco salvo
          content:
            application/json:
              schema:
                type: object
                required: [id, url]
                properties:
                  id: { type: string }
                  url: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/blocks/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    delete:
      tags: [Blocks]
      summary: Remover bloco do usuário
      operationId: deleteBlock
      security: [{ zitadelJwt: [] }]
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── AI Generate ──
  /v1/ai/generate:
    post:
      tags: [AI Generate]
      summary: Gerar o conteúdo de um campo assistido por IA do desktop
      description: |
        Uma chamada de LLM sem tools, discriminada por `kind`. Substitui a mutation
        GraphQL `generateMetadataForCommand` e a rota `POST /ai/generate_code_review_content`,
        ambas do backend legado `app.dukk.dev`.

        Só `command_metadata` devolve saída estruturada (em `command_metadata`);
        os demais `kind` devolvem texto em `content`.
      operationId: aiGenerate
      security: [{ clerkJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind, input]
              properties:
                kind:
                  type: string
                  enum:
                    [
                      command_metadata,
                      commit_message,
                      pr_title,
                      pr_description,
                      prompt_enhance,
                    ]
                  description: Qual campo assistido está pedindo a geração.
                input:
                  type: string
                  maxLength: 120000
                  description: Texto principal — o comando, o diff ou o rascunho do prompt.
                branch_name:
                  type: string
                  description: Nome da branch. Usado só em `pr_title` / `pr_description`.
                commit_messages:
                  type: array
                  items: { type: string }
                  description: Mensagens de commit da branch. Usado só em `pr_description`.
      responses:
        "200":
          description: Conteúdo gerado
          content:
            application/json:
              schema:
                type: object
                required: [content]
                properties:
                  content:
                    type: string
                    description: Texto gerado. Vazio quando `kind = command_metadata`.
                  command_metadata:
                    type: object
                    description: Presente só quando `kind = command_metadata`.
                    required: [title, command]
                    properties:
                      title: { type: string }
                      description: { type: string }
                      command:
                        type: string
                        description: Comando com os valores variáveis trocados por `{{placeholders}}`.
                      arguments:
                        type: array
                        items:
                          type: object
                          required: [name]
                          properties:
                            name: { type: string }
                            description: { type: string }
                            default_value: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "502":
          description: Falha ao gerar (provider indisponível, cota, ou resposta ininteligível)

  /v1/ai/block-title:
    post:
      tags: [AI Generate]
      summary: Gerar título para um bloco compartilhado
      operationId: aiBlockTitle
      security: [{ clerkJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [command, output]
              properties:
                command: { type: string }
                output: { type: string, maxLength: 120000 }
      responses:
        "200":
          description: Título gerado
          content:
            application/json:
              schema:
                type: object
                required: [title]
                properties:
                  title: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "502": { description: Falha ao gerar }

  /v1/ai/input-suggestions:
    post:
      tags: [AI Generate]
      summary: Prever a próxima ação na linha de input
      description: Listas vazias quando não há contexto suficiente para uma previsão útil.
      operationId: aiInputSuggestions
      security: [{ clerkJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [context_messages, history_context]
              properties:
                context_messages:
                  type: array
                  maxItems: 40
                  items: { type: string }
                history_context: { type: string, maxLength: 120000 }
                system_context: { type: string }
      responses:
        "200":
          description: Sugestões
          content:
            application/json:
              schema:
                type: object
                required: [commands, ai_queries, most_likely_action]
                properties:
                  commands:
                    type: array
                    items: { type: string }
                  ai_queries:
                    type: array
                    items:
                      type: object
                      required: [query]
                      properties:
                        query: { type: string }
                        context_block_ids:
                          type: array
                          items: { type: string }
                  most_likely_action: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "502": { description: Falha ao gerar }

  /v1/ai/relevant-files:
    post:
      tags: [AI Generate]
      summary: Ranquear os arquivos relevantes para um pedido
      description: |
        Caminhos que não vieram na lista de entrada são descartados da resposta —
        o modelo às vezes "corrige" ou inventa um path plausível.
      operationId: aiRelevantFiles
      security: [{ clerkJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query, files]
              properties:
                query: { type: string }
                files:
                  type: array
                  maxItems: 400
                  items:
                    type: object
                    required: [path]
                    properties:
                      path: { type: string }
                      symbols: { type: string }
      responses:
        "200":
          description: Caminhos relevantes, do mais para o menos
          content:
            application/json:
              schema:
                type: object
                required: [relevant_file_paths]
                properties:
                  relevant_file_paths:
                    type: array
                    items: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "502": { description: Falha ao gerar }

  /v1/ai/am-query-suggestions:
    post:
      tags: [AI Generate]
      summary: Sugerir o próximo pedido ao agente
      description: "`suggestion` é omitido quando não há sugestão útil a fazer."
      operationId: aiAmQuerySuggestions
      security: [{ clerkJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [context_messages, exit_code]
              properties:
                context_messages:
                  type: array
                  maxItems: 40
                  items: { type: string }
                system_context: { type: string }
                exit_code: { type: integer }
      responses:
        "200":
          description: Sugestão (ou nenhuma)
          content:
            application/json:
              schema:
                type: object
                required: [id]
                properties:
                  id: { type: string, format: uuid }
                  suggestion:
                    oneOf:
                      - type: object
                        required: [simple]
                        properties:
                          simple:
                            type: object
                            required: [query, should_plan_task]
                            properties:
                              query: { type: string }
                              should_plan_task: { type: boolean }
                      - type: object
                        required: [coding]
                        properties:
                          coding:
                            type: object
                            required: [query, files]
                            properties:
                              query: { type: string }
                              files:
                                type: array
                                items:
                                  type: object
                                  required: [file_name]
                                  properties:
                                    file_name: { type: string }
                                    line_numbers:
                                      type: array
                                      items: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "502": { description: Falha ao gerar }

  /v1/ai/predict-queries:
    post:
      tags: [AI Generate]
      summary: Autocompletar o pedido que está sendo digitado
      description: |
        A resposta sempre começa pelo `partial_query` recebido. Quando o modelo
        não preserva esse prefixo, o servidor devolve `suggestion` vazia em vez
        de embaralhar o texto já digitado.
      operationId: aiPredictQueries
      security: [{ clerkJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [context_messages, partial_query]
              properties:
                context_messages:
                  type: array
                  maxItems: 40
                  items: { type: string }
                partial_query: { type: string, maxLength: 120000 }
                system_context: { type: string }
      responses:
        "200":
          description: Pedido completo (prefixo + continuação), ou vazio
          content:
            application/json:
              schema:
                type: object
                required: [suggestion]
                properties:
                  suggestion: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "502": { description: Falha ao gerar }

  # ── Skill Store ──
  /v1/skill-store:
    get:
      tags: [Skill Store]
      summary: Listar skills do usuário (por scope)
      operationId: listSkills
      security: [{ zitadelJwt: [] }]
      parameters:
        - in: query
          name: scope
          schema:
            type: string
            enum: [user, installed, bundled, community, artifact]
          description: "Filtro de escopo (default: todas visíveis)"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  skills:
                    type: array
                    items: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    post:
      tags: [Skill Store]
      summary: "Criar skill (scope: user ou artifact)"
      operationId: createSkill
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [slug, content]
              properties:
                slug: { type: string }
                scope: { type: string, default: "user" }
                title: { type: string }
                description: { type: string }
                content: { type: string }
                priority: { type: integer, default: 50 }
                forked_from: { type: string }
      responses:
        "201":
          description: Skill criada
          content:
            application/json:
              schema: { type: object }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/Conflict" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/skill-store/search:
    post:
      tags: [Skill Store]
      summary: Busca semântica de skills
      operationId: searchSkills
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query]
              properties:
                query: { type: string }
                k: { type: integer, default: 8 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  skills:
                    type: array
                    items:
                      type: object
                      properties:
                        skill: { type: object }
                        score: { type: number }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/skill-store/pinned:
    get:
      tags: [Skill Store]
      summary: Skills pinadas pelo usuário
      operationId: listPinnedSkills
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  skills:
                    type: array
                    items: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/skill-store/{id}:
    parameters: [{ $ref: "#/components/parameters/SkillId" }]
    get:
      tags: [Skill Store]
      summary: Detalhe da skill
      operationId: getSkill
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    put:
      tags: [Skill Store]
      summary: Atualizar conteúdo da skill
      operationId: updateSkill
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content: { type: string }
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    delete:
      tags: [Skill Store]
      summary: Deletar skill
      operationId: deleteSkill
      security: [{ zitadelJwt: [] }]
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/skill-store/{id}/pin:
    parameters: [{ $ref: "#/components/parameters/SkillId" }]
    post:
      tags: [Skill Store]
      summary: Pinar skill
      operationId: pinSkillInStore
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Skill Store]
      summary: Despinar skill
      operationId: unpinSkillInStore
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/skill-store/{id}/publish:
    parameters: [{ $ref: "#/components/parameters/SkillId" }]
    post:
      tags: [Skill Store]
      summary: Publicar skill para revisão da comunidade
      operationId: publishSkill
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/skill-store/{id}/install:
    parameters: [{ $ref: "#/components/parameters/SkillId" }]
    post:
      tags: [Skill Store]
      summary: Instalar skill da comunidade
      operationId: installSkill
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/skill-store/import:
    post:
      tags: [Skill Store]
      summary: Importar skill de arquivo Markdown (SKILL.md)
      operationId: importSkill
      security: [{ zitadelJwt: [] }]
      responses:
        "201": { $ref: "#/components/responses/Created" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/skill-store/install-from-git:
    post:
      tags: [Skill Store]
      summary: Instalar skill direto de repositório Git
      description: |
        Clona repositório Git público com SKILL.md no root e
        registra a skill no escopo do usuário.
      operationId: installSkillFromGit
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, description: "URL do repo Git" }
                ref: { type: string, description: "Branch / tag (default: main)" }
                subdir: { type: string, description: "Subdiretório com SKILL.md" }
      responses:
        "201": { $ref: "#/components/responses/Created" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/skill-store/refresh:
    post:
      tags: [Skill Store]
      summary: Refresh user-level dos syncs de skills (bundled + plugin + local)
      description: |
        Reexecuta os 3 syncs no escopo do usuário. Pensado para ser disparado
        pelo front depois do LLM criar um SKILL.md via tool, sem precisar de
        restart do server. Idempotente; mesmo handler do admin `resync` mas sem
        `require_staff`.
      operationId: refreshSkillStore
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ── Welcome Shortcuts ──
  /v1/welcome-shortcuts:
    get:
      tags: [Welcome Shortcuts]
      summary: Listar atalhos de boas-vindas (catálogo merged com prefs)
      operationId: listWelcomeShortcuts
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/welcome-shortcuts/prefs:
    put:
      tags: [Welcome Shortcuts]
      summary: Atualizar preferências de atalhos do usuário
      operationId: upsertWelcomeShortcutPrefs
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ── SSH Targets (Dukkify) ──
  /v1/ssh-targets:
    get:
      tags: [SSH Targets]
      summary: Listar hosts SSH do usuário
      description: |
        Hosts remotos (SSH) do usuário autenticado, ordenados por `name`.
        Escopado por usuário (não requer staff).
      operationId: listSshTargets
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: Lista de hosts SSH
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/SshTarget" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [SSH Targets]
      summary: Criar/atualizar host SSH (upsert)
      description: |
        Upsert por `(user_id, name)`. Cada escrita regenera o `ssh_config`
        no diretório de secrets do usuário (bind-montado RO nas microVMs).
      operationId: createSshTarget
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateSshTargetRequest" }
      responses:
        "200":
          description: Host criado/atualizado
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SshTarget" }
        "400":
          description: |
            `invalid_name` (name fora de `[a-zA-Z0-9._-]`/1-64) ou
            `invalid_target` (host/ssh_user vazios).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/ssh-targets/{name}:
    parameters:
      - in: path
        name: name
        required: true
        schema: { type: string }
        description: Slug do host SSH a remover.
    delete:
      tags: [SSH Targets]
      summary: Remover host SSH
      description: Remove o host e regenera o `ssh_config` do usuário.
      operationId: deleteSshTarget
      security: [{ zitadelJwt: [] }]
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ═══════════════════════════════════════════════════════════════════════════
  # TENANT: ADMIN (Zitadel JWT + is_staff=true)
  # ═══════════════════════════════════════════════════════════════════════════

  /v1/admin/ping:
    get:
      tags: [Admin Ping]
      summary: Smoke test do gate staff
      description: Retorna 200 com email do staff se o gate require_auth + require_staff funcionar.
      operationId: adminPing
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  staff_email: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/admin/invites:
    get:
      tags: [Admin Invites]
      summary: Listar todos os códigos de convite
      operationId: adminListInvites
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Admin Invites]
      summary: Criar código de convite
      operationId: adminCreateInvite
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string }
                description: { type: string }
                max_uses: { type: integer }
                quota_h5: { type: integer, default: 100 }
                quota_daily: { type: integer, default: 250 }
                quota_monthly: { type: integer, default: 2500 }
                expires_at: { type: string, format: date-time }
      responses:
        "201":
          description: Convite criado
          content:
            application/json:
              schema: { type: object }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/admin/invites/{code}:
    parameters: [{ $ref: "#/components/parameters/InviteCode" }]
    get:
      tags: [Admin Invites]
      summary: Detalhes do convite + resgates + atividade por usuário
      operationId: adminGetInviteDetail
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  invite: { type: object }
                  seats_consumed: { type: integer }
                  seats_remaining: { type: integer }
                  redemptions:
                    type: array
                    items: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Admin Invites]
      summary: Expirar (não deletar) código de convite
      operationId: adminExpireInvite
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/admin/users:
    get:
      tags: [Admin Users]
      summary: Listar usuários com filtros
      operationId: adminListUsers
      security: [{ zitadelJwt: [] }]
      parameters:
        - in: query
          name: q
          schema: { type: string }
          description: Busca textual
        - in: query
          name: plan
          schema: { type: string }
        - in: query
          name: is_staff
          schema: { type: boolean }
        - in: query
          name: limit
          schema: { type: integer, default: 200 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/admin/users/{id}:
    parameters: [{ $ref: "#/components/parameters/AdminUserId" }]
    patch:
      tags: [Admin Users]
      summary: Editar usuário (is_staff, quotas)
      operationId: adminPatchUser
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                is_staff: { type: boolean }
                quota_override_h5: { type: integer }
                quota_override_daily: { type: integer }
                quota_override_monthly: { type: integer }
                clear_overrides: { type: boolean }
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/admin/logs:
    get:
      tags: [Admin Logs]
      summary: Logs do sistema
      operationId: adminListLogs
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/admin/deep-research/model:
    get:
      tags: [Admin Deep Research]
      summary: Modelo configurado para deep research (+ catálogo)
      operationId: adminGetDeepResearchModel
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: Configuração atual + opções disponíveis
          content:
            application/json:
              schema:
                type: object
                properties:
                  current_model: { type: string }
                  options:
                    type: array
                    items: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    put:
      tags: [Admin Deep Research]
      summary: Atualizar modelo de deep research
      operationId: adminSetDeepResearchModel
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [model]
              properties:
                model: { type: string }
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/admin/vps-env:
    get:
      tags: [Admin VPS Env]
      summary: Ler arquivo .env do serviço na VPS
      description: |
        Carrega o conteúdo de um .env de serviço (ex: dukk-server,
        dukk-browser) para edição pelo painel admin.
      operationId: adminGetVpsEnv
      security: [{ zitadelJwt: [] }]
      parameters:
        - in: query
          name: file
          schema: { type: string }
          description: "Identificador do arquivo de env (ex: server, browser)."
      responses:
        "200":
          description: Conteúdo do .env + metadata
          content:
            application/json:
              schema:
                type: object
                properties:
                  content: { type: string }
                  info: { type: object }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    put:
      tags: [Admin VPS Env]
      summary: Atualizar arquivo .env (com restart de serviço opcional)
      operationId: adminPutVpsEnv
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                file: { type: string }
                content: { type: string }
                restart: { type: boolean, default: false }
      responses:
        "200":
          description: .env atualizado
          content:
            application/json:
              schema: { type: object }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/admin/app/release:
    get:
      tags: [Admin App]
      summary: Metadados da versão mais recente do app desktop
      description: |
        Varre o diretório da variante na VPS e devolve a maior versão
        disponível + plataformas (`macos`/`linux`) e tamanhos.

        Config por env:
        - `DUKK_DESKTOP_RELEASES_DIR` (default `/srv/dukk/releases/desktop`)
        - `DUKK_DESKTOP_VARIANT` (default `embedded`)
        - `DUKK_DESKTOP_UPSTREAM` (upstream de origem dos artefatos)
      operationId: adminAppRelease
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: Versão mais recente + plataformas disponíveis
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AppRelease" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/admin/app/download:
    get:
      tags: [Admin App]
      summary: Download (stream) do app desktop
      description: |
        Faz STREAM do arquivo da maior versão para a plataforma pedida,
        sem bufferizar em memória. A resposta traz
        `Content-Disposition: attachment` com o nome do arquivo.
        O nome do arquivo é sempre derivado da varredura do diretório
        (nunca do input do cliente) e revalidado contra o padrão + `canonicalize`.
      operationId: adminAppDownload
      security: [{ zitadelJwt: [] }]
      parameters:
        - in: query
          name: platform
          required: false
          schema: { type: string, enum: [macos, linux], default: macos }
          description: "Plataforma alvo. Default `macos`."
      responses:
        "200":
          description: Stream binário do artefato (attachment)
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Nenhum artefato para a plataforma pedida / arquivo indisponível.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }

  /v1/admin/skill-store/pending:
    get:
      tags: [Admin Skill Store]
      summary: Skills pendentes de aprovação
      operationId: adminListPendingSkills
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  skills:
                    type: array
                    items: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/admin/skill-store/{id}/approve:
    parameters: [{ $ref: "#/components/parameters/SkillId" }]
    post:
      tags: [Admin Skill Store]
      summary: Aprovar skill para publicação
      operationId: adminApproveSkill
      security: [{ zitadelJwt: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/admin/skill-store/{id}/reject:
    parameters: [{ $ref: "#/components/parameters/SkillId" }]
    post:
      tags: [Admin Skill Store]
      summary: Rejeitar skill
      operationId: adminRejectSkill
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string }
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/admin/skill-store/resync:
    post:
      tags: [Admin Skill Store]
      summary: Re-sincronizar skills (bundled + plugin + local)
      description: |
        Incremental — não deleta nada. Útil após instalar
        plugin novo ou colocar SKILL.md em ~/.dukk/skills/.
      operationId: adminResyncSkillStore
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  bundled: { type: integer }
                  plugins: { type: integer }
                  local: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/admin/welcome-shortcuts:
    get:
      tags: [Admin Welcome Shortcuts]
      summary: Listar todos os atalhos (bruto, sem merge de prefs)
      operationId: adminListWelcomeShortcuts
      security: [{ zitadelJwt: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Admin Welcome Shortcuts]
      summary: Criar atalho de boas-vindas
      operationId: adminCreateWelcomeShortcut
      security: [{ zitadelJwt: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [label, prompt]
              properties:
                label: { type: string }
                prompt: { type: string }
                icon: { type: string }
      responses:
        "201":
          description: Atalho criado
          content:
            application/json:
              schema: { type: object }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/admin/welcome-shortcuts/{id}:
    parameters: [{ $ref: "#/components/parameters/WelcomeShortcutId" }]
    delete:
      tags: [Admin Welcome Shortcuts]
      summary: Deletar atalho (cascade nas prefs dos usuários)
      operationId: adminDeleteWelcomeShortcut
      security: [{ zitadelJwt: [] }]
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ═══════════════════════════════════════════════════════════════════════════
  # TENANT: HEADLESS (Bearer token — require_bearer)
  # ═══════════════════════════════════════════════════════════════════════════

  /v1/version:
    get:
      tags: [Version]
      summary: Versão do servidor
      operationId: version
      security: [{ serverToken: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  version: { type: string }
                  name: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ── Permissions WebSocket ──
  /v1/sessions/{id}/permissions/ws:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    get:
      tags: [Sessions]
      summary: WebSocket — permissões em tempo real
      operationId: permissionsWs
      security: [{ serverToken: [] }]
      responses:
        "101":
          description: WebSocket upgrade
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Workspace ──
  /v1/workspace/{session_id}/read:
    parameters: [{ $ref: "#/components/parameters/WorkspaceSessionId" }]
    post:
      tags: [Workspace (Headless)]
      summary: Ler arquivo no workspace da sessão
      operationId: workspaceRead
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/workspace/{session_id}/write:
    parameters: [{ $ref: "#/components/parameters/WorkspaceSessionId" }]
    post:
      tags: [Workspace (Headless)]
      summary: Escrever arquivo no workspace
      operationId: workspaceWrite
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/workspace/{session_id}/edit:
    parameters: [{ $ref: "#/components/parameters/WorkspaceSessionId" }]
    post:
      tags: [Workspace (Headless)]
      summary: Editar arquivo (find & replace)
      operationId: workspaceEdit
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/workspace/{session_id}/glob:
    parameters: [{ $ref: "#/components/parameters/WorkspaceSessionId" }]
    post:
      tags: [Workspace (Headless)]
      summary: Buscar arquivos por glob
      operationId: workspaceGlob
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/workspace/{session_id}/grep:
    parameters: [{ $ref: "#/components/parameters/WorkspaceSessionId" }]
    post:
      tags: [Workspace (Headless)]
      summary: Buscar conteúdo com regex
      operationId: workspaceGrep
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/workspace/{session_id}/tree:
    parameters: [{ $ref: "#/components/parameters/WorkspaceSessionId" }]
    get:
      tags: [Workspace (Headless)]
      summary: Árvore de diretórios do workspace
      operationId: workspaceTree
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/workspace/{session_id}/diff:
    parameters: [{ $ref: "#/components/parameters/WorkspaceSessionId" }]
    get:
      tags: [Workspace (Headless)]
      summary: Diff do workspace (git diff)
      operationId: workspaceDiff
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ── Session Ops ──
  /v1/sessions/{id}/clone:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    post:
      tags: [Session Ops (Headless)]
      summary: Clonar sessão (preserva user_id)
      operationId: cloneSession
      security: [{ serverToken: [] }]
      responses:
        "201": { $ref: "#/components/responses/Created" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sessions/{id}/compact:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    post:
      tags: [Session Ops (Headless)]
      summary: Compactar contexto da sessão
      operationId: compactSession
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sessions/{id}/export:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    post:
      tags: [Session Ops (Headless)]
      summary: Exportar sessão
      operationId: exportSession
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sessions/{id}/resume:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    post:
      tags: [Session Ops (Headless)]
      summary: Retomar sessão
      operationId: resumeSession
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Git ──
  /v1/workspace/{session_id}/git/status:
    parameters: [{ $ref: "#/components/parameters/WorkspaceSessionId" }]
    get:
      tags: [Git (Headless)]
      summary: Status do Git
      operationId: gitStatus
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/workspace/{session_id}/git/diff:
    parameters: [{ $ref: "#/components/parameters/WorkspaceSessionId" }]
    get:
      tags: [Git (Headless)]
      summary: Diff do Git
      operationId: gitDiff
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/workspace/{session_id}/git/log:
    parameters: [{ $ref: "#/components/parameters/WorkspaceSessionId" }]
    get:
      tags: [Git (Headless)]
      summary: Log do Git
      operationId: gitLog
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/workspace/{session_id}/git/branches:
    parameters: [{ $ref: "#/components/parameters/WorkspaceSessionId" }]
    get:
      tags: [Git (Headless)]
      summary: Listar branches
      operationId: gitBranches
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/workspace/{session_id}/git/checkout:
    parameters: [{ $ref: "#/components/parameters/WorkspaceSessionId" }]
    post:
      tags: [Git (Headless)]
      summary: Checkout de branch
      operationId: gitCheckout
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/workspace/{session_id}/git/commit:
    parameters: [{ $ref: "#/components/parameters/WorkspaceSessionId" }]
    post:
      tags: [Git (Headless)]
      summary: Criar commit
      operationId: gitCommit
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/workspace/{session_id}/git/branch/create:
    parameters: [{ $ref: "#/components/parameters/WorkspaceSessionId" }]
    post:
      tags: [Git (Headless)]
      summary: Criar branch
      operationId: gitBranchCreate
      security: [{ serverToken: [] }]
      responses:
        "201": { $ref: "#/components/responses/Created" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/workspace/{session_id}/git/worktree/create:
    parameters: [{ $ref: "#/components/parameters/WorkspaceSessionId" }]
    post:
      tags: [Git (Headless)]
      summary: Criar worktree
      operationId: gitWorktreeCreate
      security: [{ serverToken: [] }]
      responses:
        "201": { $ref: "#/components/responses/Created" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/workspace/{session_id}/git/pr/create:
    parameters: [{ $ref: "#/components/parameters/WorkspaceSessionId" }]
    post:
      tags: [Git (Headless)]
      summary: Criar Pull Request
      operationId: gitPrCreate
      security: [{ serverToken: [] }]
      responses:
        "201": { $ref: "#/components/responses/Created" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ── Commands ──
  /v1/commands:
    get:
      tags: [Commands (Headless)]
      summary: Listar slash-commands disponíveis
      operationId: listCommands
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/sessions/{id}/commands/run:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    post:
      tags: [Commands (Headless)]
      summary: Executar slash-command na sessão
      operationId: runCommand
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/sessions/{id}/commands/compact:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    post:
      tags: [Commands (Headless)]
      summary: Compactar sessão via comando
      operationId: commandCompactSession
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/sessions/{id}/commands/export:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    post:
      tags: [Commands (Headless)]
      summary: Exportar sessão via comando
      operationId: commandExportSession
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/sessions/{id}/commands/resume:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    post:
      tags: [Commands (Headless)]
      summary: Retomar sessão via comando
      operationId: commandResumeSession
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ── Tasks ──
  /v1/tasks:
    get:
      tags: [Tasks (Headless)]
      summary: Listar tarefas
      operationId: listTasks
      security: [{ serverToken: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/tasks/{id}:
    parameters: [{ $ref: "#/components/parameters/TaskId" }]
    get:
      tags: [Tasks (Headless)]
      summary: Obter tarefa
      operationId: getTask
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/tasks/{id}/output:
    parameters: [{ $ref: "#/components/parameters/TaskId" }]
    get:
      tags: [Tasks (Headless)]
      summary: Saída da tarefa
      operationId: getTaskOutput
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/tasks/{id}/cancel:
    parameters: [{ $ref: "#/components/parameters/TaskId" }]
    post:
      tags: [Tasks (Headless)]
      summary: Cancelar tarefa
      operationId: cancelTask
      security: [{ serverToken: [] }]
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Models & Providers ──
  /v1/models:
    get:
      tags: [Models & Providers]
      summary: Listar modelos LLM disponíveis
      operationId: listModels
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/model-catalog:
    get:
      tags: [Models & Providers]
      summary: Catálogo de modelos por provider (picker)
      description: |
        Todos os providers suportados com os modelos que dá para rodar agora,
        para o seletor de modelo do desktop e do front web.

        Cada provider vem sempre presente, mesmo sem credencial: nesse caso
        `models` é vazio e `warning` diz qual env var o liga. O cliente exibe a
        aba desabilitada com esse texto em vez de omitir o provider.

        Os modelos vêm de consulta viva ao `/models` de cada provider, com cache
        chaveado pela impressão digital da credencial (trocar a chave invalida
        só o cache daquele provider). O `id` de cada modelo já vem no formato
        que `POST /v1/sessions` aceita, com prefixo onde o roteamento exige
        (`go:`, `zen:`, `ollama:`, `lmstudio:`).

        Exige a capability `choose_model` (staff): o payload revela quais
        credenciais o servidor tem configuradas.
      operationId: getModelCatalog
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/providers:
    get:
      tags: [Models & Providers]
      summary: Listar providers LLM
      operationId: listProviders
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ── Tools ──
  /v1/tools:
    get:
      tags: [Tools (Headless)]
      summary: Listar todas as tools disponíveis
      operationId: listTools
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/tools/exec:
    post:
      tags: [Tools (Headless)]
      summary: Executar tool cloud-required (delegação sidecar local → cloud)
      description: |
        Executa uma tool que exige ambiente cloud (web/research/conexões/kanban).
        Usado pela delegação P2: o sidecar local encaminha tools cloud-required
        para a VPS. Rejeita (400) tools que não são cloud-required.
      operationId: execTool
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, description: "Nome da tool cloud-required." }
                input: { type: object, description: "Argumentos da tool." }
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/tools/{name}:
    parameters: [{ $ref: "#/components/parameters/SkillName" }]
    get:
      tags: [Tools (Headless)]
      summary: Detalhe de uma tool
      operationId: getTool
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sessions/{id}/tools/allow:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    post:
      tags: [Tools (Headless)]
      summary: Permitir tools por padrão na sessão
      description: |
        Define uma allowlist de nomes/padrões de tool (`{"patterns": ["read_file", "mcp__github__*"]}`).
        Enforced no choke point de autorização (`PermissionPolicy::authorize`) —
        qualquer tool fora da lista é negada, mesmo que a matriz de capacidade
        do `permission_mode` a permitiria. Persiste por conversa (sobrevive a
        reconexão/retry, não só à sessão em memória).
      operationId: toolsAllow
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sessions/{id}/tools/deny:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    post:
      tags: [Tools (Headless)]
      summary: Bloquear tools na sessão
      description: |
        Define uma denylist de nomes/padrões de tool. Mesma semântica de
        enforcement e persistência de `tools/allow` (bloqueio hard, sem
        perguntar; persiste por conversa). Denylist cobre só o catálogo
        interno (builtin/MCP/plugin) — não afeta `client_tools[]` declaradas
        pelo próprio cliente em modo passthrough.
      operationId: toolsDeny
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/tools/rate-limit:
    get:
      tags: [Tools (Headless)]
      summary: Status de rate limit das tools
      operationId: toolsRateLimit
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ── Telemetry ──
  /v1/telemetry:
    get:
      tags: [Telemetry (Headless)]
      summary: Eventos de telemetria
      operationId: getTelemetry
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      parameters:
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/usage/summary:
    get:
      tags: [Telemetry (Headless)]
      summary: Sumário de uso
      operationId: getUsageSummary
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ── Cache ──
  /v1/cache/stats:
    get:
      tags: [Cache (Headless)]
      summary: Estatísticas de cache
      operationId: cacheStats
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/cache/clear:
    post:
      tags: [Cache (Headless)]
      summary: Limpar cache
      operationId: cacheClear
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ── MCP ──
  # Servidores/processos MCP são isolados por tenant (TenantScope: user_id
  # sempre presente + organization_id opcional). Duas contas com um servidor
  # de mesmo `name` não colidem nem se enxergam — cada uma tem seu próprio
  # pool de processos (McpTenantPool), com idle-reap automático.
  /v1/mcp/servers:
    get:
      tags: [MCP (Headless)]
      summary: Listar servidores MCP (só da conta autenticada)
      operationId: listMcpServers
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [MCP (Headless)]
      summary: Adicionar servidor MCP (stdio) à conta autenticada
      description: |
        Só transporte `stdio` é suportado (`command` + `args` + `env`
        opcional). Persistido na tabela `mcp_servers`, escopado por
        `TenantScope` — nunca visível a outra conta.
      operationId: addMcpServer
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      responses:
        "201": { $ref: "#/components/responses/Created" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/mcp/servers/{name}:
    parameters: [{ $ref: "#/components/parameters/McpServerName" }]
    put:
      tags: [MCP (Headless)]
      summary: Atualizar servidor MCP
      operationId: updateMcpServer
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [MCP (Headless)]
      summary: Remover servidor MCP
      operationId: deleteMcpServer
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/mcp/servers/{name}/restart:
    parameters: [{ $ref: "#/components/parameters/McpServerName" }]
    post:
      tags: [MCP (Headless)]
      summary: Reiniciar servidor MCP
      operationId: restartMcpServer
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/mcp/servers/{name}/tools:
    parameters: [{ $ref: "#/components/parameters/McpServerName" }]
    get:
      tags: [MCP (Headless)]
      summary: Listar tools do servidor MCP
      operationId: listMcpServerTools
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/mcp/servers/{name}/resources:
    parameters: [{ $ref: "#/components/parameters/McpServerName" }]
    get:
      tags: [MCP (Headless)]
      summary: Listar resources do servidor MCP
      operationId: listMcpServerResources
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/mcp/servers/{name}/tools/{tool}/call:
    parameters:
      - { $ref: "#/components/parameters/McpServerName" }
      - { $ref: "#/components/parameters/McpToolName" }
    post:
      tags: [MCP (Headless)]
      summary: Chamar tool MCP
      operationId: callMcpTool
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Plugins & Agents & Hooks ──
  /v1/plugins:
    get:
      tags: [Plugins (Headless)]
      summary: Listar plugins
      operationId: listPlugins
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/plugins/{name}:
    parameters: [{ $ref: "#/components/parameters/PluginName" }]
    get:
      tags: [Plugins (Headless)]
      summary: Detalhe do plugin
      operationId: getPluginDetail
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Plugins (Headless)]
      summary: Instalar plugin
      operationId: installPlugin
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    put:
      tags: [Plugins (Headless)]
      summary: Atualizar plugin
      operationId: updatePlugin
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Plugins (Headless)]
      summary: Desinstalar plugin
      operationId: uninstallPlugin
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/plugins/{name}/publish-request:
    parameters: [{ $ref: "#/components/parameters/PluginName" }]
    post:
      tags: [Plugins (Headless)]
      summary: Solicitar publicação de plugin user-created no catálogo Dukk
      description: |
        V1 sem tabela DB: o handler loga a solicitação (structured log,
        procurável via `journalctl … | grep plugin_publish_request`) e
        responde `202 Accepted`.
      operationId: pluginPublishRequest
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                notes: { type: string, description: "Notas da solicitação (máx. 4 KB)." }
      responses:
        "202": { description: "Solicitação aceita (log estruturado)." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/agents:
    get:
      tags: [Plugins (Headless)]
      summary: Listar agentes
      operationId: listAgents
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/agents/{name}/run:
    parameters: [{ $ref: "#/components/parameters/AgentName" }]
    post:
      tags: [Plugins (Headless)]
      summary: Executar agente (stub — sempre 501)
      description: |
        Não implementado. Não existe pipeline de execução de agent no
        runtime do server (nem no CLI) — sempre responde `501` com
        `{"error": "not_implemented", "message": "agent execution pipeline
        not wired in server runtime"}`.
      operationId: runAgent
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      responses:
        "501":
          description: Not Implemented
          content:
            application/json:
              schema: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/hooks:
    get:
      tags: [Plugins (Headless)]
      summary: Listar hooks (webhook configurado por evento, na conta)
      description: |
        Um webhook por evento (`PreToolUse`/`PostToolUse`) por conta.
        Nunca devolve o secret em claro — só `{event, configured, url}`.
      operationId: listHooks
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    put:
      tags: [Plugins (Headless)]
      summary: Criar/atualizar/remover o webhook de um evento
      description: |
        Body `{"event": "PreToolUse"|"PostToolUse", "url"?, "secret"?}`.
        `url`+`secret` presentes → upsert; `url` ausente/vazia → remove o
        webhook do evento. O servidor assina cada chamada ao webhook com
        HMAC-SHA256 do corpo (header `X-Dukk-Hook-Signature`) usando este
        secret; a resposta esperada é `{"decision": "allow"|"deny",
        "reason"?}`. Timeout, erro de rede, status não-2xx ou resposta
        malformada resultam em **deny** (fail-closed) — o hook nunca degrada
        silenciosamente para "permitir".
      operationId: updateHooks
      security: [{ zitadelJwt: [] }, { intKey: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ── User Commands ──
  /v1/user-commands:
    get:
      tags: [User Commands (Headless)]
      summary: Listar comandos customizados
      operationId: listUserCommands
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [User Commands (Headless)]
      summary: Criar comando customizado
      operationId: createUserCommand
      security: [{ serverToken: [] }]
      responses:
        "201": { $ref: "#/components/responses/Created" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/user-commands/{name}:
    parameters: [{ $ref: "#/components/parameters/UserCommandName" }]
    put:
      tags: [User Commands (Headless)]
      summary: Atualizar comando
      operationId: updateUserCommand
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [User Commands (Headless)]
      summary: Deletar comando
      operationId: deleteUserCommand
      security: [{ serverToken: [] }]
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Auth ──
  /v1/auth/status:
    get:
      tags: [Auth (Headless)]
      summary: Status de autenticação
      operationId: getAuthStatus
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/auth/methods:
    get:
      tags: [Auth (Headless)]
      summary: Métodos de auth disponíveis
      operationId: listAuthMethods
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/auth/api-key:
    post:
      tags: [Auth (Headless)]
      summary: Registrar API key
      operationId: setApiKey
      security: [{ serverToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [provider, key]
              properties:
                provider: { type: string }
                key: { type: string }
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/auth/api-key/{provider}:
    parameters: [{ $ref: "#/components/parameters/AuthProvider" }]
    delete:
      tags: [Auth (Headless)]
      summary: Remover API key
      operationId: deleteApiKey
      security: [{ serverToken: [] }]
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/auth/oauth/start:
    post:
      tags: [Auth (Headless)]
      summary: Iniciar fluxo OAuth
      operationId: oauthStart
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/auth/oauth/callback:
    get:
      tags: [Auth (Headless)]
      summary: Callback OAuth
      operationId: oauthCallback
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/auth/oauth/refresh:
    post:
      tags: [Auth (Headless)]
      summary: Refresh token OAuth
      operationId: oauthRefresh
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/auth/import/claude-code:
    post:
      tags: [Auth (Headless)]
      summary: Importar credenciais do Claude Code
      operationId: importClaudeCode
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/auth/import/codex:
    post:
      tags: [Auth (Headless)]
      summary: Importar credenciais do Codex
      operationId: importCodex
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ── Config ──
  /v1/config:
    get:
      tags: [Config (Headless)]
      summary: Configuração completa
      operationId: getConfig
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    patch:
      tags: [Config (Headless)]
      summary: Atualizar configuração
      operationId: patchConfig
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/config/sources:
    get:
      tags: [Config (Headless)]
      summary: Fontes de configuração
      operationId: getConfigSources
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/providers/{id}/test:
    parameters: [{ $ref: "#/components/parameters/ProviderId" }]
    post:
      tags: [Config (Headless)]
      summary: Testar conexão com provider LLM
      operationId: testProvider
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/budget:
    get:
      tags: [Config (Headless)]
      summary: Configuração de budget
      operationId: getBudget
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    patch:
      tags: [Config (Headless)]
      summary: Atualizar budget
      operationId: patchBudget
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/theme:
    get:
      tags: [Config (Headless)]
      summary: Configuração de tema
      operationId: getTheme
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    patch:
      tags: [Config (Headless)]
      summary: Atualizar tema
      operationId: patchTheme
      security: [{ serverToken: [] }]
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }
