[object Object]

← back to Cli Printing Press

feat(catalog): add OpenRouteService under new maps category (#1513)

43c90ba03553c41837c6959c13d99aafb41d1c8e · 2026-05-16 18:11:46 +0100 · liam-ai-reality

* feat(catalog): add OpenRouteService under new maps category

Adds OpenRouteService (ORS) to the embedded catalog under a new `maps`
category, the first map / routing / VRP entry. Backed by a hand-crafted
OpenAPI 3.0 spec covering directions, matrix, isochrones, optimization,
geocode forward/reverse.

- New `maps` category registered in internal/catalog/catalog.go,
  internal/catalog/catalog_test.go, and the AGENTS.md prose enumeration.
- catalog/openrouteservice.yaml (tier: community, spec_source: docs).
- catalog/specs/openrouteservice-spec.yaml — hand-crafted OpenAPI 3.0.3,
  11 schemas, 3 reusable responses, 9 routing profiles incl driving-hgv.
- Golden update for catalog-list/stdout.txt (+3 lines: maps block).

Refs #1512

Verified locally:
  go test ./internal/catalog/...   # 71 pass
  go build ./...                   # clean
  go vet ./...                     # clean
  ./scripts/golden.sh verify       # 20 / 20 pass

* fix(cli): clarify openrouteservice auth notes

* chore(catalog): drop sandbox_endpoint and OPENROUTESERVICE_API_KEY alias

- sandbox_endpoint == prod for ORS (no separate test endpoint). Removed.
- ORS_API_KEY is the canonical env var used by ors-py / ors-js; alias
  OPENROUTESERVICE_API_KEY is verbose and not used by any existing
  community tool. Removed.

Refs #1512

* Update catalog/specs/openrouteservice-spec.yaml

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update catalog/specs/openrouteservice-spec.yaml

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update catalog/specs/openrouteservice-spec.yaml

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

Files touched

Diff

commit 43c90ba03553c41837c6959c13d99aafb41d1c8e
Author: liam-ai-reality <lm@aireality.io>
Date:   Sat May 16 18:11:46 2026 +0100

    feat(catalog): add OpenRouteService under new maps category (#1513)
    
    * feat(catalog): add OpenRouteService under new maps category
    
    Adds OpenRouteService (ORS) to the embedded catalog under a new `maps`
    category, the first map / routing / VRP entry. Backed by a hand-crafted
    OpenAPI 3.0 spec covering directions, matrix, isochrones, optimization,
    geocode forward/reverse.
    
    - New `maps` category registered in internal/catalog/catalog.go,
      internal/catalog/catalog_test.go, and the AGENTS.md prose enumeration.
    - catalog/openrouteservice.yaml (tier: community, spec_source: docs).
    - catalog/specs/openrouteservice-spec.yaml — hand-crafted OpenAPI 3.0.3,
      11 schemas, 3 reusable responses, 9 routing profiles incl driving-hgv.
    - Golden update for catalog-list/stdout.txt (+3 lines: maps block).
    
    Refs #1512
    
    Verified locally:
      go test ./internal/catalog/...   # 71 pass
      go build ./...                   # clean
      go vet ./...                     # clean
      ./scripts/golden.sh verify       # 20 / 20 pass
    
    * fix(cli): clarify openrouteservice auth notes
    
    * chore(catalog): drop sandbox_endpoint and OPENROUTESERVICE_API_KEY alias
    
    - sandbox_endpoint == prod for ORS (no separate test endpoint). Removed.
    - ORS_API_KEY is the canonical env var used by ors-py / ors-js; alias
      OPENROUTESERVICE_API_KEY is verbose and not used by any existing
      community tool. Removed.
    
    Refs #1512
    
    * Update catalog/specs/openrouteservice-spec.yaml
    
    Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
    
    * Update catalog/specs/openrouteservice-spec.yaml
    
    Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
    
    * Update catalog/specs/openrouteservice-spec.yaml
    
    Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
    
    ---------
    
    Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
---
 AGENTS.md                                        |   2 +-
 catalog/openrouteservice.yaml                    |  50 ++
 catalog/specs/openrouteservice-spec.yaml         | 689 +++++++++++++++++++++++
 internal/catalog/catalog.go                      |   1 +
 internal/catalog/catalog_test.go                 |   2 +-
 testdata/golden/expected/catalog-list/stdout.txt |   3 +
 6 files changed, 745 insertions(+), 2 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
index 8f2ea645..723ea5a2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -176,7 +176,7 @@ When adding or editing `catalog/*.yaml`, first decide whether the entry belongs
 - The entry must pass `internal/catalog` validation.
 - Required fields: `name`, `display_name`, `description`, `category`, and `tier`, plus `spec_url` and `spec_format` unless the entry is wrapper-only (`wrapper_libraries` is set and `spec_url` is omitted).
 - `spec_url`, when present, must use HTTPS.
-- `category` must be one of `ai`, `auth`, `cloud`, `commerce`, `developer-tools`, `devices`, `food-and-dining`, `marketing`, `media-and-entertainment`, `monitoring`, `payments`, `productivity`, `project-management`, `sales-and-crm`, `social-and-messaging`, `travel`, or `other`. The validator also accepts `example` as a test-only catch-all; do not use it for real catalog entries.
+- `category` must be one of `ai`, `auth`, `cloud`, `commerce`, `developer-tools`, `devices`, `food-and-dining`, `maps`, `marketing`, `media-and-entertainment`, `monitoring`, `payments`, `productivity`, `project-management`, `sales-and-crm`, `social-and-messaging`, `travel`, or `other`. The validator also accepts `example` as a test-only catch-all; do not use it for real catalog entries.
 - `tier` must be `official` or `community`.
 - `bearer_refresh`, when present, must include `bundle_url` and `pattern`; `bundle_url` must use HTTPS, and `pattern` must compile as a Go regexp.
 - `auth_key_url`, when present, must use HTTPS. It overrides any URL inferred from the spec and surfaces in the printed CLI as `Get a key at: <URL>`.
diff --git a/catalog/openrouteservice.yaml b/catalog/openrouteservice.yaml
new file mode 100644
index 00000000..c4ab17f9
--- /dev/null
+++ b/catalog/openrouteservice.yaml
@@ -0,0 +1,50 @@
+name: openrouteservice
+display_name: OpenRouteService
+description: Open-source routing, geocoding, matrix, isochrones, and VRP optimization on OpenStreetMap data.
+category: maps
+spec_url: https://raw.githubusercontent.com/mvanhorn/cli-printing-press/main/catalog/specs/openrouteservice-spec.yaml
+spec_format: yaml
+openapi_version: "3.0"
+tier: community
+spec_source: docs
+verified_date: "2026-05-16"
+homepage: https://openrouteservice.org
+auth_key_url: https://openrouteservice.org/dev/#/signup
+auth_required: true
+auth_instructions: Free signup at https://openrouteservice.org/dev/#/signup. Pass the key as the raw Authorization header value (no Bearer prefix).
+auth_env_vars:
+  - ORS_API_KEY
+notes: |
+  Single vendor covers everything a backend dispatch / routing workflow needs:
+
+    - geocode/search, geocode/reverse (Pelias) — address ↔ coordinate
+    - directions/{profile} — single route + ETA + geometry
+    - matrix/{profile} — N×M duration / distance matrix; the primary input for
+      VRP solvers like or-tools VRPTW
+    - isochrones/{profile} — reachable-area polygons; service-zone definitions
+    - /optimization (Vroom) — built-in VRP solver; alternative to running
+      or-tools locally for small (≤50 jobs, ≤3 vehicles) problems
+
+  Hosted free-plan limits: 2,000 directions/day, 500 matrix/day,
+  500 optimization/day, 500 isochrones/day, 1,000 geocode/day, 40/min on
+  most endpoints. Self-hosted ORS Docker removes request caps for routing,
+  matrix, isochrones, and optimization; geocoding is hosted-public-API only
+  and is not available on a stock self-hosted ORS instance.
+
+  Profiles include driving-car, driving-hgv (service vans / utility vehicles —
+  respects bridge weight, low-clearance, large-vehicle turn restrictions),
+  cycling-* (regular / mountain / road / electric), foot-* (walking / hiking),
+  and wheelchair.
+
+  Spec sourcing: the spec checked in at catalog/specs/openrouteservice-spec.yaml
+  is hand-crafted from the public API reference at
+  https://giscience.github.io/openrouteservice/api-reference/ and covers the
+  high-leverage endpoints listed above. ORS itself generates a SpringDoc spec
+  at runtime — for the full surface (POIs, snap-to-road, elevation, advanced
+  optimization constraints), run `docker run -p 8080:8080
+  openrouteservice/openrouteservice:latest` and fetch
+  `http://localhost:8080/ors/v2/api-docs` to refresh this spec, then re-print
+  via /printing-press-reprint openrouteservice.
+
+  The same printed binary can target hosted ORS or a self-hosted ORS backend
+  via --base-url for routing, matrix, isochrones, and optimization endpoints.
diff --git a/catalog/specs/openrouteservice-spec.yaml b/catalog/specs/openrouteservice-spec.yaml
new file mode 100644
index 00000000..18ff30c2
--- /dev/null
+++ b/catalog/specs/openrouteservice-spec.yaml
@@ -0,0 +1,689 @@
+openapi: "3.0.3"
+info:
+  title: OpenRouteService API
+  description: |
+    Open-source routing, geocoding, isochrone, distance-matrix, and VRP optimization API
+    built on OpenStreetMap. Pelias powers geocoding; Vroom powers optimization.
+
+    This spec covers the endpoints commonly used for back-end agent workflows
+    (geocoding, matrices for VRP solvers, route ETAs, isochrones, full VRP solve).
+    The full ORS surface (POIs, snap-to-road, elevation, optimization advanced
+    constraints) can be added incrementally — see notes in the catalog entry.
+
+    The hosted free API (`https://api.openrouteservice.org`) exposes routing,
+    matrix, isochrones, optimization, and geocoding. Self-hosted ORS Docker
+    covers the ORS backend endpoints (routing, matrix, isochrones, optimization);
+    geocoding is hosted-public-API only.
+
+    Auth: pass the API key in the `Authorization` header. Sign up free at
+    https://openrouteservice.org/dev/#/signup.
+
+    Rate limits (free plan, hosted): 2,000 directions/day, 500 matrix/day,
+    500 optimization/day, 500 isochrones/day, 1,000 geocode/day, 40/min on
+    most endpoints. Self-hosted backend endpoints: no hosted request caps.
+  version: "v2"
+  contact:
+    name: OpenRouteService
+    url: https://openrouteservice.org
+
+servers:
+  - url: https://api.openrouteservice.org
+    description: Hosted free API (requires API key)
+
+security:
+  - apiKey: []
+
+tags:
+  - name: directions
+    description: Route between two or more points with ETA, distance, and geometry.
+  - name: matrix
+    description: Many-to-many duration / distance matrix. Primary input for VRP solvers.
+  - name: isochrones
+    description: Reachable-area polygons by time or distance.
+  - name: optimization
+    description: VRP solver (Vroom backend). Assign N jobs to M vehicles optimally.
+  - name: geocode
+    description: Pelias forward and reverse geocoding.
+
+paths:
+  /v2/directions/{profile}:
+    post:
+      operationId: getDirections
+      summary: Compute a route between coordinates
+      description: |
+        Returns the ordered route, total duration, total distance, and turn-by-turn
+        steps. Use `driving-hgv` for service vans / heavy-goods routing (better
+        bridge-weight and turn-restriction handling than `driving-car`).
+      tags: [directions]
+      parameters:
+        - $ref: '#/components/parameters/Profile'
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/DirectionsRequest'
+      responses:
+        '200':
+          description: Route computed
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/DirectionsResponse'
+        '400': { $ref: '#/components/responses/BadRequest' }
+        '401': { $ref: '#/components/responses/Unauthorized' }
+        '429': { $ref: '#/components/responses/RateLimited' }
+
+  /v2/matrix/{profile}:
+    post:
+      operationId: getMatrix
+      summary: Many-to-many duration / distance matrix
+      description: |
+        Returns an N×M matrix of durations (seconds) and/or distances (metres) between
+        all origin / destination pairs. This is the primary input for a Vehicle Routing
+        Problem solver — feed the matrix into or-tools VRPTW.
+
+        Free-plan limits: 3,500 locations per request total (origins × destinations),
+        or 25 with `metrics: ['duration', 'distance']`.
+      tags: [matrix]
+      parameters:
+        - $ref: '#/components/parameters/Profile'
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/MatrixRequest'
+      responses:
+        '200':
+          description: Matrix computed
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MatrixResponse'
+        '400': { $ref: '#/components/responses/BadRequest' }
+        '401': { $ref: '#/components/responses/Unauthorized' }
+        '429': { $ref: '#/components/responses/RateLimited' }
+
+  /v2/isochrones/{profile}:
+    post:
+      operationId: getIsochrones
+      summary: Reachable-area polygons by time or distance
+      description: |
+        Returns one polygon per requested location × range — the area reachable
+        within the given travel time (seconds) or distance (metres). Useful for
+        defining service zones, dispatcher catchment analysis, and SLA-feasibility
+        checks at a glance.
+      tags: [isochrones]
+      parameters:
+        - $ref: '#/components/parameters/Profile'
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/IsochronesRequest'
+      responses:
+        '200':
+          description: Isochrones computed
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/IsochronesResponse'
+        '400': { $ref: '#/components/responses/BadRequest' }
+        '401': { $ref: '#/components/responses/Unauthorized' }
+        '429': { $ref: '#/components/responses/RateLimited' }
+
+  /optimization:
+    post:
+      operationId: solveOptimization
+      summary: Solve a Vehicle Routing Problem (Vroom)
+      description: |
+        Assigns jobs to vehicles optimally, minimising total travel time subject
+        to time windows, skill matching, capacity, and breaks. Backed by Vroom.
+
+        Free-plan limits: 50 jobs, 3 vehicles per request. Self-hosted ORS has no
+        such caps. For larger problems, run or-tools VRPTW locally and use only
+        the `/v2/matrix/*` endpoint here.
+      tags: [optimization]
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/OptimizationRequest'
+      responses:
+        '200':
+          description: Optimised plan
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/OptimizationResponse'
+        '400': { $ref: '#/components/responses/BadRequest' }
+        '401': { $ref: '#/components/responses/Unauthorized' }
+        '429': { $ref: '#/components/responses/RateLimited' }
+
+  /geocode/search:
+    get:
+      operationId: geocodeSearch
+      summary: Forward geocode an address or place name
+      description: |
+        Pelias forward geocoder. Converts a free-text address into one or more
+        candidate coordinates with provenance, confidence, and structured address
+        components. Restrict to a country with `boundary.country` (ISO 3166-1
+        alpha-2 or alpha-3) for higher-quality matches.
+      tags: [geocode]
+      parameters:
+        - in: query
+          name: text
+          required: true
+          schema: { type: string }
+          description: Free-form address or place name.
+        - in: query
+          name: focus.point.lon
+          schema: { type: number, format: double }
+          description: Longitude to bias results toward.
+        - in: query
+          name: focus.point.lat
+          schema: { type: number, format: double }
+          description: Latitude to bias results toward.
+        - in: query
+          name: boundary.country
+          schema: { type: string }
+          description: ISO 3166-1 country code to restrict results to (e.g. `AU`).
+        - in: query
+          name: size
+          schema: { type: integer, default: 10, minimum: 1, maximum: 40 }
+          description: Maximum candidates to return.
+      responses:
+        '200':
+          description: Geocode candidates
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GeocodeResponse'
+        '400': { $ref: '#/components/responses/BadRequest' }
+        '401': { $ref: '#/components/responses/Unauthorized' }
+        '429': { $ref: '#/components/responses/RateLimited' }
+
+  /geocode/reverse:
+    get:
+      operationId: geocodeReverse
+      summary: Reverse-geocode a coordinate into an address
+      description: |
+        Returns the nearest known address(es) for a given coordinate. Pelias-backed.
+      tags: [geocode]
+      parameters:
+        - in: query
+          name: point.lon
+          required: true
+          schema: { type: number, format: double }
+          description: Longitude.
+        - in: query
+          name: point.lat
+          required: true
+          schema: { type: number, format: double }
+          description: Latitude.
+        - in: query
+          name: size
+          schema: { type: integer, default: 10, minimum: 1, maximum: 40 }
+          description: Maximum candidates to return.
+        - in: query
+          name: boundary.circle.radius
+          schema: { type: number, format: double }
+          description: Restrict to this radius (km) around the point.
+      responses:
+        '200':
+          description: Reverse-geocode candidates
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GeocodeResponse'
+        '400': { $ref: '#/components/responses/BadRequest' }
+        '401': { $ref: '#/components/responses/Unauthorized' }
+        '429': { $ref: '#/components/responses/RateLimited' }
+
+components:
+  securitySchemes:
+    apiKey:
+      type: apiKey
+      in: header
+      name: Authorization
+      description: |
+        Free key from https://openrouteservice.org/dev/#/signup. Pass the raw key
+        as the `Authorization` header value (no `Bearer ` prefix). The CLI reads
+        the key from the `ORS_API_KEY` environment variable.
+
+  parameters:
+    Profile:
+      in: path
+      name: profile
+      required: true
+      schema:
+        type: string
+        enum:
+          - driving-car
+          - driving-hgv
+          - cycling-regular
+          - cycling-mountain
+          - cycling-road
+          - cycling-electric
+          - foot-walking
+          - foot-hiking
+          - wheelchair
+      description: |
+        Routing profile. `driving-hgv` is the right pick for service vans / utility
+        vehicles — it respects bridge weight, low-clearance, and large-vehicle turn
+        restrictions that `driving-car` ignores.
+
+  schemas:
+    Coordinate:
+      type: array
+      description: '[lon, lat] pair, decimal degrees, WGS84.'
+      items: { type: number, format: double }
+      minItems: 2
+      maxItems: 2
+
+    DirectionsRequest:
+      type: object
+      required: [coordinates]
+      properties:
+        coordinates:
+          type: array
+          minItems: 2
+          maxItems: 50
+          items: { $ref: '#/components/schemas/Coordinate' }
+          description: Ordered route waypoints. First is origin, last is destination.
+        alternative_routes:
+          type: object
+          properties:
+            target_count: { type: integer, minimum: 1, maximum: 3 }
+            weight_factor: { type: number, format: double }
+            share_factor: { type: number, format: double }
+        units:
+          type: string
+          enum: [m, km, mi]
+          default: m
+        language:
+          type: string
+          description: Localisation for textual turn instructions (e.g. `en-AU`).
+        instructions:
+          type: boolean
+          default: true
+        elevation:
+          type: boolean
+          default: false
+        preference:
+          type: string
+          enum: [fastest, shortest, recommended]
+          default: fastest
+        radiuses:
+          type: array
+          items: { type: number }
+          description: Per-waypoint snap-radius (m). Use -1 to disable snapping for that point.
+
+    DirectionsResponse:
+      type: object
+      properties:
+        routes:
+          type: array
+          items:
+            type: object
+            properties:
+              summary:
+                type: object
+                properties:
+                  distance: { type: number, format: double, description: 'Total distance, units depend on `units` request field.' }
+                  duration: { type: number, format: double, description: 'Total duration in seconds.' }
+              geometry:
+                type: string
+                description: Polyline-encoded geometry (precision 5).
+              segments:
+                type: array
+                items:
+                  type: object
+                  properties:
+                    distance: { type: number, format: double }
+                    duration: { type: number, format: double }
+                    steps:
+                      type: array
+                      items: { type: object, additionalProperties: true }
+              bbox:
+                type: array
+                items: { type: number, format: double }
+              way_points:
+                type: array
+                items: { type: integer }
+        bbox:
+          type: array
+          items: { type: number, format: double }
+        metadata:
+          type: object
+          additionalProperties: true
+
+    MatrixRequest:
+      type: object
+      required: [locations]
+      properties:
+        locations:
+          type: array
+          minItems: 2
+          items: { $ref: '#/components/schemas/Coordinate' }
+          description: All locations participating in the matrix.
+        sources:
+          type: array
+          items: { type: integer }
+          description: Indices into `locations` to use as origins. Omit to use all locations.
+        destinations:
+          type: array
+          items: { type: integer }
+          description: Indices into `locations` to use as destinations. Omit to use all locations.
+        metrics:
+          type: array
+          items: { type: string, enum: [duration, distance] }
+          default: [duration]
+        units:
+          type: string
+          enum: [m, km, mi]
+          default: m
+        resolve_locations:
+          type: boolean
+          default: false
+
+    MatrixResponse:
+      type: object
+      properties:
+        durations:
+          type: array
+          description: N×M matrix of durations in seconds.
+          items:
+            type: array
+            items: { type: number, format: double, nullable: true }
+        distances:
+          type: array
+          description: N×M matrix of distances (units per request).
+          items:
+            type: array
+            items: { type: number, format: double, nullable: true }
+        sources:
+          type: array
+          items: { type: object, additionalProperties: true }
+        destinations:
+          type: array
+          items: { type: object, additionalProperties: true }
+        metadata:
+          type: object
+          additionalProperties: true
+
+    IsochronesRequest:
+      type: object
+      required: [locations, range]
+      properties:
+        locations:
+          type: array
+          minItems: 1
+          maxItems: 5
+          items: { $ref: '#/components/schemas/Coordinate' }
+        range:
+          type: array
+          items: { type: number, format: double }
+          description: Time (seconds) or distance (metres) thresholds. One polygon per value, per location.
+        range_type:
+          type: string
+          enum: [time, distance]
+          default: time
+        interval:
+          type: number
+          format: double
+          description: Optional sub-interval to densify the response.
+        attributes:
+          type: array
+          items: { type: string, enum: [area, reachfactor, total_pop] }
+        smoothing:
+          type: number
+          format: double
+          minimum: 0
+          maximum: 100
+          default: 0
+
+    IsochronesResponse:
+      type: object
+      properties:
+        type: { type: string }
+        bbox:
+          type: array
+          items: { type: number, format: double }
+        features:
+          type: array
+          items:
+            type: object
+            properties:
+              type: { type: string }
+              properties:
+                type: object
+                additionalProperties: true
+              geometry:
+                type: object
+                additionalProperties: true
+        metadata:
+          type: object
+          additionalProperties: true
+
+    OptimizationRequest:
+      type: object
+      required: [jobs, vehicles]
+      properties:
+        jobs:
+          type: array
+          items:
+            type: object
+            required: [id, location]
+            properties:
+              id: { type: integer }
+              location:
+                type: array
+                items: { type: number, format: double }
+                minItems: 2
+                maxItems: 2
+              service: { type: integer, description: 'On-site service duration in seconds.' }
+              amount:
+                type: array
+                items: { type: integer }
+                description: Capacity demand vector (matches vehicle.capacity dimensions).
+              skills:
+                type: array
+                items: { type: integer }
+              time_windows:
+                type: array
+                items:
+                  type: array
+                  items: { type: integer, description: 'Unix epoch seconds.' }
+                  minItems: 2
+                  maxItems: 2
+              priority: { type: integer, minimum: 0, maximum: 100 }
+        vehicles:
+          type: array
+          items:
+            type: object
+            required: [id, profile]
+            properties:
+              id: { type: integer }
+              profile:
+                type: string
+                enum:
+                  - driving-car
+                  - driving-hgv
+                  - cycling-regular
+                  - cycling-mountain
+                  - cycling-road
+                  - cycling-electric
+                  - foot-walking
+                  - foot-hiking
+                  - wheelchair
+              start:
+                type: array
+                items: { type: number, format: double }
+                minItems: 2
+                maxItems: 2
+              end:
+                type: array
+                items: { type: number, format: double }
+                minItems: 2
+                maxItems: 2
+              capacity:
+                type: array
+                items: { type: integer }
+              skills:
+                type: array
+                items: { type: integer }
+              time_window:
+                type: array
+                items: { type: integer }
+                minItems: 2
+                maxItems: 2
+              breaks:
+                type: array
+                items:
+                  type: object
+                  properties:
+                    id: { type: integer }
+                    service: { type: integer }
+                    time_windows:
+                      type: array
+                      items:
+                        type: array
+                        items: { type: integer }
+                        minItems: 2
+                        maxItems: 2
+        options:
+          type: object
+          properties:
+            g: { type: boolean, description: 'Include route geometries in response.' }
+
+    OptimizationResponse:
+      type: object
+      properties:
+        code: { type: integer, description: '0 = success.' }
+        summary:
+          type: object
+          properties:
+            cost: { type: integer }
+            duration: { type: integer }
+            distance: { type: integer }
+            unassigned: { type: integer }
+            service: { type: integer }
+            waiting_time: { type: integer }
+        routes:
+          type: array
+          items:
+            type: object
+            properties:
+              vehicle: { type: integer }
+              steps:
+                type: array
+                items:
+                  type: object
+                  properties:
+                    type: { type: string, enum: [start, job, break, end] }
+                    location:
+                      type: array
+                      items: { type: number, format: double }
+                    job: { type: integer }
+                    service: { type: integer }
+                    waiting_time: { type: integer }
+                    arrival: { type: integer }
+                    duration: { type: integer }
+              cost: { type: integer }
+              duration: { type: integer }
+              distance: { type: integer }
+              service: { type: integer }
+              waiting_time: { type: integer }
+              geometry: { type: string }
+        unassigned:
+          type: array
+          items:
+            type: object
+            properties:
+              id: { type: integer }
+              location:
+                type: array
+                items: { type: number, format: double }
+              reason: { type: string }
+
+    GeocodeResponse:
+      type: object
+      properties:
+        type: { type: string, enum: [FeatureCollection] }
+        bbox:
+          type: array
+          items: { type: number, format: double }
+        features:
+          type: array
+          items:
+            type: object
+            properties:
+              type: { type: string }
+              geometry:
+                type: object
+                properties:
+                  type: { type: string, enum: [Point] }
+                  coordinates:
+                    type: array
+                    items: { type: number, format: double }
+                    minItems: 2
+                    maxItems: 2
+              properties:
+                type: object
+                properties:
+                  id: { type: string }
+                  gid: { type: string }
+                  layer: { type: string }
+                  source: { type: string }
+                  name: { type: string }
+                  confidence: { type: number, format: double }
+                  accuracy: { type: string }
+                  country: { type: string }
+                  country_a: { type: string, description: 'ISO 3166 alpha-3.' }
+                  region: { type: string }
+                  locality: { type: string }
+                  street: { type: string }
+                  housenumber: { type: string }
+                  postalcode: { type: string }
+                  label: { type: string, description: 'Human-readable full address.' }
+        geocoding:
+          type: object
+          additionalProperties: true
+
+    ApiError:
+      type: object
+      properties:
+        error:
+          type: object
+          properties:
+            code: { type: integer }
+            message: { type: string }
+        info:
+          type: object
+          properties:
+            engine:
+              type: object
+              additionalProperties: true
+            attribution: { type: string }
+            timestamp: { type: integer }
+
+  responses:
+    BadRequest:
+      description: Malformed request
+      content:
+        application/json:
+          schema: { $ref: '#/components/schemas/ApiError' }
+    Unauthorized:
+      description: API key missing or invalid
+      content:
+        application/json:
+          schema: { $ref: '#/components/schemas/ApiError' }
+    RateLimited:
+      description: Per-minute or daily quota exhausted
+      content:
+        application/json:
+          schema: { $ref: '#/components/schemas/ApiError' }
diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go
index fe3daeb2..f7cfc8b5 100644
--- a/internal/catalog/catalog.go
+++ b/internal/catalog/catalog.go
@@ -31,6 +31,7 @@ var validCategories = map[string]struct{}{
 	"developer-tools":         {},
 	"devices":                 {},
 	"food-and-dining":         {},
+	"maps":                    {},
 	"marketing":               {},
 	"media-and-entertainment": {},
 	"monitoring":              {},
diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go
index b23f8082..227c2bfc 100644
--- a/internal/catalog/catalog_test.go
+++ b/internal/catalog/catalog_test.go
@@ -242,7 +242,7 @@ func TestValidateEntry(t *testing.T) {
 func TestAllPublicCategoriesAreValid(t *testing.T) {
 	publicCategories := []string{
 		"ai", "auth", "cloud", "commerce", "developer-tools", "devices",
-		"food-and-dining", "marketing", "media-and-entertainment", "monitoring",
+		"food-and-dining", "maps", "marketing", "media-and-entertainment", "monitoring",
 		"payments", "productivity", "project-management", "sales-and-crm",
 		"social-and-messaging", "travel", "other",
 	}
diff --git a/testdata/golden/expected/catalog-list/stdout.txt b/testdata/golden/expected/catalog-list/stdout.txt
index 398e4f31..27baa4da 100644
--- a/testdata/golden/expected/catalog-list/stdout.txt
+++ b/testdata/golden/expected/catalog-list/stdout.txt
@@ -16,6 +16,9 @@ developer-tools:
 example:
   petstore             Canonical OpenAPI example - pet store management
 
+maps:
+  openrouteservice     Open-source routing, geocoding, matrix, isochrones, and VRP optimization on OpenStreetMap data.
+
 marketing:
   producthunt          Find, monitor, and export Product Hunt launches for launch research, scouting, and competitive tracking without requiring a Product Hunt API application.
 

← bf9014d4 fix(cli): synthesize undeclared path placeholders in OpenAPI  ·  back to Cli Printing Press  ·  fix(cli): match both singular and plural required-flag cobra f5ea270d →