On va où ?API

Routing API / Tutorial

How to compute a safe bike route with an API

Tutorial published on · curl, JavaScript, Python, MapLibre GL JS, MCP

You will compute a real bike trip across Paris, read what makes it safe or not, display it on a map, then handle errors the way you would in production. All you need is a free API key, a terminal and either Node.js or Python.

Get a free API key API documentation

1. Shortest is not safest

A general-purpose router looks for the shortest trip, in distance or in time. In a city, that trip often runs along main roads: they are the most direct streets, and also the ones with the densest and fastest motor traffic. For a car, that is the right answer. For a bike, it is often the least pleasant one.

What matters on a bike rarely shows in the travel time:

A good bike routing service therefore does two things: it offers several routes, and it tells you what each one is worth. That is how the « On va où ? » Routing API works: one request returns up to three variants, safe, balanced and fast, each with its share of cycle lanes, main roads and unpaved surfaces, and its elevation. Your app picks one, or lets the rider choose, with the facts in hand.

“Safe” here describes the infrastructure along the route, as OpenStreetMap maps it. It is a strong criterion for choosing a route: show the indicators to your users rather than promising a risk-free trip.

2. Get a free API key

  1. Go to console.onvaou.app and create an account with your email address (Créer un compte; the portal is in French). A verification code is emailed to you.
  2. In the Clés (Keys) menu, name the key (for example “tutorial”) and click Créer la clé (Create key).
  3. Copy it right away: it starts with ovo_live_ and will not be shown again.

The Découverte plan is free, with no credit card: 10,000 requests per month, 1 request per second, for development and non-commercial use. Commercial use starts with the Solo plan, 19 € per month excluding VAT. All of Europe is covered on every plan.

Keep the key in an environment variable rather than in your code. On macOS, Linux or WSL:

export OVO_API_KEY="ovo_live_..."

On Windows, in PowerShell:

$env:OVO_API_KEY = "ovo_live_..."

The key stays on your server. The API does not accept calls from a browser (no CORS), and a key shipped in a web page or a mobile app can be read by anyone. The map section shows how to route the call through your server.

3. First call with curl

There is a single endpoint, POST https://api.onvaou.app/v1/routes, with the key in the x-api-key header. Let’s compute a bike trip from Paris city hall (Hôtel de Ville) to Vincennes:

curl -sS -X POST https://api.onvaou.app/v1/routes \
  -H "x-api-key: $OVO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"origin":[2.3522,48.8566],"destination":[2.4392,48.8474],"mode":"bike","language":"en"}'

Only origin and destination are required. mode defaults to bike, and turn-by-turn instructions default to French: "language": "en" switches them to English (de, es, it, nl and pt are also available).

Coordinates are written [longitude, latitude], in that order, as in GeoJSON. Most map websites display them the other way round. If you swap them, the point lands in the Indian Ocean and the API answers 422 out_of_coverage.

4. Read the response and pick a variant

Here is the actual response returned on 21 September 2026, shortened (coordinates, elevation profile and steps):

