ClosedLoop AI REST API

Read-only /v1 REST API, team- and region-scoped, authenticated via X-API-Key. Exposes insights, products, themes, features, coverage/evidence, customers, customer context, competitors, trends, facets, integrations, usage/credits, and a public health check. Feedback is ingested via integrations, not written through this API.

Operations 20

GET /insights List / search insights #
GET /insights/{id} Get an insight #
GET /products List products #
GET /areas List product and feature areas #
GET /coverage Check evidence coverage #
GET /themes List / search themes #
GET /themes/{id} Get a theme #
GET /features List / search features #
GET /features/{id} Get a feature #
GET /customers List / search customers
GET /customers/{id} Get a customer
GET /context List customer context
GET /competitors List / search competitors
GET /competitors/mentions Search competitor mentions
GET /trends Time-series trends
GET /facets Facet counts
GET /integrations List integrations
GET /integrations/{id} Get an integration
GET /usage List usage records
GET /health Health check

Documentation

Specifications

Other Resources

Work with this as data

Every API here is available over the APIs.io API and to AI agents over MCP.

MCP server

One button, every client — Claude, Cursor, VS Code and the rest.

https://apis.io/mcp

Tools for apis

7 MCP tools reach this
  • find_apisBrowse and filter every API in the catalog.
  • get_api_artifactsOne API's artifacts, grouped by type.
  • get_openapiThe primary OpenAPI for this API.
  • find_similar_apisAPIs that look like this one.
  • apis_io_searchSTART HERE — APIs, providers and tags for one query, each with its total.
  • resolveTurn a domain, URL or GitHub org into the provider it belongs to.
  • find_cohortsEvery scored population of providers in the catalog.
All 92 tools →

Call it yourself

curl for this page
This API
curl "https://apis.io/api/v1/apis/closedloop-ai-rest-api"
All apis
curl "https://apis.io/api/v1/apis?limit=25"

Discovery needs no key. Ratings and market analysis are Pro.

Get an API key

Free tier, no email required.

A second provider on the same verified email joins the account you already have.

OpenAPI Specification

closedloop-public-api-openapi.yaml Raw ↑
openapi: 3.0.3

info:
  title: ClosedLoop AI Public API
  version: "1.8.0"
  x-logo:
    url: https://app.closedloop.sh/favicon.svg
    altText: ClosedLoop AI
  description: |
    # ClosedLoop AI Public API

    Programmatic access to your team's **product insights**, the structured intelligence
    ClosedLoop AI extracts from customer conversations (Gong, Fireflies, Slack, …) and
    structured feedback (surveys, webhooks).

    Pull insights, themes, features, customer context and analytics into your own tools.

    ## Two ways to integrate

    | | **This REST API** | **MCP** (live today) |
    |---|---|---|
    | Best for | server-to-server, dashboards, ETL, scripts | AI assistants (Claude, Cursor, …), agents |
    | Shape | resource endpoints, JSON | tool calls over the Model Context Protocol |
    | Auth | `X-API-Key` header | OAuth |

    Most agentic use cases are already covered by MCP. Reach for this REST API when you
    want plain HTTP resources.

    ## Base URLs (region-scoped)

    Your API key belongs to a **team**, and each team lives in exactly one region. Use the
    base URL for **your** region. A key issued in the EU only works against the EU host.

    | Region | Base URL |
    |---|---|
    | United States | `https://api.closedloop.sh/v1` |
    | European Union | `https://eu.api.closedloop.sh/v1` |

    ## Authentication

    Create a key in **Settings → API Keys** in the app, then send it on every request:

    ```
    X-API-Key: clai_live_xxxxxxxxxxxxxxxxxxxx
    ```

    Keys are **team-scoped**: every response is automatically limited to your team's data.
    Never expose a key in client-side code.

    ## Conventions

    - **Read-only**: every v1 endpoint is a `GET`; nothing is mutated. Feedback is ingested
      through your connected integrations, not this API.
    - **Pagination**: list endpoints accept `limit` (default 50, max 200) and `offset`.
      `/insights` also supports fixed-watermark cursor export with `updated_since`.
    - **Errors**: non-2xx responses return a JSON `{ error, code, hint? }` body. User-safe
      messages only; never raw internals.
    - **Rate limits**: the public API has no general fixed per-key quota. Semantic theme
      search uses implementation-defined protective rate and concurrency safeguards.
      Clients must handle `429` and honor `Retry-After`; these safeguards are enforced
      independently by API replica and are not a fleet-wide numeric quota.
    - **Linking insights and context**: both carry the same resolved `customer_id`, so you
      can line up an insight with the context (churn, competitor, satisfaction) for the same
      customer. Filter either list by `customer_id`.
    - **Time anchors**: timestamps are ISO 8601 (UTC).

  contact:
    name: ClosedLoop AI Support
    email: support@closedloop.sh
    url: https://closedloop.sh
  license:
    name: Proprietary (ClosedLoop Labs LLC)
    url: https://closedloop.sh/terms

