{
  "name": "GlobeHops",
  "description": "Explore air journeys on an interactive globe; shared route estimates and local-time schedules for people and agents.",
  "contractVersion": 1,
  "homepage": "https://tmrk.github.io/globehops/",
  "urlTemplates": {
    "simple": "https://tmrk.github.io/globehops/?route={route}",
    "comparison": "https://tmrk.github.io/globehops/?route={routeA}&label={labelA}&b.route={routeB}&b.label={labelB}",
    "trip": "https://tmrk.github.io/globehops/?route={outbound}&dep1={localDeparture}&2.route={return}&2.dep1={localDeparture}&2.stay={stay}",
    "tripComparison": "https://tmrk.github.io/globehops/?route={outboundA}&2.route={returnA}&b.route={outboundB}&b.2.route={returnB}",
    "localized": "https://tmrk.github.io/globehops/{lang}/?route={route}",
    "scheduled": "https://tmrk.github.io/globehops/?route={route}&dep1={localDeparture}",
    "document": "https://tmrk.github.io/globehops/?itinerary={base64urlJson}"
  },
  "customPaths": {
    "kinds": ["planned", "recorded"],
    "document": "A departing stop may carry track: exactly one of points ([lat,lng,altitudeMetres|null,ISO time|null]) or encoded (trkN codec), with optional kind, format and name. Never on the last stop.",
    "precedence": "Explicit geometry bypasses routing. Recorded times anchor missing stop endpoints; entered stop times win and scale playback. Track altitudes replace modelled altitudes when at least two are known.",
    "sharing": "Tracks stay in the link; nothing is uploaded. Large paths are simplified for sharing only; track_simplified reports kept/total points. Session calculations retain exact geometry."
  },
  "parameters": {
    "trkN": {
      "type": "string",
      "required": false,
      "format": "versioned URL-safe delta encoding",
      "examples": ["See planned and recorded examples in agent/examples.json"],
      "semantics": "Custom path departing 1-based stop N; composes with b./c./d. and 2.–6. prefixes. Invalid values are ignored with an issue."
    },
    "label": {
      "type": "string",
      "required": false,
      "format": "1–40 characters without controls",
      "examples": ["Via London"],
      "semantics": "Trimmed journey/alternative A label; URL-encode Unicode and reserved characters."
    },
    "b./c./d.": {
      "type": "journey group",
      "required": false,
      "format": "prefix + route|depN|arrN|trkN|label or nested 2.–6. section groups",
      "examples": ["b.route=BUD-JFK", "b.2.route=JFK-BUD", "c.dep1=2026-12-01T08:00"],
      "semantics": "Journeys B–D in order; gaps compact. Each group requires route. Parameter keys are case-insensitive. Other view parameters remain unprefixed; leg refers to A, clock may name any airport across all alternatives and their sections."
    },
    "2.–6.": {
      "type": "trip section group",
      "required": false,
      "format": "prefix + route|depN|arrN|trkN|label|stay",
      "examples": ["2.route=NBO-CDG-CPH", "2.dep1=2026-11-20T23:55", "2.stay=5d"],
      "semantics": "A trip's sections after the first (up to 6 in all), in order; the first uses the unprefixed parameters. Sections are sequential journeys separated by explicit stays; connections stay inside a section. stay (days d, hours h or minutes m) places a section without times after the previous one (default 7 days). Compose with b.–d. for compared trips (b.2.* through d.6.*); leg counts flights across A’s sections."
    },
    "airspace": {
      "type": "string",
      "required": false,
      "format": "0 or 1",
      "examples": ["0"],
      "semantics": "Restricted-airspace avoidance for this visit (app default on); overrides the saved preference."
    },
    "diversion": {
      "type": "string",
      "required": false,
      "format": "0 or 1",
      "examples": ["1"],
      "semantics": "Diversion-aware routing for this visit (app default on): keep flights within modelled reach of potential diversion airports. Not an ETOPS authorisation."
    },
    "route": {
      "type": "string",
      "required": false,
      "format": "hyphen-separated airport tokens",
      "examples": ["CPH-JFK", "CPH-LHR_180-JFK"],
      "semantics": "Up to ten stops; transfer suffix _minutes overrides the default layover. Explicit empty route clears the route."
    },
    "depN/arrN": {
      "type": "string",
      "required": false,
      "format": "YYYY-MM-DDTHH:MM[±HH:MM|Z]",
      "examples": ["2026-11-20T08:15", "2026-11-01T01:30-04:00"],
      "semantics": "Airport-local wall time at 1-based stop N. Optional :00 seconds. Date-only links are accepted as incomplete; documents require a time. Arrival at origin and departure at destination are ignored."
    },
    "t": {
      "type": "string",
      "required": false,
      "format": "ISO instant with Z or offset",
      "examples": ["2026-11-20T09:30Z"],
      "semantics": "Simulation clock instant, independent of airport-local input times."
    },
    "play": {
      "type": "string",
      "required": false,
      "format": "0 or 1",
      "examples": ["1"],
      "semantics": "Start playback on open; reduced motion prevents autoplay."
    },
    "speed": {
      "type": "string or integer",
      "required": false,
      "format": "auto|1800|3600|7200",
      "examples": ["auto", 3600],
      "semantics": "Simulation seconds per real second; auto covers the journey in about 15 seconds."
    },
    "clock": {
      "type": "string",
      "required": false,
      "format": "local|utc|airport code",
      "examples": ["local", "utc", "JFK"],
      "semantics": "Timeline display zone; airport code must belong to any journey or section of any alternative."
    },
    "units": {
      "type": "string",
      "required": false,
      "format": "km|mi",
      "examples": ["km", "mi"],
      "semantics": "Distance display only."
    },
    "follow": {
      "type": "string",
      "required": false,
      "format": "0 or 1",
      "examples": ["1"],
      "semantics": "Follow the aircraft with the camera; off by default. User camera input exits follow."
    },
    "daynight": {
      "type": "string",
      "required": false,
      "format": "0 or 1",
      "examples": ["0"],
      "semantics": "Day/night shading; on by default."
    },
    "lang": {
      "type": "string",
      "required": false,
      "format": "en|hu|sv",
      "examples": ["en", "hu", "sv"],
      "semantics": "Language; generated URLs use locale paths /en/, /hu/, /sv/. Query lang is an alias on the unprefixed path. Explicit path takes precedence."
    },
    "names": {
      "type": "string",
      "required": false,
      "format": "0 or 1",
      "examples": ["1"],
      "semantics": "Place-name labels for this visit; overrides saved preference."
    },
    "borders": {
      "type": "string",
      "required": false,
      "format": "0 or 1",
      "examples": ["0"],
      "semantics": "Country borders for this visit; overrides saved preference."
    },
    "leg": {
      "type": "integer",
      "required": false,
      "format": "1-based flight number",
      "examples": [1, 2],
      "semantics": "Frames the selected flight; must exist in the route (in a trip, flights count across sections, up to 54). Does not change the simulation instant."
    },
    "itinerary": {
      "type": "string",
      "required": false,
      "format": "base64url UTF-8 JSON without padding",
      "examples": ["See agent/examples.json"],
      "semantics": "ItineraryDocument v1 overrides readable parameters; structural schema linked below."
    }
  },
  "comparison": {
    "document": "Version 1; exactly one of stops, journeys or sections. Journeys: 1–4 labelled alternatives, each exactly one of stops or sections; every alternative uses the same form and section count (1–6).",
    "alignment": "Entered schedules remain absolute. Undated journeys align to the earliest dated departure, else assumeDeparture; without either they have no timeline.",
    "metrics": "Fastest is shortest total journey duration; arrives first is earliest final UTC arrival. Exact ties have no winner. Invalid or fewer-than-two-stop journeys are excluded; arrival comparison requires every valid journey to have an end."
  },
  "trips": {
    "document": "Version 1; sections: 1–6 sequential journeys, each with stops and an optional label; later sections may carry stayMinutes (0–525600).",
    "stays": "A stay is the time between one section arriving and the next departing, at the same airport or an open jaw. It is never a layover and is never inferred from duration. Its length comes from both sections’ times, else from stayMinutes (default 7 days), with estimated/assumed provenance.",
    "playback": "One traveller flies the sections in order on one UTC clock; automatic playback skips stays without changing any instant. Compared trips use one clock across alternatives, each with sequential sections; shared stays can be skipped without changing instants.",
    "outputs": "calculate_route returns kind \"trip\" with sections (journey details), stays (minutes, calendar days, source, provenance) and trip totals; get_journey_timeline reports a \"stay\" phase between sections."
  },
  "comparedTrips": {
    "links": "A: unprefixed plus 2.*–6.*; B–D: b.* plus b.2.*–b.6.*, through d.6.*. Links tolerate unequal counts; documents pad empty sections then fail validation until filled.",
    "alignment": "An untimed trip lines up journey by journey with the earliest timed counterpart unless the traveller chose the stay. Entered times remain absolute.",
    "metrics": "Only complete valid trips are ranked by travelMinutes (flights plus connections across sections), excluding stays. Per-section winners/deltas compare section k with section k. Ties have no winner.",
    "input": "compare_journeys: each alternative supplies route, stops or sections; sections supply route or stops, label?, stay? (integer minutes or 5d/36h/90m). route plus returnRoute and optional stay is a two-section shorthand. Equal section counts required.",
    "output": "kind trip_comparison; journeys: id/label/color/anchor/sections/stays/totals (trip totals plus travelMinutes). comparison: fastest, ranking (id/travelMinutes/deltaMinutes), sections (name/fastest/per-ID deltaMinutes). Plain comparisons add kind comparison. get_journey_timeline remains single journey/trip only."
  },
  "routing": {
    "default": "Generated agent/MCP paths follow great circles unless routing is requested; the app routes by default. Custom planned/recorded paths apply independently of routing.",
    "request": "{ \"avoidAirspace\": boolean, \"diversion\": boolean | minutes 60–370 }",
    "airspace": "Routes around EASA conflict-zone bulletins valid on the flight date, assuming an EU/EEA operator. See data/AIRSPACE.md.",
    "diversion": {
      "profile": "twin-180",
      "maxDiversionMinutes": 180,
      "oneEngineInoperativeSpeedKt": 400,
      "radiusKm": 2222.4,
      "semantics": "ETOPS/EDTO-inspired: keeps flights within still-air reach of potential diversion airports (open large/medium airports with a paved runway of at least 2,000 m by 45 m, OurAirports) or their own endpoints. Covered keeps the route, adjusted re-routes within a modest allowance, gap keeps the unconstrained route and reports the longest modelled diversion time. Not an operator authorisation; winds, tracks, airways and ATC are not modelled. See data/DIVERSION.md."
    },
    "legFields": "distanceKm is the flown (custom, routed or great-circle) distance used for estimates; greatCircleKm is always present; distanceModel says which; route.airspace and route.diversion describe the modelled constraints."
  },
  "schema": "https://tmrk.github.io/globehops/agent/itinerary.schema.json",
  "limits": {
    "stops": 10,
    "trackPoints": 20000,
    "journeys": 4,
    "tripSections": 6,
    "maximumStayMinutes": 525600,
    "minimumDocumentStops": 1,
    "urlCharacters": 8000,
    "itineraryParamCharacters": 6000,
    "maximumLayoverMinutes": 10080,
    "years": [1900, 2100],
    "timelineSamples": [2, 1000],
    "searchLimit": [1, 100]
  },
  "airportIdentifiers": {
    "accepted": ["IATA", "ICAO", "OurAirports ident", "numeric OurAirports id string"],
    "documentPattern": "^(?:[A-Za-z]{3}|[A-Za-z0-9]{4,8}|[0-9]+)$",
    "semantics": "Case-insensitive resolution; unknown codes are reported and skipped. Idents containing hyphens use numeric IDs in readable routes; the document syntax accepts 4–8 alphanumeric idents."
  },
  "timeFormats": {
    "local": "YYYY-MM-DDTHH:MM (optional :00 seconds and ±HH:MM or Z)",
    "instant": "YYYY-MM-DDTHH:MM[:SS](Z|±HH:MM)",
    "offsets": "An offset disambiguates airport-local time; it must match the zone at that wall time. Z means local UTC+0, not conversion from UTC.",
    "rules": "Runtime Intl IANA rules; DST gaps error, ambiguous times warn and choose earlier unless offset supplied."
  },
  "estimationModel": {
    "cruiseSpeedKmh": 850,
    "overheadMinutes": 30,
    "roundingMinutes": 5,
    "earthRadiusKm": 6371.0088,
    "defaultLayoverMinutes": 90,
    "notes": "Generated great circles on a sphere; custom paths preserve supplied geometry and usable altitude/time data. No winds, airline schedules, aircraft type or live flight tracking; generated altitude is illustrative."
  },
  "visualisationOptions": [
    "t",
    "play",
    "speed",
    "clock",
    "units",
    "daynight",
    "follow",
    "lang",
    "names",
    "borders",
    "airspace",
    "diversion",
    "leg"
  ],
  "validation": {
    "valid": "No error issues; warnings and info preserve validity. Structural validation precedes semantic resolution. Tolerant readable links keep usable values.",
    "issues": [
      { "code": "invalid_time", "severity": "error", "meaning": "Malformed local calendar time." },
      { "code": "date_out_of_range", "severity": "error", "meaning": "Supported years are 1900–2100." },
      { "code": "time_missing", "severity": "warning", "meaning": "A date was supplied without a time." },
      { "code": "timezone_unknown", "severity": "error", "meaning": "No zone mapped for this airport." },
      {
        "code": "timezone_unsupported",
        "severity": "error",
        "meaning": "Runtime Intl does not support the mapped zone."
      },
      {
        "code": "ambiguous_local_time",
        "severity": "warning",
        "meaning": "Repeated hour; earlier instant used unless an offset selects one."
      },
      {
        "code": "nonexistent_local_time",
        "severity": "error",
        "meaning": "Local time falls in a daylight-saving gap."
      },
      {
        "code": "offset_mismatch",
        "severity": "error",
        "meaning": "Explicit offset is invalid at this airport wall time."
      },
      {
        "code": "ignored_time",
        "severity": "warning",
        "meaning": "Arrival at origin or departure at destination is ignored."
      },
      {
        "code": "negative_flight_time",
        "severity": "error",
        "meaning": "Arrival must follow departure for a nonzero flight."
      },
      { "code": "connection_overlap", "severity": "error", "meaning": "Connection departs before its arrival." },
      { "code": "short_connection", "severity": "warning", "meaning": "Connection is shorter than 20 minutes." },
      {
        "code": "unusual_flight_time",
        "severity": "warning",
        "meaning": "Entered duration differs substantially from the estimate."
      },
      { "code": "long_layover", "severity": "warning", "meaning": "Connection exceeds seven days." },
      { "code": "future_rules", "severity": "info", "meaning": "Future times use current runtime time-zone rules." }
    ],
    "tripIssues": [
      {
        "code": "stay_overlap",
        "severity": "error",
        "meaning": "A section departs before the previous section arrives."
      }
    ],
    "agentIssues": [
      {
        "code": "use_compare_journeys",
        "severity": "error",
        "meaning": "Multi-journey documents require compare_journeys for calculations and timelines."
      },
      {
        "code": "use_trip_sections",
        "severity": "error",
        "meaning": "Multi-section trips are planned as trips (calculate_route), not as one journey."
      },
      {
        "code": "invalid_routing",
        "severity": "error",
        "meaning": "routing.diversion must be a boolean or 60–370 minutes."
      },
      { "code": "unknown_airport", "severity": "error", "meaning": "Unrecognised airport skipped." },
      { "code": "no_airports", "severity": "error", "meaning": "No known airport supplied." },
      { "code": "invalid_link", "severity": "error", "meaning": "Readable route syntax or limits invalid." },
      { "code": "invalid_assumption", "severity": "error", "meaning": "assumeDeparture is not an ISO instant." },
      {
        "code": "assume_departure_required",
        "severity": "error",
        "meaning": "Undated timeline needs an explicit anchor."
      },
      { "code": "timeline_unavailable", "severity": "error", "meaning": "No playable flight timeline." },
      { "code": "invalid_samples", "severity": "error", "meaning": "Sample count or instant array exceeds bounds." },
      { "code": "invalid_instant", "severity": "error", "meaning": "Requested instant malformed." },
      {
        "code": "invalid_clock",
        "severity": "error",
        "meaning": "Clock must identify local, utc or an airport in this route."
      },
      { "code": "invalid_leg", "severity": "error", "meaning": "Selected flight must exist in this route." },
      { "code": "link_too_large", "severity": "error", "meaning": "Cannot encode within sharing limits." }
    ],
    "warnings": [
      {
        "code": "track_simplified",
        "severity": "warning",
        "meaning": "Share URL simplifies or drops track geometry to fit; calculations retain the exact input."
      },
      {
        "code": "routing_unavailable",
        "severity": "warning",
        "meaning": "Requested routing data is not available here; those flights follow great circles."
      },
      {
        "code": "document_link_too_large",
        "severity": "warning",
        "meaning": "The complete document URL exceeds limits; use the available readable URL."
      }
    ],
    "structuralCodes": [
      "type",
      "encoding",
      "tuple",
      "instant",
      "const",
      "oneOf",
      "required",
      "items",
      "pattern",
      "range",
      "position",
      "enum",
      "additionalProperties",
      "sections_mismatch"
    ]
  },
  "versioning": "Additive compatible changes retain v1. Breaking changes require document version 2 and a v2 link parameter/version contract; existing v1 links remain readable.",
  "dataset": {
    "freshness": "https://tmrk.github.io/globehops/agent/dataset.json",
    "airports": "OurAirports, public domain; checked daily, freshness is recorded rather than guaranteed.",
    "timeZones": "timezone-boundary-builder 2026f, derived from OpenStreetMap; ODbL-1.0. Separate derivative database at src/data/generated/timezones.json.",
    "repository": "https://github.com/tmrk/globehops"
  },
  "mcp": {
    "transport": "local stdio",
    "run": "node /absolute/path/to/globehops/mcp/server.ts",
    "prerequisites": "Node >=22.18; npm ci in the checkout.",
    "baseUrlEnvironment": "GLOBEHOPS_BASE_URL",
    "tools": [
      "search_airports",
      "compare_journeys",
      "calculate_route",
      "validate_itinerary",
      "visualize_route",
      "import_route",
      "get_journey_timeline"
    ],
    "resources": [
      "globehops://agent/capabilities.json",
      "globehops://agent/itinerary.schema.json",
      "globehops://agent/examples.json",
      "globehops://agent/usage"
    ],
    "prompts": ["plan_and_visualize"],
    "documentation": "https://github.com/tmrk/globehops/blob/main/docs/AGENT_INTEGRATION.md",
    "remoteEndpoint": null
  },
  "routeImport": {
    "tool": "import_route",
    "formats": ["gpx", "kml", "kmz", "geojson", "csv", "igc", "fpl", "pln", "route"],
    "maxInputBytes": 20971520,
    "mcp": "Exactly one of text or base64 (KMZ only), optional fileName, format, view, routing and compareWithGenerated. Returns flights, warnings, document and visualization URLs; documents support 20000 points per flight.",
    "browser": "text, optional fileName, format and mode (replace by default, or compare with the current journey). KMZ requires the local MCP tool. Returns flights, warnings and the resulting share link.",
    "grouping": "Consecutive flights sharing an airport form one journey (up to 9 flights); open jaws start a new trip section (up to 6). Overflow is reported in warnings.",
    "privacy": "Parsing is local; nothing is uploaded. Shared links contain path coordinates, altitudes and times and simplify long paths within the URL budget.",
    "errors": ["unrecognised", "invalid", "no_path", "too_large", "unsupported", "empty"],
    "examples": [
      {
        "tool": "import_route",
        "arguments": { "text": "EGLL CPT DCT KJFK", "format": "route", "compareWithGenerated": true }
      },
      {
        "tool": "import_route",
        "arguments": {
          "text": "<gpx><trk><trkseg><trkpt lat=\"55.6179\" lon=\"12.656\"><time>2026-11-01T10:00:00Z</time></trkpt><trkpt lat=\"59.6519\" lon=\"17.9186\"><time>2026-11-01T11:00:00Z</time></trkpt></trkseg></trk></gpx>",
          "fileName": "flight.gpx"
        }
      }
    ]
  },
  "webMcp": {
    "experimental": true,
    "api": "document.modelContext (AbortSignal cleanup), legacy navigator.modelContext registerTool/unregisterTool or provideContext",
    "tools": [
      "import_route",
      "get_journey",
      "set_route",
      "compare_journeys",
      "set_view",
      "select_flight",
      "get_share_link"
    ],
    "network": false
  },
  "locales": ["en", "hu", "sv"],
  "licences": {
    "code": "MIT",
    "airports": "Public domain (OurAirports)",
    "timeZones": "ODbL-1.0; © OpenStreetMap contributors",
    "earth": "NASA public-use imagery; attribution in project notices",
    "borders": "Natural Earth public domain",
    "notices": "https://github.com/tmrk/globehops/blob/main/THIRD_PARTY_NOTICES.md"
  }
}