{
  "mode": "bike",
  "routes": [
    {
      "variants": ["safe", "balanced", "fast"],
      "distanceM": 7159,
      "durationS": 1525,
      "ascentM": 144,
      "descentM": 127,
      "indicators": {
        "cyclewayShare": 0.773,
        "unpavedShare": 0,
        "mainRoadShare": 0.036,
        "motorwayShare": 0
      },
      "geometry": {
        "type": "LineString",
        "coordinates": [[2.352139, 48.857037], [2.352162, 48.857078], [2.352188, 48.857125], "..."]
      },
      "elevationProfile": [[0, 39], [21, 39], [39, 39], "..."],
      "steps": [
        { "instruction": "Head north", "name": null, "distanceM": 23, "durationS": 26, "type": 11 },
        { "instruction": "Turn right", "name": null, "distanceM": 12, "durationS": 7, "type": 1 },
        { "instruction": "Turn left", "name": null, "distanceM": 4, "durationS": 2, "type": 0 },
        { "instruction": "Turn right onto Rue de Rivoli", "name": "Rue de Rivoli", "distanceM": 717, "durationS": 143, "type": 1 },
        "..."
      ]
    }
  ],
  "usage": { "month": "2026-09", "requests": 1, "quota": 10000, "plan": "decouverte" },
  "attribution": "© On va où ? · © openrouteservice by HeiGIT · © OpenStreetMap contributors"
}
FieldWhat it tells you
variantsThe labels this route carries: safe (most cycle lanes, fewest main roads), fast (quickest), balanced (best remaining trade-off).
distanceM, durationSDistance in metres, estimated duration in seconds.
ascentM, descentMTotal climb and descent, in metres.
indicators.cyclewayShareShare of the distance on cycle lanes, from 0 to 1.
indicators.mainRoadShareShare on main roads.
indicators.unpavedShareShare on unpaved surfaces.
indicators.motorwayShareShare on motorways (mostly useful for motorcycles).
geometryThe route as a GeoJSON LineString, ready for a map.
elevationProfileUp to 100 points [distance in m, altitude in m], to draw a profile.
stepsTurn-by-turn guidance: instruction, street name, distance, duration and manoeuvre type (11 for the start, 0 left, 1 right).
usageYour usage this month, also in the X-Quota-Used and X-Quota-Limit headers.
attributionThe credit to display as is next to the route; it includes the OpenStreetMap data credit.

Reading the example: 7.2 km in 25 minutes, 144 m of total climb, 77% of the trip on cycle lanes, under 4% on main roads and nothing unpaved. Between these two points there is a single sensible route, so it carries all three labels at once. The API never invents a variant to fill the list; on other trips you will get two or three distinct routes.

Picking a variant

A simple, honest rule: suggest safe by default. When fast is a different route, show the trade-off (extra minutes, extra points of cycle lanes) and let the rider decide. The JavaScript and Python examples below apply this rule.

To keep only the essentials on the command line, filter with jq:

curl -sS -X POST https://api.onvaou.app/v1/routes \
  -H "x-api-key: $OVO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"origin":[2.3522,48.8566],"destination":[2.4392,48.8474],"mode":"bike","language":"en"}' \
  | jq '.routes[] | {variants, distanceM, durationS, ascentM, indicators}'

For a mobile app, "geometry": "polyline" returns the route as an encoded polyline (Google algorithm, precision 5), which is far more compact: "okeiH{kjMGCIEGCGEEAF]CCAAp@yELy@DY@Cl@eE...".

5. JavaScript (Node.js 18 or later)

Since version 18, Node.js ships with fetch: nothing to install. Save this file as route.mjs (the .mjs extension allows top-level await), then run node route.mjs.

route.mjs

// route.mjs: run "node route.mjs" (Node.js 18 or later)
const API_URL = 'https://api.onvaou.app/v1/routes';
const apiKey = process.env.OVO_API_KEY;
if (!apiKey) {
  console.error('Set the OVO_API_KEY environment variable (free key at https://console.onvaou.app).');
  process.exit(1);
}

const res = await fetch(API_URL, {
  method: 'POST',
  headers: { 'x-api-key': apiKey, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    origin: [2.3522, 48.8566], // [longitude, latitude]: Paris city hall
    destination: [2.4392, 48.8474], // Vincennes
    mode: 'bike',
    language: 'en',
  }),
});
const data = await res.json();
if (!res.ok) {
  console.error(`Error ${res.status} ${data.error}: ${data.message}`);
  process.exit(1);
}

const pct = (share) => (share == null ? '?' : `${Math.round(share * 100)}%`);
for (const route of data.routes) {
  const { cyclewayShare, mainRoadShare, unpavedShare } = route.indicators;
  console.log(
    `${route.variants.join(', ')}: ${(route.distanceM / 1000).toFixed(1)} km, ` +
      `${Math.round(route.durationS / 60)} min, +${route.ascentM ?? '?'} m, ` +
      `cycle lanes ${pct(cyclewayShare)}, main roads ${pct(mainRoadShare)}, ` +
      `unpaved ${pct(unpavedShare)}`,
  );
}

