{
  "openapi": "3.1.0",
  "info": {
    "title": "AgentPass ARI-100 API",
    "version": "1.0.0",
    "summary": "Deterministic agent-readiness scoring for any URL.",
    "description": "Scan a URL over plain HTTP and receive an ARI-100 agent-readiness report: score, grade, level, crawler reachability, per-domain scores and ordered fix prompts. Reports are cached in memory for six hours and are also served as Markdown. No key is required for the free tier.",
    "contact": {
      "name": "AgentPass",
      "email": "hello@agentpass.dev",
      "url": "https://agentpass.dev/about"
    },
    "license": {
      "name": "CC BY 4.0",
      "identifier": "CC-BY-4.0"
    }
  },
  "servers": [
    {
      "url": "https://agentpass.dev",
      "description": "Production origin. Override with the Host header for a self-hosted instance."
    }
  ],
  "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
  "tags": [
    {
      "name": "scan",
      "description": "Run a scan or read a cached report."
    },
    {
      "name": "meta",
      "description": "Liveness and discovery."
    }
  ],
  "paths": {
    "/api/scan": {
      "get": {
        "tags": [
          "scan"
        ],
        "operationId": "scanUrl",
        "summary": "Scan a URL and return its ARI-100 report",
        "description": "Fetches the URL the way a crawler would, scores 120 checks across eight weighted domains, and returns the full report. A report younger than six hours for the same host is returned from cache unless refresh=1 is set. A cold scan of a slow origin can take up to ten seconds.",
        "parameters": [
          {
            "name": "url",
            "in": "query",
            "required": true,
            "description": "Absolute http or https URL to scan. A bare host is accepted and assumed to be https.",
            "schema": {
              "type": "string",
              "format": "uri"
            },
            "example": "https://example.com"
          },
          {
            "name": "refresh",
            "in": "query",
            "required": false,
            "description": "Set to 1 to ignore the cached report and scan again.",
            "schema": {
              "type": "boolean",
              "default": false
            }
          },
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "description": "Client-generated key. Replaying the same key within six hours returns the original report instead of starting a second paid scan.",
            "schema": {
              "type": "string",
              "maxLength": 128
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The report, fresh or cached. X-Cache is hit or miss.",
            "headers": {
              "X-Cache": {
                "description": "hit when served from the six-hour cache, miss for a fresh scan.",
                "schema": {
                  "type": "string",
                  "enum": [
                    "hit",
                    "miss"
                  ]
                }
              },
              "RateLimit-Limit": {
                "description": "Requests allowed in the current window.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Remaining": {
                "description": "Requests left in the window.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Report"
                }
              }
            }
          },
          "400": {
            "description": "url is missing or not absolute.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exhausted; read RateLimit-Reset.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "The scanned origin did not answer.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/report/{host}.json": {
      "get": {
        "tags": [
          "scan"
        ],
        "operationId": "getReportJson",
        "summary": "Cached report as JSON",
        "description": "Returns the cached report for a host. If no report under six hours old exists, the host is scanned once and the result is cached.",
        "parameters": [
          {
            "name": "host",
            "in": "path",
            "required": true,
            "description": "Hostname that was scanned, optionally with port.",
            "schema": {
              "type": "string"
            },
            "example": "example.com"
          }
        ],
        "responses": {
          "200": {
            "description": "The cached report.",
            "headers": {
              "X-Cache": {
                "description": "hit or miss.",
                "schema": {
                  "type": "string"
                }
              },
              "Cache-Control": {
                "description": "public, max-age=21600.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Report"
                }
              }
            }
          },
          "404": {
            "description": "The host could not be scanned and has no cached report.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/report/{host}.md": {
      "get": {
        "tags": [
          "scan"
        ],
        "operationId": "getReportMarkdown",
        "summary": "Cached report as Markdown",
        "description": "The same report rendered as text/markdown: score line, domain table, crawler table, failing checks with prompts and a paste-ready prompt pack.",
        "parameters": [
          {
            "name": "host",
            "in": "path",
            "required": true,
            "description": "Hostname that was scanned.",
            "schema": {
              "type": "string"
            },
            "example": "example.com"
          }
        ],
        "responses": {
          "200": {
            "description": "Markdown report.",
            "content": {
              "text/markdown": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "404": {
            "description": "No report for that host.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/last": {
      "get": {
        "tags": [
          "meta"
        ],
        "operationId": "getLastScan",
        "summary": "Summary of the most recent scan in this process",
        "description": "Small object with the host, time and score of the last completed scan. Useful for a dashboard that only needs the head line.",
        "responses": {
          "200": {
            "description": "Summary of the last scan.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "host": {
                      "type": "string",
                      "description": "Host of the last scan."
                    },
                    "scannedAt": {
                      "type": "string",
                      "format": "date-time",
                      "description": "When that scan ran."
                    },
                    "score": {
                      "type": "integer",
                      "description": "Its ARI-100 total."
                    },
                    "grade": {
                      "type": "string",
                      "description": "Its letter grade."
                    },
                    "report": {
                      "type": "string",
                      "description": "Path to the cached JSON report."
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Nothing has been scanned since the process started."
          }
        }
      }
    },
    "/healthz": {
      "get": {
        "tags": [
          "meta"
        ],
        "operationId": "health",
        "summary": "Liveness and cache size",
        "description": "Cheap check for a load balancer or an agent about to start a batch of scans.",
        "responses": {
          "200": {
            "description": "Service is up.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "description": "Always true when the process is serving."
                    },
                    "service": {
                      "type": "string",
                      "description": "Service name."
                    },
                    "uptime": {
                      "type": "number",
                      "description": "Seconds since start."
                    },
                    "reports": {
                      "type": "integer",
                      "description": "Reports currently cached."
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Report": {
        "type": "object",
        "description": "A complete ARI-100 agent-readiness report for one origin, produced by one scan.",
        "required": [
          "url",
          "finalUrl",
          "scannedAt",
          "score",
          "grade",
          "level",
          "domains",
          "crawlers",
          "fixes",
          "counts"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Origin that was scanned, normalised to scheme and host."
          },
          "finalUrl": {
            "type": "string",
            "format": "uri",
            "description": "URL the homepage resolved to after redirects; all probes are relative to this origin."
          },
          "scannedAt": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601 timestamp of the scan start."
          },
          "engine": {
            "type": "string",
            "description": "Engine identifier; always ARI-100 for this version.",
            "examples": [
              "ARI-100"
            ]
          },
          "score": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "description": "ARI-100 total: the weighted mean of applicable domain scores plus at most five bonus points, capped at 100."
          },
          "core": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "description": "Weighted mean of domain scores before the bonus is added."
          },
          "bonus": {
            "type": "integer",
            "minimum": 0,
            "maximum": 5,
            "description": "Emerging-signal points actually applied, capped at five."
          },
          "bonusSignals": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Ids of the bonus signals that passed."
          },
          "grade": {
            "type": "string",
            "enum": [
              "A+",
              "A",
              "B",
              "C",
              "D",
              "F"
            ],
            "description": "Letter grade for the total. A+ also requires an accessibility sub-score of 80 or more."
          },
          "gradeLabel": {
            "type": "string",
            "description": "Human label for the grade band, for example Agent-native."
          },
          "level": {
            "type": "integer",
            "minimum": 0,
            "maximum": 5,
            "description": "Capability level L0 to L5, computed from specific checks rather than from the total."
          },
          "accessScore": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "maximum": 100,
            "description": "Weighted share of testable crawlers that were allowed. Null when no crawler could be tested."
          },
          "accessCoverage": {
            "type": "object",
            "description": "How much of the weighted crawler matrix was testable from this server.",
            "properties": {
              "tested": {
                "type": "integer",
                "description": "Weighted crawlers that produced a decidable verdict."
              },
              "of": {
                "type": "integer",
                "description": "Weighted crawlers in the matrix."
              }
            }
          },
          "a11yScore": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "maximum": 100,
            "description": "Share of applicable static accessibility checks that passed on the home page."
          },
          "a11yReport": {
            "type": "object",
            "description": "Per-check accessibility results with notes, keyed by check id.",
            "additionalProperties": true
          },
          "domains": {
            "type": "array",
            "description": "One entry per domain, in registry order, with its weight and score.",
            "items": {
              "$ref": "#/components/schemas/Domain"
            }
          },
          "crawlers": {
            "type": "array",
            "description": "One entry per probed crawler with its verdict and the evidence behind it.",
            "items": {
              "$ref": "#/components/schemas/Crawler"
            }
          },
          "fixes": {
            "type": "array",
            "description": "Failing checks ordered by recoverable points, each with a paste-ready prompt.",
            "items": {
              "$ref": "#/components/schemas/Fix"
            }
          },
          "counts": {
            "$ref": "#/components/schemas/Counts"
          },
          "durationMs": {
            "type": "integer",
            "description": "Wall-clock milliseconds the scan took."
          },
          "cache": {
            "$ref": "#/components/schemas/CacheMeta"
          }
        }
      },
      "Domain": {
        "type": "object",
        "description": "Score for one weighted domain. Checks gated out for the site shape leave the denominator.",
        "required": [
          "id",
          "name",
          "weight",
          "score"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Stable domain id, for example access or discovery."
          },
          "name": {
            "type": "string",
            "description": "Display name of the domain."
          },
          "weight": {
            "type": "integer",
            "description": "Share of the 100-point total that this domain carries."
          },
          "score": {
            "type": [
              "number",
              "null"
            ],
            "description": "Weight-normalised score inside the domain, 0 to 100, or null when no check applied."
          },
          "checks": {
            "type": "integer",
            "description": "Number of checks that were scored inside the domain."
          },
          "passed": {
            "type": "integer",
            "description": "Number of those checks that returned pass."
          },
          "applicable": {
            "type": "integer",
            "description": "Checks with a non-null point value, which is the denominator."
          },
          "total": {
            "type": "integer",
            "description": "Checks registered to the domain, including gated ones."
          }
        }
      },
      "Crawler": {
        "type": "object",
        "description": "One probed crawler: who it claims to be, what it received, and the verdict the engine derived.",
        "properties": {
          "ua": {
            "type": "string",
            "description": "User-agent token that was sent, for example OAI-SearchBot."
          },
          "platform": {
            "type": "string",
            "description": "Platform the crawler belongs to."
          },
          "role": {
            "type": "string",
            "enum": [
              "search",
              "user",
              "train",
              "other"
            ],
            "description": "search = indexer, user = fetches on a person behalf, train = training crawler, other = niche fetcher."
          },
          "weight": {
            "type": "integer",
            "description": "Weight in the access score. Training crawlers carry zero but their policy is still read."
          },
          "tier": {
            "type": "integer",
            "description": "Reach tier 1 to 4, used for display grouping."
          },
          "status": {
            "type": "integer",
            "description": "HTTP status code returned to that user agent; 0 means the request failed."
          },
          "bytes": {
            "type": "integer",
            "description": "Response body size in bytes."
          },
          "ms": {
            "type": "integer",
            "description": "Round-trip time in milliseconds."
          },
          "robotsAllowed": {
            "type": "boolean",
            "description": "Whether robots.txt allowed the probed path for this agent under RFC 9309 longest-match rules."
          },
          "rule": {
            "type": [
              "string",
              "null"
            ],
            "description": "The robots rule that decided the answer, for example \"disallow: /private\"."
          },
          "blocked": {
            "type": "boolean",
            "description": "True when robots.txt, an edge rule or a hard failure kept the agent out."
          },
          "challenged": {
            "type": "boolean",
            "description": "True when the body looked like a bot challenge page."
          },
          "verdict": {
            "type": "string",
            "description": "One of allowed, blocked by robots.txt, blocked at the edge, challenged, rate limited, no response, unverifiable, server error."
          }
        }
      },
      "Fix": {
        "type": "object",
        "description": "A failing check plus the prompt that will make it pass.",
        "required": [
          "id",
          "domain",
          "title",
          "weight",
          "result",
          "prompt"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Check id that will be re-run to verify the fix."
          },
          "domain": {
            "type": "string",
            "description": "Domain the check belongs to."
          },
          "title": {
            "type": "string",
            "description": "Short statement of the required outcome."
          },
          "weight": {
            "type": "integer",
            "description": "Weight of the check inside its domain."
          },
          "result": {
            "type": "string",
            "enum": [
              "fail",
              "partial",
              "inconclusive"
            ],
            "description": "Current state of the check."
          },
          "evidence": {
            "type": "string",
            "description": "What the live response actually contained, quoted from the probe."
          },
          "recoverable": {
            "type": "number",
            "description": "Points recovered if this check reaches a full pass."
          },
          "prompt": {
            "type": "string",
            "description": "Instruction written to be pasted into a coding agent verbatim."
          }
        }
      },
      "Counts": {
        "type": "object",
        "description": "Check tallies for the scan.",
        "properties": {
          "total": {
            "type": "integer",
            "description": "Registered checks."
          },
          "applicable": {
            "type": "integer",
            "description": "Checks that produced points."
          },
          "pass": {
            "type": "integer",
            "description": "Checks that passed."
          },
          "partial": {
            "type": "integer",
            "description": "Checks that passed partially."
          },
          "fail": {
            "type": "integer",
            "description": "Checks that failed."
          },
          "notApplicable": {
            "type": "integer",
            "description": "Checks gated out for this site shape."
          },
          "inconclusive": {
            "type": "integer",
            "description": "Checks that could not be decided."
          }
        }
      },
      "CacheMeta": {
        "type": "object",
        "description": "Whether this response came from the in-memory report cache.",
        "properties": {
          "hit": {
            "type": "boolean",
            "description": "True when the report was served from cache."
          },
          "ageMs": {
            "type": "integer",
            "description": "Age of the cached report in milliseconds; 0 for a fresh scan."
          },
          "ttlMs": {
            "type": "integer",
            "description": "Cache lifetime in milliseconds; 21600000 for six hours."
          }
        }
      },
      "Error": {
        "type": "object",
        "description": "Problem detail document, stable across every endpoint.",
        "properties": {
          "type": {
            "type": "string",
            "format": "uri",
            "description": "URI identifying the problem type."
          },
          "title": {
            "type": "string",
            "description": "Short, human-readable summary."
          },
          "status": {
            "type": "integer",
            "description": "HTTP status code."
          },
          "detail": {
            "type": "string",
            "description": "What was wrong with this specific request."
          }
        }
      }
    },
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Optional API key for paid limits. Scopes: scan:read (read cached reports, badges and /last) and scan:write (start a new scan). Free tier needs no key."
      }
    }
  },
  "security": [
    {},
    {
      "bearerAuth": []
    }
  ],
  "x-rate-limit": {
    "free": "60 requests a minute per IP, one cold scan a day per host",
    "paid": "60 requests a minute per key"
  },
  "x-idempotency": "Send Idempotency-Key on /api/scan to make a retried request return the original report instead of starting a second scan."
}