servers:
  - url: https://api.closedloop.sh/v1
    description: United States (production)
  - url: https://eu.api.closedloop.sh/v1
    description: European Union (production)

security:
  - ApiKeyAuth: []

tags:
  - name: Insights
    description: |
      Individual **product insights**: structured, AI-processed feedback items
      (pain point, severity, workaround, competitor gap, evidence).
  - name: Products
    description: The current product, product-area, and feature-area vocabulary for the team.
  - name: Themes
    description: |
      **Themes**: top-level problem clusters of related insights, RIC-scored. The
      "what should we build?" surface. Each theme rolls up one or more buildable features.
  - name: Features
    description: |
      **Features**: the buildable, shippable children of a theme. Filter by `theme_id`.
      Same theme → features hierarchy you see on the roadmap.
  - name: Customers
    description: The people and accounts who gave feedback, with CRM context.
  - name: Context
    description: |
      **Customer context**, the strategic intelligence behind your customers: churn
      reasons, competitive mentions, satisfaction, pricing perception and more, surfaced
      from conversations. This is the `/context` surface in the app. Each record resolves
      to a customer (`customer_id`), so it lines up with insights for the same customer.
  - name: Competitors
    description: |
      Competitive pressure derived from your customers' own words. Competitors with a
      **mention trend over time** (not a static total), and a searchable feed of the exact
      mentions, each showing the customer behind it.
  - name: Analytics
    description: Trends, cohorts and facet counts over the full dataset.
  - name: Integrations
    description: Connected data sources for your team.
  - name: Usage
    description: Credit consumption and usage records for billing transparency.
  - name: Meta
    description: Service metadata.

x-tagGroups:
  - name: Prioritization
    tags: [Insights, Products, Themes, Features]
  - name: Customers & Context
    tags: [Customers, Context, Competitors]
  - name: Reporting
    tags: [Analytics]
  - name: Account
    tags: [Integrations, Usage]
  - name: Meta
    tags: [Meta]