// Suggest "safe" by default, and show the trade-off when "fast" is a different route.
const pick = (variant) => data.routes.find((r) => r.variants.includes(variant)) ?? data.routes[0];
const safe = pick('safe');
const fast = pick('fast');
console.log(`Suggested route: ${safe.variants.join(', ')}`);
if (safe !== fast) {
  const extraMin = Math.round((safe.durationS - fast.durationS) / 60);
  const extraLanes = Math.round(((safe.indicators.cyclewayShare ?? 0) - (fast.indicators.cyclewayShare ?? 0)) * 100);
  console.log(`The safest route takes ${extraMin} more min, with ${extraLanes} more points of cycle lanes.`);
}
console.log(data.attribution);

Output for our trip:

safe, balanced, fast: 7.2 km, 25 min, +144 m, cycle lanes 77%, main roads 4%, unpaved 0%
Suggested route: safe, balanced, fast
© On va où ? · © openrouteservice by HeiGIT · © OpenStreetMap contributors

When fast is a different route, the script adds a line such as “The safest route takes N more min, with M more points of cycle lanes”.

6. Python (requests)

Install requests with pip install requests, save this file as route.py, then run python3 route.py.

route.py

# route.py: pip install requests, then python3 route.py
import os
import sys

import requests

API_URL = "https://api.onvaou.app/v1/routes"
api_key = os.environ.get("OVO_API_KEY")
if not api_key:
    sys.exit("Set the OVO_API_KEY environment variable (free key at https://console.onvaou.app).")

resp = requests.post(
    API_URL,
    headers={"x-api-key": api_key},
    json={
        "origin": [2.3522, 48.8566],  # [longitude, latitude]: Paris city hall
        "destination": [2.4392, 48.8474],  # Vincennes
        "mode": "bike",
        "language": "en",
    },
    timeout=30,
)
data = resp.json()
if not resp.ok:
    sys.exit(f"Error {resp.status_code} {data.get('error')}: {data.get('message')}")


def pct(share):
    return "?" if share is None else f"{round(share * 100)}%"


for route in data["routes"]:
    ind = route["indicators"]
    climb = "?" if route["ascentM"] is None else route["ascentM"]
    print(
        f"{', '.join(route['variants'])}: {route['distanceM'] / 1000:.1f} km, "
        f"{round(route['durationS'] / 60)} min, +{climb} m, "
        f"cycle lanes {pct(ind['cyclewayShare'])}, main roads {pct(ind['mainRoadShare'])}, "
        f"unpaved {pct(ind['unpavedShare'])}"
    )


def pick(variant):
    return next((r for r in data["routes"] if variant in r["variants"]), data["routes"][0])


# Suggest "safe" by default, and show the trade-off when "fast" is a different route.
safe, fast = pick("safe"), pick("fast")
print("Suggested route:", ", ".join(safe["variants"]))
if safe is not fast:
    extra_min = round((safe["durationS"] - fast["durationS"]) / 60)
    extra_lanes = round(((safe["indicators"]["cyclewayShare"] or 0) - (fast["indicators"]["cyclewayShare"] or 0)) * 100)
    print(f"The safest route takes {extra_min} more min, with {extra_lanes} more points of cycle lanes.")
print(data["attribution"])

The output is the same as the JavaScript version.

7. Display the route on a MapLibre map

geometry is a GeoJSON LineString, which MapLibre GL JS displays as is. Since the key must not reach the browser, a small Node.js server calls the API and passes the result to the page. Put these two files in the same folder.

server.mjs

// server.mjs: node server.mjs, then open http://localhost:3000
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';

const API_URL = 'https://api.onvaou.app/v1/routes';
const API_KEY = process.env.OVO_API_KEY;
if (!API_KEY) {
  console.error('Set the OVO_API_KEY environment variable (free key at https://console.onvaou.app).');
  process.exit(1);
}
const MODES = new Set(['bike', 'ebike', 'scooter', 'wheelchair']);
const point = (text) => {
  const p = String(text ?? '').split(',').map(Number);
  return p.length === 2 && p.every(Number.isFinite) ? p : null;
};
const sendJson = (res, status, body) => {
  res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
  res.end(typeof body === 'string' ? body : JSON.stringify(body));
};

