openapi: 3.1.0
info:
  title: Intangia API
  version: '1.0.0'
  summary: Read keywords, projects, signals, and opportunities, and start scoring.
  description: |
    Bearer tokens are issued for the resource `https://app.intangia.co/api/v1`.
    A token issued for the MCP server is rejected. See the authentication guide
    on the docs site for OAuth, PKCE, and scopes.
servers:
  - url: https://app.intangia.co
security:
  - bearer: []
tags:
  - name: Keywords
  - name: Projects
  - name: Signals
  - name: Opportunities
  - name: Analyses
paths:
  /api/v1/keywords:
    get:
      tags: [Keywords]
      operationId: searchKeywords
      summary: Search Keywords
      description: An empty `q` returns no rows. `limit` is capped at 20.
      parameters:
        - $ref: '#/components/parameters/query'
        - $ref: '#/components/parameters/page'
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 20, default: 10 }
      responses:
        '200':
          description: A page of keywords.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/KeywordHit' }
                  meta: { $ref: '#/components/schemas/PageMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /api/v1/keywords/{keywordId}:
    get:
      tags: [Keywords]
      operationId: getKeyword
      summary: Get a Keyword
      parameters:
        - $ref: '#/components/parameters/keywordId'
        - name: include
          in: query
          description: Comma-separated section names.
          schema:
            type: string
            example: indications,sponsors
      responses:
        '200':
          description: The keyword card, plus any requested sections.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/KeywordCard' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
  /api/v1/keywords/{keywordId}/relations:
    get:
      tags: [Keywords]
      operationId: listKeywordRelations
      summary: List Relations
      parameters:
        - $ref: '#/components/parameters/keywordId'
        - name: relation
          in: query
          required: true
          schema:
            type: string
            enum: [indications, assets, pipeline_targets, sponsors]
        - name: activity
          in: query
          description: Required when relation is sponsors.
          schema:
            type: string
            enum: [trials, papers, patents]
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        '200':
          description: One page of links, with the full count in meta.total.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RelationPage' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
  /api/v1/projects:
    get:
      tags: [Projects]
      operationId: listProjects
      summary: List Projects
      responses:
        '200':
          description: Projects the caller belongs to.
          content:
            application/json:
              schema:
                type: object
                required: [projects]
                properties:
                  projects:
                    type: array
                    items: { $ref: '#/components/schemas/Project' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      tags: [Projects]
      operationId: createProject
      summary: Create a Project
      description: Requires `projects:write`. Does not extract scope from the brief.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateProject' }
      responses:
        '201':
          description: The project was created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreatedProject' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /api/v1/projects/{projectId}/status:
    get:
      tags: [Signals]
      operationId: getProjectStatus
      summary: Get Project Status
      parameters: [{ $ref: '#/components/parameters/projectId' }]
      responses:
        '200':
          description: Discovery and scoring status.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectStatus' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /api/v1/projects/{projectId}/signals:
    get:
      tags: [Signals]
      operationId: listSignals
      summary: List Signals
      description: Default limit is 100, maximum 500. `s3_path`, `path`, `bucket`, and `location` are rejected.
      parameters:
        - $ref: '#/components/parameters/projectId'
        - name: type
          in: query
          schema: { type: string }
        - name: analysis_mode
          in: query
          schema:
            type: string
            enum: [most_promising, novel_signal_scout, validated_opportunity]
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
      responses:
        '200':
          description: Discover candidates for the project.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SignalList' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /api/v1/projects/{projectId}/opportunities:
    get:
      tags: [Opportunities]
      operationId: listOpportunities
      summary: List Opportunities
      description: The project payload. Storage paths are omitted. The ranked scoreboard is an MCP tool.
      parameters: [{ $ref: '#/components/parameters/projectId' }]
      responses:
        '200':
          description: Scored datasets and analysis counts.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OpportunityPayload' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /api/v1/projects/{projectId}/analyses:
    post:
      tags: [Analyses]
      operationId: runAnalyses
      summary: Run Analyses
      description: |
        Requires `analyses:run` and an editor or owner. Status 202 is used both when a run starts
        and when the server asks for confirmation. Requires scoring setup to exist.
      parameters: [{ $ref: '#/components/parameters/projectId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RunAnalyses' }
      responses:
        '202':
          description: A run was recorded, or the caller must confirm.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AnalysesResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '504':
          description: The scoring service did not respond. No run was recorded.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error: The scoring service did not respond, and no run was recorded.
components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      description: Access token whose resource is https://app.intangia.co/api/v1.
  parameters:
    query:
      name: q
      in: query
      schema: { type: string }
    page:
      name: page
      in: query
      schema: { type: integer, minimum: 1, default: 1 }
    keywordId:
      name: keywordId
      in: path
      required: true
      schema: { type: string, format: uuid }
    projectId:
      name: projectId
      in: path
      required: true
      schema: { type: string, format: uuid }
  responses:
    Unauthorized:
      description: Missing, invalid, or wrong-resource bearer token.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: unauthorized }
    Forbidden:
      description: The token lacks the route scope, or the caller is not a project member.
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/Error'
              - $ref: '#/components/schemas/ScopeError'
    BadRequest:
      description: The query or body failed validation.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: The keyword does not exist.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: Keyword not found }
    Conflict:
      description: Scoring setup is missing, or the selection does not match a finished scan.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string }
    ScopeError:
      type: object
      required: [error, scope]
      properties:
        error: { type: string, const: insufficient_scope }
        scope: { type: string }
    PageMeta:
      type: object
      required: [page, pageSize]
      properties:
        page: { type: integer }
        pageSize: { type: integer }
    KeywordHit:
      type: object
      required: [id, name, type, synonyms]
      properties:
        id: { type: string }
        name: { type: string }
        type: { type: string }
        synonyms:
          type: array
          items: { type: string }
    KeywordScore:
      type: object
      nullable: true
      properties:
        score: { type: number }
        raw: { type: number, nullable: true }
        label: { type: string }
        description: { type: string }
    KeywordCard:
      type: object
      required: [id, name, type, description, synonyms, activity, scores]
      properties:
        id: { type: string }
        name: { type: string }
        type: { type: string, nullable: true }
        description: { type: string, nullable: true }
        synonyms:
          type: array
          items: { type: string }
        activity:
          type: object
          properties:
            papers: { type: integer, nullable: true }
            patents: { type: integer, nullable: true }
            trials: { type: integer, nullable: true }
            assets: { type: integer, nullable: true }
        scores:
          type: object
          properties:
            as_of: { type: string, nullable: true, description: 'YYYY-MM-DD' }
            composite: { $ref: '#/components/schemas/KeywordScore' }
            momentum: { $ref: '#/components/schemas/KeywordScore' }
            maturity: { $ref: '#/components/schemas/KeywordScore' }
            competition: { $ref: '#/components/schemas/KeywordScore' }
            risk: { $ref: '#/components/schemas/KeywordScore' }
            translation: { $ref: '#/components/schemas/KeywordScore' }
    RelationPage:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items:
            type: object
            required: [id, name]
            properties:
              id: { type: string }
              name: { type: string }
              count: { type: integer }
              stage: { type: string, nullable: true }
        meta:
          type: object
          required: [relation, limit, offset, total]
          properties:
            relation: { type: string }
            activity: { type: string }
            limit: { type: integer }
            offset: { type: integer }
            total: { type: integer }
    Project:
      type: object
      required: [id, name, role]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        created_at: { type: string }
        role: { type: string, enum: [owner, editor, viewer] }
        brief: { type: string, nullable: true }
    ScopeItem:
      type: object
      required: [entity_id, entity_type]
      properties:
        entity_id: { type: string }
        entity_type: { type: string }
        entity_name: { type: string }
        data: { type: object, additionalProperties: true }
    CreateProject:
      type: object
      required: [name]
      properties:
        name: { type: string }
        brief: { type: string }
        watchlist_items:
          type: array
          items: { $ref: '#/components/schemas/ScopeItem' }
        operand: { type: string, enum: ['$and', '$or'] }
        goals:
          type: array
          items: { type: string, enum: [target, indication, modality] }
        primary_filter_type: { type: string, default: keyword }
    CreatedProject:
      type: object
      required: [id, name, role, setup_url]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        role: { type: string, enum: [owner] }
        setup_url: { type: string, format: uri }
        scan_error: { type: string }
    Signal:
      type: object
      required: [keyword_id, signal_keyword_id, type, analysed]
      properties:
        keyword_id: { type: string }
        signal_keyword_id: { type: string }
        name: { type: string, nullable: true }
        type: { type: string }
        position: { type: integer }
        rank: { type: integer, nullable: true }
        analysed: { type: boolean }
    SignalList:
      type: object
      required: [analysis_mode, analysis_mode_label, filter, total, unanalysed_total, signals]
      properties:
        analysis_mode: { type: string }
        analysis_mode_label: { type: string }
        filter: { type: string }
        total: { type: integer }
        unanalysed_total: { type: integer }
        signals:
          type: array
          items: { $ref: '#/components/schemas/Signal' }
    ProjectStatus:
      type: object
      required: [signals_ready, scoring, summary, next, analyses]
      properties:
        signals_ready: { type: boolean }
        scoring: { type: string, enum: [not_started, running, done] }
        summary: { type: string }
        next: { type: string, enum: [wait, run_analyses, list_signals] }
        analyses:
          type: array
          items:
            type: object
            properties:
              keyword_id: { type: string }
              keyword_name: { type: string }
              task_id: { type: string }
              status: { type: string, enum: [pending, running, done, failed] }
              updated_at: { type: string }
              runs:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: string }
                    type: { type: string }
                    status: { type: string }
    OpportunityPayload:
      type: object
      required: [keywords, project_analyses, error]
      properties:
        keywords:
          type: array
          items: { type: object, additionalProperties: true }
        project_analyses:
          type: object
          properties:
            total: { type: integer }
            generating: { type: integer }
            running: { type: integer }
            completed: { type: integer }
            by_status: { type: object, additionalProperties: { type: integer } }
            items:
              type: array
              items: { type: object, additionalProperties: true }
        error: { type: string, nullable: true }
    Selection:
      type: object
      required: [keyword_id, signal_keyword_id]
      properties:
        keyword_id: { type: string }
        signal_keyword_id: { type: string }
    RunAnalyses:
      type: object
      properties:
        selections:
          type: array
          items: { $ref: '#/components/schemas/Selection' }
        top:
          type: object
          required: [type, limit]
          properties:
            type: { type: string, enum: [target, disease, modality, mechanism] }
            limit: { type: integer, minimum: 1, maximum: 100 }
            analysis_mode:
              type: string
              enum: [most_promising, novel_signal_scout, validated_opportunity]
            confirmed: { type: boolean }
            include_analysed: { type: boolean }
            readiness: { type: string }
            therapeutic_area: { type: string }
        include_analysed: { type: boolean }
        confirmed: { type: boolean }
    AnalysesResult:
      type: object
      properties:
        needs_confirmation: { type: boolean }
        assumption: { type: string }
        available: { type: integer }
        limit: { type: integer }
        signals:
          type: array
          items: { type: object, additionalProperties: true }
        runs:
          type: array
          items:
            type: object
            required: [id, keyword_id, kind]
            properties:
              id: { type: string }
              keyword_id: { type: string }
              kind: { type: string }
        skipped:
          type: array
          items:
            type: object
            properties:
              keyword_id: { type: string }
              signal_keyword_id: { type: string }
              reason: { type: string }
