openapi: 3.0.0
info:
  title: Senso Org API
  version: 0.2.0
  description: |
    The Senso API lets you ingest raw sources, query your compiled knowledge base, and generate verified content — all scoped to your organization.

    Authenticate every request with the `X-API-Key` header using your org API key.

servers:
  - url: https://apiv2.senso.ai/api/v1
    description: Production server

security:
  - OrgApiKey: []

tags:
  - name: Organization
    description: Your organization is the container for your knowledge base, API keys, and all compiled content. Every raw source you ingest is scoped here.
  - name: Users
    description: Manage who can ingest, query, and generate within your organization. Assign roles to control access.
  - name: Roles
    description: |
      Each organization gets its own copy of the built-in roles (`admin`, `collaborator`, `viewer`) plus any custom roles. List roles to discover the `role_id` you need when inviting users or changing their role — those endpoints accept `role_id` only.
  - name: API Keys
    description: |
      API keys authenticate your agents against your knowledge base. Each key is scoped to your organization — create separate keys for different agents, environments, or integrations.

      Keys can be further restricted to specific KB folders, so an external agent only queries what you allow.
  - name: Search
    description: Query your compiled knowledge base — get grounded AI answers, raw context chunks, or matching source IDs.
  - name: Knowledge Base
    description: |
      Manage your knowledge base — the folder tree where all your ingested raw sources get compiled and stored.

      Your KB is structured like a file system: **folders** organize content, and **documents** (ingested files or raw text) hold the compiled knowledge that powers queries and content generation.

      In the API, both folders and documents are called **nodes**. Every node has a `kb_node_id`, a `type` (`folder` or `content`), and a `parent_id` linking it to its parent folder. The root folder has `parent_id: null`.
  - name: Brand Kit
    description: |
      Your org-wide identity config — voice, tone, persona, and writing rules. The brand kit ensures every piece of generated content sounds like you, not like generic AI output.

      Set it once, and it applies to all content generation calls. Without a brand kit, generated content has no identity guidance.
  - name: Content Types
    description: |
      Output templates that control the format, structure, and rules for each kind of generated content. Think of them as compilation targets — the shape your knowledge base gets compiled into.

      Every generation call requires a content type. A "FAQ Article" produces different output than a "LinkedIn Post", even from the same ingested sources.
  - name: Content Generation
    description: |
      Generate verified content by querying your compiled knowledge base. The engine combines your brand kit (voice), content type (format), and ingested raw sources (knowledge) to produce grounded output.

      Every fact in the generated content traces back to a source in your knowledge base — auditable and verifiable.
  - name: Content Engine
    description: Publish verified content to configured destinations or save drafts for editorial review. The last step in the ingest → compile → query → generate → publish pipeline.
  - name: Destinations
    description: |
      Manage the publish destinations available to your org. Every org with content generation enabled is linked to the server-configured default shared destination set — that's what `engine publish` targets when no explicit publishers are selected.

      Today all destination types use the citeables system. Shared destinations are seeded globally and can be enabled per org; orgs can also register custom citeables-system domains, and remove any destination with a choice of what to do with live content (`leave`, `unpublish`, or `delete`).
  - name: Content
    description: |
      Manage compiled content items — both ingested raw sources and generated output. Track processing status, editorial workflow, verification state, and version history.
  - name: Members
    description: List members of your organization — the people and agents who can access your knowledge base.
  - name: Prompts
    description: |
      Manage prompts — the questions that drive content generation. Each prompt represents a question you want AI to answer well using your compiled knowledge base (e.g. "What are the current mortgage rates?").

      The `geo_question_id` you see in content generation requests is a prompt ID.
  - name: Credits
    description: Track ingest, query, and generation usage against your org credit balance.
  - name: Builder
    description: Builder-mode generation runs — track how agents compile, query, and generate against your knowledge base in real time.
  - name: Run Configuration
    description: Configure AI models and generation schedules for your compilation and generation pipeline.
  - name: Questions
    description: Lightweight CRUD for prompts — the queries your knowledge base is optimized to answer. Use /org/prompts for the same data with full generation run history.
  - name: Permissions
    description: Available role permissions for controlling who can ingest, query, and generate.
  - name: Tags
    description: |
      Org-scoped labels attached to prompts, KB nodes, content items, and search queries. Use tags to group prompts by topic, content by campaign, or products by release — anything you want to filter or roll up metrics by.

      **Auto-tagging.** Senso auto-tags on creation for every taggable resource: prompts at `POST /org/prompts`, raw content at `POST /org/kb/raw` and uploaded files once ingestion finishes, and search queries as they're received on `/org/search*`. The tag library grows on its own — most callers never need to hit the tag-library endpoints directly.

      Every taggable endpoint still accepts tags by id **or** by name. When you attach by name, missing tags are created automatically within your organization.
  - name: Product Lines
    description: |
      Flexible org-scoped product/service definitions. Each product line has a name and an arbitrary JSON `details` blob — use it to describe the brand lines Senso should reason about when generating or evaluating content.

      Product lines are referenced by downstream generation and evaluation pipelines; the `details` schema is intentionally open so you can carry whatever structured metadata (SKUs, URLs, positioning statements, etc.) your workflows need.
  - name: Tracked Competitors
    description: |
      A curated list of competitor brands your organization wants to monitor. Tracked competitors feed downstream analytics (share-of-voice across prompt runs, citation comparisons) and inform content-generation prompts.

      Manage entries directly (create / update / delete), batch-import suggestions, or call `POST /org/competitors/suggest` to get AI-generated suggestions seeded from your organization's website and recent prompt-run results.
  - name: Tracked Sources
    description: |
      Per-org citation-classification rules that tier each cited URL as Owned (`primary`), Tracked (`tracked`), or External (`secondary`). Tracked sources drive share-of-voice and citation analytics by deciding which domains/paths count as your own content versus third-party.

      Each rule matches by `domain`, `host`, `path_prefix`, or `exact_url`. Rules created from published content (`source_origin: published`) are read-only.
  - name: Analytics
    description: |
      Read-only GEO analytics for your own organization — how often AI answers mention you, which sources those answers cite, and which of your monitored prompts you win or lose. Every response is scoped to the org that owns the API key; the org is taken from your credentials, never from a path or query parameter, so there is no cross-org read to get wrong.

      **Every cited URL falls into exactly one tier.** `primary` (Owned) is one of your own websites, `tracked` (Tracked) is one of your configured tracked sources, and `secondary` (External) is any other domain. The tiers partition citations but *not* answers — one answer can carry citations of all three tiers. That is why the per-tier *rates* below can sum past 100% while the per-tier *shares* sum to exactly 100%.

      **There are two denominators, and they are never mixed.** `D` is `cited_run_count`, the number of answers carrying at least one citation of any tier. `S` is `cited_total`, the number of citation instances — every cited URL counted separately. **Citation Rate** (per tier) and **Citation Coverage** (per domain or page) both divide by `D` and count distinct answers. **Citation Share** (per tier or per source) divides by `S` and counts cited URLs. An answer that cites you once alongside nine third-party URLs contributes a full 1/1 to your primary Citation Rate but only 1/10 to your primary Citation Share — a high rate with a low share means you get cited but are outnumbered inside every answer. Both denominators ship in the `totals` block of every payload, so you can always redo the math or build a metric we did not ship.

      **Share of Voice divides by every brand mention, not by your tracked set.** `share_of_voice` is your `mention_total` over `brand_mention_total` — mention instances of every brand the models named, whether you track that brand or not. That is exactly the Share of Voice the Senso app reports, and the two are expected to agree to the digit. `tracked_mention_total` (you plus your tracked competitors) also ships in `totals`, but it is deliberately **not** the denominator: an organization with no tracked competitors has `tracked_mention_total` equal to `mention_total`, which would pin Share of Voice at 100% whenever you were mentioned at all. Divide by it yourself if you want the head-to-head view against your tracked set, and read that number knowing it moves whenever you edit the list. This API counts every other brand in `brand_mention_total` but never names them.

      **Answer text exists only for the latest answer per prompt × model × location.** `GET /org/analytics/answers/latest` and the `latest_answers` block of the prompt drill-down return full response text, citations, and competitor mentions for the newest answer of each combination — and nothing older. History is retained as daily additive counts only, so a time series can tell you how often you were mentioned last March but never what the model actually said. Do not treat the latest answers as a sample of a window — and note that `from`/`to` on `/org/analytics/answers/latest` filter on each answer's collection time (`run_at`), which hides combinations whose latest answer falls outside the window rather than returning older ones.

      **Built for agents as much as for developers.** Every response carries the effective `window`, the additive `totals` behind every ratio, a `data_quality` flag, `notes[]` (prose caveats generated for that specific response), and `definitions{}` (one-line metric definitions naming each denominator). Every ratio ships as `{value, display}` — `value` is the unrounded 0–1 float for math, `display` is safe to quote verbatim. A ratio is `null`, never `0`, when its denominator was zero: null means "not measured", not "measured as zero". Call `GET /org/analytics/glossary` once to learn the whole vocabulary before you quote a number.