createServer(async (req, res) => {
  const url = new URL(req.url, 'http://localhost');
  try {
    if (url.pathname === '/') {
      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
      res.end(await readFile(new URL('./map.html', import.meta.url)));
    } else if (url.pathname === '/route') {
      const origin = point(url.searchParams.get('from'));
      const destination = point(url.searchParams.get('to'));
      const mode = url.searchParams.get('mode') ?? 'bike';
      if (!origin || !destination || !MODES.has(mode)) {
        sendJson(res, 400, { error: 'bad_request', message: 'Expected parameters: from and to (lon,lat), mode.' });
        return;
      }
      // The key is sent from here, server side: the browser never sees it.
      const api = await fetch(API_URL, {
        method: 'POST',
        headers: { 'x-api-key': API_KEY, 'Content-Type': 'application/json' },
        body: JSON.stringify({ origin, destination, mode, elevation: false, instructions: false }),
      });
      sendJson(res, api.status, await api.text());
    } else {
      sendJson(res, 404, { error: 'not_found' });
    }
  } catch (err) {
    sendJson(res, 502, { error: 'upstream', message: err.message });
  }
}).listen(3000, () => console.log('Map: http://localhost:3000'));

map.html

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Safe bike route</title>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.css">
<script src="https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.js"></script>
<style>
  html, body, #map { height: 100%; margin: 0; }
  #info { position: absolute; top: 10px; left: 10px; padding: 8px 12px; background: #fff;
          border-radius: 8px; font: 14px system-ui, sans-serif; box-shadow: 0 1px 4px rgba(0, 0, 0, .25); }
</style>
</head>
<body>
<div id="map"></div>
<div id="info">Computing the route...</div>
<script>
  const map = new maplibregl.Map({
    container: 'map',
    style: 'https://tiles.openfreemap.org/styles/positron', // open basemap, no key needed
    center: [2.395, 48.852],
    zoom: 12,
    attributionControl: { compact: false }, // attribution always visible
  });

  map.on('load', async () => {
    const info = document.getElementById('info');
    const res = await fetch('/route?from=2.3522,48.8566&to=2.4392,48.8474&mode=bike');
    const data = await res.json();
    if (!res.ok) {
      info.textContent = `Error: ${data.message || data.error}`;
      return;
    }

    const features = data.routes.map((route) => ({
      type: 'Feature',
      properties: { safe: route.variants.includes('safe') },
      geometry: route.geometry,
    }));
    map.addSource('routes', {
      type: 'geojson',
      data: { type: 'FeatureCollection', features },
      attribution: data.attribution, // credit returned by the API (includes © OpenStreetMap contributors)
    });
    map.addLayer({
      id: 'other-variants',
      type: 'line',
      source: 'routes',
      filter: ['==', ['get', 'safe'], false],
      paint: { 'line-color': '#8a9aa0', 'line-width': 4 },
    });
    map.addLayer({
      id: 'safe-variant',
      type: 'line',
      source: 'routes',
      filter: ['==', ['get', 'safe'], true],
      layout: { 'line-join': 'round', 'line-cap': 'round' },
      paint: { 'line-color': '#0d7f7a', 'line-width': 6 },
    });

    const safe = data.routes.find((route) => route.variants.includes('safe')) ?? data.routes[0];
    const bounds = new maplibregl.LngLatBounds();
    for (const coord of safe.geometry.coordinates) bounds.extend(coord);
    map.fitBounds(bounds, { padding: 40 });
    info.textContent = `Safe route: ${(safe.distanceM / 1000).toFixed(1)} km, ` +
      `${Math.round(safe.durationS / 60)} min, ` +
      `${Math.round((safe.indicators.cyclewayShare ?? 0) * 100)}% cycle lanes`;
  });
</script>
</body>
</html>

Run node server.mjs and open http://localhost:3000: the safe variant is drawn in green, any other variants in grey, with a trip summary in the top-left corner.

8. E-bike, kick scooter and wheelchair in one line

The same call serves other vehicles: only the request body changes.

VehicleIn the request body
Bike"mode": "bike" (default)
E-bike"mode": "ebike"
Kick scooter"mode": "scooter"
Wheelchair"mode": "wheelchair", "avoid": ["steep"]

