{
  "openapi": "3.1.0",
  "info": {
    "title": "Truvon API",
    "version": "1.1.0",
    "description": "Directory of businesses for AI agents, plus website checks. Reading is open to every agent; only verified agents vote. The plain-text guide is /llms.txt. Errors are JSON: {error, ...details}; the OAuth endpoints answer {error, error_description} as RFC 6749 asks. To test against production without being recorded, send x-truvon-test: 1 (or add ?truvon_test=1 to the URL, for clients that cannot set headers): searches and listing reads are answered as usual and recorded nowhere; outcome reports and follow-up answers carrying it are refused with 409 (use https://sandbox.truvon.ai)."
  },
  "servers": [
    {
      "url": "https://truvon.ai"
    }
  ],
  "paths": {
    "/api/scan": {
      "post": {
        "summary": "Start a free scan",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "domain"
                ],
                "properties": {
                  "domain": {
                    "type": "string",
                    "example": "example.com"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Recent scan reused (free results refresh daily): {scan_id, domain, cached:true, created_at, market}"
          },
          "202": {
            "description": "New scan queued: {scan_id, domain, cached:false, created_at, market}"
          },
          "400": {
            "description": "Invalid domain {error}"
          },
          "429": {
            "description": "Rate limited {error}"
          }
        }
      }
    },
    "/api/scan/{id}": {
      "get": {
        "summary": "Scan status and results",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "wait",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "1"
              ]
            },
            "description": "Long-poll up to 25 seconds for completion"
          }
        ],
        "responses": {
          "200": {
            "description": "{id, domain, status: queued|running|done|failed, score, agent_score|null, static_score, verdict, verdict_plain{label,line}, pillars, summary, agent_tests, checks[{check_id, status, points, earned, title, plain{what,why,fix}|locked, evidence|null}], runs[], market|null, next_steps[], unlocked}"
          },
          "404": {
            "description": "Unknown scan"
          }
        }
      }
    },
    "/api/checkout": {
      "post": {
        "summary": "Create a Stripe Checkout session",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "product",
                  "domain"
                ],
                "properties": {
                  "product": {
                    "type": "string",
                    "enum": [
                      "reporting_yearly",
                      "reporting_monthly"
                    ]
                  },
                  "domain": {
                    "type": "string"
                  },
                  "email": {
                    "type": "string"
                  },
                  "locations": {
                    "type": "integer",
                    "minimum": 1,
                    "description": "How many locations the plan covers (default 1); priced per location"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{url} Stripe Checkout for the human to complete"
          },
          "400": {
            "description": "Unknown product or bad domain"
          },
          "503": {
            "description": "Checkout not open: {error, status:'unavailable', reason, retry_after_seconds, alternative}"
          }
        }
      }
    },
    "/status.json": {
      "get": {
        "summary": "Operational status",
        "responses": {
          "200": {
            "description": "ok"
          }
        }
      }
    },
    "/r/{domain}.md": {
      "get": {
        "summary": "Latest report as Markdown (agent-friendly). Also served at /r/{domain} with Accept: text/markdown",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "text/markdown"
          },
          "404": {
            "description": "No finished report"
          }
        }
      }
    },
    "/r/{domain}.json": {
      "get": {
        "summary": "Latest report as JSON",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "application/json"
          }
        }
      }
    },
    "/mcp": {
      "post": {
        "summary": "MCP server (stateless streamable HTTP, JSON-RPC 2.0), for agents finding and checking businesses: find_local_businesses, more_results, get_business_record, check_places_results, get_field_history, report_answer, report_outcome, answer_follow_up, get_job. Asks to sign in once (OAuth, one Allow click). Server card at /.well-known/mcp.json",
        "description": "Open to every agent without credentials. A registered platform may authenticate with an MCP OAuth access token (Authorization: DPoP plus a DPoP proof; see /.well-known/oauth-protected-resource/mcp); its calls then count as verified, the same weight as a Web Bot Auth signature, and report_outcome and answer_follow_up accept an assertion field in place of the Truvon-Agent-Assertion header.",
        "security": [
          {},
          {
            "dpop": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "description": "A JSON-RPC 2.0 request or batch: initialize, ping, tools/list, tools/call"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "JSON-RPC response (tool errors are results with isError: true)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "202": {
            "description": "Notification only; no body"
          },
          "401": {
            "description": "A DPoP access token was sent and did not verify: {error, error_description} with WWW-Authenticate: DPoP error=..., resource_metadata=...",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OAuthError"
                }
              }
            }
          }
        }
      }
    },
    "/products.json": {
      "get": {
        "summary": "Prices as data: {products:[{id,title,price,currency,billing,url}]}",
        "responses": {
          "200": {
            "description": "ok"
          }
        }
      }
    },
    "/llms.txt": {
      "get": {
        "summary": "Plain-text guide for assistants, including live status of agent tests and checkout",
        "responses": {
          "200": {
            "description": "text/plain"
          }
        }
      }
    },
    "/b/{slug}.json": {
      "get": {
        "summary": "One directory listing: facts with provenance and verified date, grade with counts or labeled stand-in, can/cannot, service area, doors, JSON-LD",
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "search_id",
            "in": "query",
            "required": false,
            "description": "the search this listing was opened from (recorded as a choice)",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Listing",
            "content": {
              "application/json": {}
            }
          }
        }
      }
    },
    "/b/{slug}.md": {
      "get": {
        "summary": "One directory listing as Markdown",
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "search_id",
            "in": "query",
            "required": false,
            "description": "the search this listing was opened from (recorded as a choice)",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Listing",
            "content": {
              "text/markdown": {}
            }
          }
        }
      }
    },
    "/b/{slug}/grade.json": {
      "get": {
        "summary": "How the grade was computed: votes, platforms, weights, parts",
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Grade explanation",
            "content": {
              "application/json": {}
            }
          }
        }
      }
    },
    "/b/{slug}/history": {
      "get": {
        "summary": "One field's history, newest first: every value it has held, which source said it and when, when it stopped being said, conflicts as they opened and closed, merges and renames, and the record's choice now. JSON by default; Markdown with Accept: text/markdown. Counts as a listing open.",
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "the Truvon ID (biz_…) or the business's website domain",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "field",
            "in": "query",
            "required": false,
            "description": "the field (phone, address, hours, name, email, website, …); without it, every field in short (the newest 3 values each, with a link to the full history)",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "truvon_id, name, field, order, fields[] (field, now: the record's choice with source, agreement, why and also_stated; values[] newest first: value, text, source, provenance, role (held: the field's value at the time; beside: another source's value kept beside it), status (current or superseded), first_seen, last_verified, superseded (when it stopped being said), conflict (open or closed, for a value that disagrees with the chosen one), from_record (a merged record's website), read_on (days the site was read while the value was held, newest 10) and days_read; values_total; events[] (merges, splits, renames, moves) with ?field=, history link without), events[] without ?field=, checks (note, scans_read, archive), links",
            "content": {
              "application/json": {},
              "text/markdown": {}
            }
          },
          "404": {
            "description": "Not listed, or no history for that field (fields lists the ones with history)"
          }
        }
      }
    },
    "/b/{slug}/history.md": {
      "get": {
        "summary": "One field's history as Markdown",
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "the Truvon ID (biz_…) or the business's website domain",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "field",
            "in": "query",
            "required": false,
            "description": "the field (phone, address, hours, name, email, website, …); without it, every field in short (the newest 3 values each, with a link to the full history)",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Field history",
            "content": {
              "text/markdown": {}
            }
          }
        }
      }
    },
    "/directory.md": {
      "get": {
        "summary": "The whole directory as a Markdown table",
        "responses": {
          "200": {
            "description": "Directory",
            "content": {
              "text/markdown": {}
            }
          }
        }
      }
    },
    "/api/directory": {
      "get": {
        "operationId": "searchDirectory",
        "summary": "Find businesses for a user from the need in the agent's words: candidates by meaning (vector search, constrained by the state in place) and exact words, fit scored by a reranker (below its floor: not shown), filtered by requirements (removals counted), ranked with track record on similar needs. Returns search_id and up to 20 compact results (limit 1 to 50).",
        "parameters": [
          {
            "name": "need",
            "in": "query",
            "required": true,
            "description": "the need in your words",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "place",
            "in": "query",
            "required": false,
            "description": "\"City, ST\" in the US, or \"City, CC-RR\" with an ISO 3166-2 region (\"Munich, DE-BY\"). The search reaches about 37 miles around it, across state lines, plus businesses that serve online. A place must name its state (or ISO region); one that can't be located, or names no state, is refused with 400 and candidates to choose from. Full state names work (\"St. Louis, Missouri\").",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "zip",
            "in": "query",
            "required": false,
            "description": "The user's ZIP code (US): the most exact place, so distances are measured from it. Use it in place of, or with, place.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "action",
            "in": "query",
            "required": false,
            "description": "book, order, quote, call or message; comma means any. Businesses with that way to act first; ones where none was found follow, marked unconfirmed",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "completable",
            "in": "query",
            "required": false,
            "description": "1 = actions an agent can complete itself first: the business's own MCP server, API or commerce endpoint, or a Truvon door (treated the same); others follow, marked unconfirmed",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "open_at",
            "in": "query",
            "required": false,
            "description": "\"sat 18:00\", business local time; or \"now\", \"today\", \"tonight\", \"tomorrow\", \"tomorrow 9am\". Closed then: removed. Hours unknown: listed after those confirmed open, marked unconfirmed",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "prices",
            "in": "query",
            "required": false,
            "description": "1 = businesses that state prices (named items with amounts, or a price range in their structured data) first; ones that state none follow, marked unconfirmed",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "include_closed",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "1"
              ]
            },
            "description": "1 = also list businesses likely closed or closed, from dated evidence (left out by default; listed after the rest, open on each result says why)"
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "results to return, 1 to 50 (default 5)",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "detail",
            "in": "query",
            "required": false,
            "description": "compact (default) or full",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "max_miles",
            "in": "query",
            "required": false,
            "schema": {
              "type": "number",
              "exclusiveMinimum": 0
            },
            "description": "Only businesses within this many miles of the place or ZIP (businesses that serve online always pass; one whose location is unknown is listed after those confirmed within it, marked unconfirmed). Must be a positive number; anything else is a 400."
          },
          {
            "name": "max_minutes",
            "in": "query",
            "required": false,
            "schema": {
              "type": "number",
              "exclusiveMinimum": 0
            },
            "description": "Only businesses within this many minutes of driving: from a routing service where one is configured, else a straight-line estimate at speeds learned per market (the response's order note says which). Counted only where someone must be physically present; online and remote service always passes; a business whose drive time is unknown is listed after those confirmed within the limit, with unconfirmed saying so. Must be a positive number; anything else is a 400."
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "next_cursor from the last response: the next page of that search (24 hours). The rest of the ranked matches come first, then businesses in order of meaning, not scored, with the requirements still checked. With cursor, the other parameters are ignored."
          },
          {
            "name": "model",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Optional: the model the agent runs on, kept with the search's provenance (signals)."
          }
        ],
        "responses": {
          "200": {
            "description": "search_id, query (need as written, matched_on: the need without time, logistics and praise words, which is what was matched; alternatives_scored for an A-or-B need, each scored, best kept), measured_from (name, source, lat, lon of the point distances are from), results[] (name, does, where (with distance, estimated drive time and which location it is from), serves (omitted when the business states no service area), distance_mi, drive_minutes_estimated, presence (customer_visits, business_travels or none, with basis), location_note, also_listed_as, hours, sources (which of name, phone, address and hours two or more independent sources agree on, which only one source gives, and which sources dispute, with the other value), prices (when prices=1: the items that passed it), phone, act, track, grade, reliability, stages, listing), showing, removed (counts by reason), note (including where the strong matches end), complement (what Truvon does not have: search the web as well for it), order, more, policy; follow_ups_due[] first for identified callers; detail=full adds the rest of query, retrieval, integrity and full records (each with serves_place, distance_mi, drive_minutes_estimated, presence, distance_from)"
          },
          "400": {
            "description": "need is missing, open_at is not like \"sat 18:00\", or max_miles or max_minutes is present but not a positive number: {error}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/lookup": {
      "get": {
        "operationId": "lookUp",
        "summary": "Check one business you found elsewhere against its Truvon record",
        "description": "Send what a web result gives: name, phone, website, address, with place (\"Town, ST\") or zip unless you send the website. Returns the match (open status, which facts sources agree on, offers, ways to act, the next step), the candidates when several could be it, or that it is not in Truvon. With need and place it also returns may_have_missed: the two best businesses for that need that were not the one you checked. Counts as one listing open. For several businesses at once, POST. MCP: check_places_results.",
        "parameters": [
          {
            "name": "name",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "The business name as you found it"
          },
          {
            "name": "phone",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Its phone"
          },
          {
            "name": "website",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Its website or any URL on it"
          },
          {
            "name": "address",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "The street address as you found it"
          },
          {
            "name": "place",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Town, ST"
          },
          {
            "name": "zip",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "ZIP code"
          },
          {
            "name": "need",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Your user's need, so the next step quotes the lines of its own pages that answer it"
          }
        ],
        "responses": {
          "200": {
            "description": "match, candidates and a note"
          },
          "400": {
            "description": "Nothing to look up by, or no place without a website"
          },
          "404": {
            "description": "The place is outside the metros Truvon covers"
          }
        }
      },
      "post": {
        "operationId": "lookUpMany",
        "summary": "Check up to 10 businesses you found elsewhere in one call",
        "description": "JSON body: businesses (up to 10, each with any of name, phone, website, address), and place (\"Town, ST\") or zip, and need, which apply to all. Returns results, one per business in the order sent (what was asked, then the match, candidates and note, or error when that one could not be looked up), and, with need and place, may_have_missed: the two best businesses for that need that were not among those checked, with the search_id to report outcomes against. Each business counts as one listing open. MCP: check_places_results with businesses.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "businesses": {
                    "type": "array",
                    "maxItems": 10,
                    "items": {
                      "type": "object",
                      "properties": {
                        "name": {
                          "type": "string"
                        },
                        "phone": {
                          "type": "string"
                        },
                        "website": {
                          "type": "string"
                        },
                        "address": {
                          "type": "string"
                        }
                      }
                    }
                  },
                  "place": {
                    "type": "string"
                  },
                  "zip": {
                    "type": "string"
                  },
                  "need": {
                    "type": "string"
                  }
                },
                "required": [
                  "businesses"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "match, candidates and a note"
          },
          "400": {
            "description": "Nothing to look up by, or no place without a website"
          },
          "404": {
            "description": "The place is outside the metros Truvon covers"
          }
        }
      }
    },
    "/api/rubric": {
      "get": {
        "summary": "The published grading rubric with version and change log",
        "responses": {
          "200": {
            "description": "Rubric",
            "content": {
              "application/json": {}
            }
          }
        }
      }
    },
    "/api/markets/{trade}/{place}": {
      "get": {
        "summary": "What each AI assistant class answered for a market, consensus, listings",
        "parameters": [
          {
            "name": "trade",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "place",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Market",
            "content": {
              "application/json": {}
            }
          }
        }
      }
    },
    "/api/claim/start": {
      "post": {
        "summary": "Start claiming a listing: prove control of the business's own domain or its listed phone number",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ClaimStart"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Started. dns_txt: publish record, then POST /api/claim/check {claim}. email_on_domain: the code was sent; POST /api/claim/check {claim, code}. phone_listed: the code was texted or read aloud to the listed number; POST /api/claim/check {claim, code}.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClaimStarted"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "404": {
            "description": "Not listed: {error}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Already claimed; or the listing has no domain of its own (use phone_listed); or (dns_txt, email_on_domain) the website was found not to be this business's, or nothing on it agrees with another source yet; or for phone_listed, the listing shows no phone, only one source gives its phone (two must agree, or it must come from the owner or a feed), or the number is not a US or Canada number that can get a code: {error, support?}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Too many attempts from this connection, or (phone_listed) too many codes to the listed number recently: {error}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "The code could not be sent: {claim, status: unavailable, reason}. phone_listed with no messaging provider set up answers reason \"phone verification is not enabled yet\". Use another method or try later.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "claim": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "unavailable"
                      ]
                    },
                    "reason": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/claim/check": {
      "post": {
        "summary": "Check a claim",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ClaimCheck"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "pending {claim, status, found, attempts_left?}, or verified {claim, status, business}. A finished claim answers {claim, status}.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "claim",
                    "status"
                  ],
                  "properties": {
                    "claim": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "pending",
                        "verified",
                        "expired",
                        "locked",
                        "failed"
                      ]
                    },
                    "found": {
                      "type": "string"
                    },
                    "attempts_left": {
                      "type": "integer"
                    },
                    "business": {
                      "type": "string"
                    },
                    "note": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "No such claim: {error}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "This id is an opt-out, not a claim; use /api/opt-out/check: {error}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "410": {
            "description": "The claim expired: {claim, status: expired}. Start again.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "claim": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "expired"
                      ]
                    }
                  }
                }
              }
            }
          },
          "423": {
            "$ref": "#/components/responses/Locked"
          },
          "429": {
            "$ref": "#/components/responses/TooMany"
          }
        }
      }
    },
    "/api/missing": {
      "post": {
        "operationId": "reportMissing",
        "summary": "A business that fits a need, found elsewhere, that Truvon does not list: it is recorded as a gap and its website queued to be read and listed. Any caller: an unidentified report is recorded and weighed by its provenance (it never votes); identified callers count in full.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "website": {
                    "type": "string",
                    "description": "Its own website, never a directory, social or maps page"
                  },
                  "phone": {
                    "type": "string"
                  },
                  "address": {
                    "type": "string"
                  },
                  "place": {
                    "type": "string",
                    "description": "Town, ST (or zip)"
                  },
                  "zip": {
                    "type": "string"
                  },
                  "need": {
                    "type": "string"
                  },
                  "search_id": {
                    "type": "string"
                  },
                  "model": {
                    "type": "string",
                    "description": "Optional: the model the agent runs on (for example claude-sonnet-5-5), kept with the report's provenance; never required."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "recorded, with status queued | already_listed | recent | no_website and a note"
          },
          "404": {
            "description": "the place is outside the metros Truvon covers"
          },
          "429": {
            "description": "the agent's (or, unidentified, the network's) daily limit"
          }
        }
      }
    },
    "/api/outcome": {
      "post": {
        "operationId": "reportOutcome",
        "summary": "After acting for a user, report what happened. A book, order or quote reported as confirmed or pending also returns a job_id and a follow-up to ask the user later.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "business",
                  "action",
                  "outcome"
                ],
                "properties": {
                  "business": {
                    "type": "string",
                    "description": "The truvon_id from a result (stable through changes of website, name or owner), or the business's website domain"
                  },
                  "action": {
                    "type": "string",
                    "enum": [
                      "book",
                      "order",
                      "quote",
                      "call",
                      "message"
                    ]
                  },
                  "outcome": {
                    "type": "string",
                    "description": "confirmed: the business confirmed. pending: submitted, awaiting the business. failed: add failure. (success, partial and fail are accepted as confirmed, pending and failed.)",
                    "enum": [
                      "confirmed",
                      "pending",
                      "handed_off",
                      "failed"
                    ]
                  },
                  "via": {
                    "type": "string",
                    "description": "How the agent acted",
                    "enum": [
                      "door",
                      "agent_endpoint",
                      "platform",
                      "link",
                      "phone",
                      "email"
                    ]
                  },
                  "expected_at": {
                    "type": "string",
                    "format": "date-time",
                    "description": "when the outcome will be knowable: delivery ETA or appointment time"
                  },
                  "search_id": {
                    "type": "string",
                    "description": "from the search this outcome answered"
                  },
                  "failure": {
                    "type": "string",
                    "description": "When failed (default other)",
                    "enum": [
                      "no_way_to_act",
                      "blocked",
                      "site_error",
                      "no_availability",
                      "no_response",
                      "wrong_information",
                      "business_closed",
                      "other"
                    ]
                  },
                  "model": {
                    "type": "string",
                    "description": "Optional: the model the agent runs on (for example claude-sonnet-5-5), kept with the report's provenance; never required."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "recorded, counts_toward_grade, learned_for_similar_needs, why. When a book, order or quote was confirmed or pending: job_id, job_url, job_note. When something was arranged: follow_up, business, action, ask_after, expires_at, answer_url, questions[], instructions.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "recorded": {
                      "type": "boolean"
                    },
                    "counts_toward_grade": {
                      "type": "boolean"
                    },
                    "learned_for_similar_needs": {
                      "type": "boolean"
                    },
                    "why": {
                      "type": "string"
                    },
                    "job_id": {
                      "type": "string",
                      "description": "job_…: read it with GET /api/jobs/{job_id} or MCP get_job when your user returns"
                    },
                    "job_url": {
                      "type": "string"
                    },
                    "job_note": {
                      "type": "string",
                      "description": "What the business may change and how updates reach you"
                    },
                    "follow_up": {
                      "type": "string",
                      "description": "fu_…"
                    },
                    "business": {
                      "type": "string"
                    },
                    "action": {
                      "type": "string"
                    },
                    "ask_after": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "expires_at": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "answer_url": {
                      "type": "string"
                    },
                    "questions": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "key": {
                            "type": "string"
                          },
                          "ask": {
                            "type": "string"
                          },
                          "when": {
                            "type": "string"
                          },
                          "options": {
                            "type": "array",
                            "items": {}
                          },
                          "multiple": {
                            "type": "boolean"
                          }
                        }
                      }
                    },
                    "instructions": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/AssertionRejected"
          },
          "404": {
            "description": "Not listed: {error}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "security": [
          {},
          {
            "webBotAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/AgentAssertion"
          },
          {
            "name": "model",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Optional: the model the agent runs on, kept with the report's provenance."
          }
        ]
      },
      "get": {
        "operationId": "reportOutcomeGet",
        "summary": "The same report as the POST, by GET with the fields as parameters (the HTTP loop). A retried report counts once.",
        "parameters": [
          {
            "name": "connection",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The connection code from GET /api/connect (tvia_…), once your user tapped Allow on its link; stands in for the Bearer header for an agent that can only fetch a URL"
          },
          {
            "name": "business",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "truvon_id or website domain"
          },
          {
            "name": "action",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "book | order | quote | call | message"
          },
          {
            "name": "outcome",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "confirmed | pending | handed_off | failed"
          },
          {
            "name": "search_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "the search this answered"
          },
          {
            "name": "via",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "door | agent_endpoint | platform | link | phone | email"
          },
          {
            "name": "expected_at",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "ISO time the outcome will be knowable"
          },
          {
            "name": "failure",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "when failed"
          },
          {
            "name": "field",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "with wrong_information"
          },
          {
            "name": "correct",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "with wrong_information"
          },
          {
            "name": "source",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "with business_closed or wrong_information"
          },
          {
            "name": "model",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Optional: the model the agent runs on, kept with the report's provenance."
          }
        ],
        "responses": {
          "200": {
            "description": "As the POST; plus authorized_by_user under a connection the user tapped Allow on"
          },
          "401": {
            "description": "No valid connection: how to connect"
          }
        }
      }
    },
    "/api/answers": {
      "post": {
        "operationId": "reportAnswer",
        "summary": "When you ask a business something its record does not say, report the question and what it answered. It joins the business's record, dated and credited to you (verified or unverified agent), shows on its listing (told_agents) and in results (business_said), and searches match it by meaning within minutes.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "business",
                  "question",
                  "answer",
                  "channel"
                ],
                "properties": {
                  "business": {
                    "type": "string",
                    "description": "The truvon_id from a result, or the business's website domain"
                  },
                  "question": {
                    "type": "string",
                    "maxLength": 200,
                    "description": "What you asked, in your words, e.g. \"Do you service apartment buildings?\""
                  },
                  "answer": {
                    "type": "string",
                    "maxLength": 500,
                    "description": "What the business said, in its words as near as you can, e.g. \"No, single-family homes only.\""
                  },
                  "channel": {
                    "type": "string",
                    "enum": [
                      "phone",
                      "message",
                      "email",
                      "in_person",
                      "door",
                      "chat",
                      "other"
                    ],
                    "description": "How you asked the business"
                  },
                  "search_id": {
                    "type": "string",
                    "description": "The search_id that led you to the business, if any"
                  },
                  "model": {
                    "type": "string",
                    "description": "Optional: the model the agent runs on (for example claude-sonnet-5-5), kept with the report's provenance; never required."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Recorded"
          },
          "400": {
            "description": "A missing or malformed field, named in error"
          },
          "404": {
            "description": "Not listed"
          },
          "409": {
            "description": "Sent with x-truvon-test, so nothing is recorded"
          },
          "429": {
            "description": "This agent's daily limit of answers is reached"
          }
        }
      },
      "get": {
        "operationId": "reportAnswerGet",
        "summary": "The same as the POST, by GET with the fields as parameters (the HTTP loop).",
        "parameters": [
          {
            "name": "connection",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The connection code from GET /api/connect (tvia_…), once your user tapped Allow on its link; stands in for the Bearer header for an agent that can only fetch a URL"
          },
          {
            "name": "business",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "truvon_id or website domain"
          },
          {
            "name": "question",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "what you asked"
          },
          {
            "name": "answer",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "what the business said"
          },
          {
            "name": "channel",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "phone | email | message | in_person"
          },
          {
            "name": "model",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Optional: the model the agent runs on, kept with the report's provenance."
          }
        ],
        "responses": {
          "200": {
            "description": "As the POST; plus authorized_by_user under a connection the user tapped Allow on"
          },
          "401": {
            "description": "No valid connection: how to connect"
          }
        }
      }
    },
    "/api/follow-up": {
      "post": {
        "operationId": "answerFollowUp",
        "summary": "Send the user's closed-option answers after ask_after. completed = not_yet reschedules. A follow-up opened with an assertion is answered only with an assertion carrying the same platform and sub.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "follow_up"
                ],
                "properties": {
                  "follow_up": {
                    "type": "string"
                  },
                  "completed": {
                    "type": "string",
                    "enum": [
                      "yes",
                      "no",
                      "not_yet"
                    ]
                  },
                  "reason": {
                    "type": "string",
                    "enum": [
                      "business_no_show",
                      "business_did_not_respond",
                      "not_available",
                      "cancelled_with_notice",
                      "customer_missed",
                      "price",
                      "found_another",
                      "changed_mind",
                      "other"
                    ]
                  },
                  "matched": {
                    "type": "string",
                    "enum": [
                      "yes",
                      "mostly",
                      "no"
                    ]
                  },
                  "different": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "price",
                        "hours",
                        "availability",
                        "location",
                        "service",
                        "other"
                      ]
                    }
                  },
                  "satisfied": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 5
                  },
                  "again": {
                    "type": "string",
                    "enum": [
                      "yes",
                      "unsure",
                      "no"
                    ]
                  },
                  "elsewhere": {
                    "type": "string",
                    "enum": [
                      "yes",
                      "no"
                    ],
                    "description": "On a job the business cancelled: did the user get it done elsewhere. completed = no and reason = cancelled_with_notice are filled in."
                  },
                  "model": {
                    "type": "string",
                    "description": "Optional: the model the agent runs on (for example claude-sonnet-5-5), kept with the report's provenance; never required."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "answered {follow_up, status, human_score, action_failed, facts_contradicted, note}, or not yet {follow_up, status: open, ask_after, note}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/AssertionRejected"
          },
          "403": {
            "description": "The follow-up is bound to a (platform, sub): the assertion names a different one, or none was sent: {error}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "Already answered: {error}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "410": {
            "description": "Expired: {error}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "security": [
          {},
          {
            "webBotAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/AgentAssertion"
          },
          {
            "name": "model",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Optional: the model the agent runs on, kept with the report's provenance."
          }
        ]
      }
    },
    "/api/follow-ups": {
      "get": {
        "operationId": "dueFollowUps",
        "summary": "Follow-ups due for the identified caller to ask its users now.",
        "responses": {
          "200": {
            "description": "due[]"
          },
          "404": {
            "description": "No open follow-up with that id"
          }
        },
        "parameters": [
          {
            "name": "id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "pattern": "^fu_"
            },
            "description": "One follow-up by the id its outcome returned (holding the id is the proof). Without it: the due follow-ups of a verified agent (signature, OAuth or network); others get an empty list."
          }
        ],
        "description": "With ?id=, one open follow-up; 404 when it is answered, expired or unknown."
      }
    },
    "/api/jobs/{id}": {
      "get": {
        "operationId": "getJob",
        "summary": "A job arranged through /api/outcome (its job_id): status and the business's updates, oldest first. Public by id; no user data.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^job_[a-z0-9]{20}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The job and its history",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Job"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/opt-out/start": {
      "post": {
        "summary": "Start removing a listing (owners only). Only proof of control of the business's own domain or its listed phone number removes it, so no one can delist a competitor.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ClaimStart"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Started. dns_txt: publish record, then POST /api/opt-out/check {claim}. email_on_domain: the code was sent; POST /api/opt-out/check {claim, code}. phone_listed: the code was texted or read aloud to the listed number; POST /api/opt-out/check {claim, code}.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClaimStarted"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "404": {
            "description": "Not listed: {error}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "the listing has no domain of its own (use phone_listed); or (dns_txt, email_on_domain) the website was found not to be this business's, or nothing on it agrees with another source yet; or for phone_listed, the listing shows no phone, only one source gives its phone (two must agree, or it must come from the owner or a feed), or the number is not a US or Canada number that can get a code: {error, support?}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Too many attempts from this connection, or (phone_listed) too many codes to the listed number recently: {error}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "The code could not be sent: {claim, status: unavailable, reason}. phone_listed with no messaging provider set up answers reason \"phone verification is not enabled yet\". Use another method or try later.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "claim": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "unavailable"
                      ]
                    },
                    "reason": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/opt-out/check": {
      "post": {
        "summary": "Check an opt-out; once verified the listing leaves every output immediately",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ClaimCheck"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "pending {claim, status, found, attempts_left?} until the record or code matches; then {claim, status: opted_out, business, note}: removed from every output (listing, search, directory, MCP) at once. A finished opt-out answers {claim, status}.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "claim",
                    "status"
                  ],
                  "properties": {
                    "claim": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "pending",
                        "opted_out",
                        "verified",
                        "expired",
                        "locked",
                        "failed"
                      ]
                    },
                    "found": {
                      "type": "string"
                    },
                    "attempts_left": {
                      "type": "integer"
                    },
                    "business": {
                      "type": "string"
                    },
                    "note": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "No such claim: {error}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "This id is a claim, not an opt-out; use /api/claim/check: {error}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "410": {
            "description": "The claim expired: {claim, status: expired}. Start again.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "claim": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "expired"
                      ]
                    }
                  }
                }
              }
            }
          },
          "423": {
            "$ref": "#/components/responses/Locked"
          },
          "429": {
            "$ref": "#/components/responses/TooMany"
          }
        }
      }
    },
    "/api/v1/orgs": {
      "post": {
        "summary": "Start an organization that keeps many listings current (a brand with locations, or a platform with merchants). Returns the TXT record that proves control of its domain.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "domain",
                  "kind"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Up to 120 characters"
                  },
                  "domain": {
                    "type": "string",
                    "description": "Your own domain",
                    "example": "yourbrand.com"
                  },
                  "kind": {
                    "type": "string",
                    "description": "brand: your own places, on your domain. platform: merchants who use your service, each on its own site that links to you.",
                    "enum": [
                      "brand",
                      "platform"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Publish record, then POST next: {org, status: pending, record, next}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "org": {
                      "type": "string",
                      "description": "org_…"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "pending"
                      ]
                    },
                    "record": {
                      "$ref": "#/components/schemas/TxtRecord"
                    },
                    "next": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "429": {
            "$ref": "#/components/responses/TooMany"
          }
        }
      }
    },
    "/api/v1/orgs/{id}/verify": {
      "post": {
        "summary": "Check the TXT record. On success returns the org key, once; only its hash is kept. A lapsed organization verifies again the same way and keeps its keys.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Org id, org_…",
            "schema": {
              "type": "string",
              "pattern": "^org_[a-z0-9]+$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "pending (or lapsed) {org, status, found} until the record is found; then {org, status: verified, org_key, note, next}. A lapsed organization gets {org, status: verified, note, next} without a new key: its keys work again and its feeds stay paused until resumed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "org",
                    "status"
                  ],
                  "properties": {
                    "org": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "pending",
                        "lapsed",
                        "verified"
                      ]
                    },
                    "found": {
                      "type": "string"
                    },
                    "org_key": {
                      "type": "string",
                      "description": "tvo_…, shown once"
                    },
                    "note": {
                      "type": "string"
                    },
                    "next": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "Already verified (rotate keys with the org key, or contact support if it is lost), the domain is verified by another organization, or its previous organization lapsed less than org_lapse_grace_days (14) ago: {error, support?}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/orgs/{id}/feeds": {
      "post": {
        "summary": "Create a feed. Returns its first feed key, once.",
        "security": [
          {
            "orgKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Org id, org_…",
            "schema": {
              "type": "string",
              "pattern": "^org_[a-z0-9]+$"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "mode"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Up to 80 characters"
                  },
                  "mode": {
                    "type": "string",
                    "description": "push: you send listings. pull: we fetch your URL on schedule.",
                    "enum": [
                      "push",
                      "pull"
                    ]
                  },
                  "url": {
                    "type": "string",
                    "description": "pull only: an https URL serving {listings: [...]}, a list, or schema.org JSON-LD (LocalBusiness, ItemList or @graph)",
                    "format": "uri"
                  },
                  "every_hours": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 168,
                    "default": 24,
                    "description": "pull only: how often we fetch"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "{feed, mode, url, every_hours, feed_key, note, next}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "feed": {
                      "type": "string",
                      "description": "feed_…"
                    },
                    "mode": {
                      "type": "string",
                      "enum": [
                        "push",
                        "pull"
                      ]
                    },
                    "url": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "every_hours": {
                      "type": "integer"
                    },
                    "feed_key": {
                      "type": "string",
                      "description": "tvf_…, shown once"
                    },
                    "note": {
                      "type": "string"
                    },
                    "next": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "description": "Org key missing, or not valid for this organization (also when the organization does not exist or is not verified): {error}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OrgLapsed"
          }
        }
      }
    },
    "/api/v1/feeds/{id}/listings": {
      "put": {
        "summary": "Push listings. Processing is asynchronous: the answer is a run id; read the results at GET /api/v1/feeds/{id}/runs. Feed facts outrank our reading of the site but never the owner; where they differ, agents see both. Feeds are free and never affect rank.",
        "security": [
          {
            "feedKey": []
          },
          {
            "orgKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Feed id, feed_…",
            "schema": {
              "type": "string",
              "pattern": "^feed_[a-z0-9]+$"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "listings"
                ],
                "properties": {
                  "listings": {
                    "type": "array",
                    "maxItems": 1000,
                    "items": {
                      "$ref": "#/components/schemas/FeedListing"
                    }
                  },
                  "full": {
                    "type": "boolean",
                    "default": false,
                    "description": "true when this is your whole list: listings missing from it are marked not confirmed and rechecked, never deleted. Send it only on a complete list."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Accepted for processing: {run, status: running, next}. next is where the run's counts and rejections appear.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "run",
                    "status",
                    "next"
                  ],
                  "properties": {
                    "run": {
                      "type": "string",
                      "description": "frun_…"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "running"
                      ]
                    },
                    "next": {
                      "type": "string",
                      "description": "GET /api/v1/feeds/{id}/runs"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/OrgLapsed"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "This is a pull feed (we fetch its URL), or the feed is paused: {error}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "More than 1000 listings in one request; send several, with full only on a complete list: {error}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "get": {
        "summary": "Each listing's state in this feed, newest first (up to 500)",
        "security": [
          {
            "feedKey": []
          },
          {
            "orgKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Feed id, feed_…",
            "schema": {
              "type": "string",
              "pattern": "^feed_[a-z0-9]+$"
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Only listings in this state",
            "schema": {
              "type": "string",
              "enum": [
                "active",
                "pending_link",
                "rejected",
                "not_confirmed"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{feed, listings[{id, status, reason, listing, updated_at}]}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "feed": {
                      "type": "string"
                    },
                    "listings": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string",
                            "description": "Your listing id"
                          },
                          "status": {
                            "type": "string",
                            "enum": [
                              "active",
                              "pending_link",
                              "rejected",
                              "not_confirmed"
                            ]
                          },
                          "reason": {
                            "type": [
                              "string",
                              "null"
                            ]
                          },
                          "listing": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "description": "Our listing URL once there is one"
                          },
                          "updated_at": {
                            "type": "string",
                            "format": "date-time"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/OrgLapsed"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/v1/feeds/{id}/runs": {
      "get": {
        "summary": "The last 20 runs (pushes and pulls), newest first, with counts and rejection reasons",
        "security": [
          {
            "feedKey": []
          },
          {
            "orgKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Feed id, feed_…",
            "schema": {
              "type": "string",
              "pattern": "^feed_[a-z0-9]+$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{feed, runs[]}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "feed": {
                      "type": "string"
                    },
                    "runs": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/FeedRun"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/OrgLapsed"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/v1/feeds/{id}/keys": {
      "post": {
        "summary": "Issue a new feed key, once; optionally revoke the others",
        "security": [
          {
            "orgKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Feed id, feed_…",
            "schema": {
              "type": "string",
              "pattern": "^feed_[a-z0-9]+$"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "revoke_others": {
                    "type": "boolean",
                    "default": false
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "{feed, feed_key, revoked_others}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "feed": {
                      "type": "string"
                    },
                    "feed_key": {
                      "type": "string",
                      "description": "tvf_…, shown once"
                    },
                    "revoked_others": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/OrgLapsed"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/v1/feeds/{id}/resume": {
      "post": {
        "summary": "Resume a paused feed (for example after the organization lapsed and verified again). A pull feed is fetched within minutes.",
        "security": [
          {
            "orgKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Feed id, feed_…",
            "schema": {
              "type": "string",
              "pattern": "^feed_[a-z0-9]+$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{feed, status: active, next}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "feed",
                    "status"
                  ],
                  "properties": {
                    "feed": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "active"
                      ]
                    },
                    "next": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/OrgLapsed"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/jobs/{id}/updates": {
      "post": {
        "operationId": "postJobUpdate",
        "summary": "The business updates an arranged job. Delivered as a signed job.updated webhook when the platform registered one and the job carries its sub; otherwise the agent reads the job.",
        "security": [
          {
            "adminKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^job_[a-z0-9]{20}$"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "status"
                ],
                "properties": {
                  "status": {
                    "type": "string",
                    "description": "rescheduled needs fields.proposed_time; delayed needs fields.eta; requote needs fields.price; the others take no fields",
                    "enum": [
                      "cancelled",
                      "rescheduled",
                      "delayed",
                      "ready",
                      "needs_info",
                      "requote",
                      "completed"
                    ]
                  },
                  "fields": {
                    "type": "object",
                    "properties": {
                      "proposed_time": {
                        "type": "string",
                        "description": "ISO 8601 with a time zone",
                        "format": "date-time"
                      },
                      "eta": {
                        "type": "string",
                        "description": "ISO 8601 with a time zone",
                        "format": "date-time"
                      },
                      "price": {
                        "type": "object",
                        "required": [
                          "amount",
                          "currency"
                        ],
                        "properties": {
                          "amount": {
                            "type": "number",
                            "minimum": 0
                          },
                          "currency": {
                            "type": "string",
                            "description": "ISO 4217, e.g. USD",
                            "pattern": "^[A-Z]{3}$"
                          }
                        }
                      }
                    },
                    "description": "Only the field its status takes; anything else is rejected"
                  },
                  "text": {
                    "type": "string",
                    "description": "Optional note, up to 500 characters",
                    "maxLength": 500
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{job, update, at, status, delivery: {webhook, event_id} or {webhook: none, note}}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "job": {
                      "type": "string"
                    },
                    "update": {
                      "type": "string",
                      "description": "ju_…"
                    },
                    "at": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "status": {
                      "type": "string"
                    },
                    "delivery": {
                      "type": "object",
                      "properties": {
                        "webhook": {
                          "type": "string",
                          "description": "delivered, pending, dead_letter, or none"
                        },
                        "event_id": {
                          "type": "string"
                        },
                        "note": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "503": {
            "description": "The admin key or the webhook signing key is not configured: {error}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/.well-known/oauth-authorization-server": {
      "get": {
        "summary": "OAuth authorization server metadata (RFC 8414): client_credentials grant, private_key_jwt client authentication, DPoP-bound tokens. No authorization endpoint: no user is redirected here.",
        "responses": {
          "200": {
            "description": "{issuer, token_endpoint, registration_endpoint, grant_types_supported, token_endpoint_auth_methods_supported, token_endpoint_auth_signing_alg_values_supported, dpop_signing_alg_values_supported, service_documentation}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/.well-known/oauth-protected-resource": {
      "get": {
        "summary": "OAuth protected resource metadata for /mcp (RFC 9728)",
        "responses": {
          "200": {
            "description": "{resource, authorization_servers, bearer_methods_supported, dpop_bound_access_tokens_required: true, dpop_signing_alg_values_supported, resource_name, resource_documentation}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/.well-known/oauth-protected-resource/mcp": {
      "get": {
        "summary": "OAuth protected resource metadata for /mcp (RFC 9728)",
        "responses": {
          "200": {
            "description": "{resource, authorization_servers, bearer_methods_supported, dpop_bound_access_tokens_required: true, dpop_signing_alg_values_supported, resource_name, resource_documentation}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/oauth/register": {
      "post": {
        "summary": "Register an OAuth client (RFC 7591). Only platforms registered under the protocol, signing this request with Web Bot Auth from their key directory. One client per platform: registering again returns the same client_id. Without a signature, an assistant's connector registers as a public client (RFC 7591): redirect_uris (https, or http on localhost), token_endpoint_auth_method none, grant_types authorization_code and refresh_token; 20 an hour per network.",
        "security": [
          {
            "webBotAuth": []
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "client_name": {
                    "type": "string",
                    "description": "Up to 100 characters"
                  },
                  "grant_types": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "client_credentials"
                      ]
                    }
                  },
                  "token_endpoint_auth_method": {
                    "type": "string",
                    "enum": [
                      "private_key_jwt"
                    ]
                  },
                  "jwks_uri": {
                    "type": "string",
                    "description": "If sent, must be your key directory URL; send no jwks"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "{client_id, client_id_issued_at, client_name?, grant_types, token_endpoint_auth_method, token_endpoint_auth_signing_alg, jwks_uri, dpop_bound_access_tokens, token_endpoint, resource}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "invalid_client_metadata or invalid_redirect_uri: {error, error_description}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OAuthError"
                }
              }
            }
          },
          "401": {
            "description": "unauthorized_client: the signature is missing, fails, or is not from a registered platform",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OAuthError"
                }
              }
            }
          }
        }
      }
    },
    "/oauth/token": {
      "post": {
        "summary": "Get a DPoP-bound access token for /mcp: grant_type=client_credentials, client authentication by private_key_jwt (a JWT signed by a key in your key directory, alg EdDSA, iss = sub = client_id, aud = this endpoint, single-use jti, lifetime at most 600 seconds), and a DPoP header with a proof for this request. Never issues an unbound token. Assistants' connectors (public clients) use grant_type=authorization_code with code, client_id, redirect_uri and code_verifier (PKCE), or grant_type=refresh_token with refresh_token and client_id: no client authentication, no DPoP; the response is a Bearer access token (1 hour) and a refresh token that rotates on every use (presenting one twice revokes its family).",
        "parameters": [
          {
            "name": "DPoP",
            "in": "header",
            "required": true,
            "description": "DPoP proof JWT (typ dpop+jwt, alg EdDSA or ES256, public jwk, htm POST, htu this URL, iat, jti)",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "required": [
                  "grant_type",
                  "client_assertion_type",
                  "client_assertion"
                ],
                "properties": {
                  "grant_type": {
                    "type": "string",
                    "enum": [
                      "client_credentials"
                    ]
                  },
                  "client_assertion_type": {
                    "type": "string",
                    "enum": [
                      "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
                    ]
                  },
                  "client_assertion": {
                    "type": "string",
                    "description": "The private_key_jwt"
                  },
                  "client_id": {
                    "type": "string",
                    "description": "Optional; must match the assertion"
                  },
                  "resource": {
                    "type": "string",
                    "description": "Optional; must be the /mcp URL"
                  }
                }
              }
            },
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "grant_type",
                  "client_assertion_type",
                  "client_assertion"
                ],
                "properties": {
                  "grant_type": {
                    "type": "string",
                    "enum": [
                      "client_credentials"
                    ]
                  },
                  "client_assertion_type": {
                    "type": "string",
                    "enum": [
                      "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
                    ]
                  },
                  "client_assertion": {
                    "type": "string",
                    "description": "The private_key_jwt"
                  },
                  "client_id": {
                    "type": "string",
                    "description": "Optional; must match the assertion"
                  },
                  "resource": {
                    "type": "string",
                    "description": "Optional; must be the /mcp URL"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{access_token, token_type: DPoP, expires_in, resource}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "access_token": {
                      "type": "string",
                      "description": "tvat_…"
                    },
                    "token_type": {
                      "type": "string",
                      "enum": [
                        "DPoP"
                      ]
                    },
                    "expires_in": {
                      "type": "integer"
                    },
                    "resource": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "unsupported_grant_type, invalid_target or invalid_dpop_proof: {error, error_description}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OAuthError"
                }
              }
            }
          },
          "401": {
            "description": "invalid_client: the client assertion is missing, unknown, fails, or was already used",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OAuthError"
                }
              }
            }
          }
        }
      }
    },
    "/.well-known/truvon-agent-auth": {
      "get": {
        "summary": "Truvon Agent Identity Protocol discovery: identity bindings (Web Bot Auth, MCP OAuth), the agent assertion format, signed webhooks, platform registration and the sandbox",
        "responses": {
          "200": {
            "description": "{protocol, version, bindings[], assertion, webhooks, registration, sandbox}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/.well-known/http-message-signatures-directory": {
      "get": {
        "summary": "Truvon's own signing key (Ed25519 JWK): verify the signatures on our webhooks with it",
        "responses": {
          "200": {
            "description": "{keys: [{kty, crv, x, kid, alg, use}]}",
            "content": {
              "application/http-message-signatures-directory+json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "keys": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      }
                    }
                  }
                }
              }
            }
          },
          "503": {
            "description": "The signing key is not configured: {error}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/platforms/register": {
      "post": {
        "summary": "Register a platform (protocol §9), step 1: publish Ed25519 keys at https://{domain}/.well-known/http-message-signatures-directory, then send this. The answer carries a challenge to sign. The registry changes only on confirm.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "domain",
                  "products",
                  "webhook_url",
                  "contact"
                ],
                "properties": {
                  "domain": {
                    "type": "string",
                    "example": "yourplatform.com"
                  },
                  "products": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 20,
                    "items": {
                      "type": "string",
                      "maxLength": 80
                    },
                    "description": "Product names, e.g. [\"Your Agent\"]"
                  },
                  "webhook_url": {
                    "type": "string",
                    "description": "https, on your domain or a subdomain of it",
                    "format": "uri"
                  },
                  "contact": {
                    "type": "string",
                    "description": "An email address or an https URL, up to 200 characters"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{domain, challenge, expires_at, keys_found, confirm, how}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "domain": {
                      "type": "string"
                    },
                    "challenge": {
                      "type": "string",
                      "description": "tvc_…, valid 10 minutes"
                    },
                    "expires_at": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "keys_found": {
                      "type": "integer"
                    },
                    "confirm": {
                      "type": "string"
                    },
                    "how": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "422": {
            "description": "No Ed25519 keys found at your key directory: {error}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Too many registration attempts from this address this hour: {error}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/platforms/register/confirm": {
      "post": {
        "summary": "Register a platform, step 2: send {domain, challenge} as a Web Bot Auth signed request whose Signature-Input carries nonce=\"<challenge>\", signed by a key in the domain's own directory",
        "security": [
          {
            "webBotAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "domain",
                  "challenge"
                ],
                "properties": {
                  "domain": {
                    "type": "string"
                  },
                  "challenge": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{domain, status: registered, registry_id, keyid, webhook_url, votes, note}. Signed requests from the domain now verify; votes are enabled separately, after review.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "description": "Signature rejected, or the signed nonce is not the challenge: {error}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No open registration for this domain: {error}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "410": {
            "description": "The challenge expired; register again: {error}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Too many registration attempts from this address this hour: {error}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/.well-known/mcp.json": {
      "get": {
        "summary": "MCP server card: name, description, URL, transport, tools. Also at /mcp/server-card",
        "responses": {
          "200": {
            "description": "Server card",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/.well-known/ai-catalog.json": {
      "get": {
        "summary": "AI catalog: {version, servers: [the MCP server card]}",
        "responses": {
          "200": {
            "description": "Catalog",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/open-data": {
      "get": {
        "summary": "Sources and licenses of the open data that can appear in listings (Overture Maps Places), the changes we make, and how a business corrects a fact",
        "responses": {
          "200": {
            "description": "Markdown",
            "content": {
              "text/markdown": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/oauth/authorize": {
      "get": {
        "summary": "For assistants' connectors (public OAuth clients): the one-click Allow page. The person sees one page with an Allow button and no account; Allow creates an install, Truvon's pseudonym for that one connector, and redirects back with a code. A client or redirect URI Truvon does not recognize is shown on the page and never redirected to.",
        "parameters": [
          {
            "name": "response_type",
            "in": "query",
            "required": true,
            "description": "code",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "client_id",
            "in": "query",
            "required": true,
            "description": "A client_id from POST /oauth/register, or the https URL of the client's Client ID Metadata Document",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "redirect_uri",
            "in": "query",
            "required": true,
            "description": "Exactly one the client registered",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "code_challenge",
            "in": "query",
            "required": true,
            "description": "PKCE: base64url SHA-256 of the code_verifier",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "code_challenge_method",
            "in": "query",
            "required": true,
            "description": "S256",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "state",
            "in": "query",
            "required": false,
            "description": "Returned unchanged",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "resource",
            "in": "query",
            "required": false,
            "description": "Optional; if sent, https://truvon.ai/mcp",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The Allow page (HTML)"
          },
          "302": {
            "description": "Back to redirect_uri with an error (unsupported_response_type, invalid_request, invalid_target)"
          },
          "400": {
            "description": "Unknown client or unregistered redirect URI (HTML; never redirected)"
          }
        }
      },
      "post": {
        "summary": "The Allow page's form (same origin only). Allow creates the install and redirects to redirect_uri with code, state and iss; Cancel redirects with error=access_denied.",
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "required": [
                  "request",
                  "decision"
                ],
                "properties": {
                  "request": {
                    "type": "string"
                  },
                  "decision": {
                    "type": "string",
                    "enum": [
                      "allow",
                      "deny"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "302": {
            "description": "Back to the client's redirect_uri"
          },
          "400": {
            "description": "The page expired (HTML)"
          },
          "403": {
            "description": "Not from Truvon's own page (HTML)"
          }
        }
      }
    },
    "/tools.json": {
      "get": {
        "summary": "Tool definitions for model APIs: the formats and their addresses",
        "description": "Truvon's agent tools (find_local_businesses, more_results, get_business_record, get_field_history, report_outcome, report_answer, answer_follow_up, get_job) as tool definitions for the OpenAI, Claude and Gemini APIs, generated from the MCP tool list. People in assistant apps connect through /mcp instead.",
        "responses": {
          "200": {
            "description": "about, mcp, formats (format to address), tools, docs"
          }
        }
      }
    },
    "/tools/{format}.json": {
      "get": {
        "summary": "Tool definitions in one API's shape",
        "description": "format: openai (Responses API function tools), anthropic (Messages API tools), gemini (functionDeclarations). Returns how_to_use, tools in that shape, calls (for each tool the HTTP method and URL to call when the model calls it), instructions (the same the MCP connector gives) and docs.",
        "parameters": [
          {
            "name": "format",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "openai",
                "anthropic",
                "gemini"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "format, how_to_use, tools, calls, instructions, docs"
          }
        }
      }
    },
    "/api/connect": {
      "get": {
        "operationId": "connectHttp",
        "summary": "The HTTP loop for an agent that can only fetch a URL: a link for your user to tap Allow on (24 hours, no account) and a connection code for you, which then rides as ?connection= on the report URLs for 30 days. Searching needs none of this.",
        "parameters": [
          {
            "name": "client",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Your assistant or product name, for Truvon's own records (never shown to the person)"
          }
        ],
        "responses": {
          "200": {
            "description": "link, connection, expires_in, next (what to do with them)"
          },
          "429": {
            "description": "Too many links from this network this hour"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "orgKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "Org key, Authorization: Bearer tvo_… . Returned once by POST /api/v1/orgs/{id}/verify. It creates feeds and feed keys, and also works anywhere a feed key does."
      },
      "feedKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "Feed key, Authorization: Bearer tvf_… . Returned once when a feed is created or a key is rotated. Good only for its own feed."
      },
      "dpop": {
        "type": "http",
        "scheme": "DPoP",
        "description": "MCP OAuth (RFC 9449): Authorization: DPoP <access_token> from POST /oauth/token, plus a DPoP header carrying a fresh proof JWT for this request (htm, htu, iat, jti, and ath = base64url SHA-256 of the token). Only for /mcp. Optional: /mcp stays open without it, but a token that is sent must verify."
      },
      "webBotAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "Signature",
        "description": "Web Bot Auth: an HTTP message signature (RFC 9421, Ed25519) carried in the Signature, Signature-Input and Signature-Agent headers, tag=\"web-bot-auth\", covering at least @authority and signature-agent, with created, expires and keyid (the RFC 7638 thumbprint of a key in https://{your-domain}/.well-known/http-message-signatures-directory). It verifies only when that domain is registered (POST /api/platforms/register). OpenAPI has no scheme for message signatures, so this entry stands for the three headers."
      },
      "adminKey": {
        "type": "apiKey",
        "in": "header",
        "name": "x-admin-auth",
        "description": "Truvon operator key. Temporary: business-side job updates take only this key until owner, door and feed sources exist."
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "What went wrong, in plain words"
          }
        },
        "description": "Some errors add fields, such as support (an email) or reason (a short code)."
      },
      "OAuthError": {
        "type": "object",
        "required": [
          "error",
          "error_description"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "OAuth error code"
          },
          "error_description": {
            "type": "string",
            "description": "What went wrong, in plain words"
          }
        }
      },
      "TxtRecord": {
        "type": "object",
        "required": [
          "type",
          "name",
          "value"
        ],
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "TXT"
            ]
          },
          "name": {
            "type": "string",
            "description": "Where to publish it, e.g. _truvon.yourdomain.com"
          },
          "value": {
            "type": "string",
            "description": "The exact value to publish"
          }
        }
      },
      "FeedListing": {
        "type": "object",
        "required": [
          "id",
          "name",
          "url"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Your own id for this listing, 1 to 128 characters, unique within a request"
          },
          "name": {
            "type": "string"
          },
          "url": {
            "type": "string",
            "description": "The listing's own web address. A brand sends places on its own domain; a platform sends merchants on their own sites, which must link to the platform.",
            "format": "uri"
          },
          "phone": {
            "type": "string"
          },
          "address": {
            "type": "object",
            "properties": {
              "street": {
                "type": "string"
              },
              "locality": {
                "type": "string"
              },
              "region": {
                "type": "string"
              },
              "postal": {
                "type": "string"
              },
              "country": {
                "type": "string"
              }
            }
          },
          "hours": {
            "description": "Text (\"Mo-Fr 09:00-17:00\") or schema.org openingHoursSpecification"
          },
          "services": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "booking_url": {
            "type": "string",
            "format": "uri"
          },
          "order_url": {
            "type": "string",
            "format": "uri"
          },
          "quote_url": {
            "type": "string",
            "format": "uri"
          },
          "service_area": {
            "description": "Where the business serves"
          },
          "description": {
            "type": "string"
          }
        }
      },
      "FeedRun": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "Run id, frun_…"
          },
          "feed_id": {
            "type": "string"
          },
          "at": {
            "type": "string",
            "format": "date-time"
          },
          "mode": {
            "type": "string",
            "enum": [
              "push",
              "pull"
            ]
          },
          "status": {
            "type": "string",
            "description": "running while a push is being processed; the counts below are final once it is no longer running"
          },
          "full_snapshot": {
            "type": "integer",
            "enum": [
              0,
              1
            ],
            "description": "1 when the run was the whole list (full)"
          },
          "received": {
            "type": "integer"
          },
          "created": {
            "type": "integer"
          },
          "updated": {
            "type": "integer"
          },
          "unchanged": {
            "type": "integer"
          },
          "rejected": {
            "type": "integer"
          },
          "pending_link": {
            "type": "integer",
            "description": "Merchants whose site we are reading to confirm it links to the platform"
          },
          "conflicts": {
            "type": "integer",
            "description": "Facts where the feed and the site differ; agents see both"
          },
          "not_confirmed": {
            "type": "integer",
            "description": "Listings missing from a full feed: marked not confirmed and rechecked, never deleted"
          },
          "rejections": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string"
                },
                "reason": {
                  "type": "string"
                }
              }
            },
            "description": "Up to 50"
          }
        }
      },
      "ClaimStart": {
        "type": "object",
        "required": [
          "business",
          "method",
          "email",
          "accept_terms"
        ],
        "properties": {
          "business": {
            "type": "string",
            "description": "The listing's domain",
            "example": "yourdomain.com"
          },
          "method": {
            "type": "string",
            "description": "dns_txt: publish a TXT record. email_on_domain: a 6-digit code sent to an address at the domain. phone_listed: a 6-digit code texted or read aloud to the phone number on the listing (from the business's own site, owner or feed; never open data alone, never a number you supply; US and Canada only).",
            "enum": [
              "dns_txt",
              "email_on_domain",
              "phone_listed"
            ]
          },
          "email": {
            "type": "string",
            "description": "A working address; for email_on_domain it must be at the business's domain",
            "format": "email"
          },
          "channel": {
            "type": "string",
            "enum": [
              "sms",
              "call"
            ],
            "description": "phone_listed only, required: sms or call (a landline needs call)"
          },
          "accept_terms": {
            "type": "boolean",
            "description": "Required, true: the owner agrees that what they confirm or add is published on the listing for anyone and any AI agent to read, stays theirs, and that they hold Truvon harmless for it (truvon.ai/terms, Business owners). Recorded on the claim."
          }
        }
      },
      "ClaimStarted": {
        "type": "object",
        "required": [
          "claim",
          "purpose",
          "method",
          "expires_at",
          "next"
        ],
        "properties": {
          "claim": {
            "type": "string",
            "description": "Claim id, claim_…"
          },
          "purpose": {
            "type": "string",
            "enum": [
              "claim",
              "opt_out"
            ]
          },
          "method": {
            "type": "string",
            "enum": [
              "dns_txt",
              "email_on_domain",
              "phone_listed"
            ]
          },
          "expires_at": {
            "type": "string",
            "format": "date-time"
          },
          "record": {
            "$ref": "#/components/schemas/TxtRecord"
          },
          "sent_to": {
            "type": "string",
            "description": "email_on_domain: the address. phone_listed: \"the listed number ending in NN\"."
          },
          "next": {
            "type": "string"
          },
          "channel": {
            "type": "string",
            "enum": [
              "sms",
              "call"
            ],
            "description": "phone_listed only"
          }
        },
        "description": "dns_txt returns record; email_on_domain and phone_listed return sent_to."
      },
      "ClaimCheck": {
        "type": "object",
        "required": [
          "claim"
        ],
        "properties": {
          "claim": {
            "type": "string",
            "description": "The claim id from the start call"
          },
          "code": {
            "type": "string",
            "description": "The 6-digit code (email_on_domain and phone_listed)"
          }
        }
      },
      "Assertion": {
        "type": "string",
        "description": "Truvon agent assertion (identity protocol §4): a compact JWS, alg EdDSA, kid in your key directory, claims iss, aud, sub, iat, exp, jti (sid, act, user_present, consent optional), lifetime at most 600 seconds, jti single use. Accepted only on a Web Bot Auth signed request."
      },
      "Job": {
        "type": "object",
        "properties": {
          "job": {
            "type": "string",
            "description": "job_…"
          },
          "business": {
            "type": "string"
          },
          "domain": {
            "type": "string"
          },
          "kind": {
            "type": "string",
            "enum": [
              "book",
              "order",
              "quote"
            ]
          },
          "status": {
            "type": "string",
            "description": "pending or confirmed when arranged, then the latest update",
            "enum": [
              "pending",
              "confirmed",
              "cancelled",
              "rescheduled",
              "delayed",
              "ready",
              "needs_info",
              "requote",
              "completed"
            ]
          },
          "scheduled_for": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "history": {
            "type": "array",
            "description": "Oldest first",
            "items": {
              "type": "object",
              "properties": {
                "update": {
                  "type": "string",
                  "description": "ju_…"
                },
                "at": {
                  "type": "string",
                  "format": "date-time"
                },
                "status": {
                  "type": "string"
                },
                "fields": {
                  "type": "object"
                },
                "text": {
                  "type": "string"
                },
                "source": {
                  "type": "string",
                  "enum": [
                    "door",
                    "owner",
                    "feed",
                    "business_webhook",
                    "admin"
                  ]
                }
              }
            }
          }
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "The request is missing something or a value is not allowed: {error}",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Missing or wrong credentials: {error}",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "No such thing: {error}",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Locked": {
        "description": "Locked after too many wrong codes: {claim, status: locked, reason}. Start again for a new code.",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "claim": {
                  "type": "string"
                },
                "status": {
                  "type": "string",
                  "enum": [
                    "locked"
                  ]
                },
                "reason": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "TooMany": {
        "description": "Too many attempts from this connection; try again in an hour: {error}",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "AssertionRejected": {
        "description": "A Truvon-Agent-Assertion was sent and failed: {error, reason}. reason is one of missing, malformed, bad_alg, platform_not_signed, platform_not_authenticated, iss_mismatch, aud_mismatch, bad_sub, missing_claim, lifetime_too_long, expired, not_yet_valid, unknown_key, bad_signature, replayed. Nothing is recorded.",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "error",
                "reason"
              ],
              "properties": {
                "error": {
                  "type": "string"
                },
                "reason": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "OrgLapsed": {
        "description": "The key is right, but the organization lapsed: its TXT record was gone at our monthly check, so its keys are refused and its feeds paused. {error, reason: org_lapsed, lapsed_at, record}. Publish the record again, POST /api/v1/orgs/{id}/verify, then POST /api/v1/feeds/{id}/resume. Also returned when the organization is not verified.",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "error"
              ],
              "properties": {
                "error": {
                  "type": "string",
                  "description": "The domain's TXT record is gone; publish it again and POST /api/v1/orgs/{id}/verify"
                },
                "reason": {
                  "type": "string",
                  "enum": [
                    "org_lapsed"
                  ]
                },
                "lapsed_at": {
                  "type": "string",
                  "format": "date-time"
                },
                "record": {
                  "type": "object",
                  "properties": {
                    "type": {
                      "type": "string"
                    },
                    "name": {
                      "type": "string"
                    },
                    "value": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "parameters": {
      "AgentAssertion": {
        "name": "Truvon-Agent-Assertion",
        "in": "header",
        "required": false,
        "description": "Platforms only, on a Web Bot Auth signed request: binds the job and follow-up to your user's pairwise sub, never a person. Without it nothing is bound and pull still works. One that fails is rejected (401), never ignored.",
        "schema": {
          "$ref": "#/components/schemas/Assertion"
        }
      }
    }
  }
}
