{
  "openapi": "3.1.0",
  "info": {
    "title": "Bostad Semantic",
    "version": "5.2.2",
    "summary": "Open semantic search over the indexed Swedish real estate market.",
    "description": "Free-text buyer intent + graded field preferences in; matched broker-listing URLs + similarity scores out. Raw listing data stays inside Bostad: a hit carries the listing URL, a similarity score and evidence relative to your own query (the cohort decile of each field you sent, a strong/weak/none bucket per wish), never a field value — the agent is always routed back to the broker's own listing page. Read-only by contract: every endpoint accepts GET (plus HEAD, and OPTIONS for CORS preflight) and nothing else; write methods (POST/PUT/PATCH/DELETE) are rejected at the network edge (expect 403, not 405). Send all parameters in the query string; there is no request body. No API key, no authentication. Present listing_url values exactly as returned: the utm_* parameters are the referral attribution and must not be stripped; when surfacing results, attribute the search to Bostad Semantic (bostad.io). The MIT license (info.license) covers this OpenAPI document itself — © Galobia AB, canonical at https://bostad.io/.well-known/openapi.json, redistribute with attribution. It does not license the API service or its data: service use is governed by the terms of use at info.termsOfService. Quickstart (two GET calls): first GET https://api.bostad.io/v1/semantic/coverage to read the live cohorts, then GET https://api.bostad.io/v1/semantic/search?m=<m_slug>&tenure=<t_slug>&living_area=highest&q_like=bastu with m_slug and t_slug from one row of that response's cohorts; non-ASCII query values are percent-encoded on the wire (sjöutsikt is sent as sj%C3%B6utsikt). A tool that cannot open a URL it built itself starts at GET https://api.bostad.io/v1/semantic/find instead and copies the URLs it prints; the trail ends in a finished search URL. Graded field values are named cohort quintiles (lowest/low/mid/high/highest — positions in the cohort's distribution, never importance weights; bare integers are rejected with a teaching 400 naming the token). Changes: parameters and response fields can be added, changed or removed; every change is listed with its info.version in the spec changelog at https://github.com/bostad-io/swedish-real-estate-api/blob/main/spec/CHANGELOG.md.",
    "contact": { "name": "Bostad.io", "email": "hello@bostad.io", "url": "https://bostad.io/for-agents" },
    "termsOfService": "https://bostad.io/terms",
    "license": { "name": "MIT", "identifier": "MIT" },
    "x-status": "available",
    "x-rate-limit": "120 requests/hour per IP",
    "x-cors": "Access-Control-Allow-Origin: * on every /v1/ response (credential-less) — callable directly from browser-resident JavaScript on any origin. CORS preflight (OPTIONS) does not count against x-rate-limit. Every /v1/ response also carries Link: rel=\"service-desc\" pointing at this document (RFC 8288).",
    "x-privacy-policy-url": "https://bostad.io/privacy"
  },
  "externalDocs": {
    "description": "The complete, self-contained spec: endpoints, the full field catalog, the free-text wish grammar, response shape, and a worked example, all in one document.",
    "url": "https://bostad.io/for-agents"
  },
  "servers": [{ "url": "https://api.bostad.io", "description": "Bostad Semantic API host (its own subdomain; the API is not served from bostad.io)." }],
  "tags": [{ "name": "Bostad Semantic™", "description": "Open semantic search over active Swedish broker listings." }],
  "paths": {
    "/v1/semantic/search": {
      "get": {
        "operationId": "semanticSearch",
        "summary": "Bostad Semantic search",
        "description": "Semantic query over the index. Scope every query to a covered cohort (m + tenure — see /v1/semantic/coverage). Add free-text wishes (q_like/q_dislike) and/or graded field preferences (any field code = lowest/low/mid/high/highest, a named cohort-relative quintile — a position, never an importance weight). Returns cohorts — one per requested (m, tenure) pair, each with up to 5 { similarity, listing_url, evidence } hits ranked inside that cohort — plus a query_echo; evidence explains each hit relative to the caller's own query (per requested field: the preference token + the listing's cohort decile; per q item: a strong/weak/none match bucket). At least one preference (a field code, q_like, or q_dislike) is required. Field preferences that don't apply to the searched tenure(s) are dropped and echoed back under not_applicable; uncovered cohort values come back under not_covered.",
        "tags": ["Bostad Semantic™"],
        "externalDocs": { "url": "https://bostad.io/for-agents" },
        "parameters": [
          { "name": "m", "in": "query", "required": true, "description": "Municipality/-ies scoping the search (mandatory). Several are separated by | (sent as %7C), a comma, or a repeated m. A value matches a /v1/semantic/coverage name in any letter case and with or without Swedish letters, so the m_slug from coverage works as is (e.g. upplands-vasby).", "style": "form", "explode": true, "schema": { "type": "array", "items": { "type": "string" }, "minItems": 1 } },
          { "name": "tenure", "in": "query", "required": false, "description": "Tenure/-s (upplåtelseform) scoping the search. Mandatory: send it as tenure, or as its permanent alias t. Prefer tenure, because some agents' fetchers drop a parameter named t. Several are separated by | (sent as %7C), a comma, or a repeated tenure. The six canonical values are Äganderätt, Bostadsrätt, Hyresrätt, Tomträtt, Arrende and Annat. A value matches in any letter case and with or without Swedish letters, so the t_slug from coverage works as is (e.g. bostadsratt), and it also matches its English name (freehold, condo, rental, leasehold). Which tenures currently have live supply is machine-readable at GET /v1/semantic/coverage. If some requested tenures are covered, the search runs on those and any uncovered ones are echoed under not_covered (200); only if none of the requested tenures are covered does the request return 400 with code COHORT_NOT_COVERED and the covered set under available/did_you_mean.", "style": "form", "explode": true, "schema": { "type": "array", "items": { "type": "string" }, "minItems": 1 } },
          { "name": "t", "in": "query", "required": false, "description": "The same parameter as tenure, accepted permanently; values sent as t and as tenure are merged. One of the two is mandatory.", "style": "form", "explode": true, "schema": { "type": "array", "items": { "type": "string" }, "minItems": 1 } },
          { "name": "q_like", "in": "query", "required": false, "description": "Free-text feature wishes, one concept per item (e.g. 'bastu', 'sjöutsikt'); separate several items with | inside one parameter (bastu|sjöutsikt) or repeat the parameter, never join them with a comma; at most 16 items combined with q_dislike, each up to 120 chars. Any phrase is legal — wishes are scored against the Swedish feature tags the listings are indexed with, so short, concrete Swedish feature terms score most precisely.", "style": "form", "explode": true, "schema": { "type": "array", "items": { "$ref": "#/components/schemas/QItem" } } },
          { "name": "q_dislike", "in": "query", "required": false, "description": "Free-text feature rejections, one concept per item, separated by | or repeated as for q_like; at most 16 items combined with q_like, each up to 120 chars. Same rules as q_like: any phrase is legal, and short, concrete Swedish feature terms score most precisely.", "style": "form", "explode": true, "schema": { "type": "array", "items": { "$ref": "#/components/schemas/QItem" } } },
          { "name": "near", "in": "query", "required": false, "description": "WGS84 reference point '<lat>,<lon>' for the distance rank field (e.g. near=59.334,18.063). Validated against a rough Sweden bounding box (lat 55.0–69.1, lon 10.5–24.2) — a point outside it 400s (catches a swapped lat/lon). Pairs with distance=<lowest..highest>: near without distance, or distance without near, returns 400 INVALID_QUERY.", "schema": { "type": "string", "pattern": "^-?\\d+(\\.\\d+)?,-?\\d+(\\.\\d+)?$" } },

          { "name": "asking_price", "in": "query", "required": false, "description": "Begärt pris: lowest = billigast, highest = dyrast.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "operating_cost", "in": "query", "required": false, "description": "Driftskostnad: lowest = lägst driftskostnad, highest = högst driftskostnad.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "estimated_annual_cost", "in": "query", "required": false, "description": "Beräknad årskostnad: lowest = lägst årskostnad, highest = högst årskostnad.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "fees_monthly_fee", "in": "query", "required": false, "description": "Månadsavgift: lowest = lägst avgift, highest = högst avgift. Applies only to tenure: Bostadsrätt.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "electricity_consumption_kwh", "in": "query", "required": false, "description": "Elförbrukning: lowest = lägst elförbrukning, highest = högst elförbrukning.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "living_area", "in": "query", "required": false, "description": "Boarea: lowest = minst, highest = störst.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "secondary_area", "in": "query", "required": false, "description": "Biarea: lowest = minst biarea, highest = störst biarea.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "rooms", "in": "query", "required": false, "description": "Antal rum: lowest = färst rum, highest = flest rum.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "bedrooms", "in": "query", "required": false, "description": "Antal sovrum: lowest = färst sovrum, highest = flest sovrum.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "bathrooms", "in": "query", "required": false, "description": "Antal badrum: lowest = färst badrum, highest = flest badrum.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "floor", "in": "query", "required": false, "description": "Våningsplan: lowest = längst ner i huset, highest = högst upp i huset.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "floor_count", "in": "query", "required": false, "description": "Antal plan: lowest = färst plan, highest = flest plan.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "interior_ceiling_height", "in": "query", "required": false, "description": "Takhöjd: lowest = lägst i tak, highest = högst i tak.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "interior_fireplace_count", "in": "query", "required": false, "description": "Antal eldstäder: lowest = färst eldstäder, highest = flest eldstäder.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "building_build_year", "in": "query", "required": false, "description": "Byggnadsår: lowest = äldst, highest = nyast.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "building_last_renovation_year", "in": "query", "required": false, "description": "Senaste renovering: lowest = längst sedan renovering, highest = senast renoverad.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "building_kitchen_renovation_year", "in": "query", "required": false, "description": "Kök renoverat: lowest = äldst kök, highest = nyast kök.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "building_bathroom_renovation_year", "in": "query", "required": false, "description": "Badrum renoverat: lowest = äldst badrum, highest = nyast badrum.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "exterior_balcony_sqm", "in": "query", "required": false, "description": "Balkongarea: lowest = minst balkong, highest = störst balkong.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "exterior_terrace_sqm", "in": "query", "required": false, "description": "Terrassarea: lowest = minst terrass, highest = störst terrass.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "exterior_garage_spaces", "in": "query", "required": false, "description": "Antal garageplatser: lowest = färst garageplatser, highest = flest garageplatser.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "plot_plot_area", "in": "query", "required": false, "description": "Tomtarea: lowest = minst tomt, highest = störst tomt. Applies only to tenures: Tomträtt, Äganderätt.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "plot_buildable_area", "in": "query", "required": false, "description": "Byggbar area: lowest = minst byggbar area, highest = störst byggbar area. Applies only to tenures: Tomträtt, Äganderätt.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "plot_outbuildings_area", "in": "query", "required": false, "description": "Komplementbyggnad area: lowest = minst, highest = störst. Applies only to tenures: Tomträtt, Äganderätt.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "energy_primary_energy", "in": "query", "required": false, "description": "Primärenergital: lowest = mest energieffektiv, highest = minst energieffektiv.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "water_distance", "in": "query", "required": false, "description": "Avstånd till vatten: lowest = närmast vatten, highest = längst från vatten.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_grundskola", "in": "query", "required": false, "description": "Avstånd till grundskola: lowest = närmast grundskola, highest = längst från grundskola.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_mataffar", "in": "query", "required": false, "description": "Avstånd till mataffär: lowest = närmast mataffär, highest = längst från mataffär.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_forskola", "in": "query", "required": false, "description": "Avstånd till förskola: lowest = närmast förskola, highest = längst från förskola.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_tagstation", "in": "query", "required": false, "description": "Avstånd till tågstation: lowest = närmast tågstation, highest = längst från tågstation.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_busshallplats", "in": "query", "required": false, "description": "Avstånd till busshållplats: lowest = närmast busshållplats, highest = längst från busshållplats.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_badplats", "in": "query", "required": false, "description": "Avstånd till badplats: lowest = närmast badplats, highest = längst från badplats.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_skidanlaggning", "in": "query", "required": false, "description": "Avstånd till skidanläggning: lowest = närmast skidanläggning, highest = längst från skidanläggning.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_vardcentral", "in": "query", "required": false, "description": "Avstånd till vårdcentral: lowest = närmast vårdcentral, highest = längst från vårdcentral.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_apotek", "in": "query", "required": false, "description": "Avstånd till apotek: lowest = närmast apotek, highest = längst från apotek.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_restaurang", "in": "query", "required": false, "description": "Avstånd till restaurang: lowest = närmast restaurang, highest = längst från restaurang.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_park", "in": "query", "required": false, "description": "Avstånd till park: lowest = närmast park, highest = längst från park.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_gym", "in": "query", "required": false, "description": "Avstånd till gym: lowest = närmast gym, highest = längst från gym.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_bibliotek", "in": "query", "required": false, "description": "Avstånd till bibliotek: lowest = närmast bibliotek, highest = längst från bibliotek.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_golfbana", "in": "query", "required": false, "description": "Avstånd till golfbana: lowest = närmast golfbana, highest = längst från golfbana.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_gymnasium", "in": "query", "required": false, "description": "Avstånd till gymnasium: lowest = närmast gymnasium, highest = längst från gymnasium.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_aktivitetspark", "in": "query", "required": false, "description": "Avstånd till aktivitetspark: lowest = närmast aktivitetspark, highest = längst från aktivitetspark.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_besokspark", "in": "query", "required": false, "description": "Avstånd till besökspark: lowest = närmast besökspark, highest = längst från besökspark.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_koloniomrade", "in": "query", "required": false, "description": "Avstånd till koloniområde: lowest = närmast koloniområde, highest = längst från koloniområde.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_idrottsanlaggning", "in": "query", "required": false, "description": "Avstånd till idrottsanläggning: lowest = närmast idrottsanläggning, highest = längst från idrottsanläggning.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_smabatshamn", "in": "query", "required": false, "description": "Avstånd till småbåtshamn: lowest = närmast småbåtshamn, highest = längst från småbåtshamn.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_campingplats", "in": "query", "required": false, "description": "Avstånd till campingplats: lowest = närmast campingplats, highest = längst från campingplats.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_elljusspar", "in": "query", "required": false, "description": "Avstånd till elljusspår: lowest = närmast elljusspår, highest = längst från elljusspår.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_vandringsled", "in": "query", "required": false, "description": "Avstånd till vandringsled: lowest = närmast vandringsled, highest = längst från vandringsled.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_cykelvag", "in": "query", "required": false, "description": "Avstånd till cykelväg: lowest = närmast cykelväg, highest = längst från cykelväg.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_naturreservat", "in": "query", "required": false, "description": "Avstånd till naturreservat: lowest = närmast naturreservat, highest = längst från naturreservat.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_vattenfall", "in": "query", "required": false, "description": "Avstånd till vattenfall: lowest = närmast vattenfall, highest = längst från vattenfall.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_kyrka", "in": "query", "required": false, "description": "Avstånd till kyrka: lowest = närmast kyrka, highest = längst från kyrka.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_slott", "in": "query", "required": false, "description": "Avstånd till slott: lowest = närmast slott, highest = längst från slott.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_kulturminne", "in": "query", "required": false, "description": "Avstånd till kulturminne: lowest = närmast kulturminne, highest = längst från kulturminne.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_vindskydd", "in": "query", "required": false, "description": "Avstånd till vindskydd: lowest = närmast vindskydd, highest = längst från vindskydd.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_rastplats", "in": "query", "required": false, "description": "Avstånd till rastplats: lowest = närmast rastplats, highest = längst från rastplats.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_sjukhus", "in": "query", "required": false, "description": "Avstånd till sjukhus: lowest = närmast sjukhus, highest = längst från sjukhus.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_begravningsplats", "in": "query", "required": false, "description": "Avstånd till begravningsplats: lowest = närmast begravningsplats, highest = längst från begravningsplats.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_motorvag", "in": "query", "required": false, "description": "Avstånd till motorväg: lowest = närmast motorväg, highest = längst från motorväg.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_jarnvag", "in": "query", "required": false, "description": "Avstånd till järnväg: lowest = närmast järnväg, highest = längst från järnväg.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_kraftledning", "in": "query", "required": false, "description": "Avstånd till kraftledning: lowest = närmast kraftledning, highest = längst från kraftledning.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_skjutbana", "in": "query", "required": false, "description": "Avstånd till skjutbana: lowest = närmast skjutbana, highest = längst från skjutbana.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_flygplats", "in": "query", "required": false, "description": "Avstånd till flygplats: lowest = närmast flygplats, highest = längst från flygplats.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_industriomrade", "in": "query", "required": false, "description": "Avstånd till industriområde: lowest = närmast industriområde, highest = längst från industriområde.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_vindkraftverk", "in": "query", "required": false, "description": "Avstånd till vindkraftverk: lowest = närmast vindkraftverk, highest = längst från vindkraftverk.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_takt", "in": "query", "required": false, "description": "Avstånd till täkt: lowest = närmast täkt, highest = längst från täkt.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_motorsportbana", "in": "query", "required": false, "description": "Avstånd till motorsportbana: lowest = närmast motorsportbana, highest = längst från motorsportbana.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_gruvhal", "in": "query", "required": false, "description": "Avstånd till gruvhål: lowest = närmast gruvhål, highest = längst från gruvhål.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "nearby_transformatorstation", "in": "query", "required": false, "description": "Avstånd till transformatorstation: lowest = närmast transformatorstation, highest = längst från transformatorstation.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "finances_debt_per_sqm_brf_area", "in": "query", "required": false, "description": "Skuldsättning per kvm upplåten med bostadsrätt: lowest = lägst skuld/kvm, highest = högst skuld/kvm. Applies only to tenure: Bostadsrätt.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "finances_savings_per_sqm", "in": "query", "required": false, "description": "Sparande/kvm: lowest = lägst sparande/kvm, highest = högst sparande/kvm. Applies only to tenure: Bostadsrätt.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "finances_equity_ratio", "in": "query", "required": false, "description": "Soliditet: lowest = lägst soliditet, highest = högst soliditet. Applies only to tenure: Bostadsrätt.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "finances_avg_interest_rate", "in": "query", "required": false, "description": "Snittränta: lowest = lägst snittränta, highest = högst snittränta. Applies only to tenure: Bostadsrätt.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "days_on_market", "in": "query", "required": false, "description": "Liggtid (derived): lowest = nyast på marknaden, highest = längst till salu.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "price_per_sqm", "in": "query", "required": false, "description": "Kvadratmeterpris (derived): lowest = lägst kr/kvm, highest = högst kr/kvm.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "operating_cost_per_sqm", "in": "query", "required": false, "description": "Driftskostnad per kvm (derived): lowest = lägst drift/kvm, highest = högst drift/kvm.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "fee_per_sqm", "in": "query", "required": false, "description": "Avgift per kvm (derived): lowest = lägst avgift/kvm, highest = högst avgift/kvm. Applies only to tenure: Bostadsrätt.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "price_to_assessment", "in": "query", "required": false, "description": "Pris/taxeringsvärde (K/T) (derived): lowest = lägst pris mot taxeringsvärdet, highest = högst pris mot taxeringsvärdet. Applies only to tenures: Tomträtt, Äganderätt.", "schema": { "$ref": "#/components/schemas/Quintile" } },
          { "name": "distance", "in": "query", "required": false, "description": "Avstånd till punkt (derived, request-time): lowest = närmast punkten, highest = längst från punkten. The percentile is computed per request from the near reference point — requires near=<lat>,<lon> (see the near parameter). Applies to all tenures.", "schema": { "$ref": "#/components/schemas/Quintile" } }
        ],
        "responses": {
          "200": {
            "description": "Matched listings grouped by requested cohort (up to 5 per cohort), the parsed request echoed back, and any uncovered/inapplicable inputs. Also reports this response's own server-side cost (latency_ms) and your remaining request budget (rate_limit, mirrored in the RateLimit-* headers).",
            "headers": {
              "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" },
              "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" },
              "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }
            },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SearchResponse" } } }
          },
          "400": {
            "description": "Unknown parameter, an invalid preference value (bare integers included — the 400 names the matching token; valid_values lists the token enum), no preference supplied, near/distance sent without its pair (or near malformed/outside Sweden), or an uncovered cohort. The body's detail is agent-readable and self-correcting: valid_parameters/valid_values echo the accepted enums, did_you_mean maps each unmatched input to the nearest accepted value (advisory — retry with the suggested name), and not_covered/available/not_applicable are included where relevant.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "429": {
            "description": "Rate limit exceeded (120 requests/hour per IP). The body carries the same rate_limit object the 200 path returns (remaining will be 0) plus _links; Retry-After gives the backoff in seconds.",
            "headers": {
              "Retry-After": { "description": "Seconds to wait before retrying.", "schema": { "type": "integer" } },
              "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" },
              "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" },
              "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["detail", "rate_limit"],
                  "properties": {
                    "detail": { "type": "string", "description": "Human-readable throttle message." },
                    "rate_limit": { "$ref": "#/components/schemas/RateLimit" },
                    "_links": { "$ref": "#/components/schemas/Links" }
                  }
                }
              }
            }
          },
          "503": {
            "description": "The embedding service is temporarily unavailable — a transient upstream dependency. Back off and retry after the Retry-After interval (agents should treat this as retryable, not terminal).",
            "headers": {
              "Retry-After": { "description": "Seconds to wait before retrying (transient upstream outage).", "schema": { "type": "integer" } }
            },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          }
        }
      }
    },
    "/v1/semantic/coverage": {
      "get": {
        "operationId": "semanticCoverage",
        "summary": "Bostad Semantic coverage",
        "description": "The live queryable cohorts — the municipalities and tenures that currently have active, embedded listings, and in cohorts each (municipality, tenure) pair that search answers with listings. Pick a covered pair before calling search; its m_slug and t_slug are copy-safe ASCII that search accepts as is.",
        "tags": ["Bostad Semantic™"],
        "externalDocs": { "url": "https://bostad.io/for-agents" },
        "responses": {
          "200": {
            "description": "The currently queryable municipalities and tenures, and the covered pairs. The RateLimit-* headers report the budget shared with /v1/semantic/search, so a caller learns the ceiling on its first, cheapest call. The body itself is conditional-GET cacheable (ETag/Last-Modified) and therefore carries no per-caller fields.",
            "headers": {
              "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" },
              "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" },
              "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }
            },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Coverage" } } }
          }
        }
      }
    },
    "/v1/semantic/find": {
      "get": {
        "operationId": "semanticFind",
        "summary": "Bostad Semantic find trail: start page",
        "description": "The first page of the find trail, for a tool that opens only an address it has already seen and refuses one it assembled itself. The trail is the search written as a path: search?m=A&tenure=B&field=position is find/A/B/field/position, several municipalities or tenures joined with a comma as search accepts them. Each page prints only what has supply today (municipalities, then tenures with supply there, then the fields that apply to the chosen tenures) as full addresses to copy, and a search node prints the finished search address first. This page lists one address per municipality with supply. If your tool can open an address you write yourself, call /v1/semantic/search directly instead; free-text wishes (q_like, q_dislike) and a map point (near, distance) exist only there, because a page cannot print your words.",
        "tags": [
          "Bostad Semantic™"
        ],
        "externalDocs": {
          "url": "https://bostad.io/for-agents"
        },
        "responses": {
          "200": {
            "description": "A trail page: UTF-8 plain text, English, one label per line with the bare absolute address on the line below it. Every address is ASCII. The RateLimit-* headers report the budget shared with search and coverage; the body is conditional-GET cacheable (ETag, Last-Modified) and carries X-Robots-Tag: noindex, nofollow.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            },
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "304": {
            "description": "Not modified (If-None-Match or If-Modified-Since matched)."
          },
          "429": {
            "description": "Rate limit exceeded (120 requests/hour per IP); the budget is the one search and coverage share. Retry-After gives the backoff in seconds.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            }
          }
        }
      }
    },
    "/v1/semantic/find/{trail}": {
      "get": {
        "operationId": "semanticFindNode",
        "summary": "Bostad Semantic find trail: a node",
        "description": "One page of the trail. The trail path is <m_slug>[,<m_slug>...]/<t_slug>[,<t_slug>...][/<field>/<position>...] with, optionally, a trailing /<field> (pick a position for that field, one address per position) or a trailing /nearby (the proximity fields, distance to the nearest of a kind). A municipality or tenure page lists what has supply; a cohort page lists the fields that apply and offers no search, because a cohort-only search is 400; a search node prints the finished /v1/semantic/search address first, then the fields that can still be added, then municipalities and tenures to widen the scope, then up one level. Every search has exactly one trail address: municipalities and tenures sorted by slug, fields in the catalog order. Any other spelling, order or duplicate, a name in another letter case or with Swedish letters, or an English tenure name is redirected (301) to that address, as search resolves names. A near miss is a 404 page with did-you-mean addresses; a municipality, tenure or field with no supply for the chosen scope is a 404 too, never a link.",
        "tags": [
          "Bostad Semantic™"
        ],
        "externalDocs": {
          "url": "https://bostad.io/for-agents"
        },
        "parameters": [
          {
            "name": "trail",
            "in": "path",
            "required": true,
            "description": "The path after find/, copied from a trail page: lilla-edet, lilla-edet/bostadsratt, lilla-edet/bostadsratt/living_area, lilla-edet/bostadsratt/living_area/highest, lilla-edet/bostadsratt/living_area/highest/nearby.",
            "schema": {
              "type": "string"
            },
            "allowReserved": true
          }
        ],
        "responses": {
          "200": {
            "description": "A trail page: UTF-8 plain text, English, one label per line with the bare absolute address on the line below it. Every address is ASCII. The RateLimit-* headers report the budget shared with search and coverage; the body is conditional-GET cacheable (ETag, Last-Modified) and carries X-Robots-Tag: noindex, nofollow.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            },
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "301": {
            "description": "Not the canonical spelling of this address; Location carries the one address for this search. Follow it.",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string",
                  "format": "uri"
                }
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            }
          },
          "304": {
            "description": "Not modified (If-None-Match or If-Modified-Since matched)."
          },
          "404": {
            "description": "Not on the trail: a municipality, tenure or field without supply for the chosen scope, an unknown name or position, or a field chosen twice. Plain text saying what was wrong, with did-you-mean addresses where one is confident, and the start address.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            },
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded (120 requests/hour per IP); the budget is the one search and coverage share. Retry-After gives the backoff in seconds.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "headers": {
      "RateLimit-Limit": { "description": "Requests permitted per window (currently 120).", "schema": { "type": "integer" } },
      "RateLimit-Remaining": { "description": "Requests left in the current window, this one already counted.", "schema": { "type": "integer" } },
      "RateLimit-Reset": { "description": "Seconds until at least one request frees up — a delta, not a wall-clock timestamp.", "schema": { "type": "integer" } }
    },
    "schemas": {
      "Quintile": {
        "type": "string",
        "enum": ["lowest", "low", "mid", "high", "highest"],
        "description": "A named cohort-relative quintile preference: lowest = bottom ~20% of the cohort, highest = top ~20%; low/mid/high are the bands in between. The value is a POSITION in the cohort's distribution, never an importance weight — every field sent carries equal weight in the final score. Bare integers (e.g. living_area=5) are rejected with a teaching 400 that names the matching token."
      },
      "QItem": {
        "type": "string",
        "maxLength": 120,
        "description": "A single free-text q_like/q_dislike wish (max 120 chars). Scored against the Swedish feature tags the listings are indexed with — short, concrete Swedish terms (e.g. 'bastu', 'sjöutsikt') score most precisely."
      },
      "Hit": {
        "type": "object",
        "required": ["similarity", "listing_url", "evidence"],
        "properties": {
          "similarity": { "type": "number", "description": "Match score relative to this request, ranked within this hit's own cohort (0–1, rounded to 4 decimals) — never an absolute grade, and not comparable across cohorts or across requests." },
          "listing_url": { "type": "string", "format": "uri", "description": "The broker's own listing page (carries utm_source=bostad.io&utm_medium=semantic; the listing's own query parameters are preserved). Use the URL exactly as returned — do not strip the utm_* parameters, they are the referral attribution — and attribute the search to Bostad Semantic (bostad.io). Listing detail is never returned by the API." },
          "evidence": { "$ref": "#/components/schemas/Evidence" }
        }
      },
      "Evidence": {
        "type": "object",
        "required": ["fields", "items"],
        "description": "Why this hit ranked, strictly relative to the caller's own query — rank, filter, and justify results without fetching every listing page first. Never carries listing attribute values, exact percentiles, raw scores, or index tags.",
        "properties": {
          "fields": {
            "type": "object",
            "description": "One entry PER FIELD CODE THE CALLER SENT (never any other field), keyed by field code.",
            "additionalProperties": {
              "type": "object",
              "required": ["preference", "cohort_decile"],
              "properties": {
                "preference": { "$ref": "#/components/schemas/Quintile", "description": "The caller's own preference token, echoed back." },
                "cohort_decile": { "type": ["integer", "null"], "minimum": 1, "maximum": 10, "description": "Which tenth of the cohort's distribution this listing sits in for the field (1 = bottom band, 10 = top band); coarse by design. null = the listing carries no value for the field, so it dropped from that hit's score mean rather than scoring zero." }
              }
            }
          },
          "items": {
            "type": "array",
            "description": "One entry per q_like/q_dislike item, keyed by the caller's own item text.",
            "items": {
              "type": "object",
              "required": ["item", "polarity", "match"],
              "properties": {
                "item": { "type": "string", "description": "The caller's own q item text, echoed back." },
                "polarity": { "type": "string", "enum": ["like", "dislike"] },
                "match": { "type": "string", "enum": ["strong", "weak", "none"], "description": "Coarse cohort-relative PRESENCE evidence for the concept in this listing (both polarities report presence, so for a dislike item 'none' is good news)." }
              }
            }
          }
        }
      },
      "CohortResults": {
        "type": "object",
        "required": ["municipality", "tenure", "results"],
        "properties": {
          "municipality": { "type": "string", "description": "A requested m value: the covered name it matched, or as sent when it is not covered." },
          "tenure": { "type": ["string", "null"], "description": "A requested tenure value: the covered name it matched, or as sent when it is not covered; null when the cohort has no tenure." },
          "results": { "type": "array", "maxItems": 5, "description": "This cohort's best matches, best first (up to 5). Empty when nothing in the cohort matched or the cohort is not covered.", "items": { "$ref": "#/components/schemas/Hit" } }
        }
      },
      "SearchResponse": {
        "type": "object",
        "required": ["count", "query_echo", "cohorts"],
        "properties": {
          "count": { "type": "integer", "description": "Number of hits returned, summed over all cohorts (at most 5 per cohort)." },
          "latency_ms": { "type": "integer", "description": "Server-side wall time spent producing this response, in milliseconds. Excludes network transit." },
          "rate_limit": { "$ref": "#/components/schemas/RateLimit" },
          "query_echo": {
            "type": "object",
            "description": "The parsed request spelled back — confirms the parse and teaches the quintile naming; reveals nothing about any listing. Prose is Swedish by design (the index is Swedish); the machine-readable halves are not.",
            "properties": {
              "cohort": {
                "type": "object",
                "description": "The cohort actually searched, after uncovered values were dropped.",
                "properties": {
                  "municipality": { "type": "array", "items": { "type": "string" } },
                  "tenure": { "type": "array", "items": { "type": "string" } }
                }
              },
              "preferences": {
                "type": "object",
                "description": "One entry per graded field the caller sent, keyed by field code.",
                "additionalProperties": {
                  "type": "object",
                  "required": ["preference", "label"],
                  "properties": {
                    "preference": { "$ref": "#/components/schemas/Quintile" },
                    "label": { "type": "string", "description": "Swedish prose spelling out what the token means in this field's units — e.g. 'Begärt pris: lowest = billigast (botten ~20 % i kohorten)'. For display and for confirming intent; branch on `preference`, never on this string." }
                  }
                }
              },
              "like": { "type": "array", "items": { "type": "string" }, "description": "The q_like items as parsed, after the split on |." },
              "dislike": { "type": "array", "items": { "type": "string" }, "description": "The q_dislike items as parsed, after the split on |." },
              "resolved": {
                "type": "object",
                "description": "Present only when a cohort value matched by letter case, slug or English name: { m: { sent: canonical name }, tenure: { sent: canonical name } }, empty groups omitted.",
                "properties": { "m": { "type": "object", "additionalProperties": { "type": "string" } }, "tenure": { "type": "object", "additionalProperties": { "type": "string" } } }
              },
              "near": {
                "type": "object",
                "description": "Present only when `near` was supplied — the caller's own reference point echoed back.",
                "properties": { "lat": { "type": "number" }, "lon": { "type": "number" } }
              }
            }
          },
          "cohorts": { "type": "array", "description": "One entry per requested (m, t) pair — also for a single-cohort request, and also when the pair matched nothing (results: []). Municipalities in request order; within each, tenures alphabetically with null last. Hits are ranked inside their cohort only: similarity is a standing within one cohort, so never merge cohorts into one ranking.", "items": { "$ref": "#/components/schemas/CohortResults" } },
          "not_covered": {
            "type": "object",
            "description": "Present when the request named cohort values with no supply. Lists the offending m/t.",
            "properties": { "m": { "type": "array", "items": { "type": "string" } }, "t": { "type": "array", "items": { "type": "string" } } }
          },
          "not_applicable": {
            "type": "object",
            "description": "Field preferences dropped because they don't apply to the searched tenure(s), keyed by field code with a reason.",
            "additionalProperties": { "type": "string" }
          },
          "did_you_mean": {
            "type": "object",
            "description": "Present alongside not_covered when a confident repair exists: { m: { input: covered name }, t: { input: covered name } }, empty groups omitted. Advisory only: it covers what did not match by name, letter case, slug or English name; retry with the suggested name.",
            "properties": { "m": { "type": "object", "additionalProperties": { "type": "string" } }, "t": { "type": "object", "additionalProperties": { "type": "string" } } }
          },
          "_links": { "$ref": "#/components/schemas/Links" }
        }
      },
      "Coverage": {
        "type": "object",
        "required": ["municipalities", "tenures", "cohorts"],
        "properties": {
          "municipalities": { "type": "array", "items": { "type": "string" }, "description": "Municipalities with active, embedded listings." },
          "tenures": { "type": "array", "items": { "type": "string" }, "description": "Tenures (upplåtelseformer) with active, embedded listings." },
          "cohorts": {
            "type": "array",
            "description": "Each (municipality, tenure) pair that search answers with listings, sorted by m then t. m and t are the canonical names; m_slug and t_slug are their ASCII spellings, which search accepts as is. A pair that is not listed has no listings in Bostad.",
            "items": {
              "type": "object",
              "required": ["m", "m_slug", "t", "t_slug", "find"],
              "properties": {
                "m": { "type": "string" },
                "m_slug": { "type": "string" },
                "t": { "type": "string" },
                "t_slug": { "type": "string" },
                "find": { "type": "string", "format": "uri", "description": "This pair's node on the find trail (/v1/semantic/find/{m_slug}/{t_slug}): a plain-text page that lists the fields that apply, each as a full address to copy. For a tool that cannot open an address it assembled itself." }
              }
            }
          },
          "_links": { "$ref": "#/components/schemas/Links" }
        }
      },
      "RateLimit": {
        "type": "object",
        "description": "Your remaining request budget, as of this response. Mirrors the RateLimit-* response headers in-band, because tool wrappers routinely surface only the body. Present on every 200 from /v1/semantic/search and on 429 bodies from this API.",
        "required": ["limit", "remaining", "reset", "window_seconds"],
        "properties": {
          "limit": { "type": "integer", "description": "Requests permitted per window (currently 120)." },
          "remaining": { "type": "integer", "description": "Requests left in the current window, this one already counted. 0 means the next call is refused." },
          "reset": { "type": "integer", "description": "Seconds until at least one request frees up. Not a wall-clock timestamp." },
          "window_seconds": { "type": "integer", "description": "Length of the rolling window in seconds (currently 3600)." }
        }
      },
      "Error": {
        "type": "object",
        "required": ["code", "detail"],
        "properties": {
          "code": { "type": "string", "enum": ["INVALID_QUERY", "COHORT_NOT_COVERED", "PREFERENCE_INAPPLICABLE", "EMBEDDING_UNAVAILABLE"], "description": "Stable machine-readable error class — branch on this, not the prose detail. INVALID_QUERY (unknown parameter, an invalid preference value — bare integers included, the detail names the matching token — or no preference supplied), COHORT_NOT_COVERED (the m/tenure cohort has no supply — see not_covered/available/did_you_mean) and PREFERENCE_INAPPLICABLE (a field preference doesn't apply to the searched tenure — see not_applicable) are returned with HTTP 400; EMBEDDING_UNAVAILABLE is a transient HTTP 503 (back off and retry after Retry-After)." },
          "detail": { "type": "string", "description": "Agent-readable error message. Self-correcting where possible: it names the repair (the valid enum, or a did-you-mean suggestion) in prose, and the structured fields below carry the same machine-readably." },
          "not_covered": { "type": "object", "properties": { "m": { "type": "array", "items": { "type": "string" } }, "t": { "type": "array", "items": { "type": "string" } } } },
          "available": { "type": "object", "description": "The covered cohort values, returned alongside a not_covered error.", "properties": { "municipalities": { "type": "array", "items": { "type": "string" } }, "tenures": { "type": "array", "items": { "type": "string" } } } },
          "not_applicable": { "type": "object", "additionalProperties": { "type": "string" } },
          "did_you_mean": { "type": "object", "description": "Confident repair suggestions, advisory only. On cohort errors: grouped { m: { input: covered name }, t: { ... } }. On unknown-parameter errors: flat { sent parameter: valid parameter }. Omitted when no candidate clears the confidence bar — the API never guesses." },
          "valid_parameters": { "type": "array", "items": { "type": "string" }, "description": "The full accepted parameter enum (every rank field plus m, tenure, t, q_like, q_dislike, near), returned on unknown-parameter errors." },
          "valid_preferences": { "type": "array", "items": { "type": "string" }, "description": "The graded field codes you can send, returned when a request named no preference at all. Pair any of these with a value from valid_values. Excludes `distance`, which cannot stand alone (it requires `near`)." },
          "valid_values": { "type": "array", "items": { "type": "string" }, "description": "The accepted quintile-token enum (lowest, low, mid, high, highest), returned on invalid preference-value errors (bare integers included) — retry with a listed token." },
          "_links": { "$ref": "#/components/schemas/Links" }
        }
      },
      "Links": {
        "type": "object",
        "description": "In-band discovery links, present on every /v1 JSON body (success and error): the same pointers as the Link response header, for clients that only surface bodies.",
        "properties": {
          "service-desc": { "type": "string", "format": "uri", "description": "This OpenAPI document." },
          "coverage": { "type": "string", "format": "uri", "description": "The coverage endpoint — the live queryable cohorts." },
          "find": { "type": "string", "format": "uri", "description": "The start of the find trail: plain-text pages for a tool that cannot open an address it assembled itself. Every page prints the next allowed addresses in full; copy one, never build one." }
        }
      }
    }
  }
}