For wheelchairs, the route keeps to slopes of 12% at most, lowered to 6% with "avoid": ["steep"], kerbs of 15 cm at most and passages at least 50 cm wide. "avoid": ["unpaved"] also rules out unpaved surfaces. A single route is returned, without alternatives. A real example in central Lyon:

curl -sS -X POST https://api.onvaou.app/v1/routes \
  -H "x-api-key: $OVO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"origin":[4.8357,45.7640],"destination":[4.8420,45.7600],"mode":"wheelchair","avoid":["steep"],"elevation":false,"language":"en"}'
{
  "mode": "wheelchair",
  "routes": [
    {
      "variants": ["safe", "balanced", "fast"],
      "distanceM": 942,
      "durationS": 661,
      "ascentM": 22,
      "descentM": 31,
      "indicators": { "cyclewayShare": 0, "unpavedShare": 0.005, "mainRoadShare": 0, "motorwayShare": 0 },
      "geometry": { "type": "LineString", "coordinates": [[4.835726, 45.763999], [4.835719, 45.763879], [4.835716, 45.763841], "..."] },
      "steps": ["..."]
    }
  ],
  "attribution": "© On va où ? · © openrouteservice by HeiGIT · © OpenStreetMap contributors"
}

942 m in 11 minutes, 22 m of climb, 0.5% unpaved. Motorcycles (moto, with avoid tolls or highways) and walking (foot) use the same call.

9. Handle 429 and 422 errors

All errors share the same shape, { "error": "...", "message": "..." }. Your code should branch on error, which is stable; message is meant for humans, in English by default (French with Accept-Language: fr). A failed calculation does not count against your quota.

StatuserrorWhat to do
429rate_limitedPlan rate exceeded (per second, all the account’s keys together). Wait for the number of seconds given by the Retry-After header, then retry.
429quota_exceededMonthly quota of the free plan reached. Retrying will not help: upgrade, or wait for next month.
422out_of_coverageA point is outside Europe, or longitude and latitude are swapped. Ask for another point.
422no_routeNo usable route for this vehicle between these points. Move the points closer to a street or path, or change vehicle.
503engine_capacity, engine_unavailableTransient: retry a little later (after Retry-After when provided).
400, 401bad_*, too_long, invalid_keyFix the request or the key: retrying as is will not help.

A 429 with its header:

HTTP/2 429
content-type: application/json; charset=utf-8
retry-after: 1
content-language: en

{"error":"rate_limited","message":"Too many requests: the Solo plan allows 2 requests per second. Retry in a second."}

A point in New York:

{"error":"out_of_coverage","message":"Point outside the covered area: the API covers Europe. For other regions, contact us (Custom plan)."}

The versions below retry what is transient, stop on everything else and turn each case into a useful message. At most three retries, with waits capped at 60 seconds: the script never hangs.

route-robust.mjs

// route-robust.mjs: node route-robust.mjs (Node.js 18 or later)
const API_URL = 'https://api.onvaou.app/v1/routes';
const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));

class RouteError extends Error {
  constructor(status, code, message) {
    super(message || `HTTP ${status}`);
    this.status = status;
    this.code = code;
  }
}

async function computeRoute(body, { maxRetries = 3 } = {}) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(API_URL, {
      method: 'POST',
      headers: { 'x-api-key': process.env.OVO_API_KEY ?? '', 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
      signal: AbortSignal.timeout(30_000),
    });
    const data = await res.json().catch(() => ({}));
    if (res.ok) return data;

    // Transient: 429 rate_limited (requests per second) and 503 (engine capacity).
    // Not 429 quota_exceeded: the monthly quota does not come back in a few seconds.
    const transient = (res.status === 429 && data.error !== 'quota_exceeded') || res.status === 503;
    if (transient && attempt < maxRetries) {
      const retryAfter = Number(res.headers.get('retry-after'));
      await sleep(Math.min(retryAfter > 0 ? retryAfter : 2 ** attempt, 60));
      continue;
    }
    throw new RouteError(res.status, data.error, data.message);
  }
}

