openapi: 3.1.0
info:
  title: What's Healthy and Why public API
  version: 1.0.0
  summary: Read-only JSON and markdown versions of every article, recipe and product, a retrieval-grounded chat endpoint, and an MCP server.
  description: |
    The content endpoints (articles, recipes, products, llms.txt, this file) are static files regenerated on each
    deploy: no key, no rate limit. GET and HEAD requests to them are readable cross-origin
    (`Access-Control-Allow-Origin: *`); there is no preflight, so send simple requests only (no custom request
    headers). `/mcp` sets its own CORS headers and `/api/chat` accepts browser calls from the site's own
    origin only. Health content is general information, not medical advice. When you reuse it, cite the
    article URL and the PubMed IDs (PMIDs) in its references, and keep its caveats and confidence rating.

    A short guide for language models and agents lives at https://whatshealthyandwhy.com/llms.txt. The source code,
    including a longer human-readable API guide, will be published; until then this file is the contract.
  contact:
    name: What's Healthy and Why
    url: https://whatshealthyandwhy.com/about
servers:
  - url: https://whatshealthyandwhy.com
tags:
  - name: articles
    description: The research articles, as JSON and as their markdown source.
  - name: recipes
  - name: products
  - name: assistant
    description: Question answering and tools for agents.
  - name: discovery
paths:
  /api/articles.json:
    get:
      tags: [articles]
      operationId: listArticles
      summary: Index of every article
      description: One line per article, sorted by topic slug and reading order, with the URL patterns for the full versions.
      responses:
        '200':
          description: The article index.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ArticleIndex' }
  /api/articles/{topic}/{slug}.json:
    get:
      tags: [articles]
      operationId: getArticle
      summary: One article as JSON
      description: Every depth level as plain text (inline `[n]` markers point at `references[].id`), the references with PMIDs and PubMed links, and provenance. `deepText` is capped at 6,000 characters; the markdown version is complete.
      parameters:
        - $ref: '#/components/parameters/Topic'
        - $ref: '#/components/parameters/Slug'
      responses:
        '200':
          description: The article.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Article' }
        '404': { description: No such article. }
  /{topic}/{slug}.md:
    get:
      tags: [articles]
      operationId: getArticleMarkdown
      summary: One article's markdown source
      description: The article's source file exactly as authored, YAML frontmatter (title, description, references with PMIDs) included. Depth levels are separated by `<!-- more -->` and `<!-- research -->` (and optionally `<!-- full -->`). Readable cross-origin.
      parameters:
        - $ref: '#/components/parameters/Topic'
        - $ref: '#/components/parameters/Slug'
      responses:
        '200':
          description: Markdown with frontmatter.
          content:
            text/markdown:
              schema: { type: string }
        '404': { description: No such article. }
  /api/recipes.json:
    get:
      tags: [recipes]
      operationId: listRecipes
      summary: Index of every recipe and kitchen remedy
      responses:
        '200':
          description: The recipe index.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RecipeIndex' }
  /api/recipes/{slug}.json:
    get:
      tags: [recipes]
      operationId: getRecipe
      summary: One recipe as JSON
      parameters:
        - name: slug
          in: path
          required: true
          schema: { type: string, examples: [green-glow-smoothie] }
      responses:
        '200':
          description: The recipe.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Recipe' }
        '404': { description: No such recipe. }
  /api/products.json:
    get:
      tags: [products]
      operationId: listProducts
      summary: The vetted product list
      responses:
        '200':
          description: Every product.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductIndex' }
  /api/chat:
    post:
      tags: [assistant]
      operationId: askQuestion
      summary: Ask a question, answered only from the site's articles
      description: |
        The question is matched against the articles (BM25 over title, question, answer, description and summary);
        up to six matches are given to Claude as excerpts, and the model answers only from them, citing article
        URLs and PMIDs. Limited to 10 model calls per IP per 10 minutes, plus an optional daily cap. Browsers must
        call from the site's own origin. While no API key is configured the endpoint answers 503 with
        `configured: false` and still returns the matching articles.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ChatRequest' }
      responses:
        '200':
          description: An answer.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ChatAnswer' }
        '400': { $ref: '#/components/responses/ChatError' }
        '403': { $ref: '#/components/responses/ChatError' }
        '413': { $ref: '#/components/responses/ChatError' }
        '429':
          description: Too many questions from this IP, or the daily cap is reached. Matching articles are still returned.
          headers:
            Retry-After: { schema: { type: integer }, description: Seconds to wait (per-IP limit only). }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ChatError' }
        '502': { $ref: '#/components/responses/ChatError' }
        '503':
          description: The assistant is switched off (`configured` false) or unavailable. Matching articles are still returned.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ChatError' }
        '504': { $ref: '#/components/responses/ChatError' }
  /mcp:
    post:
      tags: [assistant]
      operationId: mcp
      summary: Model Context Protocol server (Streamable HTTP, stateless)
      description: |
        Public and read-only. JSON-RPC 2.0 over POST; every reply is a single JSON body (no sessions, no
        server-sent events, no auth). Tools: `search(query, limit?)`, `get_article(topic + subtopic | path,
        depth?)`, `get_topic(topic)`, `get_sources(topic + subtopic | path)`, `list_topics(category?, limit?,
        offset?)` and `what_helps_with(condition)`, plus the resource template `whw://article/{topic}/{subtopic}`.
        Tool results are JSON text in `content[0].text`. 60 requests per minute per IP; batches are refused.
        Connection guide: https://whatshealthyandwhy.com/developers.
      parameters:
        - name: MCP-Protocol-Version
          in: header
          required: false
          schema: { type: string, examples: ['2025-06-18'] }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/JsonRpcMessage' }
            examples:
              search:
                value: { jsonrpc: '2.0', id: 1, method: tools/call, params: { name: search, arguments: { query: magnesium sleep, limit: 3 } } }
      responses:
        '200':
          description: JSON-RPC response.
          headers:
            X-RateLimit-Limit: { schema: { type: integer } }
            X-RateLimit-Remaining: { schema: { type: integer } }
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '202': { description: Accepted; the body held only a notification. }
        '400': { description: 'Parse error, invalid request, a batch, or an unsupported MCP-Protocol-Version (JSON-RPC error body).' }
        '413': { description: Body over 64 KB. }
        '429':
          description: Over 60 requests a minute from this IP (JSON-RPC error body).
          headers:
            Retry-After: { schema: { type: integer }, description: Seconds to wait. }
    get:
      tags: [assistant]
      operationId: mcpStream
      summary: Not offered (no server-sent event stream)
      responses:
        '405': { description: Use POST; the JSON body points to the connection guide. }
    options:
      tags: [assistant]
      operationId: mcpPreflight
      summary: CORS preflight
      responses:
        '204': { description: CORS headers for POST from any origin. }
  /llms.txt:
    get:
      tags: [discovery]
      operationId: llmsTxt
      summary: Guide for language models (llmstxt.org)
      responses:
        '200':
          description: Markdown-formatted plain text.
          content:
            text/plain:
              schema: { type: string }
  /topics-index.json:
    get:
      tags: [discovery]
      operationId: searchIndex
      summary: Compact index behind the site's instant search
      description: Topics, recipes and products with one- or two-letter keys. Built for the site's own search box; the format can change without notice, so prefer /api/articles.json.
      responses:
        '200':
          description: The search index.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
  /sitemap-index.xml:
    get:
      tags: [discovery]
      operationId: sitemap
      summary: XML sitemap index of every page
      responses:
        '200':
          description: Sitemap index.
          content:
            application/xml:
              schema: { type: string }
