openapi: 3.1.0
info:
  title: Trail API
  version: v1
  description: |
    Trail's external HTTP surface — the endpoints external apps and AI
    agents call to ingest knowledge, retrieve grounded context, search,
    and chat against a knowledge base.

    **Auth model.** All endpoints require a `Bearer` token in the
    `Authorization` header. Tokens are tenant-scoped (one key
    authenticates against any KB owned by your tenant) and created at
    [`https://app.trailmem.com/settings`](https://app.trailmem.com/settings)
    → **API Keys** → **Create new key**. The value is shown ONCE —
    save it to your secret manager immediately.

    **Endpoint stability.** Paths under `/api/v1/` are the external
    contract — request/response shapes are versioned and will not break
    without a `v2/` prefix bump. Paths under `/api/internal/` are
    private and may change without notice; they are excluded from this
    spec on purpose.

    **Source of truth.** This spec is hand-written from
    `packages/shared/src/schemas.ts` and the route handlers in
    `apps/server/src/routes/*.ts` in the trail repository. A future
    docs phase will add a CI contract test that diffs live routes
    against the spec.
  contact:
    name: Trail
    url: https://docs.trailmem.com/
  license:
    name: FSL-1.1-Apache-2.0
    url: https://github.com/broberg-ai/trail/blob/main/LICENSE
servers:
  - url: https://engine.trailmem.com
    description: Production engine (Christian's hosted fleet)
  - url: http://127.0.0.1:58021
    description: Local development engine

# ─── Tags group endpoints into Redoc nav sections ─────────────────

tags:
  - name: retrieve
    description: |
      Grounded retrieval from a knowledge base. The right primitive
      when you are building your own site-LLM and want Trail to be one
      tool among many (Pattern C — see
      [docs](https://docs.trailmem.com/site-llm-with-trail-as-tool/)).
  - name: queue
    description: |
      Submit candidate Neurons for curator review. The single write
      path into Trail's KB; everything that enters Trail flows through
      this endpoint or its sibling internal ingest routes.
  - name: chat
    description: |
      Synthesised answer with citations. The right primitive for
      Pattern A — a simple chat-proxy over a KB. For tone-controlled
      multi-tool composition, prefer `/retrieve` from your own LLM.
  - name: feedback
    description: |
      Reader feedback (👍/👎/🚩) on chat answers. Submission becomes a
      `reader-feedback` candidate in the curator's queue with the
      original question + AI answer + citations bundled in. Closes
      the embed → curation loop.
  - name: sources
    description: |
      Programmatic upload of Source files (PDFs, markdown, audio,
      images, etc.) into a knowledge base. After upload, an ingest
      pipeline compiles the source into Neurons. The right surface
      for "push content from my own app into Trail" — Slack listeners,
      webhook receivers, scheduled importers, the Eir widget's
      attach-file flow.
  - name: search
    description: |
      FTS5 keyword search across Neurons + share-gated user-notes.

# ─── Auth ─────────────────────────────────────────────────────────

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: opaque
      description: |
        Tenant-scoped bearer token. Create one at
        [`https://app.trailmem.com/settings`](https://app.trailmem.com/settings)
        → **API Keys** → **Create new key**. The value is shown ONCE;
        save it to your secret manager immediately. Store server-side
        only; never expose to the browser. Tokens look like
        `trail_live_...`. One key works for any KB owned by your
        tenant.

  # ─── Shared schemas ────────────────────────────────────────────

  schemas:
    Error:
      type: object
      properties:
        error:
          oneOf:
            - type: string
            - type: object
              description: |
                Zod flattened-error shape when the request body failed
                schema validation. Contains `fieldErrors` and
                `formErrors` arrays per
                [Zod docs](https://zod.dev/?id=flattening-errors).
      required: [error]

    CandidateKind:
      type: string
      description: |
        Discriminator for what shape of candidate this is. The most
        common kinds for external apps:

        * `chat-answer` — a Q&A pair produced by a user-facing chat
          (curator may approve to add to KB).
        * `user-correction` — a diff against an existing Neuron;
          provide its seqId in `metadata.targetNeuron`.
        * `reader-feedback` — feedback flagged from the reader UI.
        * `gap-detection` — automated finding that the KB is missing
          knowledge for a topic.

        See `packages/shared/src/schemas.ts:197` for the full enum.
      enum:
        - chat-answer
        - ingest-summary
        - ingest-page-update
        - cross-ref-suggestion
        - contradiction-alert
        - gap-detection
        - user-correction
        - reader-feedback

    CreateCandidateRequest:
      type: object
      required: [knowledgeBaseId, kind, title, content]
      properties:
        knowledgeBaseId:
          type: string
          description: |
            KB slug or canonical UUID. Both are accepted; slug is
            normalised server-side .
          examples:
            - my-product-docs
            - 01H5...
        kind:
          $ref: "#/components/schemas/CandidateKind"
        title:
          type: string
          minLength: 1
          maxLength: 500
          examples:
            - VIP customer escalation policy
        content:
          type: string
          minLength: 1
          description: Markdown body of the proposed Neuron.
        metadata:
          type: string
          nullable: true
          description: |
            JSON-stringified metadata blob. The orchestrator looks
            here for `connector` (attribution id — see
            `packages/shared/src/connectors.ts`), `sourceUrl`,
            `targetNeuron` (when kind=user-correction), etc.
          examples:
            - '{"connector":"api","sourceUrl":"https://internal.example.com/x"}'
        confidence:
          type: number
          format: float
          minimum: 0
          maximum: 1
          nullable: true
          description: |
            Optional confidence score [0, 1] the writer attaches. Feeds
            the auto-approval policy when set.
        impactEstimate:
          type: integer
          nullable: true
          description: |
            Optional positive integer estimating downstream impact of
            this Neuron. Used by curator-prioritisation.

    CreateCandidateResponse:
      type: object
      required: [candidateId, status]
      properties:
        candidateId:
          type: string
        status:
          type: string
          enum: [pending, approved, dismissed]
        kbId:
          type: string
          description: Canonical KB UUID (resolved from slug if a slug was sent).

    RetrieveRequest:
      type: object
      required: [query]
      properties:
        query:
          type: string
          minLength: 1
          description: User's question or a reformulated FTS-friendly variant.
          examples:
            - "Does reflexology help with insomnia?"
        audience:
          type: string
          enum: [curator, tool, public, student]
          default: tool
          description: |
            Controls per-Neuron audience filtering and the shape of
            `formattedContext`. `tool` is the default for site-LLM
            consumption; `public` formats for direct end-user display
            with longer narrative chunks; `student` is an
            implementation-defined custom audience the deployed system
            uses for educational sub-audiences.
        maxChars:
          type: integer
          minimum: 1
          maximum: 16000
          default: 2000
          description: |
            Hard upper-bound on `sum(chunks.content)` in the returned
            `formattedContext`. Conservative default works well for
            Gemini Flash / Claude Haiku; bump to 4000 for deeper
            contexts.
        topK:
          type: integer
          minimum: 1
          maximum: 50
          default: 5
          description: |
            Max number of Neuron-chunks to return after audience + tag
            filtering. The engine pulls `topK * 3` candidates from
            FTS to ensure filtering still leaves enough material.

    RetrieveChunk:
      type: object
      required: [documentId, seqId, title, neuronPath, content, headerBreadcrumb, rank]
      properties:
        documentId:
          type: string
          description: Internal document UUID.
        seqId:
          type: string
          pattern: '^[a-z0-9-]+_[0-9]{8}$'
          description: |
            Canonical per-KB sequence id.
            Format `{kbPrefix}_{8-digit-seq}`, stable across edits and
            recompiles. The right handle to use when citing back to a
            Neuron from your own UI.
          examples:
            - practice_00000037
        title:
          type: string
        neuronPath:
          type: string
          description: Slug-path of the Neuron in the wiki tree.
          examples:
            - /wiki/reflexology-and-sleep-practice-notes
        content:
          type: string
          description: Chunk body — the actual retrieved markdown.
        headerBreadcrumb:
          type: string
          description: "Hierarchical breadcrumb of headings the chunk sits under (e.g. `Wellness > Sleep`)."
        rank:
          type: number
          format: float
          description: FTS5 BM25 rank, [0, 1]-normalised.

    RetrieveResponse:
      type: object
      required: [hitCount, totalChars, formattedContext, chunks]
      properties:
        hitCount:
          type: integer
          description: Number of chunks returned (after filtering).
        totalChars:
          type: integer
          description: Total character count across all returned chunks.
        formattedContext:
          type: string
          description: |
            Pre-formatted markdown string ready to feed directly to an
            LLM as additional context. Consumed by `kb_retrieve`-style
            tool handlers in site-LLM orchestrators — no further
            chunk-rendering required.
        chunks:
          type: array
          items:
            $ref: "#/components/schemas/RetrieveChunk"

    ChatRequest:
      type: object
      required: [message]
      properties:
        message:
          type: string
          minLength: 1
        knowledgeBaseId:
          type: string
          description: KB slug or UUID. Required for grounded answers.
        sessionId:
          type: string
          description: |
            Optional chat-session id. When omitted + a KB is
            specified, the server creates a new session and echoes its
            id back so subsequent turns can append.
        audience:
          type: string
          enum: [curator, tool, public]
          description: |
            Controls persona-template + post-processing.
            Default: `tool` for Bearer auth, `curator` for session auth.

    Citation:
      type: object
      required: [documentId, path, filename]
      properties:
        documentId:
          type: string
        path:
          type: string
        filename:
          type: string

    ChatResponse:
      type: object
      required: [answer]
      properties:
        answer:
          type: string
        sessionId:
          type: string
          description: Present when the question was scoped to a KB.
        citations:
          type: array
          items:
            $ref: "#/components/schemas/Citation"

    ReaderFeedbackVote:
      type: string
      enum: [up, down, flag]
      description: |
        - `up`   — 👍, positive vote. No reason required.
        - `down` — 👎, negative vote. **Reason required.** Indicates
          the answer was wrong, vague, or unhelpful.
        - `flag` — 🚩, escalate to curator. **Reason required.** Use
          when the answer crossed a boundary (out-of-scope claim,
          potentially harmful, ethical concern).

    ReaderFeedbackRequest:
      type: object
      required: [vote, question, answer]
      properties:
        vote:
          $ref: "#/components/schemas/ReaderFeedbackVote"
        question:
          type: string
          minLength: 1
          maxLength: 2000
          description: The user's question that produced the answer.
        answer:
          type: string
          minLength: 1
          maxLength: 50000
          description: The AI's answer being voted on.
        citations:
          type: array
          description: Citations that grounded the answer, optional but recommended.
          items:
            $ref: "#/components/schemas/Citation"
        sessionId:
          type: string
          description: Chat session id (if the answer came from a multi-turn session).
        turnId:
          type: string
          description: Specific turn within the session (for granular cross-reference).
        reason:
          type: string
          maxLength: 2000
          description: |
            Free-text from the reader. **Required** when `vote` is `down`
            or `flag`. Omitted on `up`.
        category:
          type: string
          enum: [wrong-info, missing-info, irrelevant, tone, other]
          description: |
            Optional category for the curator's queue filter. Embedders
            with a different taxonomy may omit; the admin queue UI
            shows the canonical 5 categories as quick-click chips.
        pageUrl:
          type: string
          maxLength: 500
          description: |
            Where the feedback originated. For widget-embed integrations
            this is the page URL the user was on; the curator sees it
            in the queue card so they can correlate feedback patterns
            with content.

    DocumentSource:
      type: object
      required: [id, knowledgeBaseId, kind, filename, path, fileType, fileSize, status, createdAt]
      properties:
        id:
          type: string
          format: uuid
          description: Internal document UUID. Use with `/documents/{docId}/ingest`.
        knowledgeBaseId:
          type: string
          format: uuid
        kind:
          type: string
          enum: [source, wiki]
          description: Uploads always create `kind: source`. Compiled Neurons appear later as `kind: wiki`.
        filename:
          type: string
        path:
          type: string
          description: Logical path in the KB's source tree (default "/").
        fileType:
          type: string
          description: Extension without dot, lowercased (e.g. `pdf`, `md`, `mp3`).
        fileSize:
          type: integer
          description: Bytes.
        status:
          type: string
          enum: [pending, ready, processing, success, failed]
          description: |
            Source lifecycle:
            - `pending` — uploaded; binary extractor (PDF, audio, ...) queued
            - `ready` — text source uploaded + chunked; ready to compile
            - `processing` — ingest pipeline running (extractor or LLM compile)
            - `success` — fully compiled into Neurons; sits in KB
            - `failed` — extractor or compile errored; see `errorMessage`
        errorMessage:
          type: string
          nullable: true
          description: Human-readable failure reason. Cleared on retry.
        seq:
          type: integer
          description: Per-KB sequence number (`{kbPrefix}_{seq}`-style canonical id).
        contentHash:
          type: string
          description: SHA-256 of the uploaded bytes. Used for dedup (F162).
        tags:
          type: string
          nullable: true
          description: Comma-separated tags inherited from the upload's metadata.
        createdAt:
          type: string
          format: date-time

    DuplicateSourceError:
      type: object
      required: [error, code, existingDocumentId]
      properties:
        error:
          type: string
        code:
          type: string
          enum: [duplicate_source]
        existingDocumentId:
          type: string
          format: uuid
          description: ID of the previously-uploaded source with the same SHA-256.
        existingFilename:
          type: string
        existingPath:
          type: string
        existingCreatedAt:
          type: string
          format: date-time
        hint:
          type: string
          description: |
            Always `"Append ?force=true to upload anyway as a separate Source."`
            for predictable client handling.

    IngestTriggerResponse:
      type: object
      required: [ok, message]
      properties:
        ok:
          type: boolean
        message:
          type: string

    ReaderFeedbackResponse:
      type: object
      required: [candidateId, status, queueUrl]
      properties:
        candidateId:
          type: string
          description: ID of the created `reader-feedback` candidate.
        status:
          type: string
          enum: [pending, approved, dismissed]
          description: |
            Almost always `pending` — reader feedback rarely meets the
            auto-approval threshold (negative feedback gets confidence
            0.3, well below typical auto-approval thresholds).
        queueUrl:
          type: string
          description: Admin-relative URL to jump straight to the feedback in the queue.

    SearchHit:
      type: object
      required: [id, knowledgeBaseId, filename, path, kind, highlight, rank]
      properties:
        id:
          type: string
        knowledgeBaseId:
          type: string
        filename:
          type: string
        title:
          type: string
          nullable: true
        path:
          type: string
        kind:
          type: string
          enum: [source, wiki]
        seq:
          type: integer
          nullable: true
        highlight:
          type: string
          description: HTML snippet with `<mark>` wrapping matched terms.
        rank:
          type: number
          format: float

    SearchResponse:
      type: object
      required: [documents]
      properties:
        documents:
          type: array
          items:
            $ref: "#/components/schemas/SearchHit"

security:
  - BearerAuth: []

# ─── Paths ────────────────────────────────────────────────────────

paths:
  /api/v1/knowledge-bases/{kbId}/retrieve:
    post:
      summary: Retrieve grounded context from a KB
      operationId: retrieveContext
      tags: [retrieve]
      description: |
        Primary endpoint for **Pattern C** — site-LLM with Trail as a
        tool. Returns the top-K Neuron-chunks for a query, plus a
        pre-formatted `formattedContext` string the orchestrator can
        feed directly to its LLM.

        The deployed reflexology-practice integration calls this from
        its `kb_retrieve` tool handler; see
        [docs](https://docs.trailmem.com/site-llm-with-trail-as-tool/)
        for the full case study.
      parameters:
        - name: kbId
          in: path
          required: true
          schema:
            type: string
          description: KB slug or canonical UUID.
          example: my-product-docs
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RetrieveRequest"
            examples:
              minimal:
                summary: Minimal — just a query
                value:
                  query: "How fast must VIP escalations be paged?"
              tuned:
                summary: Tuned — bigger context window for longer answer
                value:
                  query: "Walk me through every step of our incident-response procedure"
                  audience: tool
                  maxChars: 4000
                  topK: 8
      responses:
        "200":
          description: Retrieved chunks + pre-formatted context.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RetrieveResponse"
        "400":
          description: Validation error.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing or invalid bearer token.
        "404":
          description: KB not found for this tenant.

  /api/v1/queue/candidates:
    post:
      summary: Submit a candidate Neuron for curator review
      operationId: createCandidate
      tags: [queue]
      description: |
        Submit content that should become a Neuron after curator
        review. This is the single write-path into a Trail KB — all
        ingest flows (web-clipper, MCP, REST, chat-save) ultimately
        land here.

        After approval, the candidate becomes a Neuron with a stable
        `seqId` like `{kbPrefix}_{8-digit-seq}`. The KB's auto-approval
        policy decides whether curator review is required.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateCandidateRequest"
            examples:
              policy:
                summary: Programmatic policy candidate from a webhook
                value:
                  knowledgeBaseId: my-product-docs
                  kind: chat-answer
                  title: VIP customer escalation policy
                  content: |
                    When a VIP customer raises a ticket, page the
                    on-call lead via PagerDuty within 5 minutes.
                    Escalation overrides queue priority.
                  metadata: '{"connector":"api","sourceUrl":"https://internal.example.com/policies/vip-escalation"}'
              correction:
                summary: User-correction against an existing Neuron
                value:
                  knowledgeBaseId: my-product-docs
                  kind: user-correction
                  title: "Correction: VIP SLA is 3 minutes, not 5"
                  content: |
                    The current Neuron says 5 minutes; the updated
                    policy is 3 minutes (effective 2026-04-01).
                  metadata: '{"connector":"api","targetNeuron":"myprod_a1b2c3d4"}'
      responses:
        "201":
          description: Candidate created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreateCandidateResponse"
        "400":
          description: Validation error.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing or invalid bearer token.
        "404":
          description: KB not found for this tenant.

  /api/v1/chat:
    post:
      summary: Grounded chat answer with citations
      operationId: chat
      tags: [chat]
      description: |
        Primary endpoint for **Pattern A** — a thin chat-proxy over a
        KB. Returns a synthesised answer in the KB's configured tone,
        with citations to the Neurons that grounded it.

        For multi-tool composition or distinct brand voice, prefer
        `/retrieve` from your own LLM
        ([docs](https://docs.trailmem.com/site-llm-with-trail-as-tool/)).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ChatRequest"
            examples:
              kbQuestion:
                summary: KB-grounded question
                value:
                  message: "How fast must VIP escalations be paged?"
                  knowledgeBaseId: my-product-docs
              continueSession:
                summary: Continue a previous chat session
                value:
                  message: "And what if the on-call lead is unreachable?"
                  knowledgeBaseId: my-product-docs
                  sessionId: "chat_sess_..."
      responses:
        "200":
          description: Synthesised answer + citations.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChatResponse"
              examples:
                grounded:
                  summary: Grounded answer
                  value:
                    answer: |
                      VIP escalations must be paged to the on-call lead
                      within 5 minutes via PagerDuty. This overrides
                      normal queue priority.
                    sessionId: chat_sess_...
                    citations:
                      - documentId: doc_...
                        path: /wiki/vip-customer-escalation-policy.md
                        filename: vip-customer-escalation-policy.md
        "400":
          description: Validation error.
        "401":
          description: Missing or invalid bearer token.
        "404":
          description: KB not found.

  /api/v1/knowledge-bases/{kbId}/documents/upload:
    post:
      summary: Upload a Source file (PDF, markdown, audio, image, ...)
      operationId: uploadSource
      tags: [sources]
      description: |
        Primary endpoint for **programmatically pushing Sources into
        Trail** from your own app. Multipart form upload — server
        stores the bytes, creates a `documents` row with `kind=source`,
        and for **text formats** (md, txt, html, htm, csv) auto-triggers
        the ingest pipeline immediately. For **binary formats** (pdf,
        docx, audio, images), upload returns 201 fast and the
        extractor + ingest queue handles compile asynchronously — poll
        the document's `status` field to track progress.

        **Supported extensions**: pdf, docx, pptx, doc, ppt, png, jpg,
        jpeg, webp, gif, svg, wav, mp3, m4a, ogg, flac, aac, html, htm,
        xlsx, xls, csv, md, txt. Max size 100 MB.

        **Deduplication (F162)**: every upload's bytes get SHA-256
        hashed before storage-write. If a Source with identical content
        already exists in the same KB, the server returns 409 with
        `code: "duplicate_source"` + the existing document's id. Append
        `?force=true` to upload anyway as a separate Source (useful
        when the same file legitimately appears under a different name
        or path).

        Sources written via this endpoint stamp
        `metadata.connector="upload"` by default; pass a
        `metadata.connector` in the form-data to override (e.g.
        `"sanne-andersen-site"` or `"slack-attachments"`).
      parameters:
        - name: kbId
          in: path
          required: true
          schema:
            type: string
          description: KB slug or canonical UUID.
        - name: force
          in: query
          required: false
          schema:
            type: boolean
          description: |
            Set to `true` to skip the duplicate-source check. Default
            is dedup-enabled — a content-hash collision returns 409
            without writing a new document row.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: The Source file. Max 100 MB.
                path:
                  type: string
                  default: "/"
                  description: Logical path in the KB's source tree (organisational only).
                metadata:
                  type: string
                  description: |
                    JSON-stringified metadata blob. Recognised keys:
                    `connector` (string, attribution id),
                    `sourceUrl` (string, where this content originated),
                    `tags` (array of strings, comma-joined into the Source row).
                  example: '{"connector":"sanne-site","sourceUrl":"https://sanneandersen.dk/admin/upload/12","tags":["protocol","clinical"]}'
      responses:
        "201":
          description: Source uploaded successfully. For text formats, ingest auto-triggered; for binary, extractor queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DocumentSource"
        "400":
          description: |
            Invalid request. Common cases:
            - `error: "No file provided"`
            - `error: "File type .X not allowed"`
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing or invalid bearer token.
        "404":
          description: KB not found for this tenant.
        "409":
          description: |
            Duplicate source — identical content already exists in this
            KB. Response body includes `existingDocumentId` so the client
            can link to the existing row. Use `?force=true` to override.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DuplicateSourceError"
        "413":
          description: File exceeds 100 MB.

  /api/v1/documents/{docId}/ingest:
    post:
      summary: Trigger (or re-trigger) the ingest pipeline on a Source
      operationId: triggerIngest
      tags: [sources]
      description: |
        Manually fire the ingest pipeline on a Source document. Useful
        when:

        - The auto-trigger failed and you want to retry after fixing
          the source (e.g. the LLM rate-limited; try again now).
        - You uploaded a binary that needed extraction first, the
          extraction completed, and you want to push the compile.
        - You're re-ingesting after changing the KB's ingest-model
          (`/chat-settings` PATCH) to compile with a different LLM.

        The pipeline runs async — endpoint returns 202 immediately;
        the document's `status` will transition to `processing` and
        then to `success` or `failed`. Poll via `/documents/{docId}` or
        listen on the SSE event stream for completion.

        Only Source documents (`kind=source`) can be ingested. Wiki
        documents are output, not input.
      parameters:
        - name: docId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Internal document UUID from the upload response.
      responses:
        "202":
          description: Ingest queued. Poll document.status for completion.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IngestTriggerResponse"
        "400":
          description: Document is not a Source (only `kind=source` can be ingested).
        "401":
          description: Missing or invalid bearer token.
        "404":
          description: Document not found in this tenant.
        "409":
          description: Already processing — wait for the current run to finish.

  /api/v1/knowledge-bases/{kbId}/reader-feedback:
    post:
      summary: Submit reader feedback (👍/👎/🚩) on a chat answer
      operationId: submitReaderFeedback
      tags: [feedback]
      description: |
        Turns a 👍/👎/🚩 click on a chat answer into a
        `reader-feedback` candidate in the curator's queue. The
        candidate bundles the question + answer + citations + the
        reader's reason so the curator can act on it without
        re-loading the original session.

        Used by:

        - **Admin chat** (`/kb/:kbId/chat`) — internal curator feedback
          on their own answers (so they can flag answers they
          themselves got wrong and want to fix the KB for next time).
        - **`<trail-chat>` widget** (Pattern A embed) — public-facing
          feedback from end-users on an external site, scoped to a
          Bearer-token-authenticated KB.

        **Vote semantics**: `up` requires no `reason`; `down` and
        `flag` **require** a non-empty `reason` (the server returns
        400 `reason_required_for_negative_vote` otherwise).
      parameters:
        - name: kbId
          in: path
          required: true
          schema:
            type: string
          description: KB slug or canonical UUID.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReaderFeedbackRequest"
            examples:
              upvote:
                summary: 👍 minimal — just a positive vote
                value:
                  vote: up
                  question: "How fast must VIP escalations be paged?"
                  answer: "VIP escalations must be paged within 5 minutes via PagerDuty."
              downvote:
                summary: 👎 with reason + category
                value:
                  vote: down
                  question: "What treatments work for chronic headaches?"
                  answer: "Reflexology may help with some types of headaches."
                  reason: "The answer is too vague — doesn't cite which types or research backing."
                  category: missing-info
                  pageUrl: "https://example.com/treatments"
              flag:
                summary: 🚩 escalate to curator
                value:
                  vote: flag
                  question: "Is this safe during pregnancy?"
                  answer: "Yes, this is safe."
                  reason: "AI gave a definitive medical answer — should defer to practitioner instead. Please review."
                  category: other
      responses:
        "201":
          description: Feedback candidate created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReaderFeedbackResponse"
        "400":
          description: |
            Validation error. Common case: `reason` missing on a
            `down` or `flag` vote → `error: "reason_required_for_negative_vote"`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing or invalid bearer token.
        "404":
          description: KB not found for this tenant.

  /api/v1/knowledge-bases/{kbId}/search:
    get:
      summary: FTS5 keyword search across Neurons
      operationId: searchKb
      tags: [search]
      description: |
        Full-text search using SQLite FTS5. Matches against title +
        body + headers. Also matches opt-in user-notes via a substring
        scan. Returns up to 50 documents by default, ranked by
        BM25.
      parameters:
        - name: kbId
          in: path
          required: true
          schema:
            type: string
          description: KB slug or canonical UUID.
        - name: q
          in: query
          required: true
          schema:
            type: string
          description: Search query. FTS5 syntax supported (`"phrase"`, `term1 OR term2`, `prefix*`).
          example: VIP escalation
        - name: kind
          in: query
          required: false
          schema:
            type: string
            enum: [source, wiki, all]
            default: all
          description: Filter by document kind.
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
      responses:
        "200":
          description: Search hits.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SearchResponse"
        "400":
          description: Missing/invalid query.
        "401":
          description: Missing or invalid bearer token.
        "404":
          description: KB not found.
