Plan a Train Trip on NS (Dutch Railways)

Site ns.nlTask plan-trip-x5is7wVersion v1Updated Jul 13, 2026Category transportation

Plan a train journey between two stations on the NS (ns.nl) journey planner and return travel options with departure/arrival times, duration, track, transfers, train types, live delays, crowding, and fare/supplement notes. Read-only. This skill was captured from a live agent session on ns.nl and publishes here verbatim, exactly as an agent receives it.

NoteSelectors and URL schemes drift as sites change. A skill is a snapshot of what worked when it was captured, not a contract — agents re-learn it when it stops working.

Purpose

Plan a domestic (or international-via-NS) train journey between two stations on the NS journey planner at ns.nl and return the available travel options: per-option departure/arrival times, total travel time, departure track, number of transfers, train type(s) (Intercity / Sprinter / Intercity direct / Eurocity Direct / metro / bus legs), real-time delays, crowding indication, and any fare/supplement notes. Read-only — never books, buys, or checks in.

When to Use

  • "What trains run from Amsterdam Centraal to Rotterdam Centraal around 9am tomorrow?"
  • "When do I need to leave Eindhoven to arrive at Schiphol Airport by 17:00?"
  • Comparing departure options, transfer counts, or travel times between two Dutch stations.
  • Surfacing live delays / disruptions on a specific route at a specific time.
  • Any flow that needs train travel options without purchasing a ticket.

Workflow

The NS journey planner is an Angular SPA whose state is fully encoded in the URL hash — so the optimal path is to construct a deep-link URL directly and read the rendered results, with zero form interaction. A plain browserless_agent call (no proxy) is sufficient: the homepage and the planner load cleanly and the underlying trips API returns 200 without geo-restriction (verified from a non-NL outbound IP over two iterations). The site sits behind Akamai (ak_bmsc cookie) but did not challenge plain navigation.

https://www.ns.nl/en/journeyplanner/#/?vertrek=<FROM>&vertrektype=treinstation&aankomst=<TO>&aankomsttype=treinstation&type=<vertrek|aankomst>&tijd=<YYYY-MM-DDTHH:mm>&firstMileModality=PUBLIC_TRANSPORT&lastMileModality=PUBLIC_TRANSPORT
ParamMeaning
vertrekOrigin station name, URL-encoded (e.g. Amsterdam%20Centraal). Use the canonical NS name.
aankomstDestination station name, URL-encoded.
vertrektype / aankomsttypetreinstation for a train station (other values exist for bus stops / addresses — stick to treinstation).
typevertrek = depart at tijd; aankomst = arrive by tijd.
tijdLocal (Europe/Amsterdam) ISO datetime, minute precision, no seconds/zone: 2026-06-18T09:00.
firstMileModality / lastMileModalityPUBLIC_TRANSPORT (default).

Use the English planner (/en/journeyplanner/) for English UI labels; the Dutch planner lives at /reisplanner/ and uses the identical param names.

2. Navigate and wait for the SPA to render

Run one browserless_agent call, chaining the nav, wait, and snapshot in its commands array (the session persists across calls, keyed by proxy/profile, so batching keeps its state live):

{ "method": "goto", "params": { "url": "<deep-link>", "waitUntil": "load", "timeout": 45000 } }
{ "method": "waitForTimeout", "params": { "time": 5000 } }   // Angular fetches + renders trip cards 2-5s after load

3. Read the results

{ "method": "snapshot" }

Each travel option appears as a single accessibility-tree StaticText summary with a predictable shape:

departure 17:18 arrival 18:08 travel time 0:50 track 14a You travel with Sprinter Intercity direct
departure 17:19 with 2 minutes delay arrival 18:34 travel time 1:13 track 4b You travel with Sprinter
Shorter train, extra busy and possibly full departure 17:27 with 1 minute delay arrival 18:18 travel time 0:50 with 1 time transfers You travel with metro line 52 Intercity direct

Parse from that string:

  • departure HH:MM / arrival HH:MM / travel time H:MM
  • track <n> → departure track/platform (may be absent for some itineraries — emit null)
  • with N minute(s) delay → real-time delay in minutes (absent ⇒ 0)
  • with N time transfers → transfer count (absent ⇒ 0)
  • You travel with <…> → the train/modality types in order (e.g. Intercity, Sprinter, Intercity direct, Eurocity Direct, metro line 52)
  • Crowding prefix such as Shorter train, extra busy and possibly full → crowding note
  • A leg/option marked Supplement (e.g. Intercity direct, Eurocity Direct) carries a surcharge — expand "Show details" to read the exact amount (e.g. €3.20).

For full per-leg detail (intermediate stops, walk-to-platform transfers, per-leg tracks, fare breakdown like €20.20 full / €12.12 off-peak), click an option's "Show details" button and re-snapshot.

4. Notes on result windowing

