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 local food search, 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: local
    description: Farmers markets, farm stands, CSA farms and food shops near a point, from OpenStreetMap and the USDA Local Food Directories.
  - 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/local/places.json:
    get:
      tags: [local]
      operationId: findLocalPlaces
      summary: Farmers markets, farm stands, CSA farms and food shops near a point
      description: |
        Farmers markets, farm stands, CSA farms, natural and organic grocers, produce markets, butchers, fish
        markets and dairies within `r` miles of the point, nearest first; `type` keeps one kind and `product`
        the places that sell one thing. The 50 states, DC and Puerto Rico are
        answered from a bundled download of OpenStreetMap and the USDA Local Food Directories (`coverage:
        dataset`; a place both list is one place with both links in `sources`); elsewhere the server queries
        Overpass live (`coverage: live`), and says `error: not-covered` when that is slow or down. Live lookups
        are limited to 10 per IP per 10 minutes. Readable cross-origin. OpenStreetMap data © OpenStreetMap
        contributors, ODbL 1.0: credit OpenStreetMap, and the USDA Local Food Directories, when you show them.
      parameters:
        - name: lat
          in: query
          required: true
          schema: { type: number, minimum: -90, maximum: 90, examples: [42.3736] }
        - name: lon
          in: query
          required: true
          schema: { type: number, minimum: -180, maximum: 180, examples: [-71.1097] }
        - name: r
          in: query
          required: false
          description: Radius in miles; other values snap to the nearest of 5, 10, 25 and 50.
          schema: { type: integer, enum: [5, 10, 25, 50], default: 10 }
        - name: type
          in: query
          required: false
          description: Only this category. `countsByCategory` still counts every category.
          schema: { $ref: '#/components/schemas/PlaceCategory' }
        - name: product
          in: query
          required: false
          description: |
            Only places that sell this (`Place.sells`): the listing says so (`produce`; a farmers market's listing
            says what its vendors sell), the place is that kind (every butcher sells meat, every fish market seafood,
            every dairy or cheese shop dairy), or it is a farm named for it. Most listings don't say what they sell.
            `countsByProduct` still counts every product.
          schema: { $ref: '#/components/schemas/PlaceProduct' }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 1000, default: 200 }
        - name: offset
          in: query
          required: false
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        '200':
          description: The places (possibly none). With `error` set (`not-covered`), the area has no data yet.
          headers:
            Cache-Control: { schema: { type: string, examples: ['private, max-age=600'] } }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlaceSearch' }
        '400':
          description: Missing or invalid `lat`/`lon`, an unknown `type` or `product`, or a bad `limit`/`offset`.
          content:
            application/json:
              schema: { type: object, required: [error], properties: { error: { type: string } } }
        '429':
          description: Too many live (outside the US) lookups from this IP; `error` is `rate-limited`.
          headers:
            Retry-After: { schema: { type: integer }, description: Seconds to wait. }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlaceSearch' }
        '503':
          description: The live lookup is too busy right now; `error` is `unavailable`.
          headers:
            Retry-After: { schema: { type: integer }, description: Seconds to wait. }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlaceSearch' }
  /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. }
    PlaceCategory:
      type: string
      enum: [farmers-markets, farm-stands, csa, natural-grocers, greengrocers, butchers, fishmongers, dairies]
    PlaceProduct:
      type: string
      description: 'What a place sells: honey, eggs, meat (and poultry), dairy (milk and cheese), seafood.'
      enum: [honey, eggs, meat, dairy, seafood]
    SearchPoint:
      type: object
      required: [lat, lon, label]
      properties:
        lat: { type: number }
        lon: { type: number }
        label: { type: string, description: 'The nearest town ("Cambridge, Massachusetts"), or "your location".' }
        country: { type: string, description: ISO 3166-1 alpha-2 when known. }
    Place:
      type: object
      required: [id, source, name, category, lat, lon, distanceKm, badges, sells, sourceUrl, sources, directionsUrl]
      properties:
        id:
          type: string
          description: 'Stable id: OpenStreetMap "osm:n123" (node), "osm:w456" (way), "osm:r789" (relation); USDA "usda:m309438" (m farmers market, f on-farm market, c CSA, h food hub, then the USDA listing id).'
        source: { type: string, enum: [osm, usda], description: The source of the details shown (the fuller listing when both list the place). }
        name: { type: string }
        category: { $ref: '#/components/schemas/PlaceCategory' }
        lat: { type: number }
        lon: { type: number }
        distanceKm: { type: number, description: Great-circle distance from the search point. }
        address: { type: string, description: 'One line, whatever parts are known: "12 Main St, Concord, MA 01742".' }
        openingHours: { type: string, description: 'The OpenStreetMap opening_hours value, e.g. "Sa 09:00-13:00; May-Oct". USDA listings have none.' }
        website: { type: string, format: uri }
        phone: { type: string }
        badges:
          type: array
          items: { type: string, enum: [organic, pick-your-own, co-op, seasonal, raw-milk, csa] }
          description: '`csa`: sells CSA shares (on a farm stand or market).'
        produce: { type: array, items: { type: string }, description: 'From produce=* or a USDA listing''s product groups: "vegetables", "apples". At a farmers market, what its vendors sell.' }
        sells:
          type: array
          items: { $ref: '#/components/schemas/PlaceProduct' }
          description: What it sells of the `product` values, from `produce`, its kind or a farm's name. Empty when nothing is known.
        sourceUrl: { type: string, format: uri, description: 'The place at `source`: openstreetmap.org (where anyone can correct it) or its USDA directory listing.' }
        sources:
          type: array
          description: Every listing of the place, `source`'s first.
          items:
            type: object
            required: [source, url]
            properties:
              source: { type: string, enum: [osm, usda] }
              url: { type: string, format: uri }
              updated: { type: string, description: 'When the listing was last updated, "2023-03" (USDA listings that say).' }
        directionsUrl: { type: string, format: uri }
    PlaceSearch:
      type: object
      required: [center, radiusKm, places, countsByCategory, countsByProduct, coverage, dataAsOf]
      properties:
        center: { $ref: '#/components/schemas/SearchPoint' }
        radiusKm: { type: number }
        radiusMiles: { type: integer }
        places:
          type: array
          description: Nearest first, within the radius, filtered by `type` and `product`, then paged by `offset`/`limit`.
          items: { $ref: '#/components/schemas/Place' }
        total: { type: integer, description: Places before paging. }
        offset: { type: integer }
        limit: { type: integer }
        countsByCategory:
          type: object
          description: Places per category within the radius, before the `type` filter (after `product`).
          additionalProperties: { type: integer }
        countsByProduct:
          type: object
          description: Places per product within the radius, before the `product` filter (after `type`).
          additionalProperties: { type: integer }
        coverage: { type: string, enum: [dataset, live, none] }
        dataAsOf: { type: string, format: date-time, description: 'When the data was taken: the older source''s date for the dataset, the Overpass data''s for a live search.' }
        error: { type: string, enum: [unavailable, rate-limited, not-covered] }
        attribution: { type: string }
        license: { type: string, format: uri }
    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 }