components:
  parameters:
    Topic:
      name: topic
      in: path
      required: true
      description: Topic slug (the article URL's first segment).
      schema: { type: string, examples: [turmeric] }
    Slug:
      name: slug
      in: path
      required: true
      description: Article slug (the article URL's second segment).
      schema: { type: string, examples: [curcumin] }
  responses:
    ChatError:
      description: An error, with the matching articles when retrieval ran.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ChatError' }
  schemas:
    Reference:
      type: object
      required: [id, title, authors, year, journal]
      properties:
        id: { type: string, description: 'Matches the inline [n] markers.' }
        title: { type: string }
        authors: { type: string }
        year: { type: integer }
        journal: { type: string }
        pmid: { type: string }
        url: { type: string, format: uri, description: "PubMed when there is a PMID, otherwise the source's own URL." }
    Placement:
      type: object
      properties:
        categorySlug: { type: string }
        categoryTitle: { type: string }
        subcategorySlug: { type: string }
        subcategoryName: { type: string }
        url: { type: string, format: uri }
    ArticleSummary:
      type: object
      required: [url, topic, topicSlug, slug, title, description, references, words]
      properties:
        url: { type: string, format: uri }
        topic: { type: string, description: Topic display name. }
        topicSlug: { type: string }
        slug: { type: string }
        title: { type: string }
        question: { type: string, description: "The reader's question the article answers, when set." }
        answer: { type: string, description: "One-sentence answer, when set." }
        description: { type: string }
        confidence: { type: integer, minimum: 1, maximum: 5, description: "How settled the evidence is, when rated." }
        category: { type: string }
        subcategory: { type: string }
        references: { type: integer, description: Number of references. }
        words: { type: integer }
        lastUpdated: { type: string, format: date-time, description: "Last commit touching the article, when known." }
    ArticleIndex:
      type: object
      properties:
        site: { type: string }
        url: { type: string, format: uri }
        count: { type: integer }
        links:
          type: object
          description: URL patterns; replace {topicSlug} and {slug}.
          additionalProperties: { type: string }
        notice: { type: string }
        articles:
          type: array
          items: { $ref: '#/components/schemas/ArticleSummary' }
    Article:
      type: object
      required: [url, path, topic, slug, title, description, simpleText, mediumText, deepText, references]
      properties:
        url: { type: string, format: uri }
        path: { type: string }
        alternates:
          type: object
          properties:
            json: { type: string, format: uri }
            markdown: { type: string, format: uri }
        topic:
          type: object
          properties:
            slug: { type: string }
            name: { type: string }
            url: { type: string, format: uri }
        slug: { type: string }
        order: { type: integer }
        title: { type: string }
        question: { type: string }
        answer: { type: string }
        description: { type: string }
        confidence: { type: integer, minimum: 1, maximum: 5 }
        confidenceNote: { type: string }
        evidenceSummary: { type: object, additionalProperties: true }
        category: { $ref: '#/components/schemas/Placement' }
        actions: { type: array, items: { type: string }, description: '"What you can do".' }
        cautions: { type: array, items: { type: string }, description: '"Be careful if".' }
        simpleText: { type: string, description: The summary. }
        mediumText: { type: string, description: '"How it works".' }
        deepText: { type: string, description: '"The evidence" (and "The full research"), capped at 6,000 characters.' }
        deepTruncated: { type: boolean }
        references:
          type: array
          items: { $ref: '#/components/schemas/Reference' }
        wordCount: { type: integer }
        lastUpdated: { type: string, format: date-time }
        source:
          type: object
          properties:
            path: { type: string, description: The article's file path in the source tree. }
            github: { type: string, format: uri, description: Link to the file on GitHub. Present only once the source repository is public. }
            history: { type: string, format: uri, description: Commit history of the file on GitHub. Present only once the source repository is public. }
        citeAs: { type: string }
        notice: { type: string }
    RecipeSummary:
      type: object
      properties:
        slug: { type: string }
        url: { type: string, format: uri }
        title: { type: string }
        description: { type: string }
        mealType: { type: string }
        difficulty: { type: string, enum: [easy, medium, hard] }
        totalMinutes: { type: integer }
        servings: { type: integer }
        yield: { type: string }
        tags: { type: array, items: { type: string } }
        evidence: { type: string, enum: [strong, moderate, weak, traditional], description: Evidence grade for remedy-type recipes. }
        relatedTopics: { type: array, items: { type: string }, description: Topic slugs. }
        score: { type: number, description: Relevance (MCP search results only). }
    RecipeIndex:
      type: object
      properties:
        count: { type: integer }
        recipes:
          type: array
          items: { $ref: '#/components/schemas/RecipeSummary' }
    Recipe:
      type: object
      description: The full recipe; fields beyond these may appear as the recipe library grows.
      additionalProperties: true
      properties:
        slug: { type: string }
        url: { type: string, format: uri }
        title: { type: string }
        description: { type: string }
        mealType: { type: string }
        mealTypeLabel: { type: string }
        totalMinutes: { type: integer }
        ingredients:
          type: array
          items:
            type: object
            properties:
              item: { type: string }
              quantity: { type: string }
              aisle: { type: string }
              note: { type: string }
        steps: { type: array, items: { type: string } }
        safety: { type: array, items: { type: string } }
        evidence:
          type: object
          properties:
            summary: { type: string }
            grade: { type: string }
        relatedTopics:
          type: array
          items:
            type: object
            properties:
              slug: { type: string }
              name: { type: string }
              url: { type: string, format: uri }
    ProductIndex:
      type: object
      properties:
        count: { type: integer }
        products:
          type: array
          items:
            type: object
            description: Every frontmatter field of the product (assessment, checks, sources, lastReviewed, …) plus these.
            additionalProperties: true
            properties:
              slug: { type: string }
              url: { type: string, format: uri }
              name: { type: string }
              brand: { type: string }
              category: { type: string }
              price: { type: string }
              summary: { type: string }
              image: { type: string, format: uri }
              makerUrl: { type: string, format: uri, description: "The maker's own product page." }
              relatedTopics: { type: array, items: { type: string, format: uri } }
              description: { type: string, description: The product write-up as plain text. }
    ChatRequest:
      type: object
      required: [question]
      properties:
        question: { type: string, minLength: 1, maxLength: 1000 }
        history:
          type: array
          maxItems: 6
          description: Earlier turns, oldest first; only the last six are used, each cut to 1,500 characters.
          items:
            type: object
            required: [role, content]
            properties:
              role: { type: string, enum: [user, assistant] }
              content: { type: string }
    ChatSource:
      type: object
      required: [title, url]
      properties:
        title: { type: string }
        url: { type: string, format: uri }
        topic: { type: string }
        summary: { type: string }
    ChatAnswer:
      type: object
      required: [answer, sources]
      properties:
        answer: { type: string, description: Markdown with links to site articles and PMIDs; ends with a not-medical-advice line. }
        sources:
          type: array
          description: Articles the answer links to.
          items: { $ref: '#/components/schemas/ChatSource' }
        related:
          type: array
          description: Other matching articles that were given to the model.
          items: { $ref: '#/components/schemas/ChatSource' }
        refused: { type: boolean }
    ChatError:
      type: object
      required: [error]
      properties:
        error: { type: string }
        configured: { type: boolean, description: false while no API key is set. }
        sources:
          type: array
          items: { $ref: '#/components/schemas/ChatSource' }
    JsonRpcMessage:
      type: object
      required: [jsonrpc]
      properties:
        jsonrpc: { const: '2.0' }
        id: { type: [string, integer, 'null'] }
        method: { type: string }
        params: { type: object, additionalProperties: true }