Results are anchored on tijd: for type=vertrek the list starts at/just after the requested time; for type=aankomst the list ends at/just before it (last option's arrival ≤ requested time, verified). A few earlier options may be hidden behind an "Earlier travel options" control — click it only if you need departures before the anchor.

Browser fallback (form interaction)

If a deep link ever fails to populate (e.g. an ambiguous station name that doesn't auto-resolve), drive the form. Keep the whole sequence in one browserless_agent call's commands array so the typeahead state persists:

  1. { "method": "goto", "params": { "url": "https://www.ns.nl/en/journeyplanner/#/", "waitUntil": "load", "timeout": 45000 } }.
  2. Dismiss the cookie dialog — click Reject (or Accept); it overlays the page on first visit.
  3. click the from combobox, { "method": "type", "params": { "selector": "<from combobox>", "text": "<origin>" } }, waitForTimeout ~2000ms, then click the first suggestion labelled "Railway station" in the suggestions listbox (avoid the look-alike "Bus station" option; confirm the selector via snapshot if it misses).
  4. Repeat for the to combobox.
  5. Optionally set the Departure/Arrival radio, date, and time fields.
  6. click Plan (or Show Travel options). The URL rewrites itself into the canonical deep link from step 1 — capture it for reuse.
  7. Wait and read results as in step 3.

Site-Specific Gotchas

  • No anti-bot escalation needed. A plain browserless_agent call (no proxy) loaded the planner and returned full results across both validation iterations from a non-Dutch IP. Akamai is present (ak_bmsc cookie) but did not challenge. Don't waste budget enabling a proxy unless you actually observe a 403/Access-Denied.
  • The URL hash IS the state. Everything is after #/, so changing only the hash on an already-loaded planner may not re-trigger a fetch — do a full goto of the new deep link (or a { "method": "reload", "params": { "waitUntil": "load" } }) to force a fresh search.
  • Wait after load. The trips XHR fires 2–5s after the goto (waitUntil: "load") resolves. Snapshotting too early returns the empty form. Always add a { "method": "waitForTimeout", "params": { "time": 5000 } } (or re-snapshot until option rows appear).
  • type is the depart-vs-arrive switch, not a UI radio you must click — set it in the URL. vertrek = leave-at, aankomst = arrive-by.
  • tijd is local Amsterdam time, minute precision, no timezone suffix. 2026-06-18T09:00. Adding seconds or a Z/offset can cause the SPA to fall back to "now".
  • Typeahead has bus-stop look-alikes. When using the form fallback, the suggestions list shows both a Railway station and a Bus station entry for the same city name (e.g. "Amsterdam, Centraal Station" is a bus station). Always pick the one tagged Railway station.
  • Supplement trains. "Intercity direct" (Amsterdam–Rotterdam–Breda HSL) and "Eurocity Direct" legs require a surcharge (observed €3.20); the option is tagged Supplement. Surface this — it changes the real price.
  • Crowding & shorter-train warnings are prepended to the option text ("Shorter train, extra busy and possibly full"). Capture them as advisory metadata.
  • Underlying API needs a key — see Expected Output's note. The SPA fetches https://gateway.apiportal.ns.nl/reisinformatie-api/api/v3/trips (returns 200, JSON, ~11 KB). This is NS's official, documented API (apiportal.ns.nl) and requires a registered Ocp-Apim-Subscription-Key header. If you have a free NS API key, call it directly (params below) and skip the browser entirely. Without a key, the deep-link browser path is the recommended route — the website's embedded key is not reliably extractable and reusing it would violate NS's API terms.
  • Cookie dialog only appears in the form flow / first visit. Deep-link navigation renders results behind/around it without needing dismissal, but if a snapshot looks blocked, dismiss the dialog: Your cookie preferences first.

Expected Output

{
  "success": true,
  "from": "Amsterdam Centraal",
  "to": "Rotterdam Centraal",
  "search_type": "departure",
  "search_time": "2026-06-18T09:00",
  "options": [
    {
      "departure": "09:18",
      "arrival": "10:08",
      "duration": "0:50",
      "departure_track": "14a",
      "transfers": 1,
      "train_types": ["Sprinter", "Intercity direct"],
      "delay_minutes": 0,
      "crowding_note": null,
      "supplement": "€3.20"
    },
    {
      "departure": "09:36",
      "arrival": "10:48",
      "duration": "1:12",
      "departure_track": "2a",
      "transfers": 0,
      "train_types": ["Intercity"],
      "delay_minutes": 0,
      "crowding_note": null,
      "supplement": null
    }
  ],
  "error_reasoning": null
}

search_type is "departure" (depart-at) or "arrival" (arrive-by). For arrive-by queries the options' arrival values cluster at/just before search_time.

Failure shape (e.g. unresolvable station, no service on the route/date):

{
  "success": false,
  "from": "…",
  "to": "…",
  "options": [],
  "error_reasoning": "No travel options returned — station name did not resolve / no service for the requested date-time."
}

Direct-API alternative (requires a free NS API subscription key):

GET https://gateway.apiportal.ns.nl/reisinformatie-api/api/v3/trips
    ?fromStation=Amsterdam Centraal
    &originUicCode=8400058
    &toStation=Rotterdam Centraal
    &destinationUicCode=8400530
    &dateTime=2026-06-18T09:00:00
    &lang=en
    &product=OVCHIPKAART_ENKELE_REIS
Header: Ocp-Apim-Subscription-Key: <your NS apiportal.ns.nl key>

Returns structured JSON trip objects (legs, stops, fares, real-time delays) — register at apiportal.ns.nl for a key. UIC station codes are discoverable via the same portal's nsapp-stations / places-api endpoints.

Call it

GET https://production-sfo.browserless.io/skills?token=TOKEN-HERE&domain=ns.nl&task=plan-trip-x5is7w