try {
  const data = await computeRoute({
    origin: [2.3522, 48.8566],
    destination: [2.4392, 48.8474],
    mode: 'bike',
    language: 'en',
  });
  console.log(`${data.routes.length} route(s), ${data.usage.requests} of ${data.usage.quota} requests used this month`);
} catch (err) {
  if (!(err instanceof RouteError)) throw err;
  switch (err.code) {
    case 'out_of_coverage':
      console.error('A point is outside Europe: ask the user for another point.');
      break;
    case 'no_route':
      console.error('No route for this vehicle: move the points closer to a usable street or path.');
      break;
    case 'quota_exceeded':
      console.error('Monthly quota reached: upgrade your plan at https://console.onvaou.app.');
      break;
    default:
      console.error(`Error ${err.status} ${err.code ?? ''}: ${err.message}`);
  }
  process.exitCode = 1;
}

route_robust.py

# route_robust.py: pip install requests, then python3 route_robust.py
import os
import sys
import time

import requests

API_URL = "https://api.onvaou.app/v1/routes"


class RouteError(Exception):
    def __init__(self, status, code, message):
        super().__init__(message or f"HTTP {status}")
        self.status = status
        self.code = code


def compute_route(body, max_retries=3):
    for attempt in range(max_retries + 1):
        resp = requests.post(
            API_URL,
            headers={"x-api-key": os.environ.get("OVO_API_KEY", "")},
            json=body,
            timeout=30,
        )
        try:
            data = resp.json()
        except ValueError:
            data = {}
        if resp.ok:
            return data

        # Transient: 429 rate_limited (requests per second) and 503 (engine capacity).
        # Not 429 quota_exceeded: the monthly quota does not come back in a few seconds.
        transient = (resp.status_code == 429 and data.get("error") != "quota_exceeded") or resp.status_code == 503
        if transient and attempt < max_retries:
            retry_after = resp.headers.get("Retry-After", "")
            time.sleep(min(int(retry_after) if retry_after.isdigit() else 2 ** attempt, 60))
            continue
        raise RouteError(resp.status_code, data.get("error"), data.get("message"))


MESSAGES = {
    "out_of_coverage": "A point is outside Europe: ask the user for another point.",
    "no_route": "No route for this vehicle: move the points closer to a usable street or path.",
    "quota_exceeded": "Monthly quota reached: upgrade your plan at https://console.onvaou.app.",
}

try:
    data = compute_route({"origin": [2.3522, 48.8566], "destination": [2.4392, 48.8474], "mode": "bike", "language": "en"})
    usage = data["usage"]
    print(f"{len(data['routes'])} route(s), {usage['requests']} of {usage['quota']} requests used this month")
except RouteError as err:
    sys.exit(MESSAGES.get(err.code, f"Error {err.status} {err.code or ''}: {err}"))

To avoid 429s in the first place, pace your calls to your plan’s rate: 1 request per second on Découverte, 2 on Solo, up to 25 on Business.

10. Bonus: let an AI assistant compute the route

The API is also available as an MCP (Model Context Protocol) server: an assistant such as Claude calls the compute_route tool itself and answers in plain language. The server is published on npm (onvaou-itineraires-mcp) and in the official MCP registry (app.onvaou/itineraires). It is read-only, and each calculation counts as one request.

Claude Desktop

You need Node.js 18 or later, for npx. Open the Claude Desktop configuration file (reachable from the app settings, Developer section):

Add the server, with your key:

claude_desktop_config.json

{
  "mcpServers": {
    "onvaou-itineraires": {
      "command": "npx",
      "args": ["-y", "onvaou-itineraires-mcp"],
      "env": { "OVO_API_KEY": "ovo_live_..." }
    }
  }
}

Claude Desktop does not read your terminal’s environment variables: the key goes in the env block, and this file stays on your machine. Restart Claude Desktop, then ask for example:

Compute the safest bike route from Paris city hall (2.3522, 48.8566) to Vincennes (2.4392, 48.8474), and tell me how much of it is on cycle lanes.

The assistant calls compute_route and answers with the distance, duration and indicators of the trip: for this example, on 21 September 2026, 7.2 km, 25 minutes and 77% cycle lanes. The tool works with coordinates: give them, or let the assistant work them out from addresses.

Claude Code

One line is enough, and the key comes from your environment variable:

claude mcp add onvaou-itineraires -e OVO_API_KEY="$OVO_API_KEY" -- npx -y onvaou-itineraires-mcp

Going further