paths:
  /insights:
    get:
      operationId: listInsights
      tags: [Insights]
      summary: List / search insights
      x-mint:
        metadata:
          description: "Search processed product insights by customer, severity, product scope, date, or text, with offset or fixed-watermark incremental pagination."
      description: |
        Search processed product insights. Filter by category, severity, customer,
        product, product feature, product area, feature area, date range, or a
        free-text query.

        Offset pagination is the default. For a stable incremental export, start with
        `updated_since`; then follow `next_cursor` using only `cursor` and optionally
        `limit`. The fixed export window is `updated_since < updated_at <= sync_until`.

        Product filtering returns insights with a recorded product association. Some
        insights remain unassigned when ClosedLoop AI cannot determine a reliable
        automatic match, so filtered results may not include every potentially relevant
        insight. Responses expose the IDs accepted by all four product-scope filters:
        `products[].id`, `product_features[].id`, `product_area_id`, and `feature_area_id`.
      parameters:
        - $ref: '#/components/parameters/Query'
        - name: category
          in: query
          schema: { type: string, example: "bug" }
          description: Feedback category (e.g. bug, feature request, usability).
        - name: severity
          in: query
          schema: { type: string, enum: [critical, high, medium, low] }
        - name: customer_id
          in: query
          schema: { type: string, format: uuid }
          description: Filter to insights from one customer (same key as on context).
        - $ref: '#/components/parameters/ProductId'
        - $ref: '#/components/parameters/ProductFeatureId'
        - $ref: '#/components/parameters/ProductAreaId'
        - $ref: '#/components/parameters/FeatureAreaId'
        - $ref: '#/components/parameters/DateFrom'
        - $ref: '#/components/parameters/DateTo'
        - name: updated_since
          in: query
          schema: { type: string, format: date-time }
          example: "2026-05-01T00:00:00Z"
          description: |
            Start a fixed-watermark incremental export after this insight-row update
            timestamp. Cannot be combined with `offset` or `cursor`.

            Must be a timezone-qualified ISO 8601 date-time - the offset is
            required, so `2026-05-01T00:00:00` (no `Z` or `+hh:mm`) is rejected
            with `400 VALIDATION_ERROR`, as is a date-only value.
        - name: cursor
          in: query
          schema: { type: string, maxLength: 8192 }
          description: |
            Opaque continuation cursor returned by the prior incremental page. A cursor
            request may include only `cursor` and `limit`. It is bound to the team, exact
            API key, filters, and watermark.
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: A page of insights
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Insight' }
                  pagination:
                    oneOf:
                      - $ref: '#/components/schemas/Pagination'
                      - $ref: '#/components/schemas/CursorPagination'
              examples:
                offset:
                  summary: Offset page
                  value:
                    data:
                      - id: "123e4567-e89b-42d3-a456-426614174000"
                        title: "Scheduled exports are needed for reporting"
                        category: "feature request"
                        severity: "medium"
                        customer_id: "c0ffee00-0000-4000-8000-000000000001"
                        customer_name: "Acme Co"
                        source: "gong_call_abc123"
                        source_date: "2026-05-14T10:30:00Z"
                        created_at: "2026-05-14T10:32:00Z"
                        updated_at: "2026-05-15T08:12:00Z"
                        feature_area_id: "523e4567-e89b-42d3-a456-426614174004"
                        feature_area_name: "Exports"
                        product_area_id: "423e4567-e89b-42d3-a456-426614174003"
                        product_area_name: "Operations"
                        products:
                          - id: "223e4567-e89b-42d3-a456-426614174001"
                            name: "Core Platform"
                            status: "active"
                        product_features:
                          - id: "323e4567-e89b-42d3-a456-426614174002"
                            title: "Scheduled exports"
                            product_id: "223e4567-e89b-42d3-a456-426614174001"
                    pagination: { total: 1, limit: 50, offset: 0 }
                incremental:
                  summary: Incremental page
                  value:
                    data: []
                    pagination:
                      limit: 200
                      has_more: false
                      next_cursor: null
                      sync_until: "2026-05-14T10:30:00.123456Z"
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '503': { $ref: '#/components/responses/InsightsUnavailable' }

  /insights/{id}:
    get:
      operationId: getInsight
      tags: [Insights]
      summary: Get an insight
      description: |
        Full insight detail with evidence: pain point, workaround, competitor gap,
        willingness to pay, feature area, product associations, and the verbatim quote.
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '200':
          description: Insight detail
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InsightDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/AuthenticationUnavailable' }

  /products:
    get:
      operationId: listProducts
      tags: [Products]
      summary: List products
      description: |
        Current product vocabulary for the authenticated team. Draft and active products
        are returned by default. Use these immutable IDs with `product_id` filters.
      parameters:
        - name: include_retired
          in: query
          schema: { type: boolean, default: false }
          description: Include retired products as well as draft and active products.
      responses:
        '200':
          description: Product vocabulary
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Product' }
              example:
                data:
                  - id: "123e4567-e89b-42d3-a456-426614174000"
                    name: "Core Platform"
                    description: "Shared platform capabilities"
                    aliases: ["Platform"]
                    key_features: ["Permissions", "Exports"]
                    url: "https://example.com/products/core"
                    status: "active"
                    source: "manual"
                    sort_order: 0
                    created_at: "2026-04-02T00:00:00Z"
                    updated_at: "2026-05-01T00:00:00Z"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '503': { $ref: '#/components/responses/AuthenticationUnavailable' }

  /areas:
    get:
      operationId: listAreas
      tags: [Products]
      summary: List product and feature areas
      x-mint:
        metadata:
          description: "List active product and feature areas with all-time insight totals and earliest feedback dates, or include archived areas when needed."
      description: |
        Current product-area and feature-area vocabulary. Each area includes its all-time
        insight total and earliest feedback date, so an empty filtered result can be
        distinguished from a scope with little or no coverage. Active areas are returned
        by default.
      parameters:
        - name: include_archived
          in: query
          schema: { type: boolean, default: false }
          description: Include archived product areas and feature areas.
      responses:
        '200':
          description: Nested area vocabulary with all-time coverage totals
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ProductArea' }
              example:
                data:
                  - id: "223e4567-e89b-42d3-a456-426614174000"
                    name: "Operations"
                    description: "Operational workflows"
                    status: "active"
                    sort_order: 0
                    insight_count: 84
                    feedback_since: "2026-01-12T09:00:00Z"
                    feature_areas:
                      - id: "323e4567-e89b-42d3-a456-426614174000"
                        name: "Exports"
                        description: "Data export workflows"
                        status: "active"
                        sort_order: 0
                        insight_count: 84
                        feedback_since: "2026-01-12T09:00:00Z"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '503': { $ref: '#/components/responses/AuthenticationUnavailable' }

  /coverage:
    get:
      operationId: getCoverage
      tags: [Analytics]
      summary: Check evidence coverage
      x-mint:
        metadata:
          description: "Check whether a product or area scope has enough evidence for reliable analysis, including coverage dates and windowed insight totals."
      description: |
        Classifies whether evidence in a product or area scope is informative, too thin,
        or predates team coverage. The current density threshold is 50 distinct insights.
        `policy_version` makes future policy changes explicit.

        `scope.feedback_since` and `scope.all_time_insight_count` ignore the requested date
        window but keep the product and area filters. Windowed counts apply every filter.
      parameters:
        - $ref: '#/components/parameters/ProductId'
        - $ref: '#/components/parameters/ProductFeatureId'
        - $ref: '#/components/parameters/ProductAreaId'
        - $ref: '#/components/parameters/FeatureAreaId'
        - $ref: '#/components/parameters/DateFrom'
        - $ref: '#/components/parameters/DateTo'
      responses:
        '200':
          description: Coverage verdict and supporting totals
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Coverage' }
              example:
                verdict: "informative"
                reason: "density_threshold_met"
                reads_as: "84 insights across 6 themes cover the requested scope, so a weak match is worth noticing — but it still is not proof that nobody wants it."
                policy_version: "1"
                density_threshold: 50
                filters:
                  product_id: null
                  product_feature_id: null
                  product_area_id: "223e4567-e89b-42d3-a456-426614174000"
                  feature_area_id: null
                date_range: { from: null, to: null }
                scope:
                  insight_count: 84
                  all_time_insight_count: 84
                  theme_count: 6
                  customer_count: 31
                  deal_blocker_count: 4
                  feedback_since: "2026-01-12T09:00:00Z"
                team:
                  insight_count: 508
                  feedback_since: "2025-11-03T12:00:00Z"
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '503': { $ref: '#/components/responses/AuthenticationUnavailable' }

  /themes:
    get:
      operationId: listThemes
      tags: [Themes]
      summary: List / search themes
      x-mint:
        metadata:
          description: "List or search top-level product themes by status, product scope, evidence date, or meaning, with deterministic sorting and pagination."
      description: |
        Top-level problem clusters, RIC-scored, most impactful first. Each theme rolls up
        one or more buildable features. Filter by status, product scope, date, or search
        text. Evidence filters recompute `insight_count`, `unique_customer_count`, and
        `deal_blocker_count` from distinct canonical evidence across the theme and its
        active features; themes with no matching evidence are omitted.

        Search is lexical by default: the complete `q` value must occur as one
        case-insensitive substring in the theme title or description. Set
        `search_mode=semantic` to match by meaning instead. Semantic results are ordered
        by similarity descending, then immutable theme ID; this relevance order overrides
        `sort`. If query embedding generation is unavailable, the request returns `503`
        by default. Set `allow_fallback=true` to permit lexical fallback. Always inspect
        the response's `search_mode` and `min_similarity` fields to determine what was
        applied.

        By default, merged themes are excluded. Set `include_retired=true` to add valid
        merged themes to the selected status set. Each returned merged theme has a
        `replacement_theme_id` that points directly to the final surviving theme; callers
        never need to follow a chain. Search and other filters still apply to the retired
        theme's own stored record and evidence.

        The default response is CURATED, not a census: each team has an adaptive
        size floor, computed nightly from that team's own distribution, and very
        small items below it are excluded — the same set the ClosedLoop AI app
        shows. `pagination.total` counts the curated set. Set `show_all=true` for
        every row regardless of size. Teams with too little data to compute a
        floor are never filtered.

        A theme's size is measured through its live features, so the floor cannot
        be evaluated for a finished theme. `status=shipped` and
        `status=declined` therefore return their rows in full, unfiltered by
        size. `include_retired=true` exempts only the retired rows it adds —
        the live themes alongside them stay filtered.
        `/features` differs — a feature is sized by its own insight count, so its
        floor applies whatever its status.
      parameters:
        - name: show_all
          in: query
          schema: { type: boolean, default: false }
          description: "Include themes below the team's adaptive size floor. Default false."
        - name: q
          in: query
          schema: { type: string, maxLength: 500 }
          description: Search text. Required when search_mode is semantic.
        - name: search_mode
          in: query
          schema:
            type: string
            enum: [lexical, semantic]
            default: lexical
          description: Lexical substring matching, or semantic matching against theme embeddings.
        - name: min_similarity
          in: query
          schema:
            type: number
            format: float
            minimum: 0.3
            maximum: 1
            default: 0.5
          description: Minimum cosine similarity for semantic search. Valid only when search_mode is semantic.
        - name: allow_fallback
          in: query
          schema: { type: boolean, default: false }
          description: Permit lexical fallback if semantic search is unavailable. Valid only when search_mode is semantic.
        - name: include_retired
          in: query
          schema: { type: boolean, default: false }
          description: Add valid merged themes, each with its final replacement_theme_id. Other filters still apply.
        - name: status
          in: query
          schema: { type: string, enum: [active, declined, shipped, parked] }
        - name: sort
          in: query
          schema: { type: string, enum: [ric_score, insight_count, unique_customer_count, created_at], default: ric_score }
        - $ref: '#/components/parameters/ProductId'
        - $ref: '#/components/parameters/ProductFeatureId'
        - $ref: '#/components/parameters/ProductAreaId'
        - $ref: '#/components/parameters/FeatureAreaId'
        - $ref: '#/components/parameters/DateFrom'
        - $ref: '#/components/parameters/DateTo'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: A page of themes
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination, search_mode, min_similarity]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Theme' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
                  search_mode:
                    type: string
                    enum: [lexical, semantic]
                    description: Search mode actually applied. Lexical indicates lexical/default search or an explicitly allowed fallback.
                  min_similarity:
                    type: number
                    format: float
                    nullable: true
                    description: Applied semantic threshold, or null when search_mode is lexical.
              example:
                data:
                  - id: "a1b2c3d4-e5f6-4a7b-8c9d-000000000001"
                    title: "Payment interruption recovery"
                    description: "Customers need clearer recovery paths after failed transactions."
                    ric_score: 7.8
                    status: "active"
                pagination: { total: 1, limit: 50, offset: 0 }
                search_mode: "semantic"
                min_similarity: 0.5
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/ThemesUnavailable' }

  /themes/{id}:
    get:
      operationId: getTheme
      tags: [Themes]
      summary: Get a theme
      x-mint:
        metadata:
          description: "Get one product theme with its severity breakdown, affected customers, supporting insights, buildable features, and merge destination."
      description: |
        Theme detail with its evidence: severity breakdown, affected customers, top
        supporting insights, and its features. If this theme was merged into another,
        the response is `410 Gone` with the final surviving `replacement_theme_id`.
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '200':
          description: Theme detail
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ThemeDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '410': { $ref: '#/components/responses/ThemeRetired' }
        '503': { $ref: '#/components/responses/AuthenticationUnavailable' }

  /features:
    get:
      operationId: listFeatures
      tags: [Features]
      summary: List / search features
      x-mint:
        metadata:
          description: "List or search buildable features by theme, product, area, status, or text, with scoped evidence counts and deterministic sorting."
      description: |
        Buildable features: the shippable children of a theme. Filter by `theme_id`,
        `product_id`, `feature_area_id`, `product_area_id`, `product_feature_id`,
        status, or search text.
        Product fields are null when a feature has not been assigned to a product.

        `feature_area_id` is a subject area from `/areas`, NOT a feature ID from this
        endpoint. The two are different kinds of ID: a feature ID identifies one
        buildable feature and is what `/features/{id}` takes, while a feature-area ID
        identifies the area an insight is filed under. `feature_area_id` therefore
        selects features by the area of their supporting insights.

        With an AREA filter — `feature_area_id`, `product_area_id`, or both —
        `insight_count` and `unique_customer_count` are RECOMPUTED from the distinct
        insights in that scope and the distinct customers behind them; features with
        no matching evidence are omitted, and `pagination.total` counts what is
        returned. `sort=insight_count` and `sort=unique_customer_count` then rank on
        those scoped values. `sort=ric_score` is unchanged by the filter: it stays the
        feature's stored OVERALL score across every area, so a feature can outrank
        another that has more evidence in the selected scope. Every sort resolves ties
        on the immutable feature ID, so paging is deterministic. With NEITHER area
        filter the counts are the feature's stored all-area totals. Every filter on
        this endpoint combines with AND.

        The default response is CURATED, not a census: each team has an adaptive
        size floor, computed nightly from that team's own distribution, and very
        small items below it are excluded — the same set the ClosedLoop AI app
        shows. `pagination.total` counts the curated set. Set `show_all=true` for
        every row regardless of size. Teams with too little data to compute a
        floor are never filtered.

        Unlike `/themes`, this floor applies at every status: a feature is sized
        by its own insight count, which a finished feature still has. That size is the
        feature's OVERALL insight count, so the floor is unaffected by the area
        filters and `show_all=true` does not widen the selected area. The floor and
        the published count therefore read different numbers: a returned feature can
        carry a scoped `insight_count` below the floor, because the floor judged its
        overall size, and a feature whose evidence sits entirely in the selected area
        is still hidden when its overall count is below the floor.
      parameters:
        - name: show_all
          in: query
          schema: { type: boolean, default: false }
          description: "Include features below the team's adaptive size floor. Default false."
        - $ref: '#/components/parameters/Query'
        - name: search_mode
          in: query
          schema:
            type: string
            enum: [lexical, semantic]
            default: lexical
          description: |
            Lexical substring matching against the feature's own title and
            description, or semantic matching against feature embeddings.

            Semantic search returns rows best-match-first and therefore OVERRIDES
            `sort`. Relevance order and size order are mutually exclusive: a caller
            relying on `sort=insight_count` for scope-aware ordering loses it when
            semantic is enabled.

            Semantic requests share ONE rate budget with `/themes`; they are not
            separately allowanced.
        - name: min_similarity
          in: query
          schema:
            type: number
            format: float
            minimum: 0.3
            maximum: 1
            default: 0.5
          description: |
            Minimum cosine similarity for semantic search. Valid only when
            search_mode is semantic.
        - name: allow_fallback
          in: query
          schema: { type: boolean, default: false }
          description: |
            Permit falling back to lexical when an embedding cannot be generated.
            Default false, so such a request fails with 503 rather than silently
            returning a different result set. The response always reports the mode
            that actually ran.
        - name: theme_id
          in: query
          schema: { type: string, format: uuid }
          description: Return only the features under this theme.
        - $ref: '#/components/parameters/ProductId'
        - $ref: '#/components/parameters/FeatureAreaId'
        - name: product_area_id
          in: query
          schema: { type: string, format: uuid }
          description: |
            Return only features with supporting insights filed anywhere under this
            parent product area, with `insight_count` and `unique_customer_count`
            recomputed over exactly that evidence. This is `feature_area_id` one
            level up: a product area is the parent of many feature areas, so it is
            the coarser cut of the same question. Combines with every other filter
            using AND, so pairing it with `feature_area_id` narrows to that one
            child area — and yields nothing when that area sits under a different
            product area.

            ARCHIVED child areas are excluded, which `feature_area_id` does not do:
            naming an archived area directly still counts its evidence, while its
            parent product area does not. A product area whose children are all
            archived therefore returns an empty list. `/areas` with
            `include_archived=true` sums archived children into a product area's
            `insight_count`, so that total can exceed what this filter returns.
        - name: product_feature_id
          in: query
          schema: { type: string, format: uuid }
          description: |
            Return only the feature with this ID. On THIS endpoint the parameter
            names the listed resource itself, so it selects that one feature;
            on `/insights` and `/themes` the same parameter instead means
            "linked to this feature". Combines with every other filter using
            AND, so pairing it with `feature_area_id` answers "does this feature
            have evidence in that area, and how much" in one call. Use
            `/features/{id}` to fetch one feature with its evidence, severity
            breakdown and affected customers.
        - name: status
          in: query
          schema: { type: string, enum: [active, declined, shipped, parked] }
        - name: sort
          in: query
          schema: { type: string, enum: [ric_score, insight_count, unique_customer_count, created_at], default: ric_score }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: A page of features
          co

# --- truncated at 32 KB (75 KB total) ---
# Full source: https://raw.githubusercontent.com/api-evangelist/closedloop/refs/heads/main/openapi/closedloop-public-api-openapi.yaml