paths:

  # ──────────────────────────────────────────────
  # Organization — /org/me
  # ──────────────────────────────────────────────
  /org/me:
    get:
      tags: [Organization]
      summary: Get organization details
      description: Returns full details for the authenticated organization — the container for your knowledge base, ingested sources, API keys, and generation pipeline configuration.
      operationId: getOrgMe
      responses:
        '200':
          description: Organization details.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgDetailResponse'
        '401':
          description: Unauthorized.
        '404':
          description: Organization not found.
    put:
      tags: [Organization]
      summary: Update organization details
      description: Updates org-editable fields. Only the fields provided will be updated. Providing an empty array for websites or locations will clear them.
      operationId: updateOrgMe
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateOrgSelfRequest'
      responses:
        '200':
          description: Updated organization details.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgDetailResponse'
        '400':
          description: Validation error.
        '401':
          description: Unauthorized.
        '404':
          description: Organization not found.
        '409':
          description: Slug already taken.

  /org/me/runs-enabled:
    patch:
      tags: [Organization]
      summary: Toggle the org-wide runs master switch
      description: |
        Enables or disables every scheduled prompt run and content-generation run for the organization in one call. Use this to pause all background activity (e.g. while reconfiguring your KB) without touching individual schedules. Requires `update:org` permission.
      operationId: updateOrgRunsEnabled
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateOrgRunsEnabledRequest'
      responses:
        '200':
          description: Updated organization details.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgDetailResponse'
        '400':
          description: Validation error.
        '403':
          description: Caller lacks `update:org` permission.

  # ──────────────────────────────────────────────
  # Users — /org/users
  # ──────────────────────────────────────────────
  /org/users:
    get:
      tags: [Users]
      summary: List users in organization
      description: List all users who can access your organization's knowledge base, with their roles and permissions for ingesting, querying, and generating.
      operationId: listOrgUsers
      parameters:
        - in: query
          name: limit
          schema:
            type: integer
            default: 10
        - in: query
          name: offset
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: List of org-user relationships.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/OrgUserResponse'
    post:
      tags: [Users]
      summary: Add user to organization
      description: Grant a user access to your organization. Assign a role that determines what they can do — ingest raw sources, query the knowledge base, generate content, or manage settings.
      operationId: addOrgUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateOrgUserRequest'
      responses:
        '201':
          description: User added.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgUserResponse'
        '400':
          description: Validation error.
        '404':
          description: Organization or user not found.
        '409':
          description: User already in organization.

  /org/users/{userId}:
    get:
      tags: [Users]
      summary: Get a user in the organization
      description: Returns a user's role and relationship to your organization, including their access level for the knowledge base.
      operationId: getOrgUser
      parameters:
        - in: path
          name: userId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Org-user relationship.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgUserResponse'
        '404':
          description: User not found in organization.
    put:
      tags: [Users]
      summary: Update a user's role in the organization
      description: Change a user's role to control what they can do — from read-only querying to full admin access over ingestion, generation, and settings.
      operationId: updateOrgUser
      parameters:
        - in: path
          name: userId
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateOrgUserRequest'
      responses:
        '200':
          description: Updated org-user relationship.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgUserResponse'
        '400':
          description: Validation error.
        '404':
          description: User not found in organization.
    delete:
      tags: [Users]
      summary: Remove a user from the organization
      description: Revoke a user's access to your organization's knowledge base and all associated resources.
      operationId: removeOrgUser
      parameters:
        - in: path
          name: userId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: User removed.
        '404':
          description: User not found in organization.

  /org/users/{userId}/current:
    patch:
      tags: [Users]
      summary: Set organization as current for user
      description: Marks this organization as the user's currently active organization.
      operationId: setCurrentOrg
      parameters:
        - in: path
          name: userId
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetCurrentOrgRequest'
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgUserResponse'
        '404':
          description: User not found in organization.

  /org/users/invite:
    post:
      tags: [Users]
      summary: Invite a new user to the organization by email
      description: |
        Creates a brand-new user (in Clerk and Senso) and adds them to your organization with the specified role. Use this when the email does not yet belong to any Senso user.

        If the email is already attached to an existing Senso user, use `/org/users/invite/existing` instead.
      operationId: inviteOrgUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InviteOrgUserRequest'
      responses:
        '201':
          description: User created and added.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgUserResponse'
        '400':
          description: Validation error.
        '409':
          description: User already exists or already a member.

  /org/users/invite/existing:
    post:
      tags: [Users]
      summary: Invite an existing user to the organization by email
      description: |
        Adds an existing Senso user (looked up by email) to your organization with the specified role. The user must already exist — if they don't, this returns 404, and you should use `/org/users/invite` instead.
      operationId: inviteExistingOrgUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InviteExistingOrgUserRequest'
      responses:
        '201':
          description: Existing user added.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgUserResponse'
        '400':
          description: Validation error.
        '404':
          description: No user found for that email.
        '409':
          description: User is already a member.

  # ──────────────────────────────────────────────
  # Roles — /org/roles
  # ──────────────────────────────────────────────
  /org/roles:
    get:
      tags: [Roles]
      summary: List roles for the current organization
      description: |
        Returns every role defined for your organization, including the built-in `admin`, `collaborator`, and `viewer` roles plus any custom roles. Each role has its own `role_id` per organization — resolve a role name to its `role_id` here before calling user-mutation endpoints, since those accept `role_id` only.
      operationId: listOrgRoles
      responses:
        '200':
          description: List of roles for the organization.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/RoleResponse'
        '401':
          description: Unauthorized.
        '403':
          description: Caller lacks `list:org_roles` permission.

  # ──────────────────────────────────────────────
  # API Keys — /org/api-keys
  # ──────────────────────────────────────────────
  /org/api-keys:
    get:
      tags: [API Keys]
      summary: List org API keys
      description: Returns a paginated list of active (non-revoked) API keys for the org. Requires org admin (JWT only).
      operationId: orgListAPIKeys
      parameters:
        - in: query
          name: limit
          schema:
            type: integer
            default: 10
        - in: query
          name: offset
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: Paginated list of API keys.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/APIKeyResponse'
                  total:
                    type: integer
                  limit:
                    type: integer
                  offset:
                    type: integer
        '403':
          description: Requires org admin role.
    post:
      tags: [API Keys]
      summary: Create API key
      description: Creates a new API key for authenticating agents against your knowledge base. The actual key value is only returned in this response — store it securely. Your agents use this key to ingest, query, and generate.
      operationId: createOrgApiKey
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateAPIKeyRequest'
      responses:
        '201':
          description: API key created. Key value is included only in this response.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIKeyCreateResponse'
        '400':
          description: Validation error.
        '404':
          description: Organization not found.

  /org/api-keys/{keyId}:
    get:
      tags: [API Keys]
      summary: Get API key
      description: Returns details for a specific API key. The actual key value is never returned after creation — only the name and metadata.
      operationId: getOrgApiKey
      parameters:
        - in: path
          name: keyId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: API key details.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIKeyResponse'
        '404':
          description: API key not found.
    put:
      tags: [API Keys]
      summary: Update API key
      description: Update an API key's name or settings. Use this to track which agents or environments each key authenticates.
      operationId: updateOrgApiKey
      parameters:
        - in: path
          name: keyId
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateAPIKeyRequest'
      responses:
        '200':
          description: Updated API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIKeyResponse'
        '400':
          description: Validation error.
        '404':
          description: API key not found.
    delete:
      tags: [API Keys]
      summary: Delete API key
      description: Permanently delete an API key. Any agent using this key immediately loses access to your knowledge base.
      operationId: deleteOrgApiKey
      parameters:
        - in: path
          name: keyId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: API key deleted.
        '404':
          description: API key not found.

  /org/api-keys/{keyId}/revoke:
    post:
      tags: [API Keys]
      summary: Revoke API key
      description: Permanently revokes the API key. It can no longer be used for authentication.
      operationId: revokeOrgApiKey
      parameters:
        - in: path
          name: keyId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: API key revoked.
        '404':
          description: API key not found.

  /org/api-keys/{keyId}/kb-permissions:
    get:
      tags: [API Keys]
      summary: Get KB scope for an API key
      description: Returns which knowledge base folders and documents this API key is restricted to. When scoped, search results are automatically filtered to only include content within the granted folders.
      operationId: getOrgApiKeyKBPermissions
      parameters:
        - in: path
          name: keyId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: List of KB scope grants.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/APIKeyScopeGrant'
        '404':
          description: API key not found.
    put:
      tags: [API Keys]
      summary: Set KB scope for an API key
      description: |
        Restricts this API key to specific knowledge base folders and documents. Once scoped, search results and content access are automatically filtered to only include content within the granted folders and their subfolders.

        **Requires a user (JWT) session.** Each grant records the granting user, so this endpoint cannot be called with API-key auth — an API key request returns `401`. (Reading and removing scope — `GET`/`DELETE` on this path — work with either auth.)
      operationId: setOrgApiKeyKBPermissions
      parameters:
        - in: path
          name: keyId
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetAPIKeyScopeRequest'
      responses:
        '200':
          description: Updated KB scope grants.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/APIKeyScopeGrant'
        '400':
          description: Validation error.
        '401':
          description: Called with API-key auth — this endpoint requires a user (JWT) session.
        '404':
          description: API key not found.
    delete:
      tags: [API Keys]
      summary: Remove KB scope from an API key
      description: Removes all knowledge base restrictions from the API key, restoring full access to all org content.
      operationId: deleteOrgApiKeyKBPermissions
      parameters:
        - in: path
          name: keyId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: KB scope removed.
        '404':
          description: API key not found.

  # ──────────────────────────────────────────────
  # Permissions — /org/permissions
  # ──────────────────────────────────────────────
  /org/permissions:
    get:
      tags: [Permissions]
      summary: Get available permissions
      description: Returns all available permission keys with their names, descriptions, and categories. Useful for building role management UIs.
      operationId: getOrgAvailablePermissions
      responses:
        '200':
          description: Available permissions.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AvailablePermissionsResponse'
        '401':
          description: Unauthorized.

  # ──────────────────────────────────────────────
  # Credits — /org/credits
  # ──────────────────────────────────────────────
  /org/credits/balance:
    get:
      tags: [Credits]
      summary: Get credit balance for ingest, query, and generation usage
      description: 
        Returns the organization's current credit availability and usage.
        `credits_available` reflects the remaining credits for this org. A null value indicates unlimited credits.
      responses:
        '200':
          description: Credit balance.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreditBalanceResponse'
        '401':
          description: Unauthorized.

  # ──────────────────────────────────────────────
  # Search — /org/search
  # ──────────────────────────────────────────────
  /org/search:
    post:
      tags: [Search]
      summary: Query knowledge base with grounded AI answer
      description: |
        Queries your compiled knowledge base using vector similarity,
        and returns matching chunks plus a grounded AI answer backed by source citations.
        Requires the API key to have KB scope configured.

        **Auto-tagging:** every search query received on this endpoint (and the other `/org/search*` variants) is auto-tagged server-side. The classifier focuses on user *intent* (e.g. "how do I update my beneficiary" → `beneficiary updates`), which populates search-query analytics without any caller action.
      operationId: orgSearch
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SearchRequest'
      responses:
        '200':
          description: Search results with AI answer.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SearchResponse'
        '400':
          description: Query is required.
        '403':
          description: No KB scope configured for this API key.

  /org/search/context:
    post:
      tags: [Search]
      summary: Query knowledge base — raw context chunks
      description: Same vector query as /org/search but skips AI answer generation. Returns raw compiled chunks directly — ideal for feeding into your own LLM or agent pipeline.
      operationId: orgSearchContext
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SearchRequest'
      responses:
        '200':
          description: Matching content chunks.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SearchContextResponse'
        '400':
          description: Query is required.
        '403':
          description: No KB scope configured for this API key.

  /org/search/full:
    post:
      tags: [Search]
      summary: Query knowledge base with grounded AI answer (alias)
      description: Identical to POST /org/search.
      operationId: orgSearchFull
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SearchRequest'
      responses:
        '200':
          description: Search results with AI answer.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SearchResponse'
        '400':
          description: Query is required.
        '403':
          description: No KB scope configured for this API key.

  /org/search/content:
    post:
      tags: [Search]
      summary: Query knowledge base — matching source IDs only
      description: Same vector query but deduplicates results by content item and returns only IDs and titles of matching ingested sources. No chunks or AI answer.
      operationId: orgSearchContent
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SearchRequest'
      responses:
        '200':
          description: Matched content items.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SearchContentResponse'
        '400':
          description: Query is required.
        '403':
          description: No KB scope configured for this API key.

  /org/search/stream:
    post:
      tags: [Search]
      summary: Streaming query (SSE)
      description: |
        Same vector query as /org/search, but returns results as Server-Sent Events.
        Grounded answer tokens stream first, then sources arrive after the answer completes.

        **Event sequence:**

        1. `token` (repeated) — individual answer tokens as they are generated
        2. `sources` — search result chunks and metadata, sent after the answer completes
        3. `done` — stream complete, no payload

        If answer generation fails, an `error` event is sent instead of tokens, and the stream ends (no sources).

        **Event payloads:**

        - `event: token` → `$ref: '#/components/schemas/SSETokenEvent'`
        - `event: sources` → `$ref: '#/components/schemas/SSESourcesEvent'`
        - `event: error` → `$ref: '#/components/schemas/SSEErrorEvent'`
        - `event: done` → `{}`

        When no chunks are found (empty results), the stream skips token events and sends sources (with empty results array) followed by done.
      operationId: orgSearchStream
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SearchRequest'
      responses:
        '200':
          description: SSE event stream.
          content:
            text/event-stream:
              schema:
                type: string
                description: Server-Sent Events stream. See description for event types and payloads.
        '400':
          description: Query is required.
        '403':
          description: No KB scope configured for this API key.

  # ──────────────────────────────────────────────
  # Knowledge Base — /org/kb
  # ──────────────────────────────────────────────
  /org/kb/root:
    get:
      tags: [Knowledge Base]
      summary: Get root folder
      description: Returns the root folder of your knowledge base. Every org has exactly one root folder — it's the top of the tree.
      operationId: orgKBGetRoot
      responses:
        '200':
          description: Root folder.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KBNodeResponse'

  /org/kb/my-files:
    get:
      tags: [Knowledge Base]
      summary: List top-level files and folders
      description: Returns the top-level files and folders visible to the caller, based on API key permissions. This is the best starting point for browsing a knowledge base.
      operationId: orgKBListMyFiles
      parameters:
        - in: query
          name: limit
          schema:
            type: integer
            default: 50
        - in: query
          name: offset
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: Paginated list of accessible top-level files and folders.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KBNodeListResponse'

  /org/kb/find:
    get:
      tags: [Knowledge Base]
      summary: Search files and folders by name
      description: Searches for files and folders by name within the caller's accessible scope. Use this to find specific documents without browsing the folder tree.
      operationId: orgKBFind
      parameters:
        - in: query
          name: q
          required: true
          schema:
            type: string
          description: Search query string.
        - in: query
          name: limit
          schema:
            type: integer
            default: 50
            maximum: 50
        - in: query
          name: offset
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: Matching files and folders.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KBNodeListResponse'

  /org/kb/sync-status:
    get:
      tags: [Knowledge Base]
      summary: Get vector sync status
      description: Returns whether the knowledge base is currently updating its search indexes. This happens automatically after you move or delete files/folders. While syncing is in progress, search results may be slightly stale.
      operationId: orgKBGetSyncStatus
      responses:
        '200':
          description: Sync status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SyncStatusResponse'

  /org/kb/nodes/{id}:
    get:
      tags: [Knowledge Base]
      summary: Get a file or folder
      description: Returns details for a specific file or folder by its ID. The caller must have view access.
      operationId: orgKBGetNode
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
          description: The ID of the file or folder (kb_node_id).
      responses:
        '200':
          description: File or folder details.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KBNodeResponse'
        '403':
          description: No access to this file or folder.
    delete:
      tags: [Knowledge Base]
      summary: Delete a file or folder
      description: Soft-deletes a file or folder. Deleting a folder also deletes everything inside it. Search indexes are updated asynchronously.
      operationId: orgKBDeleteNode
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
          description: The ID of the file or folder to delete.
      responses:
        '204':
          description: Deleted.
        '400':
          description: Cannot delete the root folder.
        '403':
          description: No access to this file or folder.

  /org/kb/nodes/{id}/children:
    get:
      tags: [Knowledge Base]
      summary: List contents of a folder
      description: Returns the files and subfolders inside a folder, paginated. The caller must have view access to the folder.
      operationId: orgKBListChildren
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
          description: The folder ID to list contents of.
        - in: query
          name: limit
          schema:
            type: integer
            default: 50
        - in: query
          name: offset
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: Paginated list of files and folders inside the parent.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KBNodeListResponse'
        '403':
          description: No access to this folder.
        '404':
          description: Folder not found.

  /org/kb/nodes/{id}/ancestors:
    get:
      tags: [Knowledge Base]
      summary: Get breadcrumb path
      description: Returns the full folder path from the root down to the specified file or folder (inclusive). Useful for building breadcrumb navigation.
      operationId: orgKBGetAncestors
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
          description: The ID of the file or folder.
      responses:
        '200':
          description: Breadcrumb path from root to the requested item.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KBNodeAncestorsResponse'
        '403':
          description: No access to this file or folder.

  /org/kb/nodes/{id}/rename:
    patch:
      tags: [Knowledge Base]
      summary: Rename a file or folder
      description: Renames a file or folder. For documents, this also updates the title on the current content version.
      operationId: orgKBRenameNode
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
          description: The ID of the file or folder to rename.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RenameKBNodeRequest'
      responses:
        '200':
          description: Renamed file or folder.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KBNodeResponse'
        '403':
          description: No write access to this file or folder.

  /org/kb/nodes/{id}/move:
    patch:
      tags: [Knowledge Base]
      summary: Move a file or folder
      description: Moves a file or folder to a different parent folder. Search indexes are updated asynchronously after the move.
      operationId: orgKBMoveNode
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
          description: The ID of the file or folder to move.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MoveKBNodeRequest'
      responses:
        '200':
          description: Moved file or folder.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KBNodeResponse'
        '400':
          description: Cannot move the root folder, invalid parent, or would create a circular folder structure.
        '403':
          description: No write access.

  /org/kb/raw:
    post:
      tags: [Knowledge Base]
      summary: Ingest raw text content
      description: |
        Creates a new document from text or markdown (instead of uploading a file). Optionally place it in a folder using `kb_folder_node_id`. The content is processed the same way as uploaded files — parsed, chunked, and embedded for search.

        **Auto-tagging:** the new document is auto-tagged in the background using its title, summary, and text. For uploaded files (`/org/kb/upload`) the same tagging runs after the embedding worker finishes — inspect tags with `GET /org/kb/nodes/{id}/tags` or override via the tag endpoints.
      operationId: orgKBCreateRawContent
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateRawContentRequest'
      responses:
        '202':
          description: Content created and queued for processing.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContentResponse'
        '400':
          description: Invalid request (text is required).
        '403':
          description: No write access to the target folder.

  /org/kb/nodes/{id}/raw:
    put:
      tags: [Knowledge Base]
      summary: Full replace of text content
      description: Replaces the entire text of a document. Creates a new content version. Use PATCH for partial updates.
      operationId: orgKBUpdateRawContent
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
          description: The ID of the document to update.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateRawContentRequest'
      responses:
        '200':
          description: Updated content.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContentResponse'
        '403':
          description: No write access to this document.
    patch:
      tags: [Knowledge Base]
      summary: Partial update of text content
      description: Updates specific fields of a text document without replacing the whole thing. Only the fields you send are changed.
      operationId: orgKBPatchRawContent
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
          description: The ID of the document to update.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PatchRawContentRequest'
      responses:
        '200':
          description: Updated content.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContentResponse'
        '400':
          description: At least one field must be provided.
        '403':
          description: No write access to this document.

  /org/kb/upload:
    post:
      tags: [Knowledge Base]
      summary: Ingest raw sources
      description: |
        Ingest raw sources into your knowledge base using a two-step presigned URL flow:

        1. **Request upload URLs** — Call this endpoint with file metadata (up to 10 files at once). Optionally specify `kb_folder_node_id` to ingest into a specific folder.
        2. **Upload to S3** — PUT each raw source directly to S3 using the returned `upload_url` (no API key needed, the URL is pre-authenticated). Set the `Content-Type` header to the same value you declared in the file metadata, or S3 rejects the signed request.

        After upload, a background worker compiles the raw source — parses, chunks, and embeds it for querying. The response returns a **content id**; resolve it to a KB **node id** (e.g. `GET /org/kb/find?q=<filename>`, matching `content_id`) and poll `GET /org/kb/nodes/{id}/content` until `processing_status` is `"complete"` before querying. (`GET /org/content/{id}` serves generated content only and returns a 400 for knowledge-base sources.)
      operationId: orgKBUploadFiles
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/IngestionUploadRequest'
      responses:
        '200':
          description: Upload results. Check each item's `status` field.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IngestionUploadResponse'
        '402':
          description: Insufficient credits or spending limit reached.
        '403':
          description: No write access to the target folder.
        '422':
          description: All files were skipped (duplicates, conflicts, or invalid metadata).

  /org/kb/nodes/{id}/file:
    put:
      tags: [Knowledge Base]
      summary: Replace source on existing document
      description: Ingest a new version of a raw source that's already in your knowledge base. Same presigned URL flow as `/org/kb/upload`, but targets an existing document instead of creating a new one. The knowledge base recompiles automatically. The old version is preserved — you can retrieve it by version number.
      operationId: orgKBUpdateFile
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
          description: The ID of the document to update with a new file.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/IngestionUpdateRequest'
      responses:
        '200':
          description: Upload result for the updated file.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IngestionUploadResultItem'
        '402':
          description: Insufficient credits or spending limit reached.
        '403':
          description: No write access to this document.
        '404':
          description: Document not found.

  /org/kb/folders:
    post:
      tags: [Knowledge Base]
      summary: Create a folder
      description: Creates a new folder in your knowledge base. If `parent_id` is omitted, the folder is created at the root level. Use folders to organize ingested sources by topic, department, or any structure that makes sense for your org.
      operationId: orgKBCreateFolder
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateFolderRequest'
      responses:
        '201':
          description: Folder created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KBNodeResponse'
        '400':
          description: Invalid request or invalid parent folder.
        '403':
          description: No write access to the parent folder.

  /org/kb/nodes/{id}/content:
    get:
      tags: [Knowledge Base]
      summary: Get content details for a document
      description: Returns full content metadata and text for a document. For text/markdown documents, the response includes the raw text. For uploaded files, it includes metadata like title, summary, and processing status. Optionally pass `?version=N` to retrieve a specific version.
      operationId: orgKBGetContent
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
          description: The document ID.
        - in: query
          name: version
          schema:
            type: integer
          description: Specific version number to retrieve. Omit for current version.
      responses:
        '200':
          description: Content details.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContentDetailResponse'
        '400':
          description: This item is a folder, not a document.
        '403':
          description: No access to this document.
        '404':
          description: Document or version not found.

  /org/kb/nodes/{id}/download-url:
    get:
      tags: [Knowledge Base]
      summary: Get download URL
      description: Returns a time-limited download URL for an uploaded file. The URL is pre-authenticated — no API key needed to download. Optionally pass `?version=N` to download a specific version.
      operationId: orgKBGetDownloadURL
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
          description: The document ID.
        - in: query
          name: version
          schema:
            type: integer
          description: Specific version number to download. Omit for current version.
      responses:
        '200':
          description: Presigned download URL.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContentDownloadURLResponse'
        '400':
          description: This item is a folder or text content (not a downloadable file).
        '403':
          description: No access to this document.
        '404':
          description: Document or version not found.

  /org/kb/nodes/{id}/tags:
    get:
      tags: [Tags]
      summary: List tags on a KB node
      operationId: listKBNodeTags
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Tags attached to the KB node.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/TagResponse'
        '403':
          description: No access to this node.
    put:
      tags: [Tags]
      summary: Replace KB node tags
      description: Replaces the KB node's full tag collection. Supply any mix of `tag_ids` and `tag_names`; unknown names are created. Tags can only be applied to content nodes (not folders).
      operationId: setKBNodeTags
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetResourceTagsRequest'
      responses:
        '200':
          description: Updated tag list.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/TagResponse'
        '400':
          description: Validation error, or the node is a folder.
        '403':
          description: No edit access on this node.
    post:
      tags: [Tags]
      summary: Attach a tag to a KB node (by id or name)
      operationId: attachKBNodeTag
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AttachResourceTagRequest'
      responses:
        '204':
          description: Tag attached (204 when attaching by id).
        '201':
          description: Tag attached (201 when attaching by name; returns the newly created or existing tag).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TagResponse'
        '400':
          description: Validation error, or the node is a folder.
        '403':
          description: No edit access on this node.
    delete:
      tags: [Tags]
      summary: Detach a tag from a KB node by name
      description: Detach via `name` query parameter. Idempotent.
      operationId: detachKBNodeTagByName
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
        - in: query
          name: name
          required: true
          schema:
            type: string
      responses:
        '204':
          description: Tag detached (or not present).
        '400':
          description: Missing `name` query parameter.
        '403':
          description: No edit access on this node.

  /org/kb/nodes/{id}/tags/{tagId}:
    post:
      tags: [Tags]
      summary: Attach a tag to a KB node by id
      operationId: attachKBNodeTagById
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
        - in: path
          name: tagId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Tag attached.
        '400':
          description: Tag does not belong to this organization, or the node is a folder.
        '403':
          description: No edit access on this node.
    delete:
      tags: [Tags]
      summary: Detach a tag from a KB node by id
      operationId: detachKBNodeTagById
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
        - in: path
          name: tagId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Tag detached.
        '403':
          description: No edit access on this node.

  /org/kb/nodes/{id}/permissions:
    get:
      tags: [Knowledge Base]
      summary: List permissions on a node
      description: Returns all permission grants on a KB node. Requires editor or owner access on the node.
      operationId: orgKBListPermissions
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
          description: KB node ID.
      responses:
        '200':
          description: List of permissions.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KBNodePermissionListResponse'
        '403':
          description: No share access to this node.
    post:
      tags: [Knowledge Base]
      summary: Grant permission on a node
      description: Grants a user or group access to a KB node. Requires editor or owner access. Only viewer and editor roles can be granted.
      operationId: orgKBGrantPermission
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
          description: KB node ID.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GrantPermissionRequest'
      responses:
        '201':
          description: Permission granted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KBNodePermissionResponse'
        '400':
          description: Only viewer and editor roles can be granted.
        '403':
          description: No share access to this node.
        '409':
          description: Grantee already has a permission on this node. Use PATCH to update.

  /org/kb/nodes/{id}/permissions/{permissionId}:
    patch:
      tags: [Knowledge Base]
      summary: Update a permission
      description: Changes the role on an existing permission grant. Cannot modify your own permission. Only viewer and editor roles can be assigned.
      operationId: orgKBUpdatePermission
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
          description: KB node ID.
        - in: path
          name: permissionId
          required: true
          schema:
            type: string
            format: uuid
          description: Permission ID.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdatePermissionRequest'
      responses:
        '200':
          description: Permission updated.
        '400':
          description: Cannot modify your own permission, or invalid role.
        '403':
          description: No share access to this node.
        '404':
          description: Permission not found.
    delete:
      tags: [Knowledge Base]
      summary: Revoke a permission
      description: Removes a permission grant from a node. Cannot revoke your own permission.
      operationId: orgKBRevokePermission
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
          description: KB node ID.
        - in: path
          name: permissionId
          required: true
          schema:
            type: string
            format: uuid
          description: Permission ID.
      responses:
        '200':
          description: Permission revoked.
        '400':
          description: Cannot revoke your own permission.
        '403':
          description: No share access to this node.
        '404':
          description: Permission not found.

  # ──────────────────────────────────────────────
  # Brand Kit — /org/brand-kit
  # ──────────────────────────────────────────────
  /org/brand-kit:
    get:
      tags: [Brand Kit]
      summary: Get brand kit
      description: Returns your org's brand identity — voice, tone, persona, and writing rules that shape all generated content from your compiled knowledge base.
      operationId: getBrandKit
      responses:
        '200':
          description: Brand kit.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BrandKitResponse'
    put:
      tags: [Brand Kit]
      summary: Upsert brand kit
      description: Creates or replaces your org's brand identity. The `guidelines` object accepts a fixed set of keys (any other key is rejected with a 400) — set voice, tone, persona, and writing rules so generated content sounds like you, not like generic AI.
      operationId: upsertBrandKit
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpsertBrandKitRequest'
      responses:
        '200':
          description: Brand kit saved.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BrandKitResponse'
        '400':
          description: Validation error.

    patch:
      tags: [Brand Kit]
      summary: Partially update brand kit
      description: Merges the provided `guidelines` fields into your existing brand kit without replacing the whole thing. Update just voice_and_tone or add a new writing rule without losing everything else.
      operationId: patchBrandKit
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PatchBrandKitRequest'
      responses:
        '200':
          description: Brand kit updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BrandKitResponse'
        '400':
          description: Validation error.

  # ──────────────────────────────────────────────
  # Content Types — /org/content-types
  # ──────────────────────────────────────────────
  /org/content-types:
    get:
      tags: [Content Types]
      summary: List content types
      description: Returns all output templates configured for your org. Content types define the format your compiled knowledge base gets generated into — FAQ, blog post, social post, etc. Check existing types before creating new ones to avoid duplicates.
      operationId: listContentTypes
      parameters:
        - in: query
          name: limit
          schema:
            type: integer
            default: 50
        - in: query
          name: offset
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: Paginated list of content types.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContentTypeListResponse'
    post:
      tags: [Content Types]
      summary: Create content type
      description: |
        Define a new output template for content generation. The `config.template` field is the most important — it tells the generation engine what shape to compile your knowledge into (e.g. "A concise FAQ under 800 words").

        The engine combines this template with your brand kit (voice) and ingested raw sources (knowledge) to produce grounded, verified content.
      operationId: createContentType
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateContentTypeRequest'
      responses:
        '201':
          description: Content type created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContentTypeResponse'
        '400':
          description: Validation error.
        '409':
          description: Content type name already exists.

  /org/content-types/{id}:
    get:
      tags: [Content Types]
      summary: Get content type by ID
      description: Returns the full configuration for a specific content type — including its template, writing rules, and CTA settings. Use this to inspect how content will be generated from your compiled knowledge base.
      operationId: getContentType
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Content type details.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContentTypeResponse'
        '404':
          description: Content type not found.
    put:
      tags: [Content Types]
      summary: Update content type
      description: Full replacement of a content type's name and config. Use read → merge → write to avoid losing existing fields. Changes affect all future generation calls using this type.
      operationId: updateContentType
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateContentTypeRequest'
      responses:
        '200':
          description: Updated content type.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContentTypeResponse'
        '400':
          description: Validation error.
        '404':
          description: Content type not found.
        '409':
          description: Content type name already exists.
    delete:
      tags: [Content Types]
      summary: Delete content type
      description: Permanently removes this output template. Existing content generated with this type is not affected, but no new content can be generated using it.
      operationId: deleteContentType
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Content type deleted.
        '404':
          description: Content type not found.
    patch:
      tags: [Content Types]
      summary: Partially update content type
      description: Merges the provided fields into the existing content type without replacing the whole config. Safe for tweaking a template or adding a writing rule without losing everything else.
      operationId: patchContentType
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PatchContentTypeRequest'
      responses:
        '200':
          description: Updated content type.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContentTypeResponse'
        '400':
          description: Validation error.
        '404':
          description: Content type not found.
        '409':
          description: Content type name already exists.

  # ──────────────────────────────────────────────
  # Content Generation — /org/content-generation
  # ──────────────────────────────────────────────
  /org/content-generation:
    get:
      tags: [Content Generation]
      summary: Get content generation settings
      description: Returns your org's generation pipeline configuration — how content is generated from your compiled knowledge base.
      operationId: getOrgContentGenerationSettings
      responses:
        '200':
          description: Settings retrieved.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContentGenerationSettingsResponse'
        '404':
          description: Organization not found.
    patch:
      tags: [Content Generation]
      summary: Update content generation settings
      description: Update generation pipeline settings that control how your compiled knowledge base powers content generation.
      operationId: updateOrgContentGenerationSettings
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateContentGenerationSettingsRequest'
      responses:
        '200':
          description: Settings updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContentGenerationSettingsResponse'
        '400':
          description: Invalid request payload.
        '404':
          description: Organization not found.
        '422':
          description: Cannot enable auto-publish — no publishers configured for this organization.

  /org/content-generation/sample:
    post:
      tags: [Content Generation]
      summary: Start async verified content generation from compiled knowledge base
      description: Start an async sample generation job for a single piece of content. The job queries your compiled knowledge base, combines your brand kit (voice), content type (format), and ingested raw sources (knowledge), then persists a draft content version when completed. Poll the sample job endpoint for status and results.
      operationId: generateOrgContentSample
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ContentGenerationSampleRequest'
      responses:
        '202':
          description: Sample generation job accepted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContentGenerationSampleJobSubmitResponse'
        '400':
          description: Invalid request payload.
        '500':
          description: Failed to create content generation sample job.

  /org/content-generation/sample-jobs/{sample_job_id}:
    get:
      tags: [Content Generation]
      summary: Get content generation sample job
      description: Poll an async sample generation job. Completed jobs include the generated content response; failed jobs include an operator-safe error code and message.
      operationId: getOrgContentGenerationSampleJob
      parameters:
        - name: sample_job_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Sample generation job status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContentGenerationSampleJobResponse'
        '400':
          description: Invalid sample job ID.
        '404':
          description: Content generation sample job not found.
        '500':
          description: Failed to retrieve content generation sample job.

  /org/content-generation/run:
    post:
      tags: [Content Generation]
      summary: Trigger content engine run
      description: Start a batch generation run across multiple prompts. The engine queries your compiled knowledge base and generates content for each prompt using configured content types.
      operationId: triggerOrgContentGenerationRun
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ContentGenerationRunRequest'
      responses:
        '202':
          description: Run accepted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContentGenerationRunResponse'
        '400':
          description: Invalid request payload.
        '409':
          description: An active run already exists for this org.
        '500':
          description: Runner not configured or internal error.
        '502':
          description: Runner authentication failed or trigger failed.

  /org/content-generation/job-context:
    get:
      tags: [Content Generation]
      summary: Get content generation job context
      description: Returns the org's full job context including all prompts with their queue status (create vs update), content state, and a summary of queue counts. Used internally by the content engine.
      operationId: getOrgContentGenerationJobContext
      responses:
        '200':
          description: Job context retrieved.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgJobContextResponse'
        '404':
          description: Organization not found.

  /org/content-generation/runs:
    get:
      tags: [Content Generation]
      summary: List content generation runs
      description: Returns paginated content generation runs for the authenticated org.
      operationId: listOrgContentGenerationRuns
      parameters:
        - in: query
          name: limit
          schema:
            type: integer
            default: 20
        - in: query
          name: offset
          schema:
            type: integer
            default: 0
        - in: query
          name: status
          schema:
            type: string
        - in: query
          name: trigger_mode
          schema:
            type: string
        - in: query
          name: active_only
          schema:
            type: boolean
        - in: query
          name: start_date
          schema:
            type: string
            format: date
        - in: query
          name: end_date
          schema:
            type: string
            format: date
        - in: query
          name: run_group_id
          schema:
            type: string
            format: uuid
        - in: query
          name: selection_source
          schema:
            type: string
        - in: query
          name: content_type_id
          schema:
            type: string
            format: uuid
        - in: query
          name: actor_type
          schema:
            type: string
      responses:
        '200':
          description: Paginated list of runs.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContentGenerationRunListResponse'
        '401':
          description: Unauthorized.

  /org/content-generation/runs/{run_id}:
    get:
      tags: [Content Generation]
      summary: Get a content generation run
      description: Returns status and metadata for a generation run — items generated, prompts processed, and any errors encountered.
      operationId: getOrgContentGenerationRun
      parameters:
        - in: path
          name: run_id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Run details.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContentGenerationRunDetail'
        '404':
          description: Run not found.

  /org/content-generation/runs/{run_id}/items:
    get:
      tags: [Content Generation]
      summary: List items in a content generation run
      description: Returns individual content items produced during a generation run, each grounded in your ingested raw sources.
      operationId: listOrgContentGenerationRunItems
      parameters:
        - in: path
          name: run_id
          required: true
          schema:
            type: string
            format: uuid
        - in: query
          name: limit
          schema:
            type: integer
            default: 100
        - in: query
          name: offset
          schema:
            type: integer
            default: 0
        - in: query
          name: status
          schema:
            type: string
            enum: [pending, running, succeeded, failed, skipped, stopped]
          description: Filter items by status.
      responses:
        '200':
          description: Paginated run items.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContentGenerationRunItemsResponse'
        '400':
          description: Invalid status filter.
        '404':
          description: Run not found.

  /org/content-generation/runs/{run_id}/logs:
    get:
      tags: [Content Generation]
      summary: List logs for a content generation run
      description: Detailed logs for a generation run — useful for debugging how content was generated from your knowledge base.
      operationId: listOrgContentGenerationRunLogs
      parameters:
        - in: path
          name: run_id
          required: true
          schema:
            type: string
            format: uuid
        - in: query
          name: limit
          schema:
            type: integer
            default: 100
        - in: query
          name: offset
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: Paginated run logs.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContentGenerationRunLogsResponse'
        '404':
          description: Run not found.

  # ──────────────────────────────────────────────
  # Content Engine — /org/content-engine
  # ──────────────────────────────────────────────
  /org/content-engine/publish:
    post:
      tags: [Content Engine]
      summary: Publish content via content engine
      description: Publish verified content to configured destinations. The final step in the ingest → compile → query → generate → publish pipeline.
      operationId: publishContentEngine
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ContentEnginePublishRequest'
      responses:
        '201':
          description: Content published.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContentEnginePublishResponse'
        '400':
          description: Validation error.
        '404':
          description: Organization not found.
        '409':
          description: An in-flight publish already exists for this content.
        '502':
          description: Publishing to a destination failed.

  /org/content-engine/draft:
    post:
      tags: [Content Engine]
      summary: Save content as draft via content engine
      description: Save generated content as a draft for editorial review. Content stays grounded in your knowledge base but isn't published yet.
      operationId: saveContentEngineDraft
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ContentEngineDraftRequest'
      responses:
        '201':
          description: Draft saved.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContentEngineDraftResponse'
        '400':
          description: Validation error.

  # ──────────────────────────────────────────────
  # Destinations — /org/destinations
  # ──────────────────────────────────────────────
  /org/destinations:
    get:
      tags: [Destinations]
      summary: List publish destinations
      description: |
        Returns every destination available to the org — the three shared citeables-system destinations (citeables, codeables, cucopilot) plus any custom domains this org has registered. Each row includes rollup stats (`live_count`, `last_publish_at`) and a `selected_for_generation` flag indicating whether the destination is currently active in the generation/publish pipeline.

        When `engine publish` is called with no `publisher_ids`, the backend publishes to every destination with `selected_for_generation: true`.
      operationId: listOrgDestinations
      responses:
        '200':
          description: Destinations returned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgDestinationsListResponse'
    post:
      tags: [Destinations]
      summary: Register a custom publish destination
      description: |
        Register a custom citeables-system domain as a new destination for this org. The domain is registered synchronously with the citeables service and linked to the org via `org_publishers`. The only supported `type` right now is `citeables` — additional types may be added in future releases. The call fails with 502 if the citeables registration fails (no publisher row is created in that case).
      operationId: addOrgDestination
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AddOrgDestinationRequest'
      responses:
        '201':
          description: Destination registered.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgDestinationResponse'
        '400':
          description: Validation error (e.g. unsupported `type`).
        '502':
          description: Citeables domain registration failed.

  /org/destinations/{publisherId}/remove:
    post:
      tags: [Destinations]
      summary: Remove a destination from the org
      description: |
        Remove a destination from the org. The `action` field controls what happens to content that's already live at that destination:

        | action | effect |
        |---|---|
        | `leave` | Stop publishing to this destination. Live articles remain live, their publish records are preserved. |
        | `unpublish` | Retract live articles from the destination and revert them to draft. Local content remains. |
        | `delete` | Unpublish AND hard-delete the local content records. Destructive. |

        For custom destinations, `also_remove_destination: true` deletes the publisher row itself (not just the org link), and `keep_domain: true` preserves the domain registration on the citeables side (useful for SEO migration). Both flags are no-ops for shared destinations.
      operationId: removeOrgDestination
      parameters:
        - in: path
          name: publisherId
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RemoveOrgDestinationRequest'
      responses:
        '200':
          description: Destination removed (or scheduled for removal).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RemoveOrgDestinationResponse'
        '400':
          description: Invalid action (must be `leave`, `unpublish`, or `delete`).
        '404':
          description: Destination is not assigned to this org.
        '502':
          description: External delete or deregistration failed.

  # ──────────────────────────────────────────────
  # Builder — /org/builder
  # ──────────────────────────────────────────────
  /org/builder/generation-runs:
    post:
      tags: [Builder]
      summary: Start a builder generation run
      description: Creates a new generation run record for tracking a builder session. Returns the run ID plus any brand kit and content type linked to the session.
      operationId: startBuilderGenerationRun
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/StartBuilderGenerationRunRequest'
      responses:
        '201':
          description: Generation run started.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StartBuilderGenerationRunResponse'
        '400':
          description: Validation error.
        '401':
          description: Unauthorized.

  /org/builder/generation-runs/{generationRunId}/complete:
    post:
      tags: [Builder]
      summary: Complete a builder generation run
      description: Marks a generation run as completed and optionally associates it with the resulting content and version.
      operationId: completeBuilderGenerationRun
      parameters:
        - in: path
          name: generationRunId
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CompleteBuilderGenerationRunRequest'
      responses:
        '204':
          description: Run completed.
        '400':
          description: Validation error.
        '404':
          description: Generation run not found.

  # ──────────────────────────────────────────────
  # Generated Content — /org/generated-content
  # ──────────────────────────────────────────────
  /org/generated-content/published:
    get:
      tags: [Content]
      summary: List published generated content
      description: Returns generated content items that have been published, newest first. Requires the GEO product and `read:content` permission.
      operationId: listPublishedGeneratedContent
      parameters:
        - in: query
          name: limit
          schema:
            type: integer
            default: 10
            maximum: 100
        - in: query
          name: offset
          schema:
            type: integer
            default: 0
        - in: query
          name: search
          schema:
            type: string
      responses:
        '200':
          description: Published generated content.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GeneratedContentListResponse'

  /org/generated-content/drafts:
    get:
      tags: [Content]
      summary: List draft generated content
      description: Returns generated content items still in draft, newest first. Requires the GEO product and `read:content` permission.
      operationId: listDraftGeneratedContent
      parameters:
        - in: query
          name: limit
          schema:
            type: integer
            default: 10
            maximum: 100
        - in: query
          name: offset
          schema:
            type: integer
            default: 0
        - in: query
          name: search
          schema:
            type: string
      responses:
        '200':
          description: Draft generated content.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GeneratedContentListResponse'

  /org/generated-content/{id}:
    get:
      tags: [Content]
      summary: Get generated content detail
      description: Returns a single generated content item including its question text and rendered body. Requires the GEO product and `read:content` permission.
      operationId: getGeneratedContentDetail
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Generated content detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GeneratedContentDetailResponse'
        '404':
          description: Content not found or not owned by the org.

  # ──────────────────────────────────────────────
  # Content — /org/content
  # ──────────────────────────────────────────────
  /org/content/verification:
    get:
      tags: [Content]
      summary: List content awaiting verification
      description: Returns generated content items awaiting human review — part of the verification workflow ensuring all published content is accurate and grounded.
      operationId: listContentVerification
      parameters:
        - in: query
          name: limit
          schema:
            type: integer
        - in: query
          name: offset
          schema:
            type: integer
        - in: query
          name: search
          schema:
            type: string
        - in: query
          name: status
          schema:
            type: string
            enum: [all, draft, review, rejected, published]
          description: Filter by editorial status. `review` is an alias for `draft`. Omit or use `all` to return all statuses.
        - in: query
          name: substatus
          schema:
            type: string
            enum: [pending_draft]
          description: Further narrows the result set. `pending_draft` is only valid together with `status=published` (returns published items still awaiting a verified draft).
      responses:
        '200':
          description: Verification items.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContentVerificationListResponse'

  /org/content/verification/counts:
    get:
      tags: [Content]
      summary: Content verification counts
      description: Returns lightweight counts of content by editorial status, plus per-destination published-domain summaries. Use this to render verification dashboards without paging through the full list.
      operationId: getContentVerificationCounts
      responses:
        '200':
          description: Verification counts.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContentVerificationCountsResponse'

  /org/content/versions/{versionId}/reject:
    post:
      tags: [Content]
      summary: Reject a content version
      description: Reject a generated content version during editorial review. The content won't be published.
      operationId: rejectContentVersion
      parameters:
        - in: path
          name: versionId
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RejectContentVersionRequest'
      responses:
        '204':
          description: Rejected.

  /org/content/versions/{versionId}/restore:
    post:
      tags: [Content]
      summary: Restore a content version to draft
      description: Restore a previously rejected content version back to draft status for re-review.
      operationId: restoreContentVersion
      parameters:
        - in: path
          name: versionId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Restored.

  /org/content/{id}:
    get:
      tags: [Content]
      summary: Get content by ID
      description: |
        Returns content detail by content ID. Only returns non-knowledge-base content
        (e.g. content engine generated content). Knowledge base content must be accessed
        via GET /org/kb/nodes/:id/content.
      operationId: orgGetContent
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
          description: Content ID.
      responses:
        '200':
          description: Content details.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContentDetailResponse'
        '400':
          description: Knowledge base content must be accessed through KB node endpoints.
        '404':
          description: Content not found.
    delete:
      tags: [Content]
      summary: Delete content (local + external)
      description: |
        Deletes a non-knowledge-base content item. Knowledge base content must be
        deleted via DELETE /org/kb/nodes/:id.
      operationId: deleteOrgContent
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
          description: Content ID.
      responses:
        '204':
          description: Content deleted.
        '400':
          description: Knowledge base content must be deleted through KB node endpoints.
        '404':
          description: Content not found.
        '502':
          description: External delete failed.

  /org/content/{id}/versions:
    get:
      tags: [Content]
      summary: List content versions
      description: Returns the version history for a content item, newest first, with the current version flagged via `is_current`.
      operationId: listContentVersions
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Version history.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContentVersionListResponse'
        '404':
          description: Content not found or not owned by the org.

  /org/content/{id}/unpublish:
    post:
      tags: [Content]
      summary: Unpublish content (bulk or per-record)
      description: |
        Unpublish a content item. Two modes:

        - **Bulk** (empty body): remove the content from every destination it's live on. Editorial status flips back to draft.
        - **Per-record** (body with `publish_record_ids[]`): retract only the specified publish records (one per content+destination pair). The content stays published as long as any publish_record remains live; its status flips to draft automatically once they're all gone.
      operationId: unpublishOrgContent
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UnpublishOrgContentRequest'
      responses:
        '200':
          description: Per-record unpublish completed (returns counts and any per-record failures).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UnpublishOrgContentResponse'
        '204':
          description: Bulk unpublish completed.
        '404':
          description: Content not found.
        '409':
          description: Content not published (bulk mode only).
        '502':
          description: External delete failed.

  /org/publish-records/{id}/retry:
    post:
      tags: [Content Engine]
      summary: Retry a failed publish record
      description: |
        Retry the publish for a single content+destination pair (identified by its publish record ID). Only works on records currently in the `failed` state — a successful retry flips the record to `live`. Use this when a publish fans out to multiple destinations and one destination transiently failed, rather than re-running the whole publish.
      operationId: retryPublishRecord
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Retry completed successfully.
        '404':
          description: Publish record not found or not owned by the org.
        '409':
          description: Publish record is not in a retryable state (must be `failed`).
        '502':
          description: External publish failed.

  /org/content/{id}/edit-events/bulk:
    post:
      tags: [Builder]
      summary: Bulk create content edit telemetry events
      description: Records a batch of editor interaction events for a content item. Used by the builder to track edits for analytics and feedback.
      operationId: bulkCreateContentEditEvents
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BulkBuilderEditTelemetryRequest'
      responses:
        '200':
          description: Events recorded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkBuilderEditTelemetryResponse'
        '400':
          description: Validation error.
        '404':
          description: Content not found.

  /org/content/{id}/owners:
    get:
      tags: [Content]
      summary: List content owners
      description: Returns users responsible for reviewing and maintaining a specific piece of generated content.
      operationId: listContentOwners
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Owners list.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ContentOwnerResponse'
    put:
      tags: [Content]
      summary: Replace content owners
      description: Replace the ownership list for a content item. Owners are notified when content needs verification.
      operationId: replaceContentOwners
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReplaceContentOwnersRequest'
      responses:
        '200':
          description: Owners updated.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ContentOwnerResponse'

  /org/content/{id}/owners/{userId}:
    delete:
      tags: [Content]
      summary: Remove content owner
      description: Remove a user from the ownership list for a content item.
      operationId: deleteContentOwner
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
        - in: path
          name: userId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Owner removed.

  # ──────────────────────────────────────────────
  # Members — /org/members
  # ──────────────────────────────────────────────
  /org/members:
    get:
      tags: [Members]
      summary: List organization members
      description: Returns all members of your organization — the people and agents who can access your compiled knowledge base.
      operationId: listOrgMembers
      parameters:
        - in: query
          name: limit
          schema:
            type: integer
            default: 50
            maximum: 1000
        - in: query
          name: offset
          schema:
            type: integer
        - in: query
          name: search
          schema:
            type: string
        - in: query
          name: sort
          schema:
            type: string
            enum: [name_asc, name_desc, email_asc, email_desc, created_asc, created_desc]
      responses:
        '200':
          description: Paginated members list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgMemberListResponse'

  # ──────────────────────────────────────────────
  # Questions — /org/questions
  # ──────────────────────────────────────────────
  /org/questions:
    get:
      tags: [Questions]
      summary: List org questions
      description: Returns all geo questions for the authenticated org. Use `question_type` to filter by organization or network questions.
      operationId: listOrgQuestions
      parameters:
        - in: query
          name: question_type
          schema:
            type: string
            enum: [organization, network]
            default: organization
      responses:
        '200':
          description: Questions list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GeoQuestionListResponse'
        '400':
          description: Invalid `question_type` (must be `organization` or `network`).
        '401':
          description: Unauthorized.
    post:
      tags: [Questions]
      summary: Create org question
      description: Create a question you want AI to answer well from your compiled knowledge base. Questions drive content generation when paired with a content type.
      operationId: createOrgQuestion
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateGeoQuestionRequest'
      responses:
        '201':
          description: Question created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GeoQuestionResponse'
        '400':
          description: Validation error.

  /org/questions/{questionId}:
    patch:
      tags: [Questions]
      summary: Patch org question
      description: Partially updates a question. Currently supports updating tag associations.
      operationId: patchOrgQuestion
      parameters:
        - in: path
          name: questionId
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PatchGeoQuestionRequest'
      responses:
        '200':
          description: Updated question.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GeoQuestionResponse'
        '400':
          description: Validation error.
        '404':
          description: Question not found.
    delete:
      tags: [Questions]
      summary: Delete org question
      description: Remove a question. Existing content generated from this question is not affected.
      operationId: deleteOrgQuestion
      parameters:
        - in: path
          name: questionId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Question deleted.
        '404':
          description: Question not found.

  # ──────────────────────────────────────────────
  # Prompts — /org/prompts
  # ──────────────────────────────────────────────
  /org/prompts:
    get:
      tags: [Prompts]
      summary: List prompts (geo questions)
      description: Returns paginated org-scoped prompts. Supports search and sort.
      operationId: listOrgPrompts
      parameters:
        - in: query
          name: limit
          schema:
            type: integer
            default: 50
            maximum: 100
        - in: query
          name: offset
          schema:
            type: integer
            default: 0
        - in: query
          name: search
          schema:
            type: string
        - in: query
          name: sort
          schema:
            type: string
            enum: [created_desc, created_asc, text_asc, text_desc, type_asc, type_desc]
            default: created_desc
      responses:
        '200':
          description: Paginated list of prompts.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgPromptListResponse'
    post:
      tags: [Prompts]
      summary: Create prompt
      description: |
        Creates a new org-scoped prompt. Type must be one of decision, consideration, awareness, evaluation.

        **Auto-tagging:** the prompt is auto-tagged in the background after creation (classified against the org's tag library, new tags created as needed). The response returns an empty `tags` array — re-fetch with `GET /org/prompts/{promptId}/tags` a moment later, or override manually via the tag endpoints.
      operationId: createOrgPrompt
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateOrgPromptRequest'
      responses:
        '201':
          description: Prompt created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgPromptListItem'
        '400':
          description: Validation error.

  /org/prompts/{promptId}:
    get:
      tags: [Prompts]
      summary: Get prompt with full run history
      description: |
        Returns the prompt along with all associated question runs, including mentions, claims, citations, and competitor data. Unpaginated and unfiltered — it returns everything stored for the prompt.

        For filtered metrics and latest answers, prefer `GET /org/analytics/prompts/{promptId}`: it takes a window and model/location filters, returns the metric history as a series with named denominators, and gives you the latest full answer per model × location.
      operationId: getOrgPrompt
      parameters:
        - in: path
          name: promptId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Prompt with run history.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgPromptDetailResponse'
        '403':
          description: Prompt does not belong to this organization.
        '404':
          description: Prompt not found.
    delete:
      tags: [Prompts]
      summary: Delete prompt
      description: Remove a prompt. Existing content generated from this prompt is not affected.
      operationId: deleteOrgPrompt
      parameters:
        - in: path
          name: promptId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Prompt deleted.
        '403':
          description: Prompt does not belong to this organization.
        '404':
          description: Prompt not found.

  # ──────────────────────────────────────────────
  # Run Configuration — /org/run-models, /org/run-schedule
  # ──────────────────────────────────────────────
  /org/run-models:
    get:
      tags: [Run Configuration]
      summary: Get configured AI models
      description: Returns the AI models currently configured for this org's question runs (e.g. chatgpt, gemini).
      operationId: getOrgRunModels
      responses:
        '200':
          description: Configured AI models.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgRunModelsResponse'
    put:
      tags: [Run Configuration]
      summary: Set AI models
      description: Replaces the org's configured AI models. At least one model name is required. Names are validated against the model registry — unknown names return 400 (see `GET /org/run-models/options` for the accepted list).
      operationId: setOrgRunModels
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetOrgRunModelsRequest'
      responses:
        '200':
          description: Updated AI models.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgRunModelsResponse'
        '400':
          description: Validation error (including unknown model names).

  /org/run-models/options:
    get:
      tags: [Run Configuration]
      summary: List valid model options
      description: The valid model names (with display labels) accepted by `PUT /org/run-models`, for use in pickers. Options are global, not per-org.
      operationId: getOrgRunModelOptions
      responses:
        '200':
          description: Valid model options.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ModelOptionsResponse'

  /org/scheduler-models:
    get:
      tags: [Run Configuration]
      summary: Get scheduler models
      description: The registry models (owner_models) the scheduler runs for this org. Distinct from `GET /org/run-models`, which is the legacy geo_models list — the two are kept in sync on writes.
      operationId: getOrgSchedulerModels
      responses:
        '200':
          description: The org's scheduler model opt-in.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ModelListResponse'
    put:
      tags: [Run Configuration]
      summary: Set scheduler models
      description: Replaces the org's scheduler model opt-in. Entries are validated against the scheduler-supported registry — an unsupported entry returns 400.
      operationId: setOrgSchedulerModels
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetModelsRequest'
      responses:
        '200':
          description: Updated scheduler models.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ModelListResponse'
        '400':
          description: Validation error (including unsupported models).

  /org/run-schedule:
    get:
      tags: [Run Configuration]
      summary: Get run schedule
      description: Returns the days of the week (0=Sunday, 1=Monday, ..., 6=Saturday) on which question runs are triggered.
      operationId: getOrgRunSchedule
      responses:
        '200':
          description: Run schedule.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgRunScheduleResponse'
    put:
      tags: [Run Configuration]
      summary: Set run schedule
      description: Replaces the org's run schedule. Values must be integers 0-6 (Sunday-Saturday).
      operationId: setOrgRunSchedule
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetOrgRunScheduleRequest'
      responses:
        '200':
          description: Updated run schedule.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgRunScheduleResponse'
        '400':
          description: Validation error (invalid day values).

  # ──────────────────────────────────────────────
  # Tags — /org/tags
  # ──────────────────────────────────────────────
  /org/tags:
    get:
      tags: [Tags]
      summary: List tags
      description: Returns every tag defined for the organization. Pass `counts=true` to also include usage counts per tag (prompts, content, KB content, generated content).
      operationId: listTags
      parameters:
        - in: query
          name: counts
          schema:
            type: boolean
            default: false
      responses:
        '200':
          description: Tag list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TagsListResponse'
    post:
      tags: [Tags]
      summary: Create tag
      description: Creates a new tag for the organization. Tag names are unique per org (case-insensitive). Most workflows skip this endpoint and rely on the attach-by-name endpoints, which create tags automatically.
      operationId: createTag
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTagRequest'
      responses:
        '201':
          description: Tag created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TagResponse'
        '400':
          description: Validation error.
        '409':
          description: A tag with that name already exists.

  /org/tags/{id}:
    get:
      tags: [Tags]
      summary: Get tag by id
      description: Returns a single tag along with usage counts (prompts, content).
      operationId: getTag
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Tag detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TagResponse'
        '404':
          description: Tag not found.
    patch:
      tags: [Tags]
      summary: Rename tag
      description: Updates the tag's name. Existing attachments on prompts, content, and KB nodes are preserved.
      operationId: updateTag
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateTagRequest'
      responses:
        '200':
          description: Tag updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TagResponse'
        '404':
          description: Tag not found.
        '409':
          description: A tag with the new name already exists.
    delete:
      tags: [Tags]
      summary: Delete tag
      description: Permanently deletes the tag and detaches it from every prompt, content item, and KB node it was applied to.
      operationId: deleteTag
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Tag deleted.
        '404':
          description: Tag not found.

  # ──────────────────────────────────────────────
  # Product Lines — /org/product-lines
  # ──────────────────────────────────────────────
  /org/product-lines:
    get:
      tags: [Product Lines]
      summary: List product lines
      description: Returns paginated product lines for the organization.
      operationId: listProductLines
      parameters:
        - in: query
          name: limit
          schema:
            type: integer
            default: 50
        - in: query
          name: offset
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: Paginated list of product lines.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductLineListResponse'
    post:
      tags: [Product Lines]
      summary: Create product line
      description: Creates a new product line. The `details` field accepts any JSON object — use it to capture metadata (SKUs, URLs, positioning, pricing tiers) that downstream generation and evaluation pipelines should know about.
      operationId: createProductLine
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateProductLineRequest'
      responses:
        '201':
          description: Product line created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductLineResponse'
        '400':
          description: Validation error.
        '409':
          description: A product line with that name already exists.

  /org/product-lines/{id}:
    get:
      tags: [Product Lines]
      summary: Get product line by id
      operationId: getProductLine
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Product line detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductLineResponse'
        '404':
          description: Product line not found.
    put:
      tags: [Product Lines]
      summary: Replace product line
      description: Full replacement of the product line's name and details. Use `PATCH` for partial updates.
      operationId: updateProductLine
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateProductLineRequest'
      responses:
        '200':
          description: Product line updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductLineResponse'
        '400':
          description: Validation error.
        '404':
          description: Product line not found.
        '409':
          description: A product line with that name already exists.
    patch:
      tags: [Product Lines]
      summary: Partially update product line
      description: Merges the provided fields into the existing product line. Omitted fields are left unchanged.
      operationId: patchProductLine
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PatchProductLineRequest'
      responses:
        '200':
          description: Product line updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductLineResponse'
        '400':
          description: Validation error.
        '404':
          description: Product line not found.
        '409':
          description: A product line with that name already exists.
    delete:
      tags: [Product Lines]
      summary: Delete product line
      operationId: deleteProductLine
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Product line deleted.
        '404':
          description: Product line not found.

  # ──────────────────────────────────────────────
  # Tracked Competitors — /org/competitors
  # ──────────────────────────────────────────────
  /org/competitors:
    get:
      tags: [Tracked Competitors]
      summary: List tracked competitors
      description: Returns every tracked competitor for the current organization.
      operationId: listOrgCompetitors
      responses:
        '200':
          description: Tracked competitor list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CompetitorListResponse'
    post:
      tags: [Tracked Competitors]
      summary: Add a tracked competitor
      description: Manually create a new tracked competitor entry. Requires `update:org` permission.
      operationId: createOrgCompetitor
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CompetitorRequest'
      responses:
        '201':
          description: Tracked competitor created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CompetitorResponse'
        '400':
          description: Validation error.
        '403':
          description: Caller lacks `update:org` permission.
        '409':
          description: A competitor with that name already exists for this org.

  /org/competitors/batch:
    post:
      tags: [Tracked Competitors]
      summary: Add multiple tracked competitors in one call
      description: |
        Bulk-create up to 50 competitors at once. Designed for accepting AI-generated suggestions returned by `POST /org/competitors/suggest` — the optional `source`, `rationale`, and `confidence` fields preserve provenance from the suggestion. Requires `update:org` permission.
      operationId: batchCreateOrgCompetitors
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CompetitorBatchRequest'
      responses:
        '201':
          description: Tracked competitors created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CompetitorListResponse'
        '400':
          description: Validation error.
        '403':
          description: Caller lacks `update:org` permission.

  /org/competitors/suggest:
    post:
      tags: [Tracked Competitors]
      summary: Get AI-generated competitor suggestions
      description: |
        Returns a fresh batch of suggested competitors. Suggestions are seeded from the organization's website (when set) and recent prompt-run results — orgs with neither will receive a 422. Each suggestion includes a confidence score and a short rationale; entries that already exist on the tracked list are flagged with `already_tracked: true` so the UI can disable them. Pass the suggestions you want to keep through `POST /org/competitors/batch`. Requires `update:org` permission.
      operationId: suggestOrgCompetitors
      responses:
        '200':
          description: Suggestions returned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuggestCompetitorsResponse'
        '403':
          description: Caller lacks `update:org` permission.
        '422':
          description: Org has no website and no runs — cannot generate suggestions.
        '429':
          description: Rate limit exceeded for suggestion requests.
        '503':
          description: Suggestion service unavailable.

  /org/competitors/{competitorId}:
    put:
      tags: [Tracked Competitors]
      summary: Update a tracked competitor
      operationId: updateOrgCompetitor
      parameters:
        - in: path
          name: competitorId
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CompetitorRequest'
      responses:
        '200':
          description: Tracked competitor updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CompetitorResponse'
        '400':
          description: Validation error.
        '403':
          description: Caller lacks `update:org` permission.
        '404':
          description: Tracked competitor not found.
    delete:
      tags: [Tracked Competitors]
      summary: Remove a tracked competitor
      operationId: deleteOrgCompetitor
      parameters:
        - in: path
          name: competitorId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Tracked competitor removed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted:
                    type: boolean
        '403':
          description: Caller lacks `update:org` permission.
        '404':
          description: Tracked competitor not found.

  # ──────────────────────────────────────────────
  # Tracked Sources — /org/tracked-sources
  # ──────────────────────────────────────────────
  /org/tracked-sources:
    get:
      tags: [Tracked Sources]
      summary: List tracked sources
      description: Returns all citation-classification rules for your organization.
      operationId: listOrgTrackedSources
      responses:
        '200':
          description: Tracked sources.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TrackedSourceListResponse'
    post:
      tags: [Tracked Sources]
      summary: Add a tracked source
      description: |
        Create a citation-classification rule. Requires `update:org` permission. New rules are always created active; the `active` field is ignored on create.
      operationId: createOrgTrackedSource
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TrackedSourceRequest'
      responses:
        '201':
          description: Tracked source created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TrackedSourceResponse'
        '400':
          description: Validation error (pattern required, invalid match_type/tier, etc.).
        '403':
          description: Caller lacks `update:org` permission.
        '409':
          description: A rule with this pattern already exists.

  /org/tracked-sources/{sourceId}:
    put:
      tags: [Tracked Sources]
      summary: Update a tracked source
      description: Update a citation-classification rule. Requires `update:org` permission. Rules created from published content are read-only and return 409.
      operationId: updateOrgTrackedSource
      parameters:
        - in: path
          name: sourceId
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TrackedSourceRequest'
      responses:
        '200':
          description: Tracked source updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TrackedSourceResponse'
        '400':
          description: Validation error.
        '403':
          description: Caller lacks `update:org` permission.
        '404':
          description: Tracked source not found.
        '409':
          description: Duplicate pattern, or the source is published and read-only.
    delete:
      tags: [Tracked Sources]
      summary: Remove a tracked source
      description: Delete a citation-classification rule. Requires `update:org` permission.
      operationId: deleteOrgTrackedSource
      parameters:
        - in: path
          name: sourceId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Tracked source removed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted:
                    type: boolean
        '403':
          description: Caller lacks `update:org` permission.
        '404':
          description: Tracked source not found.
        '409':
          description: The source is published and read-only.

  # ──────────────────────────────────────────────
  # Prompt tagging — /org/prompts/{promptId}/tags
  # ──────────────────────────────────────────────
  /org/prompts/{promptId}/tags:
    get:
      tags: [Tags]
      summary: List prompt tags
      operationId: listPromptTags
      parameters:
        - in: path
          name: promptId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Tags attached to the prompt.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/TagResponse'
        '404':
          description: Prompt not found.
    put:
      tags: [Tags]
      summary: Replace prompt tags
      description: Replaces the prompt's full tag collection. Supply any mix of `tag_ids` and `tag_names`; unknown names are created.
      operationId: setPromptTags
      parameters:
        - in: path
          name: promptId
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetResourceTagsRequest'
      responses:
        '200':
          description: Updated tag list.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/TagResponse'
        '400':
          description: Validation error.
        '404':
          description: Prompt not found.
    post:
      tags: [Tags]
      summary: Attach a tag to a prompt (by id or name)
      description: Attach a single tag. Supply `tag_id` to attach an existing tag, or `tag_name` to attach-by-name (the tag is created if it doesn't exist yet).
      operationId: attachPromptTag
      parameters:
        - in: path
          name: promptId
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AttachResourceTagRequest'
      responses:
        '204':
          description: Tag attached.
        '400':
          description: Validation error.
        '404':
          description: Prompt not found.
    delete:
      tags: [Tags]
      summary: Detach a tag from a prompt by name
      description: Detach by name via the `name` query parameter. Idempotent — returns 204 even if no matching tag is attached.
      operationId: detachPromptTagByName
      parameters:
        - in: path
          name: promptId
          required: true
          schema:
            type: string
            format: uuid
        - in: query
          name: name
          required: true
          schema:
            type: string
      responses:
        '204':
          description: Tag detached (or not present).
        '400':
          description: Missing `name` query parameter.
        '404':
          description: Prompt not found.

  /org/prompts/{promptId}/tags/{tagId}:
    post:
      tags: [Tags]
      summary: Attach a tag to a prompt by id
      operationId: attachPromptTagById
      parameters:
        - in: path
          name: promptId
          required: true
          schema:
            type: string
            format: uuid
        - in: path
          name: tagId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Tag attached.
        '400':
          description: Tag does not belong to this organization.
        '404':
          description: Prompt not found.
    delete:
      tags: [Tags]
      summary: Detach a tag from a prompt by id
      operationId: detachPromptTagById
      parameters:
        - in: path
          name: promptId
          required: true
          schema:
            type: string
            format: uuid
        - in: path
          name: tagId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Tag detached.
        '404':
          description: Prompt not found.

  # ──────────────────────────────────────────────
  # Content tagging — /org/content/{id}/tags
  # ──────────────────────────────────────────────
  /org/content/{id}/tags:
    get:
      tags: [Tags]
      summary: List tags on a content item
      description: Works for both ingested KB content and generated content.
      operationId: listContentTags
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Tags attached to the content item.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/TagResponse'
        '404':
          description: Content not found.
    put:
      tags: [Tags]
      summary: Replace content tags
      description: Replaces the content item's full tag collection. Supply any mix of `tag_ids` and `tag_names`; unknown names are created automatically.
      operationId: setContentTags
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetResourceTagsRequest'
      responses:
        '200':
          description: Updated tag list.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/TagResponse'
        '400':
          description: Validation error.
        '404':
          description: Content not found.
    post:
      tags: [Tags]
      summary: Attach a tag to content (by id or name)
      operationId: attachContentTag
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AttachResourceTagRequest'
      responses:
        '204':
          description: Tag attached.
        '400':
          description: Validation error.
        '404':
          description: Content not found.
    delete:
      tags: [Tags]
      summary: Detach a tag from content by name
      description: Detach via `name` query parameter. Idempotent.
      operationId: detachContentTagByName
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
        - in: query
          name: name
          required: true
          schema:
            type: string
      responses:
        '204':
          description: Tag detached (or not present).
        '400':
          description: Missing `name` query parameter.
        '404':
          description: Content not found.

  /org/content/{id}/tags/{tagId}:
    post:
      tags: [Tags]
      summary: Attach a tag to content by id
      operationId: attachContentTagById
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
        - in: path
          name: tagId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Tag attached.
        '400':
          description: Tag does not belong to this organization.
        '404':
          description: Content not found.
    delete:
      tags: [Tags]
      summary: Detach a tag from content by id
      operationId: detachContentTagById
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
        - in: path
          name: tagId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Tag detached.
        '404':
          description: Content not found.

  # ──────────────────────────────────────────────
  # Analytics — /org/analytics
  # ──────────────────────────────────────────────
  /org/analytics/glossary:
    get:
      tags: [Analytics]
      summary: Get the canonical metric glossary
      description: |
        Settle what a number actually measured before you quote it. Returns the canonical definition, denominator, and gotcha for every metric this API emits — `mention_rate`, both share-of-voice flavors, `avg_rank`, sentiment, the three Citation Rates, Citation Coverage, the three Citation Shares, `citations_per_answer`, plus the meta-concepts (`window`, `data_quality`, the citation tiers, and the latest-answers constraint).

        Static content: no database read, no filters, safe to cache for the life of a spec version. An agent that calls this once stops inventing denominators. The `definitions{}` block on every other analytics response is projected from this same source, so the two can never drift apart.
      operationId: getOrgAnalyticsGlossary
      responses:
        '200':
          description: The canonical definition of every metric this API emits.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnalyticsGlossaryResponse'
        '401':
          description: Unauthorized.
        '403':
          description: GEO analytics is not enabled for this organization, or the key lacks prompt-read access.

  /org/analytics/filters:
    get:
      tags: [Analytics]
      summary: Discover which filter values have data
      description: |
        Find out what you can usefully ask for before you ask it. Returns the models, locations, prompt types, tags, and tracked competitors that actually have data **for your organization**, plus the earliest and latest rollup days on record — so you never guess a model spelling, and never request a window that predates your first monitoring run.

        Model ids come back canonicalized to the short form the other endpoints accept, and `date_range` bounds every useful `from`/`to` pair. Call this first when you are about to build a filtered query on a org you do not already know.
      operationId: getOrgAnalyticsFilters
      responses:
        '200':
          description: The filter values that have data for this organization.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnalyticsFiltersResponse'
        '400':
          description: Organization context is missing from the credentials.
        '401':
          description: Unauthorized.
        '403':
          description: GEO analytics is not enabled for this organization, or the key lacks prompt-read access.

  /org/analytics/summary:
    get:
      tags: [Analytics]
      summary: Get the one-call analytics dashboard
      description: |
        Answer "how are we doing?" in a single request. Returns every headline metric for the window — mention rate, both share-of-voice metrics, average rank, sentiment, the three Citation Rates and the three Citation Shares, citation density — each alongside the raw additive counts it was computed from, so you can verify the math or recombine it.

        You also get the preceding window of equal length in `previous_window`, and `deltas` giving the movement of the four headline metrics in absolute points with a semantic direction (`improved` / `declined` / `flat`) rather than a raw sign you would have to interpret. A delta is omitted whenever either window lacked the denominator to compute the metric, so "no data" never reads as "fell to zero".
      operationId: getOrgAnalyticsSummary
      parameters:
        - in: query
          name: from
          description: First rollup day to include, `YYYY-MM-DD`, inclusive. Defaults to `to` minus 30 days.
          schema:
            type: string
            format: date
        - in: query
          name: to
          description: Last rollup day to include, `YYYY-MM-DD`, inclusive. Defaults to the most recent day that has data **for your active model and location filter** — not to today — so an unparameterized request never looks empty between monitoring runs.
          schema:
            type: string
            format: date
        - in: query
          name: models
          description: Comma-separated model ids to restrict to; omit for all models summed. Accepts `gpt-4.1`, `chatgpt`, `perplexity`, `aioverview`, `gemini`, `linkup`, `claude-sonnet-4-6`, `grok`, plus the provider-native aliases `google_ai_overviews` and `claude`. Any other value is rejected with a 400 listing the allowed set.
          schema:
            type: string
        - in: query
          name: location
          description: Comma-separated locations to restrict to; omit for all locations summed. Values are exact-match and case-sensitive, and must match what `GET /org/analytics/filters` returns (for example `US` or `US/California`). Also accepted as `locations` — the plural is an alias for this same parameter, because an unrecognized filter name would silently return *more* data rather than an error.
          schema:
            type: string
        - in: query
          name: prompt_type
          description: Restrict to one funnel stage.
          schema:
            type: string
            enum: [awareness, consideration, evaluation, decision]
        - in: query
          name: tag
          description: Restrict to prompts carrying this tag name.
          schema:
            type: string
      responses:
        '200':
          description: Headline metrics with their raw counts, the preceding window, and the deltas.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnalyticsSummaryResponse'
        '400':
          description: Invalid window (`from` after `to`, or a span longer than 365 days), unrecognized model, or unrecognized `prompt_type`.
        '401':
          description: Unauthorized.
        '403':
          description: GEO analytics is not enabled for this organization, or the key lacks prompt-read access.

  /org/analytics/mentions:
    get:
      tags: [Analytics]
      summary: Get the mention and share-of-voice time series
      description: |
        Chart your visibility over time and see whether a campaign actually moved it. Returns one bucket per day or ISO week with the raw counts — runs, answered runs, answers mentioning you, mention instances, both share-of-voice denominators, the rank sum, and the sentiment triple — plus the ratios derived from each bucket, and the same figures totaled over the whole window.

        Every count in the series is additive, so you can re-bucket the data however you like: sum the counts across periods and then divide. Never average the per-period rates — different periods carry different denominators, and averaging them silently reweights your window.
      operationId: getOrgAnalyticsMentions
      parameters:
        - in: query
          name: from
          description: First rollup day to include, `YYYY-MM-DD`, inclusive. Defaults to `to` minus 30 days.
          schema:
            type: string
            format: date
        - in: query
          name: to
          description: Last rollup day to include, `YYYY-MM-DD`, inclusive. Defaults to the most recent day that has data for your active model and location filter.
          schema:
            type: string
            format: date
        - in: query
          name: models
          description: Comma-separated model ids to restrict to; omit for all models summed. Accepts `gpt-4.1`, `chatgpt`, `perplexity`, `aioverview`, `gemini`, `linkup`, `claude-sonnet-4-6`, `grok`, plus the aliases `google_ai_overviews` and `claude`.
          schema:
            type: string
        - in: query
          name: location
          description: Comma-separated locations to restrict to; omit for all locations summed. Exact-match and case-sensitive (for example `US`, `US/California`). Also accepted as `locations` — the plural is an alias for this same parameter, because an unrecognized filter name would silently return *more* data rather than an error.
          schema:
            type: string
        - in: query
          name: prompt_type
          description: Restrict to one funnel stage.
          schema:
            type: string
            enum: [awareness, consideration, evaluation, decision]
        - in: query
          name: tag
          description: Restrict to prompts carrying this tag name.
          schema:
            type: string
        - in: query
          name: group_by
          description: Time bucket for the series. Weeks are ISO weeks starting Monday; a partial first or last bucket is flagged in `notes[]`.
          schema:
            type: string
            enum: [day, week]
            default: day
      responses:
        '200':
          description: Visibility time series with window totals.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnalyticsMentionsResponse'
        '400':
          description: Invalid window, unrecognized model, unrecognized `prompt_type`, or `group_by` outside `day` / `week`.
        '401':
          description: Unauthorized.
        '403':
          description: GEO analytics is not enabled for this organization, or the key lacks prompt-read access.

  /org/analytics/citations:
    get:
      tags: [Analytics]
      summary: Get the citation overview and time series
      description: |
        See how often AI answers cite anything at all, how often they cite you, and how your sources are weighted inside those answers — as both metric families at once, with the daily or weekly series underneath.

        The response returns both denominators explicitly: `D` (`cited_run_count`, answers carrying at least one citation) drives the three Citation Rates, and `S` (`cited_total`, citation instances) drives the three Citation Shares. Reading them side by side is the point — the tier rates are independent and can sum past 100% because one answer can cite several tiers, while the tier shares partition every cited URL and sum to exactly 100%.
      operationId: getOrgAnalyticsCitations
      parameters:
        - in: query
          name: from
          description: First rollup day to include, `YYYY-MM-DD`, inclusive. Defaults to `to` minus 30 days.
          schema:
            type: string
            format: date
        - in: query
          name: to
          description: Last rollup day to include, `YYYY-MM-DD`, inclusive. Defaults to the most recent day that has data for your active model and location filter.
          schema:
            type: string
            format: date
        - in: query
          name: models
          description: Comma-separated model ids to restrict to; omit for all models summed.
          schema:
            type: string
        - in: query
          name: location
          description: Comma-separated locations to restrict to; omit for all locations summed. Exact-match and case-sensitive (for example `US`, `US/California`). Also accepted as `locations` — the plural is an alias for this same parameter, because an unrecognized filter name would silently return *more* data rather than an error.
          schema:
            type: string
        - in: query
          name: prompt_type
          description: Restrict to one funnel stage.
          schema:
            type: string
            enum: [awareness, consideration, evaluation, decision]
        - in: query
          name: tag
          description: Restrict to prompts carrying this tag name.
          schema:
            type: string
        - in: query
          name: group_by
          description: Time bucket for the series. Weeks are ISO weeks starting Monday.
          schema:
            type: string
            enum: [day, week]
            default: day
      responses:
        '200':
          description: Citation totals, both metric families, and the series underneath.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnalyticsCitationsResponse'
        '400':
          description: Invalid window, unrecognized model, unrecognized `prompt_type`, or `group_by` outside `day` / `week`.
        '401':
          description: Unauthorized.
        '403':
          description: GEO analytics is not enabled for this organization, or the key lacks prompt-read access.

  /org/analytics/citations/domains:
    get:
      tags: [Analytics]
      summary: List cited domains with Citation Coverage and Citation Share
      description: |
        Map the source landscape AI answers draw on in your space, with your own domains and your tracked sources already classified. This is the endpoint behind "what should we be publishing on" and "are our own domains showing up at all".

        Each row gives the domain's **Citation Coverage** (of the answers that cited anything, the share that cited this domain — denominator `D`) and its **Citation Share** (its fraction of all citation instances — denominator `S`). Both denominators come from the same place the summary and citations endpoints use, so every grain in this API agrees. Per-domain shares sum to 100% across **all** domains in the window, not across the page of results you are looking at.
      operationId: getOrgAnalyticsCitationDomains
      parameters:
        - in: query
          name: from
          description: First rollup day to include, `YYYY-MM-DD`, inclusive. Defaults to `to` minus 30 days.
          schema:
            type: string
            format: date
        - in: query
          name: to
          description: Last rollup day to include, `YYYY-MM-DD`, inclusive. Defaults to the most recent day that has data for your active model and location filter.
          schema:
            type: string
            format: date
        - in: query
          name: models
          description: Comma-separated model ids to restrict to; omit for all models summed.
          schema:
            type: string
        - in: query
          name: location
          description: Comma-separated locations to restrict to; omit for all locations summed. Exact-match and case-sensitive (for example `US`, `US/California`). Also accepted as `locations` — the plural is an alias for this same parameter, because an unrecognized filter name would silently return *more* data rather than an error.
          schema:
            type: string
        - in: query
          name: prompt_type
          description: Restrict to one funnel stage.
          schema:
            type: string
            enum: [awareness, consideration, evaluation, decision]
        - in: query
          name: tag
          description: Restrict to prompts carrying this tag name. The domain rollup has no prompt grain, so this filter cannot be applied here — pass it and the response says so in `notes[]` and covers all prompts.
          schema:
            type: string
        - in: query
          name: tier
          description: Restrict to one classification tier — `primary` (Owned), `tracked` (Tracked), or `secondary` (External). Coverage and share still divide by the unfiltered `D` and `S`, so a filtered page's shares no longer sum to 100%.
          schema:
            $ref: '#/components/schemas/CitationTier'
        - in: query
          name: domain_contains
          description: Case-insensitive substring match on the domain.
          schema:
            type: string
        - in: query
          name: sort
          description: Order rows by citation instances (`citations`) or by Citation Coverage (`coverage`). `rank_by_citations` reflects whichever you chose.
          schema:
            type: string
            enum: [citations, coverage]
            default: citations
        - in: query
          name: limit
          schema:
            type: integer
            default: 50
            maximum: 100
        - in: query
          name: offset
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: Paged cited-domain landscape with both denominators.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnalyticsCitationDomainsResponse'
        '400':
          description: Invalid window, unrecognized model, unrecognized `prompt_type`, unrecognized `tier`, or `sort` outside `citations` / `coverage`.
        '401':
          description: Unauthorized.
        '403':
          description: GEO analytics is not enabled for this organization, or the key lacks prompt-read access.

  /org/analytics/citations/pages:
    get:
      tags: [Analytics]
      summary: List cited URLs with the prompts driving them
      description: |
        Drill from domains down to exact pages, and see which of your prompts drove each page's citations — the "what content is actually working" view. Filter to your own domain to audit your published pages, or leave it open to find the third-party URLs that keep beating you.

        Each row carries the page's **Citation Coverage** (distinct answers citing this URL ÷ `D`) and its **Citation Share** (this URL's citation instances ÷ `S`), plus up to five of the prompts that cited it most. URLs are stored canonicalized — tracking parameters stripped, address normalized — so they may not match your published links character for character.
      operationId: getOrgAnalyticsCitationPages
      parameters:
        - in: query
          name: from
          description: First rollup day to include, `YYYY-MM-DD`, inclusive. Defaults to `to` minus 30 days.
          schema:
            type: string
            format: date
        - in: query
          name: to
          description: Last rollup day to include, `YYYY-MM-DD`, inclusive. Defaults to the most recent day that has data for your active model and location filter.
          schema:
            type: string
            format: date
        - in: query
          name: models
          description: Comma-separated model ids to restrict to; omit for all models summed.
          schema:
            type: string
        - in: query
          name: location
          description: Comma-separated locations to restrict to; omit for all locations summed. Exact-match and case-sensitive (for example `US`, `US/California`). Also accepted as `locations` — the plural is an alias for this same parameter, because an unrecognized filter name would silently return *more* data rather than an error.
          schema:
            type: string
        - in: query
          name: prompt_type
          description: Restrict to one funnel stage.
          schema:
            type: string
            enum: [awareness, consideration, evaluation, decision]
        - in: query
          name: tag
          description: Restrict to prompts carrying this tag name. The webpage rollup has no prompt grain, so this filter cannot be applied here — pass it and the result still covers all prompts, exactly as on `/org/analytics/citations/domains`.
          schema:
            type: string
        - in: query
          name: tier
          description: Restrict to one classification tier — `primary` (Owned), `tracked` (Tracked), or `secondary` (External).
          schema:
            $ref: '#/components/schemas/CitationTier'
        - in: query
          name: domain
          description: Exact domain to restrict to. Use this to audit one site's pages.
          schema:
            type: string
        - in: query
          name: domain_contains
          description: Case-insensitive substring match on the page's domain.
          schema:
            type: string
        - in: query
          name: url_contains
          description: Case-insensitive substring match on the full canonicalized URL.
          schema:
            type: string
        - in: query
          name: sort
          description: Order rows by citation instances (`citations`) or by Citation Coverage (`coverage`).
          schema:
            type: string
            enum: [citations, coverage]
            default: citations
        - in: query
          name: limit
          schema:
            type: integer
            default: 50
            maximum: 100
        - in: query
          name: offset
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: Paged URL-grain citation table with both denominators.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnalyticsCitationPagesResponse'
        '400':
          description: Invalid window, unrecognized model, unrecognized `prompt_type`, unrecognized `tier`, or `sort` outside `citations` / `coverage`.
        '401':
          description: Unauthorized.
        '403':
          description: GEO analytics is not enabled for this organization, or the key lacks prompt-read access.

  /org/analytics/prompts:
    get:
      tags: [Analytics]
      summary: List per-prompt performance
      description: |
        Find the prompts where you are invisible. Returns one row per monitored prompt with its window performance — runs, answered runs, mentions, mention rate, share of voice, average rank, sentiment, and its Owned-tier Citation Rate — alongside the raw counts behind each ratio, so the table is sortable and auditable at once.

        Every row also carries a `latest` snapshot (when the prompt last ran, how many of those newest answers mentioned you, and which models produced them), so a list view can show where you stand right now without a second call. Sort ascending on `mention_rate` to put your worst-performing prompts on top.
      operationId: getOrgAnalyticsPrompts
      parameters:
        - in: query
          name: from
          description: First rollup day to include, `YYYY-MM-DD`, inclusive. Defaults to `to` minus 30 days.
          schema:
            type: string
            format: date
        - in: query
          name: to
          description: Last rollup day to include, `YYYY-MM-DD`, inclusive. Defaults to the most recent day that has data for your active model and location filter.
          schema:
            type: string
            format: date
        - in: query
          name: models
          description: Comma-separated model ids to restrict to; omit for all models summed.
          schema:
            type: string
        - in: query
          name: location
          description: Comma-separated locations to restrict to; omit for all locations summed. Exact-match and case-sensitive (for example `US`, `US/California`). Also accepted as `locations` — the plural is an alias for this same parameter, because an unrecognized filter name would silently return *more* data rather than an error.
          schema:
            type: string
        - in: query
          name: prompt_type
          description: Restrict to one funnel stage.
          schema:
            type: string
            enum: [awareness, consideration, evaluation, decision]
        - in: query
          name: tag
          description: Restrict to prompts carrying this tag name.
          schema:
            type: string
        - in: query
          name: search
          description: Case-insensitive substring match on the prompt text.
          schema:
            type: string
        - in: query
          name: sort
          description: Column to sort by. `citations` sorts on the prompt's cited-answer count, `answered` on its answered-run count, and `text` alphabetically.
          schema:
            type: string
            enum: [mention_rate, share_of_voice, citations, answered, text]
            default: mention_rate
        - in: query
          name: order
          schema:
            type: string
            enum: [asc, desc]
            default: desc
        - in: query
          name: limit
          schema:
            type: integer
            default: 50
            maximum: 100
        - in: query
          name: offset
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: Paged per-prompt performance table with window totals.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnalyticsPromptPerformanceResponse'
        '400':
          description: Invalid window, unrecognized model, unrecognized `prompt_type`, unrecognized `sort`, or `order` outside `asc` / `desc`.
        '401':
          description: Unauthorized.
        '403':
          description: GEO analytics is not enabled for this organization, or the key lacks prompt-read access.

  /org/analytics/prompts/{promptId}:
    get:
      tags: [Analytics]
      summary: Get one prompt end to end
      description: |
        Take a single prompt apart: its window totals and metrics, its day-by-day series, and the latest full answer from every model × location — response text, citations with their tiers, tracked-competitor mention counts, and whether you were named and where.

        Read the constraint before you interpret this: `latest_answers` holds only the **most recent** answer per model × location. Historical answer text is not retained, so `series` is the only record of what happened earlier in the window and it is counts, not text. Pass `include_answers=false` when you only want the trend and would rather not carry the response bodies.
      operationId: getOrgAnalyticsPromptDetail
      parameters:
        - in: path
          name: promptId
          required: true
          schema:
            type: string
            format: uuid
        - in: query
          name: from
          description: First rollup day to include, `YYYY-MM-DD`, inclusive. Defaults to `to` minus 30 days.
          schema:
            type: string
            format: date
        - in: query
          name: to
          description: Last rollup day to include, `YYYY-MM-DD`, inclusive. Defaults to the most recent day that has data for your active model and location filter.
          schema:
            type: string
            format: date
        - in: query
          name: models
          description: Comma-separated model ids to restrict to; omit for all models summed. Also narrows which latest answers are returned.
          schema:
            type: string
        - in: query
          name: location
          description: Comma-separated locations to restrict to; omit for all locations summed. Exact-match and case-sensitive (for example `US`, `US/California`). Also accepted as `locations` — the plural is an alias for this same parameter, because an unrecognized filter name would silently return *more* data rather than an error.
          schema:
            type: string
        - in: query
          name: prompt_type
          description: Restrict to one funnel stage. Rarely useful here, since the path already names a single prompt.
          schema:
            type: string
            enum: [awareness, consideration, evaluation, decision]
        - in: query
          name: tag
          description: Restrict to prompts carrying this tag name.
          schema:
            type: string
        - in: query
          name: include_answers
          description: Set `false` to omit `latest_answers` and return the metric history only. A note records that you did.
          schema:
            type: boolean
            default: true
      responses:
        '200':
          description: One prompt's metric history plus its latest full answers.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnalyticsPromptDetailResponse'
        '400':
          description: Invalid prompt ID, invalid window, unrecognized model, unrecognized `prompt_type`, or a non-boolean `include_answers`.
        '401':
          description: Unauthorized.
        '403':
          description: GEO analytics is not enabled for this organization, or the key lacks prompt-read access.
        '404':
          description: Prompt not found in this organization.

  /org/analytics/answers/latest:
    get:
      tags: [Analytics]
      summary: List the latest raw model answers
      description: |
        Read what the models actually say about you. Returns the newest stored answer for each prompt × model × location as one paged list — full response text, the cited URLs with their tiers, tracked-competitor mention counts, your rank and sentiment — so you can spot-check wording, feed downstream analysis, or let an agent quote a real answer instead of paraphrasing a metric.

        This is a snapshot, not a window: each row carries its own `run_at`, and those differ between rows. `from`/`to` are accepted, but they filter on **when each answer was collected** (`run_at`) — they do not surface history. Narrowing the window *hides* the prompt × model × location combinations whose latest answer falls outside it; it never returns an older answer, because only the newest one per combination is stored. Historical answer text is not retained anywhere in this API — use `/org/analytics/mentions` or `/org/analytics/citations` for what happened over a window. Filter with `mentioned`, `cited`, and `citation_tier` to jump straight to the answers worth reading.
      operationId: getOrgAnalyticsLatestAnswers
      parameters:
        - in: query
          name: from
          description: Return only answers **collected** (`run_at`) on or after this day, `YYYY-MM-DD`, inclusive. Optional; omit for no lower bound. This filters which of the latest answers you see — it never returns older answers.
          schema:
            type: string
            format: date
        - in: query
          name: to
          description: Return only answers **collected** (`run_at`) on or before this day, `YYYY-MM-DD`, inclusive. Optional; omit for no upper bound. Same validation as every other endpoint — 400 if `from` is after `to` or the span exceeds 365 days. Because only the newest answer per prompt × model × location is stored, narrowing the window drops whole combinations from the result rather than replacing them with an earlier answer.
          schema:
            type: string
            format: date
        - in: query
          name: models
          description: Comma-separated model ids to restrict to; omit for all models. Accepts `gpt-4.1`, `chatgpt`, `perplexity`, `aioverview`, `gemini`, `linkup`, `claude-sonnet-4-6`, `grok`, plus the aliases `google_ai_overviews` and `claude`.
          schema:
            type: string
        - in: query
          name: location
          description: Comma-separated locations to restrict to; omit for all locations. Exact-match and case-sensitive (for example `US`, `US/California`). Also accepted as `locations` — the plural is an alias for this same parameter, because an unrecognized filter name would silently return *more* data rather than an error.
          schema:
            type: string
        - in: query
          name: prompt_type
          description: Restrict to one funnel stage.
          schema:
            type: string
            enum: [awareness, consideration, evaluation, decision]
        - in: query
          name: tag
          description: Restrict to prompts carrying this tag name.
          schema:
            type: string
        - in: query
          name: mentioned
          description: Return only answers that did (`true`) or did not (`false`) name your brand. Omit for both.
          schema:
            type: boolean
        - in: query
          name: cited
          description: Return only answers that carried at least one citation (`true`) or none (`false`). Omit for both.
          schema:
            type: boolean
        - in: query
          name: citation_tier
          description: Return only answers carrying at least one citation of this tier. Because tiers do not partition answers, an answer can satisfy more than one tier filter.
          schema:
            $ref: '#/components/schemas/CitationTier'
        - in: query
          name: limit
          schema:
            type: integer
            default: 25
            maximum: 100
        - in: query
          name: offset
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: Paged list of the latest answer per prompt × model × location.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnalyticsLatestAnswersResponse'
        '400':
          description: Invalid window (`from` after `to`, or a span longer than 365 days), unrecognized model, unrecognized `prompt_type`, unrecognized `citation_tier`, or a non-boolean `mentioned` / `cited`.
        '401':
          description: Unauthorized.
        '403':
          description: GEO analytics is not enabled for this organization, or the key lacks prompt-read access.


components:
  securitySchemes:
    OrgApiKey:
      type: apiKey
      in: header
      name: X-API-Key

  schemas:

    # ── Organization ──────────────────────────
    OrgDetailResponse:
      type: object
      description: Full organization details returned by GET /org/me and PUT /org/me.
      properties:
        org_id:
          type: string
          format: uuid
        name:
          type: string
        slug:
          type: string
        clerk_org_id:
          type: string
          nullable: true
        logo_url:
          type: string
          nullable: true
        primary_website_url:
          type: string
          nullable: true
        network_id:
          type: string
          format: uuid
          nullable: true
          description: Optional. Networks are no longer required for organizations — this is null for standalone orgs.
        network_name:
          type: string
          nullable: true
        industry_id:
          type: string
          format: uuid
        industry_name:
          type: string
        partner_id:
          type: string
          format: uuid
        is_free_tier:
          type: boolean
        enable_runs:
          type: boolean
        enable_content_generation:
          type: boolean
        content_auto_publish:
          type: boolean
        content_schedule:
          type: array
          items:
            type: integer
          description: Days of week (0=Sunday, 6=Saturday) for content generation runs.
        websites:
          type: array
          items:
            $ref: '#/components/schemas/OrgWebsiteResponse'
        locations:
          type: array
          items:
            $ref: '#/components/schemas/OrgLocationResponse'
        models:
          type: array
          items:
            $ref: '#/components/schemas/OrgModelResponse'
        schedule:
          type: array
          items:
            type: integer
          description: Days of week (0=Sunday, 6=Saturday) for question runs.
        publishers:
          type: array
          items:
            $ref: '#/components/schemas/PublisherResponse'
        spend_limit:
          type: number
          format: float
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        deleted_at:
          type: string
          format: date-time
          nullable: true

    UpdateOrgSelfRequest:
      type: object
      description: All fields are optional. Only provided fields are updated. Providing an empty array for websites or locations will clear them.
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
        slug:
          type: string
          minLength: 1
          maxLength: 255
        logo_url:
          type: string
          nullable: true
          description: Pass empty string to clear the logo.
        websites:
          type: array
          items:
            $ref: '#/components/schemas/OrgWebsiteRequest'
        locations:
          type: array
          items:
            $ref: '#/components/schemas/OrgLocationRequest'

    UpdateOrgRunsEnabledRequest:
      type: object
      description: Toggles the org-wide runs master switch — affects all scheduled prompt and content-generation runs.
      required:
        - enable_runs
      properties:
        enable_runs:
          type: boolean

    OrgWebsiteRequest:
      type: object
      required:
        - url
      properties:
        url:
          type: string
          format: uri

    OrgLocationRequest:
      type: object
      required:
        - country_code
      properties:
        country_code:
          type: string
          minLength: 2
          maxLength: 2
          description: ISO 3166-1 alpha-2 country code.
        region_name:
          type: string
          nullable: true

    OrgWebsiteResponse:
      type: object
      properties:
        org_website_id:
          type: string
          format: uuid
        url:
          type: string

    OrgLocationResponse:
      type: object
      properties:
        org_location_id:
          type: string
          format: uuid
        country_code:
          type: string
        region_name:
          type: string
          nullable: true

    OrgModelResponse:
      type: object
      properties:
        geo_model_id:
          type: string
          format: uuid
        name:
          type: string

    # ── Users ─────────────────────────────────
    CreateOrgUserRequest:
      type: object
      required:
        - user_id
        - role_id
      properties:
        user_id:
          type: string
          format: uuid
        role_id:
          type: string
          format: uuid
        is_current:
          type: boolean
          default: false

    UpdateOrgUserRequest:
      type: object
      required:
        - role_id
      properties:
        role_id:
          type: string
          format: uuid
        is_current:
          type: boolean

    InviteOrgUserRequest:
      type: object
      description: Invite a brand-new user (created in Clerk and Senso) and add them to the organization.
      required:
        - email
        - given_name
        - family_name
        - role_id
      properties:
        email:
          type: string
          format: email
        given_name:
          type: string
          minLength: 1
          maxLength: 255
        family_name:
          type: string
          minLength: 1
          maxLength: 255
        role_id:
          type: string
          format: uuid
          description: Role to assign in this organization. Resolve from `GET /org/roles`.
        is_current:
          type: boolean
          default: false

    InviteExistingOrgUserRequest:
      type: object
      description: Add an already-existing Senso user (looked up by email) to the organization.
      required:
        - email
        - role_id
      properties:
        email:
          type: string
          format: email
        role_id:
          type: string
          format: uuid
          description: Role to assign in this organization. Resolve from `GET /org/roles`.
        is_current:
          type: boolean
          default: false

    SetCurrentOrgRequest:
      type: object
      required:
        - is_current
      properties:
        is_current:
          type: boolean

    OrgUserResponse:
      type: object
      properties:
        org_user_id:
          type: string
          format: uuid
        org_id:
          type: string
          format: uuid
        user_id:
          type: string
          format: uuid
        role_id:
          type: string
          format: uuid
        is_current:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        deleted_at:
          type: string
          format: date-time
          nullable: true

    # ── Roles ─────────────────────────────────
    RoleResponse:
      type: object
      description: A role defined for an organization. Built-in roles are `admin`, `collaborator`, and `viewer`; orgs may also define custom roles.
      properties:
        role_id:
          type: string
          format: uuid
        org_id:
          type: string
          format: uuid
        name:
          type: string
          description: The raw role name (e.g. `admin`, `viewer`, `collaborator`).
        description:
          type: string
          nullable: true
        is_system:
          type: boolean
          description: True for the built-in admin / collaborator / viewer roles. Custom roles are false.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        deleted_at:
          type: string
          format: date-time
          nullable: true

    # ── API Keys ──────────────────────────────
    CreateAPIKeyRequest:
      type: object
      required:
        - name
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
        expires_at:
          type: string
          format: date-time
          nullable: true

    UpdateAPIKeyRequest:
      type: object
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
        expires_at:
          type: string
          format: date-time
          nullable: true

    APIKeyResponse:
      type: object
      properties:
        id:
          type: string
          format: uuid
        organization_id:
          type: string
          format: uuid
        name:
          type: string
        scoped:
          type: boolean
          description: True if the key has explicit KB grants (restricted access). False means full KB access.
        expires_at:
          type: string
          format: date-time
          nullable: true
        revoked_at:
          type: string
          format: date-time
          nullable: true
        last_used_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    APIKeyCreateResponse:
      type: object
      description: Returned only on creation. The `key` value is shown once — store it securely.
      properties:
        id:
          type: string
          format: uuid
        organization_id:
          type: string
          format: uuid
        name:
          type: string
        key:
          type: string
          description: The raw API key value. Only returned on creation.
        expires_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    # ── Search ────────────────────────────────
    SearchRequest:
      type: object
      required:
        - query
      properties:
        query:
          type: string
        max_results:
          type: integer
          default: 5
          maximum: 20
          description: Maximum number of chunks/results to return. Capped at 20.
        content_ids:
          type: array
          items:
            type: string
            format: uuid
          description: Restrict search to specific content IDs. Omit to search all accessible content.
        require_scoped_ids:
          type: boolean
          default: false
          description: When true, only chunks from the specified content_ids are returned.

    SearchResultChunk:
      type: object
      properties:
        content_id:
          type: string
          format: uuid
        version_id:
          type: string
          format: uuid
        chunk_index:
          type: integer
        chunk_text:
          type: string
        score:
          type: number
          format: float
        rank:
          type: integer
          description: Position in the result set (1-indexed).
        title:
          type: string
        vector_id:
          type: string
        source_type:
          type: string
          description: "Content source: file, raw, or web."
        content_type:
          type: string
          description: "MIME type (e.g. application/pdf, text/markdown)."

    SearchResponse:
      type: object
      properties:
        query:
          type: string
        answer:
          type: string
          description: AI-generated answer synthesised from matching chunks.
        results:
          type: array
          items:
            $ref: '#/components/schemas/SearchResultChunk'
        total_results:
          type: integer
        max_results:
          type: integer
        processing_time_ms:
          type: integer

    SearchContextResponse:
      type: object
      properties:
        query:
          type: string
        results:
          type: array
          items:
            $ref: '#/components/schemas/SearchResultChunk'
        total_results:
          type: integer
        max_results:
          type: integer

    SearchContentItem:
      type: object
      properties:
        content_id:
          type: string
          format: uuid
        title:
          type: string

    SearchContentResponse:
      type: object
      properties:
        query:
          type: string
        contents:
          type: array
          items:
            $ref: '#/components/schemas/SearchContentItem'
        total:
          type: integer

    # ── SSE Event Schemas ────────────────────────

    SSETokenEvent:
      type: object
      description: A single answer token streamed from the LLM.
      properties:
        token:
          type: string
          description: Partial answer text.

    SSESourcesEvent:
      type: object
      description: Search result chunks, sent after the answer stream completes.
      properties:
        results:
          type: array
          items:
            $ref: '#/components/schemas/SearchResultChunk'
        total_results:
          type: integer
        max_results:
          type: integer

    SSEErrorEvent:
      type: object
      description: Sent when answer generation fails. No sources or done event follows.
      properties:
        error:
          type: string
          description: Error message.

    # ── Knowledge Base ────────────────────────
    KBNodeContentInfo:
      type: object
      description: Content metadata for a document (only present on documents, not folders).
      properties:
        id:
          type: string
          format: uuid
        type:
          type: string
          description: Source type (file, raw, web).
        content_type:
          type: string
          description: MIME type (e.g. application/pdf, text/markdown).
        title:
          type: string
        summary:
          type: string
        version_num:
          type: integer
          format: int64
        processing_status:
          type: string
          description: Ingestion status (pending, processing, completed, failed).
        error_code:
          type: string
          nullable: true
          description: Ingestion failure classification; present only when `processing_status` is `failed`.

    KBNodeResponse:
      type: object
      description: A file or folder in your knowledge base. Check the `type` field to determine which one — `folder` for folders, `content` for documents.
      properties:
        kb_node_id:
          type: string
          format: uuid
          description: Unique identifier for this file or folder. Use this ID in `/org/kb/nodes/{id}/...` endpoints.
        org_id:
          type: string
          format: uuid
        parent_id:
          type: string
          format: uuid
          nullable: true
          description: The ID of the parent folder. Null for the root folder.
        content_id:
          type: string
          format: uuid
          nullable: true
          description: The content ID associated with this document. Null for folders. Use this ID with `/org/content/{id}` to poll processing status.
        type:
          type: string
          enum: [folder, content]
          description: "`folder` for folders, `content` for documents (uploaded files or raw text)."
        name:
          type: string
          description: Display name of the file or folder.
        content:
          # allOf wrapper, not a bare $ref: in OpenAPI 3.0 any key placed beside a $ref
          # is discarded by tooling, so the nullable flag and this description were being
          # silently dropped from the rendered reference.
          allOf:
            - $ref: '#/components/schemas/KBNodeContentInfo'
          nullable: true
          description: Content metadata (title, summary, processing status). Only present for documents, not folders.
        effective_role:
          type: string
          enum: [viewer, editor, owner, admin]
          description: Your permission level on this file or folder.
        created_by:
          type: string
          format: uuid
          nullable: true
        tags:
          type: array
          description: Tags attached to this node. Always present (empty array for folders).
          items:
            $ref: '#/components/schemas/TagResponse'
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    KBNodeListResponse:
      type: object
      properties:
        nodes:
          type: array
          items:
            $ref: '#/components/schemas/KBNodeResponse'
        total:
          type: integer
        limit:
          type: integer
        offset:
          type: integer

    KBNodeAncestorsResponse:
      type: object
      properties:
        ancestors:
          type: array
          items:
            $ref: '#/components/schemas/KBNodeResponse'
          description: The full folder path from root to the requested item (inclusive). Useful for building breadcrumb navigation.

    SyncStatusResponse:
      type: object
      properties:
        syncing:
          type: boolean
          description: Whether search indexes are currently being updated. This happens automatically after moves or deletes. While true, search results may be slightly stale.

    CreateRawContentRequest:
      type: object
      required:
        - text
      properties:
        kb_folder_node_id:
          type: string
          format: uuid
          description: The folder to place this document in. Omit to create at the root level.
        title:
          type: string
          minLength: 1
          maxLength: 255
        summary:
          type: string
          maxLength: 1000
        text:
          type: string
          description: Raw markdown/text content.
        tag_ids:
          type: array
          items:
            type: string
            format: uuid
          description: Org tag IDs to associate with this content.

    UpdateRawContentRequest:
      type: object
      required:
        - title
        - text
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 255
        summary:
          type: string
          maxLength: 1000
        text:
          type: string
        tag_ids:
          type: array
          nullable: true
          items:
            type: string
            format: uuid
          description: Full replacement of tag associations. Pass null to leave unchanged.

    PatchRawContentRequest:
      type: object
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 255
        summary:
          type: string
          maxLength: 1000
        text:
          type: string
        tag_ids:
          type: array
          nullable: true
          items:
            type: string
            format: uuid
          description: Full replacement of tag associations. Pass null to leave unchanged.

    CreateFolderRequest:
      type: object
      required:
        - name
      properties:
        parent_id:
          type: string
          format: uuid
          description: The parent folder to create this folder inside. Omit to create at the root level.
        name:
          type: string
          minLength: 1
          maxLength: 255

    RenameKBNodeRequest:
      type: object
      required:
        - name
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255

    MoveKBNodeRequest:
      type: object
      required:
        - new_parent_id
      properties:
        new_parent_id:
          type: string
          format: uuid
          description: The ID of the folder to move this item into.

    ContentDownloadURLResponse:
      type: object
      description: A time-limited download URL for retrieving an uploaded file from your knowledge base.
      properties:
        url:
          type: string
          description: Presigned S3 download URL (inline disposition — browser renders the file).
        download_url:
          type: string
          description: "Same file signed with `Content-Disposition: attachment` so the browser downloads rather than renders it inline."
        filename:
          type: string
          description: Original filename.
        content_type:
          type: string
          description: MIME type of the file.
        file_size_bytes:
          type: integer
          format: int64
          description: Size of the file in bytes.
        expiry_utc_ms:
          type: integer
          format: int64
          description: URL expiry time as Unix timestamp in milliseconds.

    # ── Permissions ────────────────────────────

    GrantPermissionRequest:
      type: object
      required:
        - grantee_type
        - grantee_id
        - role
      properties:
        grantee_type:
          type: string
          enum: [user, group]
          description: Whether the grantee is a user or a named org group.
        grantee_id:
          type: string
          format: uuid
          description: User ID or group ID to grant access to.
        role:
          type: string
          enum: [viewer, editor]
          description: Role to grant. Only viewer and editor can be granted.

    UpdatePermissionRequest:
      type: object
      required:
        - role
      properties:
        role:
          type: string
          enum: [viewer, editor]
          description: New role. Only viewer and editor can be assigned.

    KBGrantee:
      type: object
      description: Who a KB grant is for. The identity core (type, id, display_name) is uniform; email is set only for user grantees.
      properties:
        type:
          type: string
          enum: [user, group]
        id:
          type: string
          format: uuid
          description: user_id or group_id
        display_name:
          type: string
        email:
          type: string
          nullable: true
          description: Set only for user grantees.

    KBNodePermissionResponse:
      type: object
      properties:
        id:
          type: string
          format: uuid
        node_id:
          type: string
          format: uuid
        role:
          type: string
          enum: [viewer, editor, owner]
        grantee:
          $ref: '#/components/schemas/KBGrantee'
        granted_at:
          type: string
          format: date-time

    KBNodePermissionListResponse:
      type: object
      properties:
        grants:
          type: array
          items:
            $ref: '#/components/schemas/KBNodePermissionResponse'

    # ── Ingestion ─────────────────────────────
    IngestionUploadFileItem:
      type: object
      required:
        - filename
        - file_size_bytes
        - content_type
        - content_hash_md5
      properties:
        filename:
          type: string
        file_size_bytes:
          type: integer
          format: int64
          minimum: 1
        content_type:
          type: string
          description: MIME type of the file.
        content_hash_md5:
          type: string
          description: MD5 hash of the file content (32 hex chars).

    IngestionUploadRequest:
      type: object
      required:
        - files
      properties:
        kb_folder_node_id:
          type: string
          format: uuid
          description: The folder to upload files into. Omit to upload to the root level.
        files:
          type: array
          minItems: 1
          maxItems: 10
          items:
            $ref: '#/components/schemas/IngestionUploadFileItem'

    IngestionUploadResultItem:
      type: object
      properties:
        ingestion_run_id:
          type: string
          format: uuid
        content_id:
          type: string
          format: uuid
        filename:
          type: string
        status:
          type: string
          description: "upload_pending, conflict, duplicate, or invalid."
        upload_url:
          type: string
          description: Presigned S3 upload URL. Only present for upload_pending items.
        expires_in:
          type: integer
          description: Seconds until the upload URL expires.
        error:
          type: string
        existing_content_id:
          type: string
          format: uuid

    IngestionUploadSummary:
      type: object
      properties:
        total:
          type: integer
        success:
          type: integer
        skipped:
          type: integer

    IngestionUploadResponse:
      type: object
      properties:
        summary:
          $ref: '#/components/schemas/IngestionUploadSummary'
        results:
          type: array
          items:
            $ref: '#/components/schemas/IngestionUploadResultItem'

    IngestionUpdateRequest:
      type: object
      required:
        - file
      properties:
        file:
          $ref: '#/components/schemas/IngestionUploadFileItem'

    # ── Brand Kit ─────────────────────────────
    BrandKitGuidelines:
      type: object
      description: Brand guidelines. Only the keys below are accepted; unknown keys are rejected with a 400.
      additionalProperties: false
      properties:
        brand_name:
          type: string
        brand_domain:
          type: string
        brand_description:
          type: string
        voice_and_tone:
          type: string
        author_persona:
          type: string
        global_writing_rules:
          type: array
          items:
            type: string

    UpsertBrandKitRequest:
      type: object
      required:
        - guidelines
      properties:
        guidelines:
          $ref: '#/components/schemas/BrandKitGuidelines'

    BrandKitResponse:
      type: object
      properties:
        brand_kit_id:
          type: string
          format: uuid
        org_id:
          type: string
          format: uuid
        guidelines:
          $ref: '#/components/schemas/BrandKitGuidelines'
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    # ── Content Types ─────────────────────────
    ContentTypeConfig:
      type: object
      description: Configuration for this content type. Only the keys below are accepted; unknown keys are rejected with a 400.
      additionalProperties: false
      properties:
        template:
          type: string
          description: Markdown template / structural guidance for generated drafts.
        template_spec:
          type: object
          description: Structured template specification.
        cta_text:
          type: string
        cta_destination:
          type: string
        writing_rules:
          type: array
          items:
            type: string

    CreateContentTypeRequest:
      type: object
      required:
        - name
        - config
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
        config:
          $ref: '#/components/schemas/ContentTypeConfig'

    UpdateContentTypeRequest:
      type: object
      required:
        - name
        - config
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
        config:
          $ref: '#/components/schemas/ContentTypeConfig'

    ContentTypeResponse:
      type: object
      properties:
        content_type_id:
          type: string
          format: uuid
        org_id:
          type: string
          format: uuid
        name:
          type: string
        config:
          $ref: '#/components/schemas/ContentTypeConfig'
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    ContentTypeListResponse:
      type: object
      properties:
        content_types:
          type: array
          items:
            $ref: '#/components/schemas/ContentTypeResponse'
        total:
          type: integer
        limit:
          type: integer
        offset:
          type: integer

    # ── Prompts ───────────────────────────────
    CreateOrgPromptRequest:
      type: object
      required:
        - question_text
        - type
      properties:
        question_text:
          type: string
          minLength: 1
          maxLength: 500
        type:
          type: string
          enum: [decision, consideration, awareness, evaluation]

    OrgPromptListItem:
      type: object
      properties:
        prompt_id:
          type: string
          format: uuid
        text:
          type: string
        type:
          type: string
          enum: [decision, consideration, awareness, evaluation]
        tags:
          type: array
          items:
            $ref: '#/components/schemas/TagResponse'
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    OrgPromptListResponse:
      type: object
      properties:
        prompts:
          type: array
          items:
            $ref: '#/components/schemas/OrgPromptListItem'
        total:
          type: integer
        limit:
          type: integer
        offset:
          type: integer

    OrgPromptDetailResponse:
      type: object
      properties:
        prompt_id:
          type: string
          format: uuid
        org_id:
          type: string
          format: uuid
          nullable: true
        text:
          type: string
        type:
          type: string
          enum: [decision, consideration, awareness, evaluation]
        geo_pool_id:
          type: string
          format: uuid
          nullable: true
        scope:
          type: string
        tags:
          type: array
          items:
            $ref: '#/components/schemas/TagResponse'
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        runs:
          type: array
          items:
            $ref: '#/components/schemas/OrgPromptRunResponse'

    OrgPromptRunResponse:
      type: object
      properties:
        run_id:
          type: string
          format: uuid
        response_text:
          type: string
          nullable: true
        model:
          type: string
          nullable: true
        country:
          type: string
          nullable: true
        region:
          type: string
          nullable: true
        target_mentioned:
          type: boolean
        target_sov:
          type: number
          format: float
          nullable: true
        target_rank:
          type: integer
          nullable: true
        target_sentiment:
          type: number
          format: float
          nullable: true
        is_latest:
          type: boolean
        evals:
          type: array
          items:
            $ref: '#/components/schemas/OrgPromptRunMentionResponse'
        competitors:
          type: array
          items:
            $ref: '#/components/schemas/OrgPromptRunMentionResponse'
        claims:
          type: array
          items:
            $ref: '#/components/schemas/OrgPromptRunClaimResponse'
        created_at:
          type: string
          format: date-time

    OrgPromptRunMentionResponse:
      type: object
      properties:
        mention_id:
          type: string
          format: uuid
        org:
          type: string
        text:
          type: string
        rank:
          type: integer
          nullable: true
        sentiment:
          type: string
          nullable: true
        is_target_org:
          type: boolean

    OrgPromptRunClaimResponse:
      type: object
      properties:
        claim_id:
          type: string
          format: uuid
        text:
          type: string
        order:
          type: integer
        sentiment:
          type: string
          nullable: true
        target_mentioned:
          type: boolean
          nullable: true
        citations:
          type: array
          items:
            $ref: '#/components/schemas/OrgPromptRunCitationResponse'

    OrgPromptRunCitationResponse:
      type: object
      properties:
        citation_id:
          type: string
          format: uuid
        source_url:
          type: string
          nullable: true
        citation_type:
          $ref: '#/components/schemas/CitationTier'
        order:
          type: integer
          description: The citation's position in the answer's citation list, where 1 is cited first.

    # ── Run Configuration ─────────────────────
    OrgRunModelItem:
      type: object
      properties:
        geo_model_id:
          type: string
          format: uuid
        name:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    OrgRunModelsResponse:
      type: object
      properties:
        models:
          type: array
          items:
            $ref: '#/components/schemas/OrgRunModelItem'

    SetOrgRunModelsRequest:
      type: object
      required:
        - models
      properties:
        models:
          type: array
          minItems: 1
          items:
            type: string
            minLength: 1
            maxLength: 255
          description: List of model name strings (e.g. ["chatgpt", "gemini"]).

    ModelOptionsResponse:
      type: object
      description: Valid-model picker list.
      properties:
        valid_models:
          type: array
          items:
            type: object
            properties:
              name:
                type: string
                description: Canonical model name the write endpoints accept.
              display_name:
                type: string
                description: Human-readable label for pickers.

    ModelListResponse:
      type: object
      description: Registry models in an owner's opt-in set.
      properties:
        models:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
                format: uuid
              provider:
                type: string
                description: Execution provider (e.g. brightdata, anthropic, openai).
              model:
                type: string
                description: Model name (e.g. chatgpt, claude, perplexity).
              execution_mode:
                type: string
                enum: [batch_async, single_sync]
              adapter_key:
                type: string

    SetModelsRequest:
      type: object
      required:
        - models
      properties:
        models:
          type: array
          minItems: 1
          items:
            type: string
          description: Replacement scheduler model set as "provider/model" pairs (e.g. ["brightdata/chatgpt", "anthropic/claude"]), validated against the scheduler-supported registry.

    OrgRunScheduleResponse:
      type: object
      properties:
        schedule:
          type: array
          items:
            type: integer
            minimum: 0
            maximum: 6
          description: Days of week to trigger runs. 0=Sunday, 1=Monday, 2=Tuesday, 3=Wednesday, 4=Thursday, 5=Friday, 6=Saturday.

    SetOrgRunScheduleRequest:
      type: object
      required:
        - schedule
      properties:
        schedule:
          type: array
          items:
            type: integer
            minimum: 0
            maximum: 6
          description: Days of week to trigger runs. 0=Sunday, 1=Monday, 2=Tuesday, 3=Wednesday, 4=Thursday, 5=Friday, 6=Saturday.

    # ── Content Generation ────────────────────
    PublisherResponse:
      type: object
      properties:
        publisher_id:
          type: string
          format: uuid
        name:
          type: string
        slug:
          type: string
        type:
          type: string
        display_url:
          type: string
        logo_url:
          type: string
          nullable: true
        active:
          type: boolean

    # ── Destinations ──────────────────────────
    OrgDestinationResponse:
      type: object
      properties:
        publisher_id:
          type: string
          format: uuid
        scope:
          type: string
          enum: [shared, org]
          description: "`shared` is a Senso-provided destination (citeables/codeables/cucopilot); `org` is a custom domain this org registered."
        type:
          type: string
          description: Destination type. Today all destinations are `citeables` type (they publish via the citeables system); additional types may be added later.
        name:
          type: string
        slug:
          type: string
        display_url:
          type: string
        live_count:
          type: integer
          description: Number of content items currently live at this destination.
        last_publish_at:
          type: string
          format: date-time
          nullable: true
        selected_for_generation:
          type: boolean
          description: True when this destination is currently active in the org's generation/publish pipeline (i.e. `engine publish` without an explicit `publisher_ids` will target it).

    OrgDestinationsListResponse:
      type: object
      properties:
        destinations:
          type: array
          items:
            $ref: '#/components/schemas/OrgDestinationResponse'

    AddOrgDestinationRequest:
      type: object
      required:
        - type
        - name
        - domain
      properties:
        type:
          type: string
          enum: [citeables]
          description: Destination type. Only `citeables` is supported for custom domains in this version.
        name:
          type: string
          description: Display name for the destination (e.g. "Example Citeables").
        domain:
          type: string
          description: The custom domain to register with the citeables service (e.g. `content.example.com`).

    RemoveOrgDestinationRequest:
      type: object
      required:
        - action
      properties:
        action:
          type: string
          enum: [leave, unpublish, delete]
          description: |
            What to do with content already live at this destination.
            - `leave` — stop publishing here but leave live articles in place
            - `unpublish` — retract live articles and revert to draft
            - `delete` — unpublish and hard-delete the local content records
        also_remove_destination:
          type: boolean
          default: false
          description: When true, delete the publisher row itself after handling live content. Only meaningful for custom destinations.
        keep_domain:
          type: boolean
          default: false
          description: When true, preserve the custom domain's registration on the citeables side (useful for SEO migration). Only meaningful for custom citeables domains.

    RemoveOrgDestinationResponse:
      type: object
      properties:
        affected_record_count:
          type: integer
          description: Publish records touched by the operation.
        unpublished_count:
          type: integer
          description: Articles unpublished from the destination.
        deleted_content_count:
          type: integer
          description: "Local content records hard-deleted (only non-zero when `action` is `delete`)."
        destination_removed:
          type: boolean
          description: "True when the publisher row was removed (requires `also_remove_destination: true` and ownership)."
        domain_deregistered:
          type: boolean
          description: True when the custom domain was deregistered from the citeables service.
        partial_failures:
          type: array
          items:
            type: object
            properties:
              publish_record_id:
                type: string
                format: uuid
              error:
                type: string
          description: Any per-record failures encountered during unpublish/delete.

    UnpublishOrgContentRequest:
      type: object
      description: Optional body for per-record unpublish. Omit the body entirely for bulk unpublish.
      properties:
        publish_record_ids:
          type: array
          items:
            type: string
            format: uuid
          description: When present and non-empty, only these publish records are retracted. When omitted, every live publish record for the content is retracted (bulk mode).

    UnpublishOrgContentResponse:
      type: object
      description: Returned from per-record unpublish mode (bulk mode returns 204 No Content).
      properties:
        unpublished_count:
          type: integer
        failures:
          type: array
          description: Human-readable failure messages for records that could not be unpublished.
          items:
            type: string

    ContentGenerationSettingsResponse:
      type: object
      properties:
        org_id:
          type: string
          format: uuid
        enable_content_generation:
          type: boolean
        content_auto_publish:
          type: boolean
        content_schedule:
          type: array
          items:
            type: integer
        selected_content_type_id:
          type: string
          format: uuid
          nullable: true
          description: The content type used for scheduled generation runs.
        publishers:
          type: array
          items:
            $ref: '#/components/schemas/PublisherResponse'

    UpdateContentGenerationSettingsRequest:
      type: object
      properties:
        enable_content_generation:
          type: boolean
          nullable: true
          description: Can be enabled without publishers when auto-publish is false.
        content_auto_publish:
          type: boolean
          nullable: true
          description: Requires at least one publisher configured.
        content_schedule:
          type: array
          items:
            type: integer
            minimum: 0
            maximum: 6
          description: Days of week to trigger content generation runs. 0=Sunday, 1=Monday, 2=Tuesday, 3=Wednesday, 4=Thursday, 5=Friday, 6=Saturday.
        selected_content_type_id:
          type: string
          format: uuid
          nullable: true
          description: The content type to use for scheduled generation runs.

    ContentGenerationSampleRequest:
      type: object
      required:
        - geo_question_id
        - content_type_id
      properties:
        geo_question_id:
          type: string
          format: uuid
          description: The prompt (question) to generate content for. This is the `prompt_id` returned by `POST /org/prompts`.
        content_type_id:
          type: string
          format: uuid
          description: The content type (template) that controls the output format.
        publish_destination:
          type: string
          nullable: true
          description: Publisher slug to publish to. Omit to save as draft only.

    ContentGenerationPublishResult:
      type: object
      properties:
        publisher:
          type: string
        display_url:
          type: string
        status:
          type: string
        error_msg:
          type: string

    ContentGenerationSampleResponse:
      type: object
      properties:
        content_id:
          type: string
          format: uuid
        version_id:
          type: string
          format: uuid
        version_num:
          type: integer
          format: int64
        raw_markdown:
          type: string
        seo_title:
          type: string
        url_slug:
          type: string
        meta_data:
          type: object
        json_ld:
          type: object
        editorial_status:
          type: string
        publish_status:
          type: string
        publish_destination:
          type: string
          nullable: true
        publish_results:
          type: array
          items:
            $ref: '#/components/schemas/ContentGenerationPublishResult'
        publish_error:
          type: string
          nullable: true

    ContentGenerationSampleJobSubmitResponse:
      type: object
      properties:
        message:
          type: string
        sample_job_id:
          type: string
          format: uuid
        org_id:
          type: string
          format: uuid
        status:
          type: string
          enum: [queued, running, completed, failed, expired]

    ContentGenerationSampleJobError:
      type: object
      properties:
        code:
          type: string
          nullable: true
        message:
          type: string

    ContentGenerationSampleJobResponse:
      type: object
      properties:
        sample_job_id:
          type: string
          format: uuid
        org_id:
          type: string
          format: uuid
        status:
          type: string
          enum: [queued, running, completed, failed, expired]
        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
        result:
          nullable: true
          allOf:
            - $ref: '#/components/schemas/ContentGenerationSampleResponse'
        error:
          nullable: true
          allOf:
            - $ref: '#/components/schemas/ContentGenerationSampleJobError'

    ContentGenerationRunRequest:
      type: object
      properties:
        prompt_ids:
          type: array
          items:
            type: string
            format: uuid
        content_type_id:
          type: string
          format: uuid
          nullable: true
          description: Override the org's default content type for this run.
        publisher_ids:
          type: array
          items:
            type: string
            format: uuid
          description: Restrict publishing to specific publishers.

    ContentGenerationRunResponse:
      type: object
      properties:
        message:
          type: string
        org_id:
          type: string
          format: uuid
        run_id:
          type: string
          format: uuid
        prompt_ids:
          type: array
          items:
            type: string
            format: uuid

    ContentEngineDraftRequest:
      type: object
      required:
        - geo_question_id
        - raw_markdown
        - seo_title
      properties:
        geo_question_id:
          type: string
          format: uuid
          description: The prompt (question) this content answers.
        content_id:
          type: string
          format: uuid
          nullable: true
        generation_run_id:
          type: string
          format: uuid
          nullable: true
          description: Builder generation run to associate with this draft.
        raw_markdown:
          type: string
        seo_title:
          type: string
        summary:
          type: string

    ContentEngineDraftResponse:
      type: object
      properties:
        content_id:
          type: string
          format: uuid
        version_id:
          type: string
          format: uuid
        version_num:
          type: integer
          format: int64
        editorial_status:
          type: string

    ContentEnginePublishRequest:
      type: object
      required:
        - geo_question_id
        - raw_markdown
        - seo_title
      properties:
        geo_question_id:
          type: string
          format: uuid
          description: The prompt (question) this content answers.
        content_id:
          type: string
          format: uuid
          nullable: true
        generation_run_id:
          type: string
          format: uuid
          nullable: true
          description: Builder generation run to associate with this publish action.
        ever_published:
          type: boolean
        raw_markdown:
          type: string
        seo_title:
          type: string
        summary:
          type: string
        publisher_ids:
          type: array
          items:
            type: string
            format: uuid
          description: |
            Restrict publishing to a specific subset of destinations (by publisher UUID — see `GET /org/destinations`). Omit to publish to every destination currently selected for generation — for a newly onboarded org with content generation enabled, that's the shared `citeables` destination by default.
        mark_as_published:
          type: boolean
          description: When true, records the content as already published externally (manual publish) instead of pushing it to destinations.
        manual_published_at:
          type: string
          format: date-time
          nullable: true
          description: Timestamp to record as the publish time when `mark_as_published` is true. Defaults to now if omitted.

    ContentEnginePublishResponse:
      type: object
      properties:
        content_id:
          type: string
          format: uuid
        version_id:
          type: string
          format: uuid
        version_num:
          type: integer
          format: int64
        citeables_action:
          type: string
        publish_status:
          type: string
        editorial_status:
          type: string
        publish_destination:
          type: string
        marked_as_published:
          type: boolean
          description: True when the content was recorded as manually published (via `mark_as_published`) rather than pushed to destinations.
        publish_destinations:
          type: array
          items:
            $ref: '#/components/schemas/ContentGenerationPublishResult'

    # ── Content ───────────────────────────────
    ContentTagsInfo:
      type: object
      description: Category and topic tags associated with the content item.
      properties:
        category_id:
          type: string
          format: uuid
        category_name:
          type: string
        topic_id:
          type: string
          format: uuid
        topic_name:
          type: string

    ContentResponse:
      type: object
      description: Content item returned by create/update operations.
      properties:
        id:
          type: string
          format: uuid
        org_id:
          type: string
          format: uuid
        type:
          type: string
          description: Source type (file, raw, web).
        title:
          type: string
        summary:
          type: string
        tags:
          type: object
          nullable: true
          properties:
            category_id:
              type: string
            category_name:
              type: string
            topic_id:
              type: string
            topic_name:
              type: string
        org_tags:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
                format: uuid
              name:
                type: string
        latest_content_version_id:
          type: string
          format: uuid
        version_num:
          type: integer
          format: int64
        processing_status:
          type: string
          description: Ingestion status (pending, processing, completed, failed).
        error_code:
          type: string
          nullable: true
          description: Ingestion failure classification; present only when `processing_status` is `failed`.
        content_type:
          type: string
          description: MIME type.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    ContentDetailResponse:
      type: object
      description: Full content details for a document, including metadata, processing status, and raw text (for text/markdown content).
      properties:
        id:
          type: string
          format: uuid
        org_id:
          type: string
          format: uuid
        type:
          type: string
          description: Source type (file, raw, web).
        title:
          type: string
        summary:
          type: string
        tags:
          type: object
          nullable: true
          description: Legacy category/topic tags.
          properties:
            category_id:
              type: string
            category_name:
              type: string
            topic_id:
              type: string
            topic_name:
              type: string
        org_tags:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
                format: uuid
              name:
                type: string
        version_num:
          type: integer
          format: int64
        editorial_status:
          type: string
        processing_status:
          type: string
          description: Ingestion status (pending, processing, completed, failed).
        content_type:
          type: string
          description: MIME type (e.g. application/pdf, text/markdown).
        text:
          type: string
          description: Raw text content. Only present for raw/markdown content.
        error_code:
          type: string
          nullable: true
          description: Ingestion failure classification; present only when `processing_status` is `failed`.
        uploaded_by:
          type: object
          nullable: true
          description: Provenance of the most recent version (who created/uploaded it).
          properties:
            actor_type:
              type: string
              description: Who created the version (user, api_key, system, scheduled_job).
            user_id:
              type: string
              format: uuid
              nullable: true
            email:
              type: string
              nullable: true
            given_name:
              type: string
              nullable: true
            family_name:
              type: string
              nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    RejectContentVersionRequest:
      type: object
      properties:
        reason:
          type: string

    ContentOwnerResponse:
      type: object
      properties:
        user_id:
          type: string
          format: uuid
        email:
          type: string
        given_name:
          type: string
        family_name:
          type: string

    ReplaceContentOwnersRequest:
      type: object
      properties:
        user_ids:
          type: array
          items:
            type: string
            format: uuid

    ContentVersionResponse:
      type: object
      properties:
        version_id:
          type: string
          format: uuid
        version_num:
          type: integer
          format: int64
        title:
          type: string
        summary:
          type: string
        editorial_status:
          type: string
        is_current:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    ContentVersionListResponse:
      type: object
      properties:
        content_id:
          type: string
          format: uuid
        versions:
          type: array
          items:
            $ref: '#/components/schemas/ContentVersionResponse'

    GeneratedContentListItem:
      type: object
      properties:
        content_id:
          type: string
          format: uuid
        version_id:
          type: string
          format: uuid
        question_text:
          type: string
        title:
          type: string
        summary:
          type: string
        editorial_status:
          type: string
        version_num:
          type: integer
          format: int64
        generated_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    GeneratedContentListResponse:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/GeneratedContentListItem'
        total:
          type: integer
        limit:
          type: integer
        offset:
          type: integer

    GeneratedContentDetailResponse:
      type: object
      properties:
        content_id:
          type: string
          format: uuid
        content_type:
          type: string
        editorial_status:
          type: string
        title:
          type: string
        summary:
          type: string
        question_text:
          type: string
        version_num:
          type: integer
          format: int64
        generated_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        text:
          type: string
          description: Rendered body of the generated content.

    ContentVerificationItem:
      type: object
      properties:
        content_id:
          type: string
          format: uuid
        version_id:
          type: string
          format: uuid
        title:
          type: string
        summary:
          type: string
        editorial_status:
          type: string
        ever_published:
          type: boolean
        generated_at:
          type: string
          format: date-time
        published_at:
          type: string
          format: date-time
          nullable: true
        manually_marked_published:
          type: boolean
          description: True when the content was recorded as published manually (not pushed through a destination).
        citation_rate:
          type: number
          format: float
          nullable: true
          description: |
            **Citation Coverage for this content item, despite the field name** — the name is retained for backward compatibility, but the metric is not a Citation Rate.

            Numerator: distinct answers to this item's linked prompt, in the last 30 days, that cited one of this item's live published URLs. Denominator: `D` — the distinct answers to that same prompt in the same 30 days that carried at least one citation of any tier. Both are answer grain, so an answer citing this page three times counts once.

            Expressed as a percentage on a **0–100** scale, not a 0–1 fraction. Null when `D` was zero — no cited answers to measure against — which means not measured, never measured as zero.
        geo_question_id:
          type: string
          format: uuid
        question_text:
          type: string
        question_type:
          type: string
        persona_name:
          type: string
          nullable: true
        owners:
          type: array
          items:
            $ref: '#/components/schemas/ContentOwnerResponse'
        org_tags:
          type: array
          items:
            $ref: '#/components/schemas/TagResponse'
        destinations:
          type: array
          items:
            $ref: '#/components/schemas/PublishDestinationResponse'
        rejection:
          allOf:
            - $ref: '#/components/schemas/ContentRejectionResponse'
          nullable: true
          description: Present only when the content has been rejected.

    PublishDestinationResponse:
      type: object
      description: A destination (publisher) this content was published to, with delivery state.
      properties:
        publish_record_id:
          type: string
          format: uuid
        publisher_id:
          type: string
          format: uuid
          nullable: true
        publisher_name:
          type: string
        publisher_slug:
          type: string
        publisher_type:
          type: string
        publisher_scope:
          type: string
        state:
          type: string
        external_url:
          type: string
          nullable: true
        last_attempt_at:
          type: string
          format: date-time
          nullable: true
        last_error:
          type: string
          nullable: true

    ContentRejectionResponse:
      type: object
      properties:
        reason:
          type: string
        rejected_at:
          type: string
          format: date-time
          nullable: true
        rejected_by:
          allOf:
            - $ref: '#/components/schemas/ContentOwnerResponse'
          nullable: true

    ContentVerificationListResponse:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/ContentVerificationItem'
        total_count:
          type: integer
        draft_count:
          type: integer
        rejected_count:
          type: integer
        pending_published_draft_count:
          type: integer
          description: Count of published items still awaiting a verified draft.
        limit:
          type: integer
        offset:
          type: integer

    ContentVerificationCountsResponse:
      type: object
      description: Lightweight count-only summary returned by GET /org/content/verification/counts.
      properties:
        draft_count:
          type: integer
        published_count:
          type: integer
        rejected_count:
          type: integer
        pending_published_draft_count:
          type: integer
        published_domain_summaries:
          type: array
          items:
            $ref: '#/components/schemas/PublishedDomainSummaryResponse'

    PublishedDomainSummaryResponse:
      type: object
      description: |
        One publisher's rollup across everything your organization has published there, including how often AI answers cite it.

        The citation figures ship as numerator, denominator, and ratio together, so you can verify the math or recombine publishers yourself — see the individual field descriptions for exactly what each one counts.
      properties:
        publisher_id:
          type: string
          format: uuid
        publisher_name:
          type: string
        external_url:
          type: string
          nullable: true
        item_count:
          type: integer
          description: Distinct content items currently live at this publisher.
        citation_rate:
          type: number
          format: float
          nullable: true
          description: |
            **Citation Coverage for this publisher, despite the field name** — the name is retained for backward compatibility, but the metric is not a Citation Rate.

            `citation_numerator / citation_denominator`, expressed as a percentage on a **0–100** scale rather than a 0–1 fraction. Null when the denominator was zero: no answer in the window carried a citation, so there was nothing to measure against. Null means not measured, never measured as zero.
        citation_numerator:
          type: integer
          description: Distinct **answers** in the window that cited one of this organization's own published pages at this publisher. Answer grain — an answer citing three of your pages here counts once, not three times. Matched at the full page-URL level, so pages other organizations publish on a shared domain never count toward it.
        citation_denominator:
          type: integer
          description: "**`D`** — distinct answers in the window that carried at least one citation of any tier, across all of this organization's monitored prompts. This is the canonical Citation Coverage denominator: not all runs, not answered (non-empty) runs, and not a count of citation instances."
        citation_window_days:
          type: integer
          description: Length of the trailing window both counts are taken over, in days. Currently 30.
        citation_missing_days_excluded:
          type: boolean
          description: True when days with no monitoring runs were left out of the window rather than counted as zeros. They contribute nothing to either count, so a gap in monitoring cannot silently drag the coverage down.

    # ── Members ───────────────────────────────
    OrgMemberResponse:
      type: object
      properties:
        org_user_id:
          type: string
          format: uuid
        org_id:
          type: string
          format: uuid
        user_id:
          type: string
          format: uuid
        email:
          type: string
        given_name:
          type: string
        family_name:
          type: string
        role_id:
          type: string
          format: uuid
        role_display_name:
          type: string
          description: The raw role name (e.g. `admin`, `viewer`, `collaborator`). Use this to render a role label without a second lookup.

    OrgMemberListResponse:
      type: object
      description: Paginated wrapper returned by GET /org/members.
      properties:
        members:
          type: array
          items:
            $ref: '#/components/schemas/OrgMemberResponse'
        total:
          type: integer
        limit:
          type: integer
        offset:
          type: integer

    # ── Credits ────────────────────────────────
    CreditBalanceResponse:
      type: object
      properties:
        org_id:
          type: string
          format: uuid
        credits_available:
          type: number
          format: float
          nullable: true
        usage:
          type: number
          format: float
        is_free_tier:
          type: boolean

    # ── Brand Kit (patch) ──────────────────────
    PatchBrandKitRequest:
      type: object
      properties:
        guidelines:
          allOf:
            - $ref: '#/components/schemas/BrandKitGuidelines'
          description: Only the keys provided are merged into the existing guidelines. Unknown keys are rejected with a 400.

    # ── Content Types (patch) ──────────────────
    PatchContentTypeRequest:
      type: object
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
          nullable: true
        config:
          allOf:
            - $ref: '#/components/schemas/ContentTypeConfig'
          description: Only the keys provided are merged into the existing config. Unknown keys are rejected with a 400.
          nullable: true

    # ── Builder ────────────────────────────────
    StartBuilderGenerationRunRequest:
      type: object
      required:
        - source_route
        - client_run_id
      properties:
        source_route:
          type: string
          description: Route or context identifier for where the run was initiated.
        client_run_id:
          type: string
          format: uuid
          description: Client-generated UUID for idempotency.
        content_id:
          type: string
          format: uuid
          nullable: true
        content_version_id:
          type: string
          format: uuid
          nullable: true
        content_type_id:
          type: string
          format: uuid
          nullable: true
        agent_session_id:
          type: string
          format: uuid
          nullable: true
        experiment_key:
          type: string
          nullable: true
        experiment_arm:
          type: string
          nullable: true

    StartBuilderGenerationRunResponse:
      type: object
      properties:
        generation_run_id:
          type: string
          format: uuid
        brand_kit_id:
          type: string
          format: uuid
          nullable: true
        content_type_id:
          type: string
          format: uuid
          nullable: true
        started_at:
          type: string
          format: date-time

    CompleteBuilderGenerationRunRequest:
      type: object
      properties:
        status:
          type: string
          description: Completion status (e.g. completed, cancelled).
        content_id:
          type: string
          format: uuid
          nullable: true
        content_version_id:
          type: string
          format: uuid
          nullable: true

    BuilderEditTelemetryEventInput:
      type: object
      properties:
        event_type:
          type: string
        edit_source:
          type: string
        session_id:
          type: string
          format: uuid
          nullable: true
        client_event_id:
          type: string
          format: uuid
          nullable: true
        content_version_id:
          type: string
          format: uuid
          nullable: true
        generation_run_id:
          type: string
          format: uuid
          nullable: true
        payload:
          type: object
          additionalProperties: true
        meta_data:
          type: object
          additionalProperties: true
        client_created_at:
          type: string
          format: date-time
          nullable: true

    BulkBuilderEditTelemetryRequest:
      type: object
      properties:
        events:
          type: array
          items:
            $ref: '#/components/schemas/BuilderEditTelemetryEventInput'

    BulkBuilderEditTelemetryResponse:
      type: object
      properties:
        inserted_count:
          type: integer
          description: Number of events successfully recorded.
        duplicate_count:
          type: integer
          description: Number of events skipped due to duplicate client_event_id.

    # ── API Key KB Scope ───────────────────────
    APIKeyScopeGrant:
      type: object
      properties:
        node_id:
          type: string
          format: uuid
          description: The ID of the folder or document this grant applies to. Granting access to a folder includes all its contents and subfolders.
        role:
          type: string
          enum: [viewer, editor, owner, admin]
          description: "Permission level: `viewer` (read-only search and browse), `editor` (read + upload/modify), `owner`, or `admin`."

    SetAPIKeyScopeRequest:
      type: object
      required:
        - grants
      properties:
        grants:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/APIKeyScopeGrant'

    # ── Permissions ────────────────────────────
    PermissionItem:
      type: object
      properties:
        key:
          type: string
          description: Unique permission key used in role definitions.
        name:
          type: string
          description: Human-readable permission name.
        description:
          type: string
          description: What this permission allows.
        category:
          type: string
          description: Logical grouping for the permission.

    AvailablePermissionsResponse:
      type: object
      properties:
        permissions:
          type: array
          items:
            $ref: '#/components/schemas/PermissionItem'

    # ── Questions (/org/questions) ─────────────
    GeoQuestionTag:
      type: object
      properties:
        id:
          type: string
          format: uuid
        org_id:
          type: string
          format: uuid
        name:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    GeoQuestionResponse:
      type: object
      properties:
        geo_question_id:
          type: string
          format: uuid
        org_id:
          type: string
          format: uuid
          nullable: true
        question_text:
          type: string
        type:
          type: string
          enum: [decision, consideration, awareness, evaluation]
        geo_pool_id:
          type: string
          format: uuid
          nullable: true
        persona_id:
          type: string
          format: uuid
          nullable: true
        persona_question_id:
          type: string
          format: uuid
          nullable: true
        persona_name:
          type: string
          nullable: true
        tags:
          type: array
          items:
            $ref: '#/components/schemas/GeoQuestionTag'
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    GeoQuestionListResponse:
      type: object
      properties:
        questions:
          type: array
          items:
            $ref: '#/components/schemas/GeoQuestionResponse'
        total:
          type: integer
        limit:
          type: integer
          description: Always 0 on this endpoint — the org questions list is not paginated.
        offset:
          type: integer
          description: Always 0 on this endpoint — the org questions list is not paginated.
        sort_by:
          type: string
          description: Always empty on this endpoint.

    CreateGeoQuestionRequest:
      type: object
      required:
        - question_text
        - type
      properties:
        question_text:
          type: string
          minLength: 1
          maxLength: 255
        type:
          type: string
          enum: [decision, consideration, awareness, evaluation]
        geo_pool_id:
          type: string
          format: uuid
          nullable: true
        tag_ids:
          type: array
          items:
            type: string
            format: uuid

    PatchGeoQuestionRequest:
      type: object
      description: At least one of `tag_ids` or `type` must be provided (a request with neither returns 400).
      properties:
        tag_ids:
          type: array
          nullable: true
          items:
            type: string
            format: uuid
          description: Full replacement of tag associations. Pass null to clear all tags.
        type:
          type: string
          enum: [decision, consideration, awareness, evaluation]
          nullable: true
          description: Update the question's funnel stage.

    # ── Content Generation Runs ────────────────
    ContentGenerationRunListItem:
      type: object
      properties:
        run_id:
          type: string
          format: uuid
        run_group_id:
          type: string
          format: uuid
          nullable: true
        org_id:
          type: string
          format: uuid
        org_name:
          type: string
        status:
          type: string
        active:
          type: boolean
        trigger_mode:
          type: string
        trigger_source:
          type: string
        actor_user_id:
          type: string
          format: uuid
          nullable: true
        actor_type:
          type: string
        actor_name:
          type: string
          nullable: true
        actor_email:
          type: string
          nullable: true
        selected_content_type_id:
          type: string
          format: uuid
          nullable: true
        selection_source:
          type: string
        publisher_selection_source:
          type: string
          description: How the publisher set for this run was chosen (e.g. org_default, explicit).
        selected_publishers:
          type: array
          nullable: true
          items:
            $ref: '#/components/schemas/PublisherResponse'
        requested_prompt_ids:
          type: array
          nullable: true
          items:
            type: string
            format: uuid
        requested_prompt_count:
          type: integer
        resolved_prompt_count:
          type: integer
        pending_items:
          type: integer
        running_items:
          type: integer
        succeeded_items:
          type: integer
        failed_items:
          type: integer
        skipped_items:
          type: integer
        stopped_items:
          type: integer
        error_summary:
          type: string
          nullable: true
        started_at:
          type: string
          format: date-time
          nullable: true
        completed_at:
          type: string
          format: date-time
          nullable: true
        last_heartbeat_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    ContentGenerationRunListResponse:
      type: object
      properties:
        runs:
          type: array
          items:
            $ref: '#/components/schemas/ContentGenerationRunListItem'
        total:
          type: integer
        limit:
          type: integer
        offset:
          type: integer

    ContentGenerationRunDetail:
      type: object
      properties:
        run:
          $ref: '#/components/schemas/ContentGenerationRunListItem'
        items:
          type: array
          nullable: true
          items:
            $ref: '#/components/schemas/ContentGenerationRunItem'
        logs:
          type: array
          nullable: true
          items:
            $ref: '#/components/schemas/ContentGenerationRunLog'

    ContentGenerationRunItem:
      type: object
      properties:
        run_item_id:
          type: string
          format: uuid
        run_id:
          type: string
          format: uuid
        geo_question_id:
          type: string
          format: uuid
        question_text:
          type: string
        queue_type:
          type: string
          enum: [create, update]
        status:
          type: string
        failed_at_step:
          type: string
          nullable: true
        operator_message:
          type: string
          nullable: true
        technical_message:
          type: string
          nullable: true
        content_id:
          type: string
          format: uuid
          nullable: true
        version_id:
          type: string
          format: uuid
          nullable: true
        publish_summary:
          type: object
          nullable: true
          description: Per-destination publish outcome summary (present once the item has attempted publishing).
        started_at:
          type: string
          format: date-time
          nullable: true
        completed_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    ContentGenerationRunItemsResponse:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/ContentGenerationRunItem'
        total:
          type: integer
        limit:
          type: integer
        offset:
          type: integer

    ContentGenerationRunLog:
      type: object
      properties:
        log_id:
          type: string
          format: uuid
        run_id:
          type: string
          format: uuid
        run_item_id:
          type: string
          format: uuid
          nullable: true
        level:
          type: string
          enum: [info, warn, error]
        event_type:
          type: string
        message:
          type: string
        technical_details:
          type: object
          nullable: true
          description: Additional technical detail. Only returned on admin/partner-scoped views; omitted on org-scoped requests.
        created_at:
          type: string
          format: date-time

    ContentGenerationRunLogsResponse:
      type: object
      properties:
        logs:
          type: array
          items:
            $ref: '#/components/schemas/ContentGenerationRunLog'
        total:
          type: integer
        limit:
          type: integer
        offset:
          type: integer

    # ── Content Generation Job Context ─────────
    OrgJobContextPrompt:
      type: object
      properties:
        geo_question_id:
          type: string
          format: uuid
        question_text:
          type: string
        type:
          type: string
        has_content:
          type: boolean
        queue_type:
          type: string
          enum: [create, update]
        content_id:
          type: string
          format: uuid
          nullable: true
        latest_version_id:
          type: string
          format: uuid
          nullable: true
        editorial_status:
          type: string
          nullable: true
        ever_published:
          type: boolean
        citeables_action:
          type: string

    OrgJobContextSummary:
      type: object
      properties:
        total_prompts:
          type: integer
        create_queue_count:
          type: integer
        update_queue_count:
          type: integer

    OrgJobContextResponse:
      type: object
      properties:
        org_id:
          type: string
          format: uuid
        org_name:
          type: string
        org_slug:
          type: string
        content_auto_publish:
          type: boolean
        selected_content_type_id:
          type: string
          format: uuid
          nullable: true
        prompts:
          type: array
          items:
            $ref: '#/components/schemas/OrgJobContextPrompt'
        summary:
          $ref: '#/components/schemas/OrgJobContextSummary'

    # ── Tags (/org/tags) ───────────────────────
    TagResponse:
      type: object
      properties:
        id:
          type: string
          format: uuid
        org_id:
          type: string
          format: uuid
        name:
          type: string
        prompt_count:
          type: integer
          description: Number of prompts this tag is attached to. Only present when `counts=true` was requested.
        content_count:
          type: integer
          description: Number of content items this tag is attached to. Only present when `counts=true` was requested.
        kb_content_count:
          type: integer
          description: Of the `content_count`, how many are KB-sourced. Only present when `counts=true` was requested.
        generated_content_count:
          type: integer
          description: Of the `content_count`, how many are generated content. Only present when `counts=true` was requested.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    TagsListResponse:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/TagResponse'
        total_count:
          type: integer

    CreateTagRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255

    UpdateTagRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255

    SetResourceTagsRequest:
      type: object
      description: |
        Body for PUT endpoints that replace a resource's full tag collection. Supply any mix of `tag_ids` and `tag_names` — unknown names are created automatically within the caller's organization. Pass both empty (or an empty object) to clear all tags.
      properties:
        tag_ids:
          type: array
          items:
            type: string
            format: uuid
        tag_names:
          type: array
          items:
            type: string
            minLength: 1
            maxLength: 255

    AttachResourceTagRequest:
      type: object
      description: |
        Body for POST endpoints that attach a single tag. Provide either `tag_id` (attach an existing tag) or `tag_name` (attach by name; tag is created if it doesn't exist).
      properties:
        tag_id:
          type: string
          format: uuid
        tag_name:
          type: string
          minLength: 1
          maxLength: 255

    # ── Product Lines (/org/product-lines) ──────
    ProductLineResponse:
      type: object
      properties:
        product_line_id:
          type: string
          format: uuid
        org_id:
          type: string
          format: uuid
        name:
          type: string
        details:
          type: object
          additionalProperties: true
          description: Open-ended JSON blob describing this product line. Shape is up to you.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    ProductLineListResponse:
      type: object
      properties:
        product_lines:
          type: array
          items:
            $ref: '#/components/schemas/ProductLineResponse'
        total:
          type: integer
        limit:
          type: integer
        offset:
          type: integer

    CreateProductLineRequest:
      type: object
      required: [name, details]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
        details:
          type: object
          additionalProperties: true
          description: Arbitrary JSON metadata for this product line.

    UpdateProductLineRequest:
      type: object
      required: [name, details]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
        details:
          type: object
          additionalProperties: true

    PatchProductLineRequest:
      type: object
      description: Partial update — supply at least one of `name` or `details`.
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
        details:
          type: object
          additionalProperties: true

    # ── Tracked Competitors ───────────────────
    CompetitorRequest:
      type: object
      description: Create / update payload for a tracked competitor.
      required:
        - name
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
        url:
          type: string
          format: uri
          maxLength: 2048
          nullable: true

    CompetitorBatchRequest:
      type: object
      description: Bulk-create payload, typically used to accept suggestions from `POST /org/competitors/suggest`.
      required:
        - items
      properties:
        items:
          type: array
          minItems: 1
          maxItems: 50
          items:
            $ref: '#/components/schemas/CompetitorBatchItem'

    CompetitorBatchItem:
      type: object
      description: A single entry in a batch-create request. Includes optional provenance fields so accepted suggestions preserve their source / rationale / confidence.
      required:
        - name
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
        url:
          type: string
          format: uri
          maxLength: 2048
          nullable: true
        source:
          type: string
          enum: [manual, suggested_run_text, suggested_web_search]
          nullable: true
          description: Where this entry came from. Defaults to `manual` when omitted.
        rationale:
          type: string
          maxLength: 280
          nullable: true
        confidence:
          type: number
          format: float
          minimum: 0
          maximum: 1
          nullable: true

    CompetitorResponse:
      type: object
      properties:
        id:
          type: string
          format: uuid
        org_id:
          type: string
          format: uuid
        name:
          type: string
        url:
          type: string
          format: uri
          nullable: true
        source:
          type: string
          description: Provenance — typically `manual`, `suggested_run_text`, or `suggested_web_search`.
        rationale:
          type: string
          nullable: true
        confidence:
          type: number
          format: float
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    CompetitorListResponse:
      type: object
      properties:
        competitors:
          type: array
          items:
            $ref: '#/components/schemas/CompetitorResponse'
        total:
          type: integer

    # ── Tracked Sources ────────────────────────
    TrackedSourceRequest:
      type: object
      required:
        - pattern
        - match_type
        - tier
      properties:
        pattern:
          type: string
          maxLength: 2048
          description: The value to match cited URLs against, interpreted per `match_type`.
        match_type:
          type: string
          enum: [domain, host, path_prefix, exact_url]
        tier:
          type: string
          enum: [primary, tracked, secondary]
          description: "Classification tier: `primary` (Owned), `tracked` (Tracked), `secondary` (External)."
        category:
          type: string
          enum: [affiliated_domain, published_content, social, press]
          nullable: true
          description: Optional sub-category; only meaningful for the `tracked` tier.
        label:
          type: string
          maxLength: 255
          nullable: true
        priority:
          type: integer
          nullable: true
        active:
          type: boolean
          nullable: true
          description: Honored on update; ignored on create (new rules are always active).

    TrackedSourceResponse:
      type: object
      properties:
        id:
          type: string
          format: uuid
        org_id:
          type: string
          format: uuid
        pattern:
          type: string
        match_type:
          type: string
          enum: [domain, host, path_prefix, exact_url]
        tier:
          type: string
          enum: [primary, tracked, secondary]
        category:
          type: string
          enum: [affiliated_domain, published_content, social, press]
          nullable: true
        label:
          type: string
          nullable: true
        priority:
          type: integer
        source_origin:
          type: string
          enum: [manual, onboarding, published]
          description: How the rule was created. `published` rules are read-only.
        active:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    TrackedSourceListResponse:
      type: object
      properties:
        tracked_sources:
          type: array
          items:
            $ref: '#/components/schemas/TrackedSourceResponse'
        total:
          type: integer

    SuggestionDTO:
      type: object
      description: A single AI-generated competitor suggestion.
      properties:
        name:
          type: string
        url:
          type: string
          format: uri
          nullable: true
        source:
          type: string
          description: Origin of the suggestion — `suggested_run_text` (mined from prompt-run citations) or `suggested_web_search` (LLM-generated from the org's website).
        confidence:
          type: number
          format: float
          minimum: 0
          maximum: 1
        rationale:
          type: string
        already_tracked:
          type: boolean
          description: True when an active competitor with this name already exists on the org. UIs typically disable the picker checkbox for these.

    SuggestCompetitorsResponse:
      type: object
      properties:
        mode:
          type: string
          description: Which seed strategy generated the response (e.g. `runs_text`, `web_search`).
        suggestions:
          type: array
          items:
            $ref: '#/components/schemas/SuggestionDTO'
        sampled_run_ids:
          type: array
          items:
            type: string
            format: uuid
          description: Prompt-run IDs sampled to seed suggestions. Present only when `mode` involves runs.
        duration_ms:
          type: integer
        cached:
          type: boolean
          description: True when the response came from the per-org suggestion cache instead of a fresh generation.

    # ── Analytics (/org/analytics) ─────────────
    CitationTier:
      type: string
      enum: [primary, tracked, secondary]
      description: "Classification tier for a cited URL: `primary` (Owned) — one of your own websites; `tracked` (Tracked) — one of your configured tracked sources; `secondary` (External) — any other, third-party domain. Every cited URL is exactly one tier, so the tiers partition citations; they do not partition answers, because one answer can cite several tiers."

    AnalyticsRate:
      type: object
      description: |
        A ratio, shipped as an unrounded fraction plus a ready-to-quote string. Use `value` for math and `display` verbatim for anything a human reads — it is why `0.012` never reaches a slide as "0.012%".

        Wherever a field of this shape is nullable, `null` means the metric's denominator was zero, so the metric was **not measured** in this window. It never means the metric was measured as zero.
      properties:
        value:
          type: number
          format: float
          minimum: 0
          maximum: 1
          description: The unrounded fraction, 0–1.
        display:
          type: string
          description: The same fraction rendered as a percentage with one decimal place, e.g. `42.3%`.

    AnalyticsRankValue:
      type: object
      description: An average ordinal position, where 1 means "named first". Lower is better, and `display` carries the `#` so the number is never mistaken for a percentage or a score.
      properties:
        value:
          type: number
          format: float
          description: The mean position, e.g. `2.4`.
        display:
          type: string
          description: The same value rendered with its rank marker, e.g. `#2.4`.

    AnalyticsFrequency:
      type: object
      description: A per-answer intensity, such as citations per cited answer. This is **not** a rate — it is unbounded above and never carries a percent sign.
      properties:
        value:
          type: number
          format: float
          description: The intensity, e.g. `4.2`.
        display:
          type: string
          description: The value with its unit spelled out, e.g. `4.20 citations per cited answer`.

    AnalyticsRateTrend:
      type: object
      description: How one metric moved against the preceding window of equal length. `delta` is in absolute points on the 0–1 scale, never a percentage of a percentage, and `direction` is semantic so a caller never has to know which way is good for this metric.
      properties:
        prev:
          type: number
          format: float
          description: The metric's value in the preceding window, as a 0–1 fraction.
        delta:
          type: number
          format: float
          description: Current value minus previous value, in absolute points on the 0–1 scale (`0.005` = +0.5 points).
        direction:
          type: string
          enum: [improved, declined, flat]
          description: Semantic movement, not arithmetic direction.
        display:
          type: string
          description: The delta rendered in percentage points, e.g. `+0.5 pts`.

    AnalyticsWindow:
      type: object
      description: The span these numbers actually cover, echoed back so no figure travels without its window.
      properties:
        from:
          type: string
          format: date
          description: First rollup day included, inclusive.
        to:
          type: string
          format: date
          description: Last rollup day included, inclusive.
        days:
          type: integer
          description: Number of days in `[from, to]` inclusive.
        latest_data_day:
          type: string
          format: date
          nullable: true
          description: The most recent rollup day that has data **for your active model and location filter**. The default window anchors on this, so it explains a window that looks shifted and tells you how fresh the data really is. Null when the filter matches no data at all.

    AnalyticsDataQuality:
      type: object
      description: A deterministic confidence flag derived from the answered-run count, so the payload — not the reader — decides when a number needs hedging. `low` does not mean the number is wrong; it means one more run would move it a lot.
      properties:
        level:
          type: string
          enum: [low, medium, high]
          description: "`low` under 100 answered runs in the window, `medium` under 400, `high` otherwise."
        answered_count:
          type: integer
          format: int64
          description: Non-empty answers in the window — the count the level is derived from.
        reasons:
          type: array
          description: Plain-language reasons for the level, including the "treat rates as directional" hedge at `low`. Empty at `high`.
          items:
            type: string

    AnalyticsNotes:
      type: array
      description: |
        Prose caveats generated **for this specific response** — an empty dataset, a zero denominator, a partial edge week, a filter that could not be applied at this grain, a window bumping the 396-day retention boundary.

        These are machine-readable guidance written for LLM consumers: read them before summarizing the payload, and surface the relevant ones to the human alongside the numbers. Do not present a metric from this API without first checking whether a note explains it.
      items:
        type: string

    AnalyticsDefinitions:
      type: object
      description: |
        One-line canonical definitions for exactly the metrics this response contains, each naming its denominator. Projected from `GET /org/analytics/glossary`, so it cannot drift from the canonical vocabulary.

        Written for LLM consumers: quote these definitions rather than paraphrasing a metric name. Paraphrase is how invented denominators — "cited 30% of the time" — end up in a customer-facing summary.
      additionalProperties:
        type: string

    AnalyticsSentimentCounts:
      type: object
      description: The mention-sentiment split, as counts only. `positive + neutral + negative = mentioned_count`. No averaged sentiment score is returned anywhere in this API — averaging categorical labels invents a number.
      properties:
        positive:
          type: integer
          format: int64
        neutral:
          type: integer
          format: int64
        negative:
          type: integer
          format: int64

    AnalyticsTotals:
      type: object
      description: |
        Every numerator and every denominator behind the `metrics` block, summed over the window and the active filter.

        These are additive rollup measures: add them across windows, models, or locations and re-derive any ratio yourself, or build a metric this API does not ship. Ratios are not additive, which is exactly why the counts travel with them.
      properties:
        run_count:
          type: integer
          format: int64
          description: Prompt-run answers attempted. One answer is one prompt run against one model in one location on one day, including runs that returned nothing.
        answered_count:
          type: integer
          format: int64
          description: Runs that came back non-empty. This is the `mention_rate` denominator — empty runs do not count against you.
        mentioned_count:
          type: integer
          format: int64
          description: Answers that named your brand at least once. Answer grain, so an answer naming you three times counts once here.
        mention_total:
          type: integer
          format: int64
          description: Mention instances for your brand (occurrences, not answers). The numerator of `share_of_voice`.
        tracked_mention_total:
          type: integer
          format: int64
          description: Mention instances across you plus your tracked competitors. Shipped as a raw count so you can compute a head-to-head share against your tracked set yourself — it is deliberately **not** the `share_of_voice` denominator. An organization with no tracked competitors has this equal to `mention_total`, so dividing by it would report 100% whenever you were mentioned at all.
        brand_mention_total:
          type: integer
          format: int64
          description: Mention instances across every brand the models named, tracked or not. The denominator of `share_of_voice`. The other brands are counted here but never enumerated by name anywhere in this API.
        rank_sum:
          type: integer
          format: int64
          description: Sum of your brand's ordinal position over the answers that mentioned it. Additive, so windows recombine; divide by `mentioned_count` to get `avg_rank`.
        sentiment:
          $ref: '#/components/schemas/AnalyticsSentimentCounts'
        cited_run_count:
          type: integer
          format: int64
          description: "**`D`** — answers carrying at least one citation of any tier. This is the denominator for every Citation Rate and every Citation Coverage in this API. It is neither `run_count` nor `answered_count`; a ratio divided by either of those is not a Citation Rate."
        primary_cited_run_count:
          type: integer
          format: int64
          description: Answers carrying at least one citation of one of your own domains (tier `primary` / Owned). The numerator of `primary_citation_rate`.
        tracked_cited_run_count:
          type: integer
          format: int64
          description: Answers carrying at least one citation of one of your tracked sources (tier `tracked`). The numerator of `tracked_citation_rate`.
        external_cited_run_count:
          type: integer
          format: int64
          description: Answers carrying at least one citation of a third-party domain (tier `secondary` / External). The numerator of `external_citation_rate`. "External" here means the `secondary` tier only — not everything you do not own.
        cited_total:
          type: integer
          format: int64
          description: "**`S`** — citation instances: every cited URL, all tiers. This is the denominator for every Citation Share. One answer citing five pages contributes 5 here and 1 to `cited_run_count`."
        primary_cited_total:
          type: integer
          format: int64
          description: Citation instances pointing at your own domains. The numerator of `primary_citation_share`.
        tracked_cited_total:
          type: integer
          format: int64
          description: Citation instances pointing at your tracked sources. The numerator of `tracked_citation_share`.
        external_cited_total:
          type: integer
          format: int64
          description: Citation instances pointing at third-party domains. The numerator of `external_citation_share`. The three tier totals sum to exactly `cited_total`, because every cited URL is exactly one tier.
        prompt_count:
          type: integer
          format: int64
          description: Distinct monitored prompts that produced data in the window under the active filter.
        model_count:
          type: integer
          format: int64
          description: Distinct models that produced data in the window under the active filter.
        location_count:
          type: integer
          format: int64
          description: Distinct locations that produced data in the window under the active filter.

    AnalyticsMetrics:
      type: object
      description: |
        Every ratio derivable from `totals`. Each is `null` when its denominator was zero — "not measured", never "measured as zero" — and `notes[]` will say which denominator was missing.

        The three tier **rates** divide by `D` and are independent, so they can sum past 100% (one answer citing both an owned and an external page counts toward both). The three tier **shares** divide by `S` and partition, so they sum to exactly 100%. Rate and Share routinely disagree, and the disagreement is the insight.
      properties:
        mention_rate:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRate'
          nullable: true
          description: Of the non-empty answers in this window (denominator `answered_count`), the share that named your brand. Answer grain — an answer naming you three times counts once. Null when there were no answered runs.
        share_of_voice:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRate'
          nullable: true
          description: Your share of every brand mention the models made — `mention_total` divided by `brand_mention_total` (mention instances of all brands, tracked or not). Instance grain, so a brand named twice in one answer counts twice. This is the same Share of Voice the Senso app reports and the two are expected to match exactly. It is **not** divided by `tracked_mention_total` — for an organization with no tracked competitors that denominator equals the numerator and pins the value at 100%, which is why neither the app nor this API uses it. Both counts ship in `totals` if you want the head-to-head view against your tracked set. Null when no brand mentions of any kind were recorded.
        avg_rank:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRankValue'
          nullable: true
          description: Mean ordinal position of your brand across the answers that mentioned it (denominator `mentioned_count`), where 1 is named first. Lower is better. Null means you were never mentioned — which is not the same as ranking last.
        primary_citation_rate:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRate'
          nullable: true
          description: Citation Rate for the Owned tier. Of the answers that cited anything (denominator `D` = `cited_run_count`), the share that cited one of your own domains. Null when `D` was zero.
        tracked_citation_rate:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRate'
          nullable: true
          description: Citation Rate for the Tracked tier. Of the answers that cited anything (denominator `D` = `cited_run_count`), the share that cited one of your tracked sources. Null when `D` was zero.
        external_citation_rate:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRate'
          nullable: true
          description: Citation Rate for the External tier. Of the answers that cited anything (denominator `D` = `cited_run_count`), the share that cited a third-party domain — the `secondary` tier only. Null when `D` was zero.
        primary_citation_share:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRate'
          nullable: true
          description: Citation Share for the Owned tier. Of all citation instances (denominator `S` = `cited_total`), the fraction pointing at your own domains. Instance grain, so this is a genuinely different number from `primary_citation_rate` and the two must never be substituted for each other. Null when `S` was zero.
        tracked_citation_share:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRate'
          nullable: true
          description: Citation Share for the Tracked tier. Of all citation instances (denominator `S` = `cited_total`), the fraction pointing at your tracked sources. Null when `S` was zero.
        external_citation_share:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRate'
          nullable: true
          description: Citation Share for the External tier. Of all citation instances (denominator `S` = `cited_total`), the fraction pointing at third-party domains. The three tier shares sum to exactly 100%; if they do not, that is a producer bug rather than rounding. Null when `S` was zero.
        citations_per_answer:
          allOf:
            - $ref: '#/components/schemas/AnalyticsFrequency'
          nullable: true
          description: Citation instances divided by cited answers (`S` ÷ `D`) — how densely answers cite. An intensity, not a rate — unbounded above and never a percentage. `4.2` means the average citing answer cited 4.2 URLs. Null when `D` was zero.

    AnalyticsGlossaryEntry:
      type: object
      description: One metric's canonical definition, as maintained in the cross-repo metrics glossary.
      properties:
        metric:
          type: string
          description: The field name as it appears in analytics payloads.
        definition:
          type: string
          description: One-line canonical definition. Quote this rather than paraphrasing.
        denominator:
          type: string
          description: What the metric divides by, named explicitly. Absent for metrics that are counts rather than ratios, or reads `n/a` for the fields that *are* denominators.
        gotcha:
          type: string
          description: The mistake people actually make with this metric — a mismatched denominator, a grain confusion, or a null that reads as a zero. Absent when there is no common trap.

    AnalyticsGlossaryResponse:
      type: object
      description: The canonical definition of every metric this API emits. Static content; the `definitions{}` block on every other analytics response is a projection of these same entries.
      properties:
        entries:
          type: array
          items:
            $ref: '#/components/schemas/AnalyticsGlossaryEntry'

    AnalyticsFilterOption:
      type: object
      description: One selectable filter value with a human label.
      properties:
        id:
          type: string
          description: The value to pass back in a query parameter.
        display_name:
          type: string
          description: The label to show a human.

    AnalyticsDateRange:
      type: object
      description: The span of rollup days this organization actually has, so you never request a window that predates the first monitoring run or runs past the newest data.
      properties:
        earliest_day:
          type: string
          format: date
          nullable: true
          description: Oldest rollup day on record. Null when the organization has no rollup data yet.
        latest_day:
          type: string
          format: date
          nullable: true
          description: Newest rollup day on record. Null when the organization has no rollup data yet.

    AnalyticsFiltersResponse:
      type: object
      description: The filter values that have data for this organization — the answer to "what can I ask for?".
      properties:
        models:
          type: array
          description: Models with data, as canonical short ids plus display names. Pass `id` values in the `models` query parameter.
          items:
            $ref: '#/components/schemas/AnalyticsFilterOption'
        locations:
          type: array
          description: Locations with data, in the exact case the `location` parameter expects (for example `US`, `US/California`).
          items:
            type: string
        prompt_types:
          type: array
          description: Funnel stages available on this organization's prompts.
          items:
            type: string
            enum: [awareness, consideration, evaluation, decision]
        tags:
          type: array
          description: Tag names attached to this organization's prompts, accepted by the `tag` parameter.
          items:
            type: string
        tracked_competitors:
          type: array
          description: The competitors this organization tracks by name. Editing this list does not change `share_of_voice`, whose denominator is `brand_mention_total` — every brand the models named, tracked or not. It changes only `tracked_mention_total`, the raw count behind a head-to-head share you compute yourself.
          items:
            $ref: '#/components/schemas/AnalyticsFilterOption'
        date_range:
          $ref: '#/components/schemas/AnalyticsDateRange'
        notes:
          $ref: '#/components/schemas/AnalyticsNotes'

    AnalyticsPeriod:
      type: object
      description: One window's counts and the ratios derived from them. Used for the preceding comparison window on the summary.
      properties:
        window:
          $ref: '#/components/schemas/AnalyticsWindow'
        totals:
          $ref: '#/components/schemas/AnalyticsTotals'
        metrics:
          $ref: '#/components/schemas/AnalyticsMetrics'

    AnalyticsDeltas:
      type: object
      description: Window-versus-window movement of the headline metrics. A metric is null whenever either window lacked the denominator to compute it, so "no data" never reads as "fell to zero".
      properties:
        mention_rate:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRateTrend'
          nullable: true
          description: Movement in `mention_rate`. Higher is better, so a positive delta is `improved`.
        share_of_voice:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRateTrend'
          nullable: true
          description: Movement in `share_of_voice`. Higher is better, so a positive delta is `improved`. The denominator is `brand_mention_total` in both windows, so this never moves merely because you edited your tracked-competitor list — but it does move when the models start naming more brands overall.
        primary_citation_rate:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRateTrend'
          nullable: true
          description: Movement in the Owned-tier Citation Rate (numerator and denominator both answer grain, denominator `D` in each window).

    AnalyticsSummaryResponse:
      type: object
      description: The one-call dashboard — headline metrics with their raw counts, the preceding equal-length window, and the deltas between them.
      properties:
        window:
          $ref: '#/components/schemas/AnalyticsWindow'
        totals:
          $ref: '#/components/schemas/AnalyticsTotals'
        metrics:
          $ref: '#/components/schemas/AnalyticsMetrics'
        previous_window:
          allOf:
            - $ref: '#/components/schemas/AnalyticsPeriod'
          nullable: true
          description: The equal-length window immediately preceding this one. Null when there is no earlier data to compare against.
        deltas:
          allOf:
            - $ref: '#/components/schemas/AnalyticsDeltas'
          nullable: true
          description: Movement of the headline metrics against `previous_window`. Null when there is no previous window.
        data_quality:
          $ref: '#/components/schemas/AnalyticsDataQuality'
        notes:
          $ref: '#/components/schemas/AnalyticsNotes'
        definitions:
          $ref: '#/components/schemas/AnalyticsDefinitions'

    MentionSeriesPoint:
      type: object
      description: One day or ISO week of visibility counts, plus the ratios derived from that bucket alone. The counts are additive; the ratios are not — sum counts across buckets and divide, never average the rates.
      properties:
        period_start:
          type: string
          format: date
          description: First day of the bucket. For `group_by=week` this is the Monday of the ISO week.
        run_count:
          type: integer
          format: int64
          description: Prompt-run answers attempted in this bucket.
        answered_count:
          type: integer
          format: int64
          description: Non-empty answers in this bucket — the `mention_rate` denominator for this bucket.
        mentioned_count:
          type: integer
          format: int64
          description: Answers in this bucket that named your brand.
        mention_total:
          type: integer
          format: int64
          description: Mention instances for your brand in this bucket.
        tracked_mention_total:
          type: integer
          format: int64
          description: Mention instances across you plus tracked competitors in this bucket. A raw count for your own math — not this bucket's `share_of_voice` denominator.
        brand_mention_total:
          type: integer
          format: int64
          description: Mention instances across all brands — this bucket's `share_of_voice` denominator.
        rank_sum:
          type: integer
          format: int64
          description: Sum of your ordinal positions over the answers in this bucket that mentioned you.
        sentiment:
          $ref: '#/components/schemas/AnalyticsSentimentCounts'
        mention_rate:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRate'
          nullable: true
          description: Of this bucket's non-empty answers (denominator `answered_count` for the bucket), the share that named your brand. Null when the bucket had no answered runs.
        share_of_voice:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRate'
          nullable: true
          description: This bucket's `mention_total` over its `brand_mention_total` — your share of every brand mention made in the bucket, on the same denominator as the app's Share of Voice. Null when that denominator was zero.
        avg_rank:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRankValue'
          nullable: true
          description: This bucket's `rank_sum` over its `mentioned_count`, where 1 is named first. Null when you were not mentioned in the bucket.

    AnalyticsMentionsResponse:
      type: object
      description: The visibility time series with the window totals it sums to.
      properties:
        window:
          $ref: '#/components/schemas/AnalyticsWindow'
        group_by:
          type: string
          enum: [day, week]
          description: The bucket size actually used.
        totals:
          $ref: '#/components/schemas/AnalyticsTotals'
        metrics:
          $ref: '#/components/schemas/AnalyticsMetrics'
        series:
          type: array
          description: One entry per bucket, oldest first. Buckets with no runs are still counted in the window totals.
          items:
            $ref: '#/components/schemas/MentionSeriesPoint'
        data_quality:
          $ref: '#/components/schemas/AnalyticsDataQuality'
        notes:
          $ref: '#/components/schemas/AnalyticsNotes'
        definitions:
          $ref: '#/components/schemas/AnalyticsDefinitions'

    CitationSeriesPoint:
      type: object
      description: One day or ISO week of citation counts at both grains, with the rates (÷ `D`) and shares (÷ `S`) derived from that bucket alone.
      properties:
        period_start:
          type: string
          format: date
          description: First day of the bucket. For `group_by=week` this is the Monday of the ISO week.
        run_count:
          type: integer
          format: int64
          description: Prompt-run answers attempted in this bucket.
        answered_count:
          type: integer
          format: int64
          description: Non-empty answers in this bucket.
        cited_run_count:
          type: integer
          format: int64
          description: This bucket's `D` — answers carrying at least one citation of any tier. Every rate below divides by it.
        primary_cited_run_count:
          type: integer
          format: int64
          description: Answers in this bucket carrying at least one Owned-tier citation.
        tracked_cited_run_count:
          type: integer
          format: int64
          description: Answers in this bucket carrying at least one Tracked-tier citation.
        external_cited_run_count:
          type: integer
          format: int64
          description: Answers in this bucket carrying at least one External (`secondary`) citation.
        cited_total:
          type: integer
          format: int64
          description: This bucket's `S` — citation instances across all tiers. Every share below divides by it.
        primary_cited_total:
          type: integer
          format: int64
          description: Owned-tier citation instances in this bucket.
        tracked_cited_total:
          type: integer
          format: int64
          description: Tracked-tier citation instances in this bucket.
        external_cited_total:
          type: integer
          format: int64
          description: External (`secondary`) citation instances in this bucket. The three tier totals sum to this bucket's `cited_total`.
        primary_citation_rate:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRate'
          nullable: true
          description: Citation Rate for the Owned tier in this bucket. Of the bucket's answers that cited anything (denominator `D` = its `cited_run_count`), the share that cited one of your own domains. Null when the bucket's `D` was zero.
        tracked_citation_rate:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRate'
          nullable: true
          description: Citation Rate for the Tracked tier in this bucket, over the same denominator `D`. Null when the bucket's `D` was zero.
        external_citation_rate:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRate'
          nullable: true
          description: Citation Rate for the External tier in this bucket, over the same denominator `D`. Null when the bucket's `D` was zero.
        primary_citation_share:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRate'
          nullable: true
          description: Citation Share for the Owned tier in this bucket. Of the bucket's citation instances (denominator `S` = its `cited_total`), the fraction pointing at your own domains. Null when the bucket's `S` was zero.
        tracked_citation_share:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRate'
          nullable: true
          description: Citation Share for the Tracked tier in this bucket, over the same denominator `S`. Null when the bucket's `S` was zero.
        external_citation_share:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRate'
          nullable: true
          description: Citation Share for the External tier in this bucket, over the same denominator `S`. The three shares in a bucket sum to exactly 100%. Null when the bucket's `S` was zero.

    AnalyticsCitationsResponse:
      type: object
      description: The citation headline — both denominators, all tier numerators, both metric families, and the series underneath.
      properties:
        window:
          $ref: '#/components/schemas/AnalyticsWindow'
        group_by:
          type: string
          enum: [day, week]
          description: The bucket size actually used.
        totals:
          $ref: '#/components/schemas/AnalyticsTotals'
        metrics:
          $ref: '#/components/schemas/AnalyticsMetrics'
        series:
          type: array
          description: One entry per bucket, oldest first.
          items:
            $ref: '#/components/schemas/CitationSeriesPoint'
        data_quality:
          $ref: '#/components/schemas/AnalyticsDataQuality'
        notes:
          $ref: '#/components/schemas/AnalyticsNotes'
        definitions:
          $ref: '#/components/schemas/AnalyticsDefinitions'

    CitationDenominatorBlock:
      type: object
      description: The two denominators, exposed on the source-grain endpoints because every coverage and share on the page divides by one of them and you need both to verify or re-aggregate the rows.
      properties:
        cited_run_count:
          type: integer
          format: int64
          description: "**`D`** — answers carrying at least one citation of any tier. Every `citation_coverage` on this page divides by it."
        cited_total:
          type: integer
          format: int64
          description: "**`S`** — citation instances across all tiers. Every `citation_share` on this page divides by it."
        unique_domains:
          type: integer
          format: int64
          description: Distinct cited domains matching the filters. Present on the domains endpoint; equals `total`.
        unique_pages:
          type: integer
          format: int64
          description: Distinct cited URLs matching the filters. Present on the pages endpoint; equals `total`.

    CitedDomainItem:
      type: object
      description: One cited domain's performance over the window.
      properties:
        domain:
          type: string
          description: The cited domain.
        tier:
          $ref: '#/components/schemas/CitationTier'
        tier_label:
          type: string
          enum: [Owned, Tracked, External]
          description: The product-facing name for `tier` — `Owned`, `Tracked`, or `External`. Safe to show a human verbatim.
        cited_run_count:
          type: integer
          format: int64
          description: Distinct answers that cited this domain. The numerator of `citation_coverage`; an answer citing this domain three times counts once.
        cited_total:
          type: integer
          format: int64
          description: Citation instances for this domain. The numerator of `citation_share`; an answer citing this domain three times counts three.
        citation_coverage:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRate'
          nullable: true
          description: Citation Coverage for this domain. Of the answers that cited anything (denominator `D` = `denominators.cited_run_count`), the share that cited **this** domain. Same denominator as the tier Citation Rates, narrower numerator — which is why a tier rate is not the sum of its domains' coverages. Null when `D` was zero.
        citation_share:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRate'
          nullable: true
          description: Citation Share for this domain. Of all citation instances (denominator `S` = `denominators.cited_total`), the fraction pointing at this domain. Per-domain shares sum to 100% across **all** domains in the window, not across this page of results. Null when `S` was zero.
        avg_citation_rank:
          type: number
          format: float
          nullable: true
          description: Mean position of this domain within the citation list of the answers that cited it, where 1 is cited first — `citation_rank_sum` divided by this row's `cited_run_count` (distinct cited answers), not by `cited_total`. Answer grain, matching the Senso app. Lower is better. Null when the domain was never cited in the window.
        rank_by_citations:
          type: integer
          description: The row's 1-based position in the current sort — by citation instances unless you passed `sort=coverage` — continuing across pages via `offset`.

    AnalyticsCitationDomainsResponse:
      type: object
      description: The paged cited-domain landscape with the denominators every row divides by.
      properties:
        window:
          $ref: '#/components/schemas/AnalyticsWindow'
        denominators:
          $ref: '#/components/schemas/CitationDenominatorBlock'
        total:
          type: integer
          description: Total domains matching the filters, across all pages.
        limit:
          type: integer
        offset:
          type: integer
        domains:
          type: array
          items:
            $ref: '#/components/schemas/CitedDomainItem'
        data_quality:
          $ref: '#/components/schemas/AnalyticsDataQuality'
        notes:
          $ref: '#/components/schemas/AnalyticsNotes'
        definitions:
          $ref: '#/components/schemas/AnalyticsDefinitions'

    CitedPagePrompt:
      type: object
      description: One of your prompts that drove citations of a page.
      properties:
        prompt_id:
          type: string
          format: uuid
        prompt_text:
          type: string
        cited_run_count:
          type: integer
          format: int64
          description: Distinct answers to this prompt that cited the page.

    CitedPageItem:
      type: object
      description: One cited URL's performance over the window, plus the prompts driving it.
      properties:
        url:
          type: string
          description: The canonicalized cited URL — tracking parameters stripped and the address normalized when the rollup was written, so it may not match your published link character for character.
        domain:
          type: string
          description: The URL's domain.
        tier:
          $ref: '#/components/schemas/CitationTier'
        tier_label:
          type: string
          enum: [Owned, Tracked, External]
          description: The product-facing name for `tier` — `Owned`, `Tracked`, or `External`.
        cited_run_count:
          type: integer
          format: int64
          description: Distinct answers that cited this URL. The numerator of `citation_coverage`.
        cited_total:
          type: integer
          format: int64
          description: Citation instances for this URL. The numerator of `citation_share`.
        citation_coverage:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRate'
          nullable: true
          description: Citation Coverage for this page. Of the answers that cited anything (denominator `D` = `denominators.cited_run_count`), the share that cited **this** URL. Answer grain, so it is always at or below 100% — it is not citations per run. Null when `D` was zero.
        citation_share:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRate'
          nullable: true
          description: Citation Share for this page. Of all citation instances (denominator `S` = `denominators.cited_total`), the fraction pointing at this URL. Per-page shares sum to 100% across **all** pages in the window, not across this page of results. Null when `S` was zero.
        avg_citation_rank:
          type: number
          format: float
          nullable: true
          description: Mean position of this URL within the citation list of the answers that cited it, where 1 is cited first — `citation_rank_sum` divided by this row's `cited_run_count` (distinct cited answers), not by `cited_total`. Answer grain, matching the Senso app. Lower is better. Null when the URL was never cited in the window.
        top_prompts:
          type: array
          description: Up to five of the prompts that cited this page most, drawn from the rollup's top-20 prompt map. The biggest drivers, not an exhaustive list — do not sum these to re-derive `cited_run_count`.
          items:
            $ref: '#/components/schemas/CitedPagePrompt'

    AnalyticsCitationPagesResponse:
      type: object
      description: The paged URL-grain citation table with the denominators every row divides by.
      properties:
        window:
          $ref: '#/components/schemas/AnalyticsWindow'
        denominators:
          $ref: '#/components/schemas/CitationDenominatorBlock'
        total:
          type: integer
          description: Total URLs matching the filters, across all pages.
        limit:
          type: integer
        offset:
          type: integer
        pages:
          type: array
          items:
            $ref: '#/components/schemas/CitedPageItem'
        data_quality:
          $ref: '#/components/schemas/AnalyticsDataQuality'
        notes:
          $ref: '#/components/schemas/AnalyticsNotes'
        definitions:
          $ref: '#/components/schemas/AnalyticsDefinitions'

    PromptLatestSnapshot:
      type: object
      description: Where this prompt stands right now, taken from the newest answers rather than the window — so a list row can show current state without a second call. Not a sample of the window.
      properties:
        run_at:
          type: string
          format: date-time
          nullable: true
          description: When the newest of these answers was produced. Null when the prompt has never run.
        answer_count:
          type: integer
          description: How many latest answers exist for this prompt — one per model × location combination that has data.
        mentioned_count:
          type: integer
          description: How many of those latest answers named your brand.
        models:
          type: array
          description: Canonical model ids that produced those latest answers.
          items:
            type: string
        share_of_voice:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRate'
          nullable: true
          description: >-
            Mean share of voice across these latest answers — the "right now"
            figure the Senso app's prompt table shows. This is a DIFFERENT
            question from the `share_of_voice` on the row above it, which
            aggregates the whole window. When reconciling a single prompt
            against the app, compare against this one. Null when none of the
            latest answers carry a share-of-voice value.

    PromptPerformanceItem:
      type: object
      description: One monitored prompt's performance over the window, with the raw counts behind every ratio.
      properties:
        prompt_id:
          type: string
          format: uuid
        prompt_text:
          type: string
        prompt_type:
          type: string
          description: Funnel stage — `awareness`, `consideration`, `evaluation`, or `decision`. Legacy aliases (`topic`, `ranking`, `comparison`, `brand-specific`) map onto these.
        tags:
          type: array
          items:
            type: string
        run_count:
          type: integer
          format: int64
          description: Answers attempted for this prompt in the window.
        answered_count:
          type: integer
          format: int64
          description: Non-empty answers for this prompt — this row's `mention_rate` denominator.
        mentioned_count:
          type: integer
          format: int64
          description: Answers to this prompt that named your brand.
        mention_total:
          type: integer
          format: int64
          description: Mention instances for your brand on this prompt.
        tracked_mention_total:
          type: integer
          format: int64
          description: Mention instances across you plus tracked competitors on this prompt. A raw count for your own math — not this row's `share_of_voice` denominator.
        brand_mention_total:
          type: integer
          format: int64
          description: Mention instances across all brands on this prompt — this row's `share_of_voice` denominator.
        rank_sum:
          type: integer
          format: int64
          description: Sum of your ordinal positions over the answers to this prompt that mentioned you.
        sentiment:
          $ref: '#/components/schemas/AnalyticsSentimentCounts'
        cited_run_count:
          type: integer
          format: int64
          description: This prompt's `D` — its answers carrying at least one citation of any tier.
        primary_cited_run_count:
          type: integer
          format: int64
          description: This prompt's answers carrying at least one Owned-tier citation. The numerator of `primary_citation_rate`.
        mention_rate:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRate'
          nullable: true
          description: Of this prompt's non-empty answers (denominator its own `answered_count`), the share that named your brand. Null when the prompt had no answered runs in the window.
        share_of_voice:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRate'
          nullable: true
          description: This prompt's `mention_total` over its `brand_mention_total` — your share of every brand mention made on this prompt, on the same denominator as the app's Share of Voice. Null when that denominator was zero.
        avg_rank:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRankValue'
          nullable: true
          description: This prompt's `rank_sum` over its `mentioned_count`, where 1 is named first. Null when the prompt never mentioned you.
        primary_citation_rate:
          allOf:
            - $ref: '#/components/schemas/AnalyticsRate'
          nullable: true
          description: Citation Rate for the Owned tier on this prompt. Of this prompt's answers that cited anything (denominator its own `cited_run_count`, i.e. `D` scoped to the prompt), the share that cited one of your own domains. Null when that `D` was zero.
        latest:
          allOf:
            - $ref: '#/components/schemas/PromptLatestSnapshot'
          nullable: true
          description: Current state from the newest answers. Null when the prompt has no stored answers.

    AnalyticsPromptPerformanceResponse:
      type: object
      description: The paged per-prompt performance table, with the org-wide totals the rows sit inside.
      properties:
        window:
          $ref: '#/components/schemas/AnalyticsWindow'
        totals:
          $ref: '#/components/schemas/AnalyticsTotals'
        metrics:
          $ref: '#/components/schemas/AnalyticsMetrics'
        total:
          type: integer
          description: Total prompts matching the filters, across all pages.
        limit:
          type: integer
        offset:
          type: integer
        prompts:
          type: array
          items:
            $ref: '#/components/schemas/PromptPerformanceItem'
        data_quality:
          $ref: '#/components/schemas/AnalyticsDataQuality'
        notes:
          $ref: '#/components/schemas/AnalyticsNotes'
        definitions:
          $ref: '#/components/schemas/AnalyticsDefinitions'

    AnswerCitation:
      type: object
      description: One cited URL inside a stored answer.
      properties:
        url:
          type: string
          description: The cited URL, canonicalized by the rollup pipeline.
        domain:
          type: string
          description: The URL's domain — what the tier classification was decided on.
        citation_type:
          $ref: '#/components/schemas/CitationTier'

    LatestAnswerItem:
      type: object
      description: |
        The most recent stored answer for one prompt × model × location, with everything the model said about you in it.

        This is the only place full answer text exists. Nothing older is retained, so a set of these is a snapshot of the present, not a sample of any window.
      properties:
        prompt_id:
          type: string
          format: uuid
        prompt_text:
          type: string
        prompt_type:
          type: string
          description: Funnel stage — `awareness`, `consideration`, `evaluation`, or `decision`.
        provider:
          type: string
          description: The provider that produced the answer, in the scheduler's native spelling.
        model:
          type: string
          description: The model that produced the answer, as a canonical short id.
        location:
          type: string
          description: The location the prompt was run from, e.g. `US` or `US/California`.
        run_at:
          type: string
          format: date-time
          description: When this answer was produced. Rows in one response will differ, because each is the newest for its own combination.
        response_text:
          type: string
          description: The model's full answer text.
        empty:
          type: boolean
          description: True when the run returned nothing. Empty answers are excluded from `answered_count` everywhere else in this API.
        mentioned:
          type: boolean
          description: True when this answer named your brand.
        rank:
          type: integer
          nullable: true
          description: Your brand's ordinal position in this answer, where 1 is named first. Null when you were not mentioned.
        sentiment:
          type: string
          nullable: true
          description: How this answer's mention of you was classified — `positive`, `neutral`, or `negative`. Null when you were not mentioned.
        sov_pct:
          type: number
          format: float
          nullable: true
          description: The scheduler's per-answer share-of-voice figure for your brand, on a 0–100 scale. This is a single-answer value and is not the window `share_of_voice` metric — do not average these across rows to approximate it. Null when the scheduler recorded none.
        citations:
          type: array
          description: The URLs this answer cited, capped at 50 entries by the rollup pipeline. An answer that cited more will be truncated here while its counts elsewhere remain complete.
          items:
            $ref: '#/components/schemas/AnswerCitation'
        competitor_mentions:
          type: object
          description: Mention counts per brand within this answer, covering **your tracked competitors only**. Untracked brands the model named are counted in `brand_mention_total` (the `share_of_voice` denominator) but are never enumerated by name anywhere in this API.
          additionalProperties:
            type: integer
        has_primary_citation:
          type: boolean
          description: True when this answer cited at least one of your own domains (tier `primary`).
        has_tracked_citation:
          type: boolean
          description: True when this answer cited at least one of your tracked sources (tier `tracked`).
        has_external_citation:
          type: boolean
          description: True when this answer cited at least one third-party domain (tier `secondary`). All three flags can be true at once — tiers partition citations, not answers.

    AnalyticsLatestAnswersResponse:
      type: object
      description: The org-wide latest-answer feed, paged. There is no window here — the endpoint returns the newest answer per prompt × model × location by construction, and each row carries its own `run_at`.
      properties:
        total:
          type: integer
          description: Total answers matching the filters, across all pages.
        limit:
          type: integer
        offset:
          type: integer
        answers:
          type: array
          items:
            $ref: '#/components/schemas/LatestAnswerItem'
        notes:
          $ref: '#/components/schemas/AnalyticsNotes'
        definitions:
          $ref: '#/components/schemas/AnalyticsDefinitions'

    AnalyticsPromptDetailResponse:
      type: object
      description: |
        One prompt end to end — its window totals and metrics, its day-by-day series, and the latest full answer from every model × location.

        The split is deliberate and load-bearing: `series` is the history, as additive counts only, and `latest_answers` is the present, as full text. There is no history of answer text.
      properties:
        prompt_id:
          type: string
          format: uuid
        prompt_text:
          type: string
        prompt_type:
          type: string
          description: Funnel stage — `awareness`, `consideration`, `evaluation`, or `decision`.
        tags:
          type: array
          items:
            type: string
        window:
          $ref: '#/components/schemas/AnalyticsWindow'
        totals:
          $ref: '#/components/schemas/AnalyticsTotals'
        metrics:
          $ref: '#/components/schemas/AnalyticsMetrics'
        series:
          type: array
          description: Daily visibility counts and ratios for this prompt, oldest first. The only record of what happened earlier in the window — counts, never text.
          items:
            $ref: '#/components/schemas/MentionSeriesPoint'
        latest_answers:
          type: array
          description: The most recent answer per model × location for this prompt, with full text, citations, and tracked-competitor mentions. Empty when you passed `include_answers=false` (a note records that) or when the prompt has never run.
          items:
            $ref: '#/components/schemas/LatestAnswerItem'
        data_quality:
          $ref: '#/components/schemas/AnalyticsDataQuality'
        notes:
          $ref: '#/components/schemas/AnalyticsNotes'
        definitions:
          $ref: '#/components/schemas/AnalyticsDefinitions'
