# Routing24 route optimizer > Plan and optimize vehicle delivery routes: turn a list of stop addresses and vehicles into an efficient multi-stop route plan with stop assignments, sequence, distance and ETAs, plus a shareable plan link. Use whenever the user wants to plan routes, optimize delivery or pickup stops, build a delivery run or dispatch schedule, solve a vehicle routing problem (Rich VRP), sequence stops for one or more drivers, vans or trucks from a depot, do route planning or last-mile delivery optimization, edit routes manually (move stops between routes, unassign stops, split/merge routes, change vehicles, undo), or asks for Routing24 routing24.com. Driven from the user's own browser tab via WebMCP tools (routing24_geocode, routing24_optimize, routing24_status, routing24_edit, routing24_save, ...) registered on document.modelContext. Turn a natural-language routing request ("optimize these 8 addresses with 2 vans from this depot") into an optimized route plan on **Routing24**, shown on the map, with a link the user can open. Routing24 exposes **no public Optimization API** — you drive it through the page. The route optimization runs **client-side in the browser** (WASM), while geocoding, routing/distance matrices, and ML/LLM run on **Routing24's own servers**, reached with an opaque auth token issued for the user's account (registered or anonymous). The page registers **WebMCP tools** under the `routing24_*` prefix on `document.modelContext` (the W3C WebMCP draft surface; the app bundles a polyfill so the tools exist even without native browser support). Discover them with `getTools()`, invoke them with `executeTool(tool, JSON.stringify(args))` — which resolves to a **JSON string** you must parse (and **rejects** on validation or handler errors). The optimizer runs client-side in WASM, so `routing24_optimize` is asynchronous: you start it, then **poll `routing24_status`**. This document is self-contained (runtime requirements + procedure + API contract + snippets). It is generated from Routing24's own types, so it always matches the deployed API; the installable skill version of the same content is at `https://routing24.com/routing24.skill`. ## Runtime requirements - This skill drives Routing24 **in the user's own browser**. It needs either a **WebMCP-capable browser agent host**, or a JS-eval browser integration — **Claude in Chrome / Cowork** (Chrome or Edge) with the `navigate` and `javascript_tool` tools, which calls the same tools via `document.modelContext`. If neither is available, tell the user this automation requires a browser integration and stop. - You drive Routing24 through the `routing24_*` WebMCP tools; there is **no public API and no API key**. Route optimization runs **client-side in the browser**, while geocoding, routing/distance matrices, and ML/LLM run on **Routing24's own servers** under an opaque token issued for the user's account (registered or anonymous). **No sign-in is required** — the full flow (geocode, optimize, render, save, share) works anonymously. Do not ask the user to log in. Signing in changes **where** the plan is stored (see the plan-link note under *Notes & pitfalls*) and which **paid constraints** a solve may keep (see *Plans & paid features*) — neither is a reason to stop or to ask for a login. ## Plans & paid features Most of `OptimizeInput` is free. The fields below need a paid Routing24 plan; everything not listed — addresses, loads, time windows (**including multi-day `+1` offsets**), shifts, capacity, `max_reloads` and every `cost` field the table does not name — works on every account. | Capability | Plan | Fields | | --- | --- | --- | | Alternative order groups | PRO | `group` | | Alternative pickup/delivery locations | PRO | `group` | | Driver breaks & driving limits | PRO | `break_rules`, `fixed_breaks`, `period_driving_limit_s`, `period_driven_s` | | Force allow / deny orders | PRO | `force_allow_sites`, `force_deny_sites` | | Load-distance cost (cost per load-km) | PRO | `cost.load_distance` | | Max distance | PRO | `max_distance` | | Max duration | PRO | `max_duration_s` | | Max overtime | PRO | `max_overtime_s` | | Max time in vehicle (shelf life) | PRO | `max_time_in_vehicle_s`, `max_ride_overtime_s`, `cost.ride_overtime` | | Order sequences | PRO | `sequence_group`, `sequence_rank` | | Overtime cost per hour | PRO | `cost.overtime` | | Pickup & delivery (transfers) | PRO | `transfer_type`, `transfer_id` | | Product segregation (load classes) | PRO | `load_class`, `no_mix_load_classes` | | Reload depots | PRO | `reload_depots` | | Skills (vehicle & order tags) | PRO | `required_tags`, `forbidden_tags`, `tags` | **What happens on a free account.** Nothing is rejected and nothing is hidden: a plan using paid fields solves normally for the first **5 optimizations**. After that `routing24_optimize` still runs, but it **drops those constraints from the solve** and says so in its result — `paidFeatures.stripped` lists what it ignored and `paidFeatures.freeRunsLeft` is `0`. **When `stripped` is non-empty the routes do not honour those constraints.** Tell the user plainly which ones were ignored and that upgrading restores them; never present such a plan as if the request was fully satisfied. The allowance is per browser, so it is not something you can reset or work around — do not retry the call hoping for a different answer. ## Procedure 1. **Open the app.** `navigate` to `https://routing24.com/app/plan/new/optimize`. 2. **Verify the tools are present.** Via `javascript_tool`: ```js const mc = document.modelContext ?? navigator.modelContext; (await mc?.getTools())?.map(t => t.name) ?? null; ``` Expect the `routing24_*` names (`routing24_optimize` etc.). If `null` or missing, the page may still be loading — wait 2s and retry, then reload once; if still missing, tell the user the Routing24 agent tools aren't available on this page and stop. 3. **Set up a call helper** (used by every later step; `executeTool` takes the tool OBJECT from `getTools()` and a JSON-string argument, and resolves to a JSON string): ```js const mc = document.modelContext ?? navigator.modelContext; const tools = await mc.getTools(); window.__r24call = async (name, args = {}) => { const tool = tools.find(t => t.name === name); if (!tool) throw new Error('tool not found: ' + name); const raw = await mc.executeTool(tool, JSON.stringify(args)); return raw == null ? null : JSON.parse(raw); }; ``` 4. **(Optional) Note who's signed in.** `await __r24call('routing24_get_auth_user')` returns `{ user }` — the user's email, or `"anonymous"`. **Do not require sign-in** — the whole flow works anonymously. This only affects *where* the saved plan lives and what you tell the user about the plan link (see step 10). 5. **Parse the request** into the `routing24_optimize` input shape (see the **API contract** for the full definition). Times are seconds-since-midnight; all fields except the addresses are optional. The schema requires **≥1 stop** and **≥1 vehicle**; with a single stop there is nothing to sequence, so expect real requests to carry ≥2 stops. Bad input makes the call reject with a message naming the offending fields — relay it to the user. 6. **Geocode + confirm.** Run `await __r24call('routing24_geocode', { addresses: [] })`. - Show the user any row with `ok:false` (not found) and ask them to fix the address. Also surface `matched` values that look wrong and confirm. - Once confirmed, use the returned `lat`/`lng` for each entity (pass them in the `routing24_optimize` input so you don't re-geocode). `routing24_optimize` will geocode any address you leave without coordinates, but doing it here lets the user confirm ambiguous matches first. 7. **Start optimization.** `await __r24call('routing24_optimize', { depot, stops, vehicles })`. It returns quickly with `{ started: true, planUuid }`. - If the result carries `paidFeatures`, the plan uses constraints outside the user's subscription. `stripped` is empty while the free allowance lasts; once it is non-empty the solve **ignored** those constraints — carry that into step 10 (see *Plans & paid features*). 8. **Poll progress.** Every ~3 seconds run `await __r24call('routing24_status')` until `phase === 'done'` or `phase === 'error'`. - Relay `progress` (0–1) and, once available, `routes` / `distance` / `feasible`. - Small jobs finish in seconds; large ones can take minutes (keep the tab active). If `phase === 'error'`, report `error` and stop. - To abort on the user's request: `await __r24call('routing24_cancel')`. 9. **Show + save.** Run `await __r24call('routing24_render')` (brings the routes onto the map), then `await __r24call('routing24_save')` to persist and get `{ saved, planUrl }`. - To *show the user the map*, take a screenshot of the tab after render — the tools return JSON, not images. - To read back the **full solution** (per-route ordered stops with resolved addresses/coords, ETAs and loads, plus the unassigned list), call `await __r24call('routing24_solution')`. It works both now and later on a plan reopened by URL, so you never have to reconstruct routes from `routing24_status` rollups. 10. **Report** to the user: - Number of routes, total distance (with unit), total duration, and any `unassignedCount` (stops that couldn't be served — mention them). - The optimized **stop sequence per route** (pull it from `routing24_solution` when the user wants the itinerary, not just totals). - Whether the solution is `feasible`. - **Any `paidFeatures.stripped` from step 7** — name the constraints the solve ignored and that upgrading restores them. A plan that quietly drops the user's tags, breaks or transfers is worse than no plan. - The **plan link** (`planUrl`) they can open, and note the map on screen shows the routes. If the user is **anonymous** (from step 4), add that this link opens the plan **only on this computer** and may be deleted later — it is not a durable share link. ## Editing routes manually Once a plan has a solution (fresh solve or a plan opened by URL), you can edit it in place — the same manual-editing engine the UI's drag & drop uses: 1. **Read the current arrangement**: `await __r24call('routing24_solution')`. Note each route's `slot` (0-based, empty routes included) and each stop's `id` — edit ops address routes by slot and stops by id. 2. **Compose ONE batch** of ops in `routing24_edit` and apply it atomically: move/re-plan stops (`move_sites` — anchor with `before`/`after` a planned stop id, or `to_route` + `placement: 'append' | 'best'`), `unassign_sites`, `set_route_vehicle`, `create_route`, `remove_routes` (empty routes only), `split_route`, `merge_routes`, `mark_user_assigned` / `clear_user_assigned`. Later ops see earlier ops' effects; the first rejection rolls back the whole batch (`rejection.code` says why — `stale_revision` means the plan changed under you: re-read `routing24_solution` and rebuild the batch). 3. **Judge the result, don't guess**: edits are never rejected for breaking constraints — problems are REPORTED instead. Check `state.feasible`, `state.problemsCount`, per-route `problems`, and `userAssignedReports` (per manually-placed stop: `intrinsic` = would violate anywhere, `introduced` = caused by this placement). If a placement looks bad, fix it with another batch or `routing24_undo`. **Always check `state.driftFromOptimized`** — how far the plan has moved from the last full solve (the user's own manual edits count too). When `severity` is `degraded` or `severe` you MUST tell the user, quoting `summary` — it leads with the COST change, the number the user pays (e.g. "cost +2348 (+12.3%), distance +12.4%, 1 new problem vs the last optimization") — and offer a fix: `routing24_optimize_route` for a single rough route, or a fresh `routing24_optimize`. Report `improved` drift too — it reinforces that the edits helped. 4. **Tidy up**: `routing24_optimize_route` re-sequences one rough route (sub-second, synchronous); `remove_routes` deletes emptied routes — slots COMPACT afterwards, so always take fresh slots from the returned `state.routes`. 5. **Explain unassigned stops**: `routing24_solution` with `{ refresh_diagnostics: true }` returns `unassignedDiagnostics` — a prose summary + per-site category/explanation/blockers/levers you can relay. 6. **Save** with `routing24_save` when the user is happy. While you edit, the app locks behind a full-screen **"Agent controlled"** overlay (blue dashed frame + a floating pane at the bottom) so the user can't race you; they resume via its **Take control** button. `routing24_undo` / `routing24_redo` walk the SAME history as the user's own edits — never undo speculatively; prefer a compensating `routing24_edit` batch. ## JavaScript snippets Ready-to-eval expressions for `javascript_tool` — each resolves to a value that comes back to you. Replace the ADDRESS/STOP/VEHICLE placeholders. ```js // 0) Discover the tools (undefined/missing names => page still loading; retry). const mc = document.modelContext ?? navigator.modelContext; (await mc?.getTools())?.map(t => t.name) ?? null; // 1) One-time call helper: executeTool takes the tool OBJECT + a JSON-string // argument and resolves to a JSON string (rejects on errors). const mc2 = document.modelContext ?? navigator.modelContext; const tools = await mc2.getTools(); window.__r24call = async (name, args = {}) => { const tool = tools.find(t => t.name === name); if (!tool) throw new Error('tool not found: ' + name); const raw = await mc2.executeTool(tool, JSON.stringify(args)); return raw == null ? null : JSON.parse(raw); }; // 2) (Optional) who's signed in — { user: email | "anonymous" }. Never gate on // this; the whole flow works anonymously. It only affects where the plan is // stored. await __r24call('routing24_get_auth_user'); // 3) Geocode depot + all stop addresses. Inspect results for ok:false and // sanity-check the `matched` strings with the user before optimizing. await __r24call('routing24_geocode', { addresses: ["DEPOT ADDRESS", "STOP 1 ADDRESS", "STOP 2 ADDRESS"], }); // 4) Start optimization. Prefer passing lat/lng from step 3 so nothing is // re-geocoded (addresses alone also work; they'll be geocoded). await __r24call('routing24_optimize', { depot: { address: "DEPOT ADDRESS" /*, lat, lng */ }, stops: [ // priority (1..1000, default 1): higher-priority stops are kept when not // everything fits. required_tags: only a vehicle carrying these tags may serve it. { id: "S1", address: "STOP 1 ADDRESS", delivery: 1, service_duration_s: 300, priority: 10 }, { id: "S2", address: "STOP 2 ADDRESS", delivery: 1, service_duration_s: 300, required_tags: ["reefer"] }, ], vehicles: [ // tags satisfy stops' required_tags. max_reloads caps mid-route reloads // (multi-trip); vehicles reload at the depot by default when needed. // break_rules: EU driver-break preset shown (45 min before exceeding // 4.5 h driving, splittable 15+30); // US = { max_driving_s: 28800, duration_s: 1800, service_counts: true }. Omit for no breaks. // cost: only when the user gives real rates (per km/mi, per hour, // fixed per use) — omit it entirely (as here) to minimize plain // distance; never send zeros for that. Explicit 0 is for mixed // fleets: { cost: { distance: 0, duration: 6 } } bike vs // { cost: { distance: 2, duration: 18, fixed: 40 } } van. { available_count: 2, capacity: 20, tw_early_s: 8 * 3600, tw_late_s: 18 * 3600, tags: ["reefer"], max_reloads: 2, break_rules: [{ max_driving_s: 16200, duration_s: 2700, split_first_s: 900, split_second_s: 1800 }], }, ], // options: { time_limit_s: 30 }, }); // 5) Poll (~every 3s) until phase is "done" or "error". Relay progress meanwhile. await __r24call('routing24_status'); // 6) Show routes on the map, then persist and get the plan link. await __r24call('routing24_render'); await __r24call('routing24_save'); // -> { saved, planUrl } // 7) Full solution: per-route-slot ordered stops (resolved id/address/lat/lng, // ETAs, loads, waits, problems) + unassigned list, user-assigned marks and // undo/redo depths. Works now and on a plan reopened by URL. Route `slot` // is the address the editing tools use. await __r24call('routing24_solution'); // 8) Current plan URL (also returned by save). await __r24call('routing24_plan_url'); // 9) Manual editing — ONE atomic batch: move two stops after S07 on their // route, drop S99 to unassigned, and rebind route slot 2 to vehicle VAN-3. // Problems don't reject: judge state.feasible / userAssignedReports. await __r24call('routing24_edit', { ops: [ { op: 'move_sites', sites: ['S12', 'S13'], after: 'S07' }, { op: 'unassign_sites', sites: ['S99'] }, { op: 'set_route_vehicle', route: 2, vehicle: 'VAN-3' }, ], }); // 10) Plan an unassigned stop onto route slot 1 at the engine-chosen cheapest // position, then tidy the sequence (sub-second, synchronous). await __r24call('routing24_edit', { ops: [{ op: 'move_sites', sites: ['S99'], to_route: 1, placement: 'best' }], }); await __r24call('routing24_optimize_route', { route: 1 }); // 11) Empty a route and remove it (remove_routes takes EMPTY routes only, so // unassign first; slots compact — re-read them from the returned state). await __r24call('routing24_edit', { ops: [ { op: 'unassign_sites', sites: ['S3', 'S4'] }, { op: 'remove_routes', routes: [3] }, ], }); // 12) Undo the last edit (SHARED history with the user's manual edits — never // undo speculatively). Redo mirrors it. await __r24call('routing24_undo'); // 13) Why are stops unassigned? Prose diagnostics (summary + per-site // explanation/blockers/levers), recomputed on demand. (await __r24call('routing24_solution', { refresh_diagnostics: true })).unassignedDiagnostics; // Optional: cancel a long-running solve (keeps the best solution so far). await __r24call('routing24_cancel'); // Optional single blocking wait (prefer separate status calls to stream progress): await (async () => { for (let i = 0; i < 400; i++) { const s = await __r24call('routing24_status'); if (s.phase === "done" || s.phase === "error") return s; await new Promise((r) => setTimeout(r, 3000)); } return __r24call('routing24_status'); })(); ``` ## API reference WebMCP tools registered on `document.modelContext` on every `https://routing24.com/app/*` page (`navigator.modelContext` is a deprecated alias). Discover them with `getTools()`; invoke with `executeTool(tool, JSON.stringify(args))` — it resolves to a **JSON string of the result object** (parse it) and **rejects** on validation/handler errors. `routing24_optimize` is **fire-and-poll**: it returns immediately after starting the background WASM solve; observe it with `routing24_status`. Shapes reuse the solver's own `Site`/`VehicleType`/`Location` fields and are validated at runtime — the JSON Schema is authoritative. Zero-argument tools take `'{}'`. ### `routing24_get_auth_user` → `{ user: string }` No input. Returns the signed-in user's email, or `"anonymous"` when nobody is logged in. **Informational only** — every tool works anonymously, so never gate the flow on this or ask the user to sign in. It only tells you *where* a saved plan will live (see `routing24_save`). ### `routing24_geocode` — `GeocodeInput` → `{ results: GeocodeResult[] }` Batch-geocodes address strings (order preserved; every input gets a row back). `ok:false` (`quality:"failed"`) = not found → ask the user to correct it. ```ts // Input for the `routing24_geocode` WebMCP tool. type GeocodeInput = { addresses: string[]; // Address strings to geocode (free-form, as a user would type them). }; ``` ```ts type GeocodeResult = { input: string; // The address string as passed in. ok: boolean; matched?: string; // Canonical/expanded address the geocoder matched. lat?: number; lng?: number; quality: "rooftop" | "failed"; }; ``` ### `routing24_optimize` — `OptimizeInput` → `OptimizeStarted` Creates a fresh plan, fetches the O/D matrix, and starts the solve. Returns at once. Fields marked `PRO`/`STARTER` below need a paid plan — see *Plans & paid features*; `paidFeatures` in the result says what this account actually got. ```ts type OptimizeInput = { depot: OptimizeDepot; // Single depot, used as both start and end for every vehicle. stops: OptimizeStop[]; // min 1 vehicles: OptimizeVehicle[]; // min 1 options?: { time_limit_s?: integer }; }; ``` ```ts // The single depot: a place plus depot constraints (open window, handling). type OptimizeDepot = { lat?: number; lng?: number; address?: string; tw_early_s?: number; tw_late_s?: number; service_duration_s?: number; no_break?: boolean; id?: string; }; ``` ```ts // A delivery/pickup stop: a place plus solver site constraints. type OptimizeStop = { lat?: number; lng?: number; address?: string; tw_early_s?: number; tw_late_s?: number; service_duration_s?: number; no_break?: boolean; pickup?: number; delivery?: number; release_time_s?: number; priority?: number; required_tags?: string[]; // PRO: Skills (vehicle & order tags) forbidden_tags?: string[]; // PRO: Skills (vehicle & order tags) group?: string; // PRO: Alternative order groups, Alternative pickup/delivery locations transfer_type?: "pickup" | "delivery" | "depot"; // PRO: Pickup & delivery (transfers) transfer_id?: string; // PRO: Pickup & delivery (transfers) load_class?: string; // PRO: Product segregation (load classes) sequence_group?: string; // PRO: Order sequences sequence_rank?: number; // PRO: Order sequences max_time_in_vehicle_s?: integer; // PRO: Max time in vehicle (shelf life) — >= 0 max_ride_overtime_s?: integer; // PRO: Max time in vehicle (shelf life) — >= 0 id?: string; }; ``` ```ts // A vehicle (type): solver vehicle constraints; `available_count` clones this // type. The three reference lists take **stop/depot ids** (as passed in this // request) and mirror task.fbs `Vehicle.force_allow_sites` / // `force_deny_sites` / `reload_depots`. type OptimizeVehicle = { tw_early_s?: number; tw_late_s?: number; capacity?: number; available_count?: number; cost?: { fixed?: number; distance?: number; duration?: number; stop?: number; overtime?: number; ride_overtime?: number; load_distance?: number }; // PRO: Load-distance cost (cost per load-km) (cost.load_distance), Max time in vehicle (shelf life) (cost.ride_overtime), Overtime cost per hour (cost.overtime) start_late_s?: number; tags?: string[]; // PRO: Skills (vehicle & order tags) max_reloads?: number; max_duration_s?: number; // PRO: Max duration max_overtime_s?: number; // PRO: Max overtime max_distance?: number; // PRO: Max distance break_rules?: { max_driving_s?: number; duration_s?: number; split_first_s?: number; split_second_s?: number; service_counts?: boolean }[]; // PRO: Driver breaks & driving limits fixed_breaks?: { tw_early_s?: number; tw_late_s?: number; duration_s?: number }[]; // PRO: Driver breaks & driving limits period_driving_limit_s?: number; // PRO: Driver breaks & driving limits period_driven_s?: number; // PRO: Driver breaks & driving limits no_mix_load_classes?: { classes: string[] }[]; // PRO: Product segregation (load classes) id?: string; force_allow_sites?: string[]; // PRO: Force allow / deny orders — Stop ids ONLY this-typed vehicles may serve (pinned compatibility). force_deny_sites?: string[]; // PRO: Force allow / deny orders — Stop ids this vehicle type must never serve. reload_depots?: string[]; // PRO: Reload depots — Depot ids where mid-route reloads may happen (multi-trip). Omitted = single-trip unless `max_reloads` is set (then the home depot is used); pair with `max_reloads` to cap trips. }; ``` ```ts // Result of `routing24_optimize`: the solve is running, poll for progress. type OptimizeStarted = { started: true; planUuid: string; paidFeatures?: PaidFeatureReport; // Present only when the plan uses paid features outside this account. warnings?: string[]; // Non-blocking notes about how the request was interpreted (e.g. an all-zero cost model replaced by the plain-distance default). Omitted when there is nothing to say. }; ``` ```ts // What `routing24_optimize` did with the paid features the plan uses. Absent // when the plan uses none, or when every one of them is included in the user's // subscription — the common case, and nothing to report. type PaidFeatureReport = { locked: string[]; // Paid capabilities the plan uses that this account's plan does not include, as `Feature` member names (e.g. `"Skills"`, `"DriverBreaks"`). stripped: string[]; // The subset DROPPED from this solve because the free allowance for paid features is spent. Empty while the allowance lasts. A non-empty list means the routes ignore those constraints — say so when reporting the plan, and name the upgrade as the way to get them back. freeRunsLeft: number; // Free solves left that may use locked features (0 once spent). }; ``` - Each depot/stop needs **either** `lat`+`lng` **or** an `address` (geocoded automatically; if any fail, the call rejects listing them). - Times are **seconds since midnight**. `delivery`/`pickup` are single-dimension loads. `available_count` = identical vehicles of that type. The single depot is both start and end. - Stops sharing a `group` are mutually exclusive alternatives: the solver serves at most one per group and reports the rest as `alternativesNotChosen`. - **Alternative pickup/delivery locations**: several stops with the same `transfer_id` and role (all `pickup` or all `delivery`), sharing one `group` of their own (equal loads), are candidate locations for that end — the solver serves exactly one candidate per role and reports the rest as `alternativesNotChosen`. Max 8 candidates per role, 16 pickup x delivery combinations. - **Sequences**: stops sharing a `sequence_group` are served all-or-none, by ONE vehicle, in non-decreasing `sequence_rank` order (other stops may come between them). Plain stops only (no transfers, no `group`), one service window each, 2..16 stops per group. - **Product segregation**: give stops a `load_class` and vehicles `no_mix_load_classes` — groups of class names; two classes of one group never ride together on that vehicle's route (reload trips included). Groups are independent; a class no stop carries is ignored; at most 64 distinct classes per task. Both ends of a transfer carry the same class. - **Shelf life**: on a plain stop `max_time_in_vehicle_s` bounds the time from its `release_time_s` to the start of service — the clock is PINNED at `release_time_s`, there is no departure-relative form, so a stop without one is measured from the start of the planning horizon (0), which is stricter, not looser: **always send `release_time_s` with it**. A stop whose `tw_early_s` is later than `release_time_s` + the bound cannot be served at all and rejects the whole call. A plain stop carrying a `pickup` load is dropped on its own instead (the bound covers `delivery` goods, on board from the depot; a pickup load boards at service and rides on unbounded): the solve runs and that stop comes back in `unassigned`, reported as `field_not_applicable`. On a linked (pickup & delivery) stop it instead bounds the ride time from pickup to delivery (waiting counts; a reload does not reset it) — set identically on every end of the transfer: ends that disagree do NOT reject the call, they are dropped from the solve together, so the plan comes back without them and they appear in no route and in no `unassigned` list. `max_ride_overtime_s` (linked stops only, requires `max_time_in_vehicle_s`, priced at the vehicle's `cost.ride_overtime`) allows a priced band of ride time past the bound; beyond it the bound is hard. On a plain (depot) stop the field does not apply: that stop is left unassigned with a reason, rather than rejecting the call. A bounded LINKED stop combines with driver breaks: break placement avoids the pickup-to-delivery span where it can, and a break placed inside it counts as ride time, priced against the bound and its band. On a solve, a pair whose ride cannot fit bound + band around the mandated breaks is simply not served: both ends come back in `unassigned`. `shelf_life` problem rows appear only on evaluated or manually edited routes. - Vehicle `force_allow_sites`/`force_deny_sites`/`reload_depots` reference the stop/depot ids **from this request**; unknown ids reject the call. `max_distance` is in the plan's display unit (km/mi); `cost.duration` and `cost.overtime` are per hour (`max_overtime_s` caps paid overtime past `max_duration_s`). - **Vehicle costs**: `cost.distance` is per km/mi (the plan's display unit), `cost.fixed` per vehicle used, `cost.load_distance` per unit of load carried per km/mi (every leg charges the load on board times the leg length — load-dependent fuel/refrigeration burn; it counts as pricing the fleet like `cost.distance`). An omitted cost field means 0. To simply minimize travel distance, **omit `cost` entirely — do not send zeros**: a fleet whose every distance/duration rate is 0 has nothing to optimize, so the zeros are ignored, plain distance is minimized, and the result carries a `warnings` entry. An explicit 0 next to priced vehicles is honored (a bike with free mileage beside a van at 2/km steers mileage onto the bike). - **Driver breaks** (per vehicle). `break_rules` (max 1): a driving-trigger rule — after `max_driving_s` of accumulated driving the driver needs a `duration_s` pause; optional `split_first_s`/`split_second_s` allow taking it as two ordered parts; `service_counts: true` lets any contiguous non-driving time (service, waiting) of the required length satisfy the rule. Legal presets: **EU** (561/2006) `{ max_driving_s: 16200, duration_s: 2700, split_first_s: 900, split_second_s: 1800 }`; **US** (FMCSA) `{ max_driving_s: 28800, duration_s: 1800, service_counts: true }`. `fixed_breaks` (max 2): compulsory clock-window pauses (e.g. lunch) that must START inside `[tw_early_s, tw_late_s]`. Carry-in allowance: `period_driving_limit_s` minus `period_driven_s` (externally tracked, per driver) caps the route's total driving time (EU week = 201600, US 7-day = 216000). Stops/depot take `no_break: true` to forbid hosting a break there; if no allowed host exists the break is placed there anyway and the route reports a `break_location` problem. Breaks never interrupt a travel leg: a single leg longer than `max_driving_s` cannot be repaired and reports `break_schedule`. Planned breaks come back as `type:"break"` stops in `routing24_solution`. Breaks combine with ride-bounded transfers: see **Shelf life** above. ### `routing24_status` → `OptimizeStatus` No input. Snapshot of the current optimization — poll (~every 3s) while solving. `phase` walks `idle → geocoding → matrix → solving → saving → done` (or `error`). ```ts type OptimizeStatus = { phase: "idle" | "geocoding" | "matrix" | "solving" | "saving" | "done" | "error"; running: boolean; progress?: number; // 0..1 while solving, when available. feasible?: boolean; routes?: number; stops?: number; unassignedCount?: number; distance?: number; distanceUnit?: "km" | "mi"; durationHours?: number; problemsCount?: number; // Total constraint-problem markers across all routes (0 = feasible). driftFromOptimized?: OptimizedDrift; // Drift vs the last full optimization (absent while a solve runs). error?: string; planUuid?: string; }; ``` ### `routing24_solution` — `SolutionInput` → `PlanSolution` The **full** solution for the currently-loaded plan — whether you just optimized it or opened it via its URL — with no parsing needed. Same rollups as `routing24_status`, plus one entry per route **slot** (empty routes included; `slot` is the address the editing tools use) with the ordered depot→stops→depot sequence, each stop resolved to its `id`/`address`/`lat`/`lng`, arrival & departure times (seconds since midnight, physical timeline), per-leg distance/duration, loads, waits and problems — plus the unassigned list, user-assigned/user-unassigned marks, `revision` and undo/redo depths. Pass `{ refresh_diagnostics: true }` to recompute `unassignedDiagnostics` when `diagnosticsStale` is true. Returns `{ available: false }` when the plan has no solution yet — call it once `routing24_status` reports `phase:"done"`. ```ts // Input for `routing24_solution`. type SolutionInput = { refresh_diagnostics?: boolean; // Recompute the unassigned-site diagnostics before returning them (runs an engine pass; only needed when `diagnosticsStale` was true). }; ``` ```ts // The full optimized solution for the currently-loaded plan — whether it was // just optimized in this tab or opened via its URL. This is the structured data // behind {@link OptimizeStatus}: the same rollups plus every route's ordered // stops. `available` is `false` (and the detail fields are omitted) when the // loaded plan has no solution yet. type PlanSolution = { available: boolean; // `false` when the loaded plan carries no optimized solution yet. planUuid?: string; planUrl?: string; // Absolute URL of the plan's optimize page. feasible?: boolean; // True when every constraint is satisfied. complete?: boolean; // True when the solve ran to completion (not cancelled part-way). distanceUnit?: "km" | "mi"; routeCount?: number; // Number of routes used. stopCount?: number; // Number of site stops served across all routes. unassignedCount?: number; // Number of site stops that could not be served. distance?: number; // Total travel distance across all routes, in `distanceUnit`. durationHours?: number; // Total duration across all routes, hours. unassigned?: string[]; // Ids of the sites left unassigned, if any. cost?: SolutionCost; // Economic cost (money). Coverage is `unassignedCount`, never a cost. objective?: SolverObjective; // Solver comparison scalar — see {@link SolverObjective}; never money. problemsCount?: number; // Total constraint-problem markers across all routes (0 = feasible). routes?: PlanSolutionRoute[]; // One entry per route slot (empty slots included), in slot order. revision?: number; // Session revision the routes reflect; bumps on every committed edit. undoDepth?: number; // Committed edits that `routing24_undo` can walk back. redoDepth?: number; // Undone edits that `routing24_redo` can re-apply. userAssigned?: string[]; // Site ids currently marked user-assigned (manually placed). userUnassigned?: string[]; // Unassigned site ids that were removed by the user/agent (not the solver). alternativesNotChosen?: string[]; // Group-alternative sites not chosen (their sibling serves the group). editedAt?: number; // Last manual-edit timestamp (ms since epoch); absent = never edited. diagnosticsStale?: boolean; // True when `unassignedDiagnostics` predates the latest edits — pass `refresh_diagnostics: true` to recompute before reading it. unassignedDiagnostics?: UnassignedDiagnosticsReport; // Why sites are unassigned, in prose (capped; see `truncated`/`omitted`). insertionQuotes?: InsertionQuote[]; // Ready-to-serve quotes for the unassigned orders (one per quotable site; none when nothing fits). Refreshed with the diagnostics — pass `refresh_diagnostics: true` when `diagnosticsStale`. driftFromOptimized?: OptimizedDrift; // Drift vs the last full optimization (user's manual edits included) — when `severity` is `degraded`/`severe`, tell the user and offer a fix. }; ``` ```ts // One route slot: an ordered stop sequence, bounded by depot stops when the // vehicle has a start/finish depot (open-ended ends start/finish at an order // instead). Empty slots (a vehicle with no stops, e.g. just created by // `create_route`) are included with `siteCount: 0` so `slot` stays a // complete, editable address space. type PlanSolutionRoute = { slot: number; // 0-based route slot — the address every `routing24_edit` op (`to_route`, `route`, `target`, `sources`) and `routing24_optimize_route` use. Slots compact after `remove_routes`; re-read them from the returned state. index: number; // 1-based route number, matching the on-screen order (slot + 1). vehicleId?: string; // Id of the vehicle serving this route. siteCount: number; // Count of site stops served (the depot start/end are excluded). distance: number; // Total travel distance of the route, in `distanceUnit`. durationHours: number; // Total route duration (travel + service + wait), hours. startTimeS?: number; // When the route starts (depot departure, or arrival at the first order on an open-start route), seconds since midnight. endTimeS?: number; // When the route ends (depot return, or service completion at the last order on an open-ended route), seconds since midnight. feasible?: boolean; // True when every constraint on this route is satisfied. problems?: PlanProblem[]; // Route-level constraint problems (also pinned per-stop). cost?: RouteCost; // Economic cost of this route (absent on pre-feature solutions). stops: PlanSolutionStop[]; // Stops in visit order; bounded by depot stops unless the vehicle's start/finish depot is empty (open-ended). }; ``` ```ts type PlanSolutionStop = { seq: number; // 0-based position within the route (0 = the starting depot). type: "depot" | "site" | "break"; // The depot, a delivery/pickup site, or a scheduled driver break. A `break` stop is a solver-planned pause taken AT the previous stop's location: it has no `id`/`address`, `serviceDurationS` is the pause length, and its travel-leg fields are 0. id?: string; // The site/depot id (the id you passed to optimize, or an auto-assigned one). address?: string; // Matched street address, when known. lat?: number; lng?: number; arrivalTimeS: number; // Arrival time, seconds since midnight (physical driver timeline). departureTimeS: number; // End of service (arrival + wait + service), seconds since midnight. serviceDurationS: number; // Time spent servicing this stop, seconds. waitDurationS?: number; // Wait before the stop's time window opens, seconds (0/absent = none). legDistance: number; // Distance of the travel leg arriving at this stop, in `distanceUnit`. legDurationS: number; // Duration of the travel leg arriving at this stop, seconds. carriedLoad: number; // Vehicle load carried on arrival. deliveredLoad: number; // Load delivered at this stop. pickedUpLoad: number; // Load picked up at this stop. problems?: PlanProblem[]; // Constraint problems materializing at this stop (absent = none). marginalCost?: number; // Removal saving — how much cheaper (money) / shorter (seconds) this route gets without this stop, same vehicle kept. An estimate for the CURRENT stop order: savings are NOT additive across stops and change after any edit or re-optimize. Absent on depots and pre-feature plans. marginalDurationS?: number; }; ``` ```ts // ECONOMIC cost of the solution, in the task's money units (the unit costs // configured on the vehicles). Report money to the user from here. It does // NOT include unassigned-order penalties — coverage is a count // (`unassignedCount`), not a cost. Absent on pre-feature solutions. type SolutionCost = { total: number; // Full economic cost: fixed + distance + duration (incl. overtime) + stop + ride overtime + load-distance. overtime: number; // Overtime premium — a breakdown line already inside `total`. vehicle: number; // `total` minus `overtime`. stop?: number; // Per-stop cost addend inside `total` (absent when no vehicle prices stops). rideOvertime?: number; // Cost of the linked orders' ride time inside their overtime band, at the serving vehicle's `cost.ride_overtime` — an addend inside `vehicle` (absent when no route ran into a priced band). loadDistance?: number; // Carriage cost: load on board times leg length, summed over legs, at each serving vehicle's `cost.load_distance` — an addend inside `vehicle` (absent when no vehicle prices load-distance). }; ``` ```ts // One route's ECONOMIC cost, in the task's money units. Same shape rules as // {@link SolutionCost}. type RouteCost = { total: number; // Full economic cost of the route: fixed + distance + duration (incl. overtime) + stop + ride overtime + load-distance. overtime: number; // Overtime premium inside `total`. vehicle: number; // `total` minus `overtime`. stop?: number; // Per-stop cost addend inside `total` (absent when the vehicle has no stop cost). rideOvertime?: number; // Cost of this route's linked orders' ride time inside their overtime band, at the vehicle's `cost.ride_overtime` — an addend inside `vehicle` (absent when the route ran into no priced band). loadDistance?: number; // This route's carriage cost: load on board times leg length, summed over legs, at the vehicle's `cost.load_distance` — an addend inside `vehicle` (absent when the vehicle prices no load-distance). }; ``` ```ts // The scalar the solver minimizes — SOLVER UNITS, NOT MONEY, never quote it // to the user. Each unassigned order carries a synthetic penalty priced // above any possible serving cost, so fewer-unassigned always outranks // cheaper-routes. Use it ONLY to compare two states of the same plan: // lower `objective.total` = better plan. Equivalent hand rule: fewer // unassigned wins; ties break on lower `cost.total`. type SolverObjective = { total: number; // cost.total + unassignedPenalty. Comparisons only. unassignedPenalty: number; // Σ synthetic penalties of unassigned orders (incl. alternatives not chosen). }; ``` ```ts // One constraint problem (common.fbs `Problem`). type PlanProblem = { type: "capacity" | "max_distance" | "vehicle_incompatible" | "unreachable" | "time_window" | "max_duration" | "depot_time_window" | "shift_window" | "precedence" | "driving_allowance" | "break_schedule" | "break_location" | "sequence" | "incompatible_load_class" | "shelf_life" | "field_not_applicable"; amount?: number; // How far over the constraint (see {@link PlanProblemType} for units). dimension?: number; // Capacity dimension index (`capacity` problems only). classes?: string[]; // Conflicting load-class names (`incompatible_load_class` only). }; ``` ```ts // Prose explanation of why sites are unassigned (common.fbs // `UnassignedDiagnosticsLlm` — the agent-facing projection, capped in size). type UnassignedDiagnosticsReport = { summary: string; // One-paragraph overview of the unassigned situation. truncated?: boolean; // True when the per-site list was capped (see `omitted`). sites: { site: string; category: string; explanation: string; blockers?: string[]; levers?: string[] }[]; omitted?: { sitesCount: number; dominantKind?: string; dominantConstraint?: string }; // Rollup of the sites the cap left out. }; ``` ```ts // The cheapest FEASIBLE way to serve one unassigned order in the current // routes — REAL units (money / seconds), safe to quote to the user. Directly // replayable as a `move_sites` edit: use `before`/`after` as the anchor (or // `to_route: routeSlot` with `placement: "best"` when both are absent). // `routeSlot` absent = a spare `vehicleId` vehicle serves it on a fresh // single-visit route, its fixed cost included (an existing route's fixed // cost is sunk and excluded). Estimates for the CURRENT arrangement only: // quotes are NOT additive across orders and go stale with any edit or // re-optimize (`diagnosticsStale`). type InsertionQuote = { site: string; routeSlot?: number; vehicleId?: string; cost: number; // Route-cost increase, money. durationS: number; // Route-duration increase, seconds. before?: string; after?: string; deliveryAfter?: string; // Pair quote (site = the pickup): where the DELIVERY step is quoted, after the pickup (precedence-aware). Absent for a single order. }; ``` ```ts // How far the CURRENT plan has drifted from the last full optimization — // covering every change since that solve, the user's manual edits included. // Recompute-free: the baseline is frozen at solve completion; only a new // `routing24_optimize` (or the user's OPTIMIZE button) resets it. Absent when // the plan predates the baseline feature or was never solved. // // REPORTING OBLIGATION: when `severity` is `degraded` or `severe`, tell the // user (quote `summary`) and offer to fix it — `routing24_optimize_route` for // a single rough route, or a fresh solve. type OptimizedDrift = { optimizedAt: number; // When the baseline solve completed (ms since epoch). distancePct: number; // Travel-distance change vs the baseline, percent (+ = longer). durationPct: number; // Total-duration change vs the baseline, percent (+ = slower). distance: number; // Distance change in `distanceUnit` (+ = longer). durationHours: number; // Duration change in hours (+ = slower). cost?: number; // ECONOMIC cost change, money (+ = more expensive). Coverage drift is `unassigned` (a count) — the two are never merged into one number. Absent when the baseline carried no cost. costPct?: number; // Cost change vs the baseline, percent (+ = more expensive). problems: number; // New problem markers vs the baseline (+ = worse). unassigned: number; // Newly unassigned stops vs the baseline (+ = worse). severity: "improved" | "neutral" | "degraded" | "severe"; // Fixed-threshold verdict: any new problem/unassigned ⇒ at least `degraded`; cost, distance or duration > +5% ⇒ `degraded`, > +15% ⇒ `severe`; everything ≤ baseline with a ≥1% gain ⇒ `improved`; else `neutral`. summary: string; // One ready-to-quote sentence, lexicographic: coverage first, then money. }; ``` - `insertionQuotes` are ready-to-serve estimates in REAL units (safe to quote as money/minutes): the cheapest feasible placement per unassigned order, replayable directly as `move_sites` (anchor on `before`/`after`, or `to_route: routeSlot` + `placement:"best"`). For a pickup&delivery pair the quote keys on the pickup (`site`); `deliveryAfter` anchors the delivery step (always after the pickup, precedence-aware). Per-stop `marginalCost`/`marginalDurationS` are the reverse: what serving that stop adds to its route. Both are estimates for the CURRENT arrangement — never sum them, and refresh via `refresh_diagnostics` when `diagnosticsStale`. - `PlanProblem` codes worth decoding: `break_schedule` = the driver-break rules could not be met (typically a single leg longer than the driving trigger); `break_location` = a break had to be placed at a `no_break` stop; `shelf_life` = a ride ran past `max_time_in_vehicle_s` + band on an evaluated or edited route (a solve drops such pairs to `unassigned` instead); `field_not_applicable` = an intrinsic mismatch (e.g. `max_ride_overtime_s` on a plain stop), reads as an unassigned reason. - Two registers, never mixed: `cost` is MONEY (the task's unit costs; `cost.overtime` is a line inside `cost.total`, not an addend) and coverage is a COUNT (`unassignedCount`/`unassigned`). `objective` is the solver's comparison scalar in synthetic units — lower = better plan, and it already prices every unassigned order above any possible serving cost, so dropping stops NEVER improves it. Never quote `objective` as money. Hand rule: fewer unassigned wins; ties break on lower `cost.total`. - `driftFromOptimized` compares the CURRENT plan to the last full solve and covers every change since it — the user's manual edits included. On `severity: "degraded" | "severe"` you MUST tell the user (quote `summary`) and offer `routing24_optimize_route` or a fresh solve. - `type:"break"` stops are solver-planned driver breaks, taken at the PREVIOUS stop's location: no `id`/`address`, `serviceDurationS` = pause length, travel-leg fields 0. They don't count toward `siteCount`, can't be addressed by `routing24_edit` (no id), and re-place themselves automatically after any edit. ### `routing24_edit` — `EditInput` → `EditResult` Manually edit the loaded solution: an ordered batch of edit ops applied **all-or-nothing** (the first rejection rolls the whole batch back; later ops see earlier ops' effects, e.g. `create_route` then `move_sites` onto the new slot). Stops are addressed by their `id`, routes by `slot` from `routing24_solution`. **Constraint problems never reject an edit** — they are scored and reported (`state`, `userAssignedReports`) exactly like the UI's manual drag & drop; rejections are structural only (`rejection.code`: unknown ids, bad anchors, non-empty route removal, `stale_revision` when the plan changed under you → re-read `routing24_solution` and rebuild). Applying an edit locks the whole app behind a full-screen "Agent controlled" overlay until the user clicks "Take control". Undoable — every batch is one entry in the shared undo history. ```ts // Input for `routing24_edit` (edits.fbs `EditBatch`): an ordered batch applied // ALL-OR-NOTHING — the first rejected op rolls the whole batch back. Ops see the // effects of earlier ops in the same batch (e.g. `create_route` then // `move_sites` onto the new slot). Revision guarding is handled internally. type EditInput = { ops: (EditMoveSites | EditSetRouteVehicle | EditUnassignSites | EditMarkUserAssigned | EditClearUserAssigned | EditCreateRoute | EditRemoveRoutes | EditSplitRoute | EditMergeRoutes)[]; // min 1 }; ``` ```ts // Move `sites` as ONE ordered block (edits.fbs `MoveSites`). Destination is // exactly one of: `before`/`after` (anchor site id, must be planned and not in // `sites`), or `to_route` (+ `placement`). Also the way to plan an unassigned // site: move it from nowhere onto a route. type EditMoveSites = { op: "move_sites"; sites: string[]; // min 1 before?: string; // Anchor site id: insert the block immediately before it. after?: string; // Anchor site id: insert the block immediately after it. to_route?: integer; // Destination route slot (see `PlanSolutionRoute.slot`). — >= 0 placement?: "append" | "best"; // With `to_route`: `append` to the end, or `best` = cheapest insertion. user_assigned?: boolean; // Mark the sites user-assigned (badge + problem report). Default true. }; ``` ```ts // Rebind a route slot to another fleet vehicle (edits.fbs `SetRouteVehicle`). type EditSetRouteVehicle = { op: "set_route_vehicle"; route: integer; // >= 0 vehicle: string; // Vehicle id; rejects `vehicle_overused` when its count is exhausted. }; ``` ```ts // Remove sites from their routes into the unassigned list (edits.fbs `UnassignSites`). type EditUnassignSites = { op: "unassign_sites"; sites: string[]; // min 1 user_unassigned?: boolean; // Stamp the "removed by you" marker (default true). Pass false to unassign without claiming the removal, keeping the site's original unassigned reason. }; ``` ```ts // Mark already-planned sites user-assigned (badge only; edits.fbs `MarkUserAssigned`). type EditMarkUserAssigned = { op: "mark_user_assigned"; sites: string[]; // min 1 }; ``` ```ts // Drop the user-assigned mark + report ("Dismiss"; edits.fbs `ClearUserAssigned`). type EditClearUserAssigned = { op: "clear_user_assigned"; sites: string[]; // min 1 }; ``` ```ts // Append an empty route slot for `vehicle` (edits.fbs `CreateRoute`). type EditCreateRoute = { op: "create_route"; vehicle: string; }; ``` ```ts // Delete EMPTY route slots (edits.fbs `RemoveRoutes`; `route_not_empty` // otherwise — `unassign_sites` or move the stops first). Remaining slots compact: // put this op last in the batch and re-read slots from the result. type EditRemoveRoutes = { op: "remove_routes"; routes: integer[]; // min 1 }; ``` ```ts // Split a route in two after site `after` (edits.fbs `SplitRoute`). The head // keeps the slot + vehicle; the tail becomes a new appended slot served by // `vehicle` (defaults to the same vehicle type). type EditSplitRoute = { op: "split_route"; route: integer; // >= 0 after: string; // Site id that becomes the last stop of the head route. vehicle?: string; }; ``` ```ts // Append `sources` routes' stops onto `target`, leaving the sources empty (edits.fbs `MergeRoutes`). type EditMergeRoutes = { op: "merge_routes"; target: integer; // >= 0 sources: integer[]; // min 1 }; ``` ```ts // Result of `routing24_edit`. type EditResult = { applied: boolean; // True when the whole batch committed; false = nothing was applied. errorCode?: "solve_in_progress" | "no_solution" | "slot_out_of_range" | "route_too_small" | "optimize_running" | "nothing_to_undo" | "nothing_to_redo" | "session_error"; error?: string; // Human-readable failure detail accompanying `errorCode`. rejection?: EditOpStatus; // Why the batch rolled back (all-or-nothing), when the engine rejected it. state?: SessionState; // Current state — post-apply, or unchanged when the batch was rejected. userAssignedReports?: UserAssignedProblemReport[]; // Problem reports for all currently user-assigned sites (when `applied`). }; ``` ```ts // The rejected op of a failed batch (edits.fbs `EditStatus`). type EditOpStatus = { edit: number; // Index into the submitted `ops` array. code: EditRejectionCode; reason?: string; // Human-readable rejection detail, when the engine supplies one. }; ``` ```ts // Why an edit batch was rejected (edits.fbs `EditRejection`, minus `none`). // Notable: `stale_revision` = the plan changed under you — re-read // `routing24_solution` and rebuild the batch; `optimize_in_progress` = wait for // the running optimization; slot codes mean your `slot` numbers are outdated. type EditRejectionCode = "slot_out_of_range" | "nothing_to_undo" | "nothing_to_redo" | "unknown_site" | "unknown_vehicle" | "bad_anchor" | "anchor_not_assigned" | "anchor_in_moved_sites" | "ambiguous_destination" | "missing_destination" | "destination_mismatch" | "route_not_empty" | "vehicle_overused" | "group_conflict" | "stale_revision" | "too_many_trips" | "overlapping_slots" | "empty_split" | "site_not_assigned" | "optimize_in_progress" | "not_sole_op"; ``` ```ts // Post-mutation session snapshot shared by the editing tools' results. type SessionState = { revision?: number; // Session revision after the operation. undoDepth?: number; redoDepth?: number; feasible?: boolean; problemsCount?: number; // Total constraint-problem markers across all routes (0 = feasible). distance?: number; distanceUnit?: "km" | "mi"; durationHours?: number; unassignedCount?: number; unassigned?: string[]; // Ids of the sites currently unassigned. cost?: SolutionCost; // Economic cost (money). Coverage is `unassignedCount`, never a cost. objective?: SolverObjective; // Solver comparison scalar — see {@link SolverObjective}; never money. userAssigned?: string[]; userUnassigned?: string[]; routes?: RouteSummary[]; // Every route slot, fresh — supersedes slots from before the mutation. driftFromOptimized?: OptimizedDrift; // Drift vs the last full optimization — report `degraded`/`severe`. }; ``` ```ts // One route slot's summary in a {@link SessionState} (post-edit snapshot). type RouteSummary = { slot: number; // 0-based route slot (fresh — valid for the next edit batch). vehicleId?: string; stops: string[]; // Ordered site ids (depots excluded); empty = empty slot. distance?: number; // Total travel distance, in `SessionState.distanceUnit`. durationHours?: number; // Total duration (travel + service + wait), hours. feasible?: boolean; problems?: PlanProblem[]; cost?: RouteCost; // Economic cost of this route (absent on pre-feature solutions). }; ``` ```ts // The problem report behind a user-assigned site's badge (edits.fbs // `UserAssignedReport`): what placing it costs, split into problems the site // would have anywhere (`intrinsic`) vs ones this placement introduced. type UserAssignedProblemReport = { site: string; route?: number; // Route slot the site sits on. intrinsic: PlanProblem[]; introduced: PlanProblem[]; }; ``` - Idioms: plan an unassigned stop with `move_sites` + `to_route` + `placement:"best"`; empty-then-remove a route with `[unassign_sites, remove_routes]` in one batch; swap two group alternatives with `unassign_sites` (`user_unassigned:false`) + `move_sites`. - `remove_routes` compacts the remaining slots — take fresh slots from `state.routes`, never reuse pre-batch numbers. ### `routing24_optimize_route` — `OptimizeRouteInput` → `OptimizeRouteResult` Re-sequence ONE route's stops (single-route re-optimization). Synchronous and fast (sub-second to a few seconds) — no polling; other routes are untouched. Use it to tidy a route after manual moves. Undoable like any edit. ```ts // Input for `routing24_optimize_route`: re-sequence ONE route's stops. type OptimizeRouteInput = { route: integer; // Route slot to optimize (see `PlanSolutionRoute.slot`); needs ≥2 stops. — >= 0 time_limit_s?: integer; // Wall budget in seconds; omit to auto-scale with route size (1.5–5s). — >= 1 }; ``` ```ts // Result of `routing24_optimize_route`. type OptimizeRouteResult = { started: boolean; // False when the run could not start (see `errorCode`/`error`). errorCode?: "solve_in_progress" | "no_solution" | "slot_out_of_range" | "route_too_small" | "optimize_running" | "nothing_to_undo" | "nothing_to_redo" | "session_error"; error?: string; state?: SessionState; // Post-run state (present when `started`). }; ``` ### `routing24_undo` / `routing24_redo` → `HistoryResult` No input. Walk the edit history one committed batch at a time. The edit history is SHARED with the user's own manual edits — an undo can revert something the user just did by hand, so never call it speculatively; prefer a compensating routing24_edit batch. Returns {applied:false, errorCode:"nothing_to_undo" | "nothing_to_redo"} on an empty history (error carries the human-readable detail). ```ts // Result of `routing24_undo` / `routing24_redo`. type HistoryResult = { applied: boolean; // False when nothing was undone/redone (see `errorCode`). errorCode?: "solve_in_progress" | "no_solution" | "slot_out_of_range" | "route_too_small" | "optimize_running" | "nothing_to_undo" | "nothing_to_redo" | "session_error"; error?: string; state?: SessionState; }; ``` ### `routing24_render` → `{ ok: true }` No input. Navigates to the plan's optimize page so the routes draw on the map (screenshot the tab afterwards to show the user). ### `routing24_save` → `{ saved: boolean; planUrl: string }` No input. Persists the plan. When **anonymous**, the plan is stored in this browser on this computer only — the link opens just here and may be deleted later, so it is not a durable share link. Tell the user this when you hand over the link. (If the user is signed in, the plan persists to their account and the link opens on their other devices too.) ### `routing24_plan_url` → `{ planUrl: string }` No input. Absolute URL of the current plan's optimize page. ### `routing24_cancel` → `{ ok: true }` No input. Aborts an in-flight solve (mirrors the UI's cancel button). ### Machine-readable JSON Schema The API reference above IS the schema (generated from the same typia collection) — it is deliberately not repeated here as raw JSON. The machine-readable copy (OpenAPI 3.1 / JSON Schema 2020-12) ships as `references/schema.json` inside the installable skill (`https://routing24.com/routing24.skill`) and in the source repo (`https://github.com/routing24/skill`). ## Version & keeping current - This skill is **version 1.2.1**. Its bundled reference (`references/api.md` + `references/schema.json`) is generated from Routing24's own types and is correct as of this version. - The **always-current** copy of the full contract is served at `https://routing24.com/llms.txt` (regenerated from the deployed API on every release). If a call rejects with a validation error that looks like a field this reference doesn't describe, fetch that URL and use its schema — then consider re-downloading the latest skill from `https://routing24.com/routing24.skill`. - To update the skill itself, re-download `https://routing24.com/routing24.skill` and re-install it; that is the update mechanism. ## Notes & pitfalls - You drive everything through the user's own tab via the WebMCP tools; you never call a Routing24 server API directly. The tools do that for you (geocoding, routing/matrix, ML/LLM) under an opaque token, while the optimizer itself runs client-side in the tab. - `executeTool` resolves to a **JSON string** (parse it; `null` means no value) and **rejects** on validation or handler errors — wrap calls in try/catch and relay the message. You cannot receive an image from the tools — to show the user the map, call `routing24_render` then screenshot the tab. - Prefer `document.modelContext`; `navigator.modelContext` is a deprecated alias kept for older hosts. - `routing24_optimize` starts a **new plan** each time. When the user is **anonymous**, the plan is stored **only in this browser on this computer** and may be deleted later, so the plan link opens only here — it is not a durable share link. Say this when you hand over the link. (Signing in before saving persists the plan to the user's account so the link also opens on their other devices — but sign-in is never required to plan, save, or share.) - If `routing24_status` never leaves `matrix`/`solving`, the network (matrix service) or the solve may be slow — keep polling; only treat it as failed on `phase:'error'`. - **Editing needs a solved plan** (`routing24_solution` reports `available:true`) and refuses while a full solve runs (`errorCode:"solve_in_progress"`). Route `slot`s are 0-based and COMPACT after `remove_routes` — always re-read them from the returned `state.routes` or a fresh `routing24_solution`. - **Two failure channels on the editing tools**: `rejection.code` = the ENGINE refused the batch (structural: unknown ids, bad anchors, `stale_revision`, …); `errorCode` = the tool refused or failed around the engine (`solve_in_progress`, `no_solution`, `slot_out_of_range`, `route_too_small`, `optimize_running`, `nothing_to_undo`, `nothing_to_redo`, `session_error`). `session_error` means the editing session itself failed and the app resynced — re-read `routing24_solution` and retry ONCE. - **Edits are never rejected for violating constraints** — time windows, capacity etc. are scored and reported (`state.problemsCount`, per-route `problems`, `userAssignedReports`), exactly like the UI's manual drag & drop. Rejections are structural only (`rejection.code`: unknown ids, bad anchors, non-empty route removal, `stale_revision`, …). - While you edit, the app locks behind a full-screen **"Agent controlled"** overlay until the user clicks its "Take control" button — tell the user to take control from there when you hand the plan back. The user may also edit concurrently before your first edit: a `stale_revision` rejection means re-read `routing24_solution` and rebuild your batch. - `routing24_undo`/`routing24_redo` walk the same history as the user's own manual edits — never undo blindly. - **`driftFromOptimized` is your metrics conscience.** It compares the CURRENT plan to the last full solve (baseline frozen at solve completion; only a new solve resets it) and covers ALL changes since — including edits the user made by hand while you were away. On every `routing24_solution`/`routing24_status` read and in every edit result: `severity` `degraded`/`severe` MUST be relayed to the user with the ready-made `summary`, plus an offer to re-optimize. Absent drift = the plan predates the baseline feature or has no completed solve — nothing to compare against. - **Money, coverage and the objective are three separate registers — never mix them.** `cost` is money (report it to the user); `unassignedCount` is coverage (a count, never a cost); `objective` is the solver's comparison scalar in synthetic units where every unassigned order is priced above any possible serving cost. Judge "is the plan better?" by: fewer unassigned wins, ties break on lower `cost.total` (equivalently: lower `objective.total`). Unassigning a stop always WORSENS the plan even when `cost` falls — never present dropping stops as savings, and never quote `objective` numbers as money. (Distinct from the vehicle cost INPUTS on `routing24_optimize`, where `cost.duration`/`cost.overtime` are per-hour rates.) - **Marginal costs are estimates, never sums.** `insertionQuotes` (cheapest way to serve an unassigned order) and per-stop `marginalCost` (removal saving) are real money/seconds and safe to quote — but they hold for the CURRENT arrangement only: never add them up across orders, and re-read after any edit or re-optimize (`refresh_diagnostics` when `diagnosticsStale`). To act on a quote, replay it as `move_sites` with its `before`/`after` anchor. - **Units**: `max_distance` (vehicle constraint) and every returned distance use the plan's display unit (`distanceUnit`: km or mi); `cost.duration` and `cost.overtime` are per hour; `cost.load_distance` is per load unit per km/mi; all times are seconds-since-midnight. - **Vehicle cost inputs: omit, don't zero.** An omitted cost field is 0. When the user gives no rates at all, send no `cost` objects — the optimizer then minimizes plain travel distance. Never write `cost: { distance: 0, duration: 0 }` to mean "no preference": an all-zero fleet has nothing to optimize, so the zeros are ignored (distance is minimized) and the optimize result returns a `warnings` entry you must relay. Explicit zeros are for mixed fleets only, e.g. `{ id: "Bike", cost: { distance: 0, duration: 6 } }` next to `{ id: "Van", cost: { distance: 2, duration: 18, fixed: 40 } }` makes mileage free on the bike and priced on the van.