openapi: 3.1.0
info:
  title: AI Product Index — read API
  version: 1.0.0
  description: |
    Machine-readable directory where AI products register themselves so AI
    agents can discover them. This is a static site: the read API is a set of
    pre-built JSON documents regenerated on every accepted registration.

    WRITE PATHS (autonomous, via GitHub issues on https://github.com/110kc3/seo):
    "[register] ..." = new listing, "[update] ..." = full replacement by the
    original submitter, "[upgrade] ..." = paid tier change with on-chain receipt
    verification. Bodies are JSON conforming to https://index.percall.dev/api/schema.json.
    Full protocol: https://index.percall.dev/llms.txt · MCP server: mcp/server.mjs in the repo.

    HEALTH: listing URLs are re-checked weekly; 3 consecutive failures delist
    (strike state at https://index.percall.dev/health.json).

    AUDIT: GET /api/score?url=... is free and returns an A-F grade plus per-check
    pass/fail. POST /api/audit is the paid half — the same checks, plus why each
    one failed, fixes ranked by weight, and a paste-ready code snippet per
    failure. Priced per call over x402, and both protocol versions are accepted:
    v2 (PAYMENT-REQUIRED / PAYMENT-SIGNATURE / PAYMENT-RESPONSE) and v1
    (X-PAYMENT / X-PAYMENT-RESPONSE), so clients on either spec can pay.

    RETIRED: /api/check, /api/liveness, /api/route and /api/watch return 410
    endpoint_retired without accepting payment. New watches and top-ups are
    closed; existing prepaid weekly sweeps continue until credits are exhausted.
    POST /api/audit is the only paid HTTP endpoint.

    TIERS: verified and featured exist in the schema and index ranking; the tier
    field is server-set, so the read API does not change shape when prices
    change. Current machine-payment terms are published at /api/x402/info.
  contact:
    url: https://github.com/110kc3/seo
servers:
  - url: https://index.percall.dev
paths:
  /api/search:
    get:
      operationId: searchListings
      summary: Search the registry
      description: >-
        Ranked search over every listing. Matches on name, tags, slug and
        description, weighted in that order, with whole-word hits beating
        substrings. A query that matches nothing returns what the corpus does
        contain, so the next query can be narrowed rather than guessed.
      parameters:
        - name: q
          in: query
          required: false
          schema: { type: string, maxLength: 512 }
          description: Free text. Omit to list everything (subject to limit).
        - name: category
          in: query
          required: false
          schema: { type: string, enum: [api, app, agent, mcp, other] }
        - name: tag
          in: query
          required: false
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
      responses:
        "200":
          description: Matching listings, best first.
          content:
            application/json:
              schema:
                type: object
                required: [ok, query, total, count, results]
                properties:
                  ok: { type: boolean }
                  query: { type: string }
                  total: { type: integer, description: Matches before the limit was applied. }
                  count: { type: integer }
                  results: { type: array, items: { type: object } }
  /ask:
    post:
      operationId: askNlweb
      summary: Ask the index a question in natural language (NLWeb)
      description: >-
        NLWeb (nlweb.ai) natural-language query. The answer is a list of
        schema.org objects under `results`, grounded only in this registry —
        nothing is generated. GET /ask?query=... is accepted for convenience.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                query:
                  type: object
                  properties:
                    text: { type: string, description: The question. }
                    site: { type: string, description: Corpus to query; this endpoint serves one. }
                prefer:
                  type: object
                  properties:
                    mode: { type: string, enum: [list, summarize], default: summarize }
            example: { query: { text: free MCP servers } }
      responses:
        "200":
          description: >-
            An answer, or an elicitation when the question was empty.
          content:
            application/json:
              schema:
                type: object
                required: [_meta, results]
                properties:
                  _meta:
                    type: object
                    properties:
                      response_type: { type: string, enum: [answer, elicitation, failure] }
                      version: { type: string }
                  results:
                    type: array
                    items: { type: object, description: A schema.org object. }
                  summary: { type: string, description: One-sentence answer. Present by default; send mode=list to omit it. }
  /mcp:
    post:
      operationId: mcpRpc
      summary: Model Context Protocol over streamable HTTP
      description: >-
        JSON-RPC 2.0. Methods: initialize, ping, tools/list, tools/call.
        Tools: search_products, get_product, score_url, how_to_register.
        No authentication and nothing to install — any MCP client that accepts
        a URL can use this index as a tool source. Server card at
        /.well-known/mcp.json.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [jsonrpc, method]
              properties:
                jsonrpc: { type: string, enum: ["2.0"] }
                id: { type: [string, integer, "null"] }
                method: { type: string }
                params: { type: object }
            example: { jsonrpc: "2.0", id: 1, method: tools/list }
      responses:
        "200":
          description: A JSON-RPC result or error.
          content:
            application/json: {}
        "202":
          description: Accepted — the message was a notification, so there is no reply.
  /api/index.json:
    get:
      operationId: getRegistry
      summary: Full registry — every listing in one document
      responses:
        "200":
          description: The complete registry.
          content:
            application/json:
              schema:
                type: object
                required: [count, updated, listings]
                properties:
                  count:
                    type: integer
                  updated:
                    type: [string, "null"]
                    description: Date (YYYY-MM-DD) of the most recent listing.
                  listings:
                    type: array
                    items:
                      $ref: "https://index.percall.dev/api/schema.json"
  /api/schema.json:
    get:
      operationId: getListingSchema
      summary: JSON Schema for a listing (also the submission schema)
      responses:
        "200":
          description: JSON Schema (draft-07).
          content:
            application/json: {}
  /listings/{slug}.json:
    get:
      operationId: getListing
      summary: One listing
      parameters:
        - name: slug
          in: path
          required: true
          schema:
            type: string
            pattern: "^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$"
      responses:
        "200":
          description: The listing.
          content:
            application/json:
              schema:
                $ref: "https://index.percall.dev/api/schema.json"
        "404":
          description: Unknown slug.
  /llms-full.txt:
    get:
      operationId: getFullText
      summary: The whole index as plain text (for LLM context windows)
      responses:
        "200":
          description: Plain-text dump of every listing.
          content:
            text/plain: {}
  /api/stats.json:
    get:
      operationId: getStats
      summary: Traffic stats for this index over a 30-day window
      description: |
        Aggregated request counts by inferred client type (ai_agent, ai_crawler,
        browser, script, ...) and by path bucket, plus agent_share. Client type is
        inferred from the self-reported user-agent; no IP addresses or other
        personal data are collected.
      responses:
        "200":
          description: Aggregated counters.
          content:
            application/json: {}
        "503":
          description: Stats not published yet (code stats_not_enabled).
  /api/x402/info:
    get:
      operationId: getPaymentTerms
      summary: Payment terms for the paid endpoint, without provoking a 402
      description: |
        Returns the active rail's network, asset, price in atomic units, payTo
        address and the x402 protocol versions accepted. Everything here also
        appears in the 402 challenge — this just saves a wasted request.
      responses:
        "200":
          description: Active payment terms.
          content:
            application/json: {}
        "503":
          description: Rail not configured (code payments_not_enabled).
  /api/score:
    get:
      operationId: getFreeScore
      summary: Agent-readability score (free, no payment)
      description: |
        Returns an A-F letter grade, the numeric score out of 100, and every
        check with a label and pass/fail — the verdict, free and without signup.

        It deliberately omits what POST /api/audit sells: the per-check reason for
        failure, fixes ranked by weight, and a paste-ready code snippet for each
        failing check.

        The response carries `check_set`, naming the checklist that produced the
        grade. `v2` (the default) scores 20 checks: the 13 established ones plus
        seven 2026 agent-readiness surfaces — Content Signals, the A2A 1.0 card
        path, an MCP server card, an RFC 9727 API catalog, an Agent Skills index,
        markdown content negotiation and a Web Bot Auth directory. Every one of
        those carries less weight than the cheapest 2025 check, because adoption
        is still under 15% and missing one is normal rather than negligent.
        `v1` scores only the original 13, and is kept so a grade published
        earlier stays reproducible.

        Results are cached per URL *and check set* for an hour and a cached
        answer is unmetered; uncached audits are limited to 20/hour/IP because
        each fetches the target. Private, loopback and link-local targets are
        refused, same as the paid path.
      parameters:
        - name: url
          in: query
          required: true
          schema:
            type: string
            format: uri
            pattern: "^https?://"
          description: Public http(s) URL to score.
        - name: checks
          in: query
          required: false
          schema:
            type: string
            enum: [v1, v2]
            default: v2
          description: >
            Which checklist to score against. Omit for the current default. An
            unrecognised value is normalised to the default rather than refused.
      responses:
        "200":
          description: The score.
          content:
            application/json: {}
        "400":
          description: Missing or rejected url.
        "429":
          description: Rate limited (code rate_limited).
        "502":
          description: The target site could not be read.
  /api/audit:
    post:
      operationId: auditUrl
      summary: Agent-readability audit of one URL (paid, x402)
      description: |
        Scores how readable a site is to AI agents across the current 20-check
        set: 13 established web signals plus seven lower-weight 2026 discovery
        surfaces. Returns per-check detail and ranked next_steps.

        Payment is x402 over HTTP, in either protocol version — one 402 answers
        both. v2 clients read the base64 PAYMENT-REQUIRED header, retry with
        PAYMENT-SIGNATURE, and get the receipt in PAYMENT-RESPONSE. v1 clients
        read the same 402's JSON body (v1 field names: maxAmountRequired, a
        network name, a flat resource URL), retry with X-PAYMENT, and get the
        receipt in X-PAYMENT-RESPONSE. The target URL is validated before any
        charge, so a request that would be rejected is never billed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                  pattern: "^https?://"
                  description: Public http(s) URL to audit. Private and local hosts are rejected.
      responses:
        "200":
          description: The audit. PAYMENT-RESPONSE header carries the settlement receipt.
          content:
            application/json:
              schema:
                type: object
                required: [ok, url, score, max_score, grade, checks]
                properties:
                  ok: { type: boolean }
                  url: { type: string }
                  audited_at: { type: string, format: date-time }
                  score: { type: integer }
                  max_score: { type: integer }
                  grade: { type: string }
                  checks:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        weight: { type: integer }
                        pass: { type: boolean }
                        detail: { type: string }
                        fix: { type: string }
                  next_steps:
                    type: array
                    items: { type: string }
        "400":
          description: Invalid JSON, invalid target URL, or malformed payment payload.
        "402":
          description: Payment required, terms mismatched, authorization replayed, or settlement failed.
        "413":
          description: Body too large.
        "502":
          description: Target unreachable, or the payment facilitator did not respond.
        "503":
          description: Payment rails not configured (code payments_not_enabled).
