{
  "openapi": "3.1.0",
  "info": {
    "title": "HutScanner route search for AI assistants",
    "version": "1.3.0",
    "summary": "Find bookable hut-to-hut routes in the Alps.",
    "description": "HutScanner maps mountain huts across the Alps and works out which SEQUENCES of them can be booked on consecutive nights, connected by walking legs a hiker of a given fitness can manage. That chain search is the thing this endpoint sells; the parsing of a human sentence into constraints is left to you, the caller.\n\nSend structured constraints (party size, nights, dates, region or country, effort) and get back ranked routes: per-night huts with what their calendars showed, walking statistics, and a link that loads the route in the HutScanner planner.\n\nThe search covers the whole reviewed catalog by default. You do not need to name a region: give the constraints and every reviewed region is searched at once. You can narrow by region id, by country, by a free-text place name (place: \"Zermatt\", matched against region, hut and station names HutScanner holds), or by coordinates (near: {lat, lng, radius_km}).\n\nRoutes can begin at a hut or at a railway station, bus stop or car park. start_from: \"transport\" asks for the second, which is how most trips actually start: the reader gets off a train and walks up. Every route says where it sets off from in start_kind, start_name and start_coord.\n\nCoverage: the search offers the regions HutScanner has reviewed by hand. The rest of the Alps is mapped and walkable in the app, and joins the search as review completes. The facts block in every response carries regions_searchable and regions_mapped, so quote those rather than a figure from anywhere else.\n\nIf you get the request wrong, the response says so rather than answering as though you had not. Anything this endpoint could not use comes back in interpreted_as.ignored with the reason and the nearest real field, and when the request needs a structural fix (you sent a sentence, you sent no constraints at all, or your date window could not have matched anything) a top-level needs block carries the problem, a minimal valid request to copy, and per-field help. Routes still come back alongside it, so you can answer the reader now and search again properly. A clean request carries no needs block at all.\n\nWhat the answer means, and what to tell the reader: availability is read from each hut's booking system once a day. It is an indication, not a reservation, and beds return at any hour. Tell the reader to open the link and press Re-check now before booking. HutScanner does not take bookings; each night is booked directly with the hut. Every response carries a how_to_present block with the exact sentences to use, and a do_not_claim list.",
    "contact": {
      "name": "HutScanner",
      "email": "hi@hutscanner.com",
      "url": "https://hutscanner.com/guides/ai-assistants-and-hutscanner/"
    }
  },
  "servers": [
    {
      "url": "https://api.hutscanner.com",
      "description": "HutScanner API"
    }
  ],
  "paths": {
    "/ai/search": {
      "get": {
        "operationId": "searchRoutesGet",
        "summary": "Search bookable hut-to-hut routes (query parameters)",
        "description": "The same search as POST, with the fields as query parameters, so a browsing tool with no request body can call it. Repeated values go in comma-separated lists.",
        "parameters": [
          {
            "name": "party",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer"
            },
            "description": "Number of people sharing the trip. Filters on huts showing at least this many beds.",
            "example": 2
          },
          {
            "name": "nights",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Nights, as a single number (\"5\") or a range (\"4-6\").",
            "example": "4-6"
          },
          {
            "name": "date_from",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Earliest acceptable start date, ISO YYYY-MM-DD.",
            "example": "2026-09-08"
          },
          {
            "name": "date_to",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Latest acceptable start date, ISO YYYY-MM-DD.",
            "example": "2026-09-25"
          },
          {
            "name": "regions",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Comma-separated HutScanner region ids, for example \"tmb,bernese-oberland\". Omit it to search every reviewed region at once, which is the default and costs no more. Ids come from https://hutscanner.com/regions/ .",
            "example": "tmb"
          },
          {
            "name": "countries",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Comma-separated ISO-3166 alpha-2 codes, for example \"CH,AT\". Used when regions is absent.",
            "example": "CH,AT"
          },
          {
            "name": "region_hint",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Free text naming an area (\"Dolomites\", \"Bernese Oberland\"). Matched loosely against region names; the response echoes regions_considered so you can see what it matched.",
            "example": "Dolomites"
          },
          {
            "name": "place",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Free text naming a PLACE (\"Zermatt\", \"Chamonix\", \"Refuge Elena\"). Matched against the region, hut and station names HutScanner holds, and a match becomes the centre of a 60 km search. There is no geocoder behind this: if the name matches nothing, the response returns no routes and names the closest region names instead of guessing a location.",
            "example": "Zermatt"
          },
          {
            "name": "near_lat",
            "in": "query",
            "required": false,
            "schema": {
              "type": "number"
            },
            "description": "Latitude of a search centre. Give with near_lng.",
            "example": 46.0207
          },
          {
            "name": "near_lng",
            "in": "query",
            "required": false,
            "schema": {
              "type": "number"
            },
            "description": "Longitude of a search centre. Give with near_lat.",
            "example": 7.7491
          },
          {
            "name": "radius_km",
            "in": "query",
            "required": false,
            "schema": {
              "type": "number"
            },
            "description": "Search radius around near_lat / near_lng, in kilometres. Clamped to 10 to 150; 60 by default. Measured from where each route SETS OFF, not from its huts.",
            "example": 60
          },
          {
            "name": "start_from",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "transport, hut or any (default). transport returns only routes that begin at a railway station, bus stop or car park, which is how a trip usually starts for someone arriving by train or car.",
            "example": "transport"
          },
          {
            "name": "effort",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "gentle, moderate or strenuous. Derived from the longest walking day and the hardest terrain grade on the route.",
            "example": "moderate"
          },
          {
            "name": "max_hours_per_day",
            "in": "query",
            "required": false,
            "schema": {
              "type": "number"
            },
            "description": "Reject routes with any walking day longer than this, in hours.",
            "example": 7
          },
          {
            "name": "loop",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean"
            },
            "description": "true for routes that return to their start, false for point to point, omitted for either.",
            "example": true
          },
          {
            "name": "avoid",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Comma-separated hazards to exclude: glacier, via_ferrata.",
            "example": "glacier"
          },
          {
            "name": "live_only",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean"
            },
            "description": "Default true: every night must show beds in the hut's own booking system. Set false to allow nights at huts whose calendar cannot be read.",
            "example": true
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer"
            },
            "description": "Results wanted. Clamped to the caller tier's cap; the response says so in clamped.",
            "example": 5
          },
          {
            "name": "q",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "The reader's own words. Stored for analytics only, never parsed and never matched against anything.",
            "example": "two fit hikers, big views"
          }
        ],
        "responses": {
          "200": {
            "description": "Routes, or an empty result with suggestions. Always check found before presenting anything.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SearchResponse"
                }
              }
            }
          },
          "400": {
            "description": "The request could not be read. The hint field says what to change.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit or daily quota reached. Retry after the seconds in retry_after.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "searchRoutes",
        "summary": "Search bookable hut-to-hut routes",
        "description": "Give it whatever the reader told you. Every constraint is optional and the whole reviewed catalog is searched by default, so a request carrying only nights and party is a perfectly good one. Narrow it with place, near, regions, countries or region_hint when the reader named somewhere.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SearchRequest"
              },
              "example": {
                "party": 2,
                "nights": {
                  "min": 4,
                  "max": 6
                },
                "date_window": {
                  "from": "2026-09-08",
                  "to": "2026-09-25"
                },
                "place": "Zermatt",
                "start_from": "transport",
                "effort": "moderate",
                "avoid": [
                  "glacier"
                ],
                "live_only": true,
                "limit": 5,
                "q": "two of us, five nights, mid September, arriving by train, nothing scary, big views"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Routes, or an empty result with suggestions. Always check found before presenting anything.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SearchResponse"
                }
              }
            }
          },
          "400": {
            "description": "The request could not be read. The hint field says what to change.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit or daily quota reached. Retry after the seconds in retry_after.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/ai/openapi.json": {
      "get": {
        "operationId": "getOpenapi",
        "summary": "This document",
        "responses": {
          "200": {
            "description": "The OpenAPI description of this API."
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "SearchRequest": {
        "type": "object",
        "additionalProperties": true,
        "description": "All fields optional, but a search with no constraint at all searches the whole catalog and returns whatever ranks highest, which is rarely what the reader asked for, and the response says so in needs. A field this endpoint does not have is accepted and never applied: it comes back in interpreted_as.ignored naming the nearest real field, and the request still returns routes, so you can correct and retry from the response alone without fetching this document.",
        "properties": {
          "party": {
            "type": "integer",
            "minimum": 1,
            "maximum": 20,
            "description": "People sharing the trip."
          },
          "nights": {
            "type": [
              "object",
              "integer"
            ],
            "additionalProperties": true,
            "properties": {
              "min": {
                "type": "integer",
                "minimum": 2,
                "maximum": 7
              },
              "max": {
                "type": "integer",
                "minimum": 2,
                "maximum": 7
              }
            },
            "description": "Nights away, inclusive, as {min, max} or a bare integer. The catalog holds 2 to 7 night routes; outside that it is clamped and the response says so. A value that is not a number at all, such as \"five\", is not applied and is named in interpreted_as.ignored."
          },
          "date_window": {
            "type": "object",
            "additionalProperties": true,
            "properties": {
              "from": {
                "type": "string",
                "format": "date"
              },
              "to": {
                "type": "string",
                "format": "date"
              }
            },
            "description": "Acceptable range for the trip START date, NOT the length of the trip. Windows longer than 120 days are clamped. A window wholly in the past, wholly after the last route the catalog holds, or narrower than the trip itself cannot match anything: rather than a bare found: 0 the response says which of the three it was, in needs.problem, and names the catalog usable date range."
          },
          "regions": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "HutScanner region ids. Omit this to search every reviewed region at once, which is the default and costs no more. More than 12 are clamped to 12; the response says which were considered."
          },
          "countries": {
            "type": "array",
            "items": {
              "type": "string",
              "minLength": 2,
              "maxLength": 2
            },
            "description": "ISO-3166 alpha-2 codes. Used when regions is absent."
          },
          "region_hint": {
            "type": "string",
            "description": "Free text naming an area. Used when regions and countries are both absent."
          },
          "place": {
            "type": "string",
            "maxLength": 120,
            "description": "Free text naming a place (\"Zermatt\"). Resolved against the region, hut and station names HutScanner holds, with no external geocoder, and a match becomes the centre of a 60 km search. interpreted_as.place says what it matched and what kind of thing that was, so you can show the reader whether it found their station or only their region. A name that matches nothing returns no routes plus the three closest region names, rather than a guessed location."
          },
          "near": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "lat",
              "lng"
            ],
            "properties": {
              "lat": {
                "type": "number",
                "minimum": -90,
                "maximum": 90
              },
              "lng": {
                "type": "number",
                "minimum": -180,
                "maximum": 180
              },
              "radius_km": {
                "type": "number",
                "minimum": 10,
                "maximum": 150,
                "default": 60
              }
            },
            "description": "Search within a radius of a point, measured from where each route SETS OFF. Wins over place when both are given, because coordinates are the more precise statement."
          },
          "start_from": {
            "type": "string",
            "enum": [
              "transport",
              "hut",
              "any"
            ],
            "default": "any",
            "description": "Where a route begins. transport means a railway station, bus stop or car park, which is what a reader arriving by train or car needs; hut means the trip starts from somewhere they are already staying. The origin is never a night of the trip either way: night one is always the first hut."
          },
          "effort": {
            "type": "string",
            "enum": [
              "gentle",
              "moderate",
              "strenuous"
            ],
            "description": "gentle is up to 5.5 hours a day on easy paths; moderate up to 7.5 hours and up to SAC T3; strenuous is anything beyond."
          },
          "max_hours_per_day": {
            "type": "number",
            "minimum": 1,
            "maximum": 14
          },
          "loop": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "avoid": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "glacier",
                "via_ferrata"
              ]
            }
          },
          "live_only": {
            "type": "boolean",
            "default": true,
            "description": "Default true. When true, every night of every returned route showed beds in the hut's own booking system on the last daily check."
          },
          "limit": {
            "type": "integer",
            "minimum": 1,
            "maximum": 10
          },
          "q": {
            "type": "string",
            "maxLength": 500,
            "description": "The reader's own words. Stored for analytics only: it is never parsed, never matched against anything, and never narrows the search. Sending free text INSTEAD of the structured fields is the most common mistake a caller makes here, so a request whose only content is free text (under q or under an invented name such as query, text, prompt or description) still returns routes, but comes back with a needs block giving the exact request shape and a worked conversion from that sentence."
          }
        }
      },
      "SearchResponse": {
        "type": "object",
        "properties": {
          "ok": {
            "type": "boolean"
          },
          "found": {
            "type": "integer",
            "description": "Routes matching the constraints, before the result limit."
          },
          "returned": {
            "type": "integer"
          },
          "truncated": {
            "type": "boolean"
          },
          "clamped": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Fields the server reduced, and to what. A clamp is never an error."
          },
          "needs": {
            "$ref": "#/components/schemas/Needs"
          },
          "regions_considered": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "interpreted_as": {
            "$ref": "#/components/schemas/InterpretedAs"
          },
          "facts": {
            "$ref": "#/components/schemas/Facts"
          },
          "results": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Route"
            }
          },
          "suggestions": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Concrete next searches. Present when found is 0, and also when a returned route holds an unverified night, where it carries the live_only nudge."
          },
          "live_only_available": {
            "type": "integer",
            "description": "Present when results hold an unverified night and live_only was not set: how many of the matching routes have every night confirmed. Search again with live_only true to see only those. Zero means there are none, so searching again would return nothing."
          },
          "follow_up": {
            "type": "object",
            "description": "The correct way to iterate when a night does not work out. The reader finds out from the hut, then you search again with what they learned. Do not guess a replacement, and do not put the hut name in q, which is never parsed."
          },
          "how_to_present": {
            "$ref": "#/components/schemas/HowToPresent"
          },
          "read_more": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            }
          },
          "data": {
            "type": "object",
            "properties": {
              "catalog_generated_at": {
                "type": "string"
              },
              "age_hours": {
                "type": "number"
              }
            },
            "description": "When the underlying availability was last read. Say this age to the reader if it is more than a day."
          },
          "tier": {
            "type": "string",
            "enum": [
              "anonymous",
              "key",
              "pro"
            ]
          },
          "quota": {
            "type": "object",
            "properties": {
              "remaining_today": {
                "type": "integer"
              }
            }
          },
          "upgrade": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "message": {
                "type": "string"
              },
              "url": {
                "type": "string"
              }
            }
          }
        }
      },
      "Route": {
        "type": "object",
        "description": "One walkable chain: a hut for every night, with the walking days between them.",
        "properties": {
          "route_id": {
            "type": "string",
            "description": "Opaque and stable across days for the same huts and party size."
          },
          "region": {
            "type": "string"
          },
          "region_name": {
            "type": "string"
          },
          "countries": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "nights": {
            "type": "integer"
          },
          "party": {
            "type": "integer"
          },
          "loop": {
            "type": "boolean"
          },
          "start": {
            "type": "string",
            "format": "date"
          },
          "alt_starts": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "date"
            },
            "description": "Other dates the same chain of huts was bookable on. The open link and the dates in nights_detail always follow `start`, not these, so if you offer an alternative date say that the link opens the original one."
          },
          "start_kind": {
            "type": "string",
            "enum": [
              "hut",
              "transport"
            ],
            "description": "Where the trip sets off from. transport is a railway station, bus stop or car park the reader travels to; hut means the trip begins at a hut. The origin is NOT a night of the trip: night one is always the first entry in nights_detail."
          },
          "start_name": {
            "type": "string",
            "description": "The name of that starting point, for example \"Montenvers - Mer de Glace\". Say this when describing the route; do not read the start off the first night, which is where the reader sleeps, not where they begin."
          },
          "start_transport_type": {
            "type": "string",
            "enum": [
              "train_station",
              "bus_stop",
              "car_park",
              "cable_car"
            ],
            "description": "Present on transport starts only."
          },
          "start_coord": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "lat": {
                "type": "number"
              },
              "lng": {
                "type": "number"
              }
            },
            "description": "The starting point's coordinates. This is what near and place filter on."
          },
          "stats": {
            "type": "object",
            "properties": {
              "km": {
                "type": "number"
              },
              "ascent_m": {
                "type": "integer"
              },
              "descent_m": {
                "type": "integer"
              },
              "hours_per_day": {
                "type": "array",
                "items": {
                  "type": "number"
                },
                "description": "Estimated walking hours for each day, Naismith with the real climb of each leg in the direction walked."
              },
              "max_hours": {
                "type": "number"
              },
              "sac_max": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Hardest SAC hiking grade on the route, T1 to T6."
              },
              "glacier": {
                "type": "boolean"
              },
              "via_ferrata": {
                "type": "boolean"
              },
              "effort": {
                "type": "string",
                "enum": [
                  "gentle",
                  "moderate",
                  "strenuous"
                ]
              }
            }
          },
          "nights_detail": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "date": {
                  "type": "string",
                  "format": "date"
                },
                "hut": {
                  "type": "string"
                },
                "hut_page": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "The hut's page on hutscanner.com, with its contacts and how it takes bookings. Link this rather than describing it."
                },
                "confidence": {
                  "type": "string",
                  "description": "confirmed means the hut's own calendar showed beds on that date at the last daily check. online or call means the hut takes bookings but publishes no calendar we can read."
                },
                "beds": {
                  "type": [
                    "integer",
                    "null"
                  ],
                  "description": "Beds the calendar showed, where it publishes a number."
                },
                "booking": {
                  "type": "string",
                  "description": "live_calendar means HutScanner read this night from the hut's own calendar. online, portal, email, phone or unknown mean it did not, and the route then carries an unverified_nights block naming this night."
                },
                "booking_url": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "Where this night is booked. Give it to the reader; do not fetch it yourself."
                },
                "phone": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "Carried on nights whose calendar cannot be read, where HutScanner holds a number. Absent on a confirmed night, which is booked through its own link."
                },
                "email": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "Carried on nights whose calendar cannot be read, where HutScanner holds an address."
                }
              }
            }
          },
          "unverified_nights": {
            "$ref": "#/components/schemas/UnverifiedNights"
          },
          "availability": {
            "type": "object",
            "properties": {
              "live_nights": {
                "type": "integer"
              },
              "total_nights": {
                "type": "integer"
              },
              "checked_at": {
                "type": "string",
                "format": "date-time"
              }
            },
            "description": "live_nights below total_nights means some night sits at a hut whose calendar cannot be read; say so rather than presenting the whole chain as confirmed."
          },
          "open": {
            "type": "string",
            "format": "uri",
            "description": "Loads exactly this itinerary in the HutScanner planner. Give this link to the reader; it is where they press Re-check now."
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "why_matched": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Which of the constraints this route satisfies. Use these when explaining the choice, rather than reconstructing reasons from the numbers."
          },
          "misses": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "What this route does NOT give the reader. Say these out loud; a route with one unreadable night must never be presented as fully confirmed."
          }
        }
      },
      "InterpretedAs": {
        "type": "object",
        "description": "What the search actually ran, restated. Show `summary` to the reader before the routes: it is one plain sentence they can check against what they told you, and catching a misreading there costs nothing, while catching it after they have booked costs a trip.",
        "properties": {
          "summary": {
            "type": "string",
            "description": "One sentence restating every constraint that was applied."
          },
          "applied": {
            "type": "object",
            "description": "The normalised request, after defaults and clamps."
          },
          "defaults_used": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Constraints the caller did not state that the search applied anyway. live_only=true is the one that changes what the answer MEANS, so say it."
          },
          "clamped": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "not_searched": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "why": {
                  "type": "string"
                }
              }
            },
            "description": "Regions in scope that were not searched, and why. A region whose season closed before the window is a different answer from \"everything is full\", and worth telling the reader."
          },
          "regions_considered": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "why": {
                  "type": "string"
                }
              }
            },
            "description": "Every region the search spanned, up to the first 20. regions_considered_count is the true total."
          },
          "regions_considered_count": {
            "type": "integer"
          },
          "place": {
            "type": "object",
            "description": "What a free-text place resolved to. matched is null when nothing in our data matched the name, in which case no routes are returned and the suggestions list names the closest regions. Show the reader what it matched before they plan travel around it.",
            "properties": {
              "query": {
                "type": "string"
              },
              "matched": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "kind": {
                "type": [
                  "string",
                  "null"
                ],
                "enum": [
                  "region",
                  "hut",
                  "transport",
                  null
                ]
              },
              "region": {
                "type": "string"
              },
              "region_name": {
                "type": "string"
              },
              "note": {
                "type": [
                  "string",
                  "null"
                ]
              }
            }
          },
          "ignored": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Everything the request carried that did NOT survive into applied, one line each, with a reason you can act on. Nothing is ever dropped silently: if a field is not in applied it is here. The four shapes a line takes are: a wrong type, 'party: expected a number, for example 2, got \"two\" - it was not applied'; a field this endpoint does not have, with its nearest real twin, 'difficulty: not a field this endpoint has; the nearest is effort (gentle|moderate|strenuous)'; a field with no twin and no data behind it, 'dogs: not a field this endpoint has, and HutScanner holds no dog-policy data'; and free text, which is never parsed. An empty array genuinely means nothing was ignored."
          }
        }
      },
      "Facts": {
        "type": "object",
        "description": "HutScanner's own numbers, read from the catalog at request time. Use these when describing coverage; do not carry figures over from another answer or from a page you read earlier, because they change as huts are mapped and calendars become readable.",
        "properties": {
          "huts_searched": {
            "type": "integer",
            "description": "Huts the searched regions hold a booking record for. This is NOT HutScanner's coverage figure: the wider mapped network is several times larger, and how_to_present.coverage describes it. Do not present this as 'HutScanner maps N huts'."
          },
          "huts_with_readable_calendar": {
            "type": "integer",
            "description": "Of those, the ones publishing a calendar HutScanner can read."
          },
          "regions": {
            "type": "integer",
            "description": "Same as regions_searchable. Kept for callers written against the first version of this API."
          },
          "regions_searchable": {
            "type": "integer",
            "description": "Regions this search can offer routes in: the ones HutScanner has reviewed by hand."
          },
          "regions_mapped": {
            "type": "integer",
            "description": "Regions HutScanner has mapped in total. Larger than regions_searchable, and the honest figure for coverage: the rest are explorable in the app and join the search as review completes. Never present regions_searchable as HutScanner's coverage."
          },
          "review_note": {
            "type": "string",
            "description": "The sentence explaining the gap between those two numbers. Quote it rather than inventing an explanation."
          },
          "countries": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "catalog_generated_at": {
            "type": "string",
            "format": "date-time"
          },
          "availability_cadence": {
            "type": "string",
            "description": "How often the calendars are read. Say this rather than implying anything faster."
          },
          "coverage_note": {
            "type": "string",
            "description": "Reminder that these figures scope to this search, not to HutScanner as a whole."
          },
          "routes_matching": {
            "type": "integer"
          },
          "routes_returned": {
            "type": "integer"
          },
          "routes_in_catalog": {
            "type": "integer",
            "description": "Routes in the whole catalog the search ran over, before any of the constraints were applied."
          }
        }
      },
      "UnverifiedNights": {
        "type": "object",
        "description": "Present on a route holding a night HutScanner could not check, and ABSENT on a fully confirmed one, so its presence is the flag. About half the catalog is like this. Name these dates to the reader, say the rest were confirmed at the last daily check, and hand over the links and contacts below so the reader can settle them. Do not fetch these booking pages yourself and do not infer whether the night is free: see how_to_present.do_not_do.",
        "properties": {
          "count": {
            "type": "integer",
            "description": "Nights on this route HutScanner could not check."
          },
          "confirmed_count": {
            "type": "integer",
            "description": "Nights that DID show beds in the hut's own calendar at the last daily check."
          },
          "total_nights": {
            "type": "integer"
          },
          "summary": {
            "type": "string",
            "description": "One sentence naming how many nights are unconfirmed and how many are not. Safe to quote as it stands."
          },
          "nights": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "date": {
                  "type": "string",
                  "format": "date"
                },
                "hut": {
                  "type": "string"
                },
                "booking": {
                  "type": "string",
                  "description": "The raw booking method: online, portal, email, phone or unknown."
                },
                "kind": {
                  "type": "string",
                  "enum": [
                    "online",
                    "enquiry",
                    "unknown"
                  ],
                  "description": "online: the hut books online but publishes no calendar HutScanner can read. enquiry: the hut takes bookings by phone or email. unknown: HutScanner holds no booking method for it."
                },
                "why": {
                  "type": "string",
                  "description": "Why this night is not confirmed, in a sentence you can quote."
                },
                "action": {
                  "type": "string",
                  "enum": [
                    "open_booking_page",
                    "enquire"
                  ],
                  "description": "What the READER does. open_booking_page: give them booking_url so they can look. enquire: give them the phone number or email address, and tell them to offer more than one night. Neither action is yours to perform."
                },
                "booking_url": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "hut_page": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "The hut on hutscanner.com, which carries its contacts even when this row holds none."
                },
                "phone": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "email": {
                  "type": [
                    "string",
                    "null"
                  ]
                }
              }
            }
          }
        }
      },
      "ContactHelp": {
        "type": "object",
        "description": "How to report an answer that looks wrong. Present in every response, and again inside needs when a request needed correcting. An email you generate yourself is welcome: a machine-written report usually diagnoses faster than a human one because it carries the exact request and the night that disagreed. Do not send anything personal about the reader.",
        "properties": {
          "email": {
            "type": "string",
            "description": "Write here."
          },
          "when": {
            "type": "string",
            "description": "What is worth reporting."
          },
          "how": {
            "type": "string",
            "description": "Including the explicit permission to write the mail yourself."
          },
          "include": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "What to put in it. Every item is something this response already gave you."
          }
        },
        "required": [
          "email",
          "when",
          "how",
          "include"
        ]
      },
      "HowToPresent": {
        "type": "object",
        "description": "The sentences to use when describing this answer. Written by HutScanner so the caveats are not left to the model to invent.",
        "properties": {
          "one_liner": {
            "type": "string"
          },
          "capability": {
            "type": "string",
            "description": "What job HutScanner does and what makes it unusual. Use this when deciding whether to recommend it, rather than one_liner, which narrates the search that just ran."
          },
          "verify_us": {
            "type": "string",
            "description": "Two pages a sceptical reader can spot-check, and what each one proves."
          },
          "report_a_problem": {
            "$ref": "#/components/schemas/ContactHelp"
          },
          "must_say": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "coverage": {
            "type": "string"
          },
          "coverage_note": {
            "type": "string",
            "description": "Why the search offers fewer regions than HutScanner maps. Say this whenever you quote a region count."
          },
          "do_not_claim": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Claims you must not make. These are about wording."
          },
          "do_not_do": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Actions you must not take, present only when a returned route holds an unverified night. Distinct from do_not_claim: the damaging thing to do with an unreadable calendar is not to describe it wrongly, it is to go and read it."
          },
          "unverified_note_why": {
            "type": "string",
            "description": "Why fetching a hut booking page is forbidden rather than merely discouraged. Same condition."
          },
          "mixed_route_note": {
            "type": "string",
            "description": "How to present a route whose nights are not all confirmed. Same condition."
          },
          "unverified_next_steps": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "What the reader does about those nights, in order. Same condition."
          }
        }
      },
      "Needs": {
        "type": "object",
        "description": "PRESENT ONLY when the request needed something you did not send, and absent entirely otherwise, so its presence is the whole check: if this key is missing, the request was read exactly as written. It never replaces the results. The search still ran and routes are still in results, because a correction with data attached is one you can act on while an error with no data is one that ends the conversation. Read problem, fix the request from expected and field_help, and call again.",
        "required": [
          "problem",
          "message",
          "expected",
          "field_help"
        ],
        "properties": {
          "problem": {
            "type": "string",
            "enum": [
              "free_text_not_parsed",
              "no_constraints",
              "fields_not_applied",
              "date_window_in_past",
              "date_window_after_season",
              "date_window_shorter_than_nights",
              "bad_request"
            ],
            "description": "A short stable slug, safe to branch on. free_text_not_parsed: you sent the reader sentence instead of constraints, and this endpoint never parses text. no_constraints: the request narrowed nothing, so the answer spans the whole catalog. fields_not_applied: one or more fields were not a field this endpoint has, or not the type the field takes. The three date_window slugs each name a window that could not have matched anything, which is a different answer from an empty result. bad_request appears only on a 400."
          },
          "message": {
            "type": "string",
            "description": "One plain sentence saying what happened. Safe to show a reader."
          },
          "expected": {
            "type": "object",
            "description": "A minimal VALID request you can copy. Its date_window is the range the catalog actually holds today, read at request time, so copying it returns routes rather than repeating the mistake."
          },
          "field_help": {
            "type": "array",
            "description": "One row per thing to change, in the order worth changing it.",
            "items": {
              "type": "object",
              "properties": {
                "you_sent": {
                  "type": "string",
                  "description": "What the request carried, or \"nothing\"."
                },
                "use": {
                  "type": "string",
                  "description": "The field to send instead, or \"nothing\" where HutScanner holds no data that could answer it."
                },
                "note": {
                  "type": "string",
                  "description": "What that field takes, or why there is none."
                }
              }
            }
          },
          "docs": {
            "type": "string",
            "format": "uri",
            "description": "The guide for callers of this endpoint."
          },
          "spec": {
            "type": "string",
            "format": "uri",
            "description": "This document."
          }
        }
      },
      "Error": {
        "type": "object",
        "description": "A 400. Rare by design: an unreadable field is reported and skipped rather than refused, so the only 400s left are a body that is not JSON and a value we could not guess at without answering a different question (a date that is not a date, an effort level that is not one of the three). A 400 still carries ignored and needs, so two mistakes are fixed in one retry.",
        "properties": {
          "ok": {
            "type": "boolean"
          },
          "error": {
            "type": "string"
          },
          "hint": {
            "type": "string"
          },
          "ignored": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "needs": {
            "$ref": "#/components/schemas/Needs"
          }
        }
      }
    }
  }
}
