Skip to main content
Glama
README.md
# TfL API TypeScript Client

[![npm version](https://badge.fury.io/js/tfl-ts.svg)](https://badge.fury.io/js/tfl-ts)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue.svg)](https://www.typescriptlang.org/)
[![Node.js](https://img.shields.io/badge/Node.js-18+-green.svg)](https://nodejs.org/)
[![CI](https://github.com/ghcpuman902/tfl-ts/actions/workflows/ci.yml/badge.svg)](https://github.com/ghcpuman902/tfl-ts/actions/workflows/ci.yml)

> Typed TfL client: friendly wrappers, 84 raw REST endpoints, and offline station topology for 20 rail lines.

<img src="https://raw.githubusercontent.com/ghcpuman902/tfl-ts/main/assets/tfl-cli.gif" alt="tfl CLI checking central and victoria, then listing the journey endpoints" width="640">

<img src="https://raw.githubusercontent.com/ghcpuman902/tfl-ts/main/assets/board.png" alt="Board at tfl.manglekuo.com, with line status and live arrivals" width="640">

Built with tfl-ts → [Board](https://tfl.manglekuo.com)

## How this compares

| | tfl-ts | [tfl-unified-api](https://github.com/CarbonCollins/tfl-unified-api-nodejs) | [tfl-journey-planner](https://github.com/Lissy93/node-tfl-journey-planner) | [tfl-mcp](https://github.com/daanrongen/tfl-mcp) | [mcp-tfl](https://github.com/pipeworx-io/mcp-tfl) |
| --- | --- | --- | --- | --- | --- |
| TypeScript types | Yes | No | No | Source only | Yes (`src/index.ts`) |
| Zero dependencies | Yes | No | No | No | No |
| Raw endpoint coverage | 84 | 10 of 84 | 1 of 84 | 59 of 84 | 8 of 84 |
| Offline station data | Yes | No | No | No | No |
| MCP support | Yes | No | No | Yes | Yes |

Counts use the 84 operations in the committed OpenAPI snapshot (`ENDPOINT_COUNT` in `src/generated/endpoints.ts`, `pathCount` in `src/generated/openapi/spec.meta.json`). Checked against each repo's source and the `package.json` published to npm on 16 October 2026.

tfl-ts publishes `.d.ts` files. Runtime dependencies: none. `typescript` is a peer dependency, for people compiling against the types. Station order and hubs ship in `tfl-ts/meta`, with no network call. `tfl mcp` is the local MCP server.

[tfl-unified-api](https://www.npmjs.com/package/tfl-unified-api) 2.0.2 is JavaScript, with no `types` field. It depends on `moment`, `request`, and `request-promise-native`. The Line module calls ten of the 84 paths: meta modes, severity, disruption categories, service types, route sequence, route by id, lines by mode, status by id, status over a date range, and status by mode. `getRoutesByServiceType` builds `/Line/Route/{types}`, which sits outside those 84 paths. `db/lineColors.json` is line colours, not stations.

[tfl-journey-planner](https://www.npmjs.com/package/tfl-journey-planner) 1.1.2 is LiveScript compiled to JavaScript, with no `types` field. It depends on `request` and `querystring`. The module calls `GET /Journey/JourneyResults/{from}/to/{to}` only.

[@daanrongen/tfl-mcp](https://www.npmjs.com/package/@daanrongen/tfl-mcp) 1.4.3 is written in TypeScript. The published tarball is `dist/main.js`, `package.json`, and `README.md`, and `package.json` has no `types` field. It depends on `@modelcontextprotocol/sdk`, `effect`, and `zod`. Its tools call 59 of the 84 paths. They also call `/Road/all/Disruption` (the snapshot path is `/Road/all/Disruption/{disruptionIds}`), `/Vehicle/EmissionSurcharge`, and `/Vehicle/UlezCompliance`.

[@pipeworx/mcp-tfl](https://www.npmjs.com/package/@pipeworx/mcp-tfl) 0.1.2 sets `"types": "src/index.ts"`, and that file is in the tarball. It depends on `@modelcontextprotocol/sdk` and `tsx`. Six tools call 8 of the 84 paths.

Station order ships with tfl-ts, in `tfl-ts/meta`.

## Install and first call

Get a Primary key from the [TfL API Portal](https://api-portal.tfl.gov.uk/). Subscribe to "500 Requests per min", then Profile → Show.

```bash
pnpm add tfl-ts
export TFL_APP_KEY=your-primary-key
pnpm exec tfl raw line.statusByIds --ids victoria
```

```json
[
  {
    "id": "victoria",
    "name": "Victoria",
    "lineStatuses": [{ "statusSeverityDescription": "Good Service" }]
  }
]
```

Line names, station order, and colours ship in the package. Status, arrivals, and journeys hit TfL at runtime.

On the portal you'll see two keys (Primary and Secondary); either works. `app_id` has been unused since Jan 2021.

<details>
<summary>Pass credentials in the constructor instead</summary>

```typescript
const client = new TflClient({
  appKey: 'your-primary-key',
});
```

</details>

## Quick starts

### Node

```typescript
import TflClient from 'tfl-ts';

const client = new TflClient(); // reads TFL_APP_KEY from process.env

const status = await client.line.getStatus({ modes: ['tube'] });

const arrivals = await client.stopPoint.getArrivals({
  stopPointIds: ['940GZZLUOXC'], // Oxford Circus
});

const journey = await client.journey.plan({
  from: '940GZZLUOXC',
  to: '940GZZLUBND',
});

const { matches } = await client.stopPoint.search({
  query: 'Oxford Circus',
  modes: ['tube'],
});

const busStops = await client.stopPoint.searchBusStops('Trafalgar Sq');
```

### Next.js

Server Component with ISR (~60s). Boards and explorer: [tfl-components](https://github.com/ghcpuman902/tfl-components) · [tfl.manglekuo.com/docs/explorer](https://tfl.manglekuo.com/docs/explorer). Tube boards use official line colours; bus boards use route-number chips. Do not mix. See [examples/README.md](examples/README.md).

```typescript
import TflClient, { sortLinesBySeverityAndOrder } from 'tfl-ts';

export const revalidate = 60;

const client = new TflClient();
const tube = await client.line.getStatus({ modes: ['tube'] });
const sorted = sortLinesBySeverityAndOrder(tube);
```

```bash
pnpm dlx shadcn@latest add https://tfl.manglekuo.com/r/tube-status-board.json
pnpm dlx shadcn@latest add https://tfl.manglekuo.com/r/tfl-roundel.json
```

The roundel ships a placeholder unless `NEXT_PUBLIC_ALLOW_TFL_ROUNDEL=true` (you accept trademark responsibility).

### Agent / MCP

Read-only MCP (`npx tfl-ts mcp`), cached and rate-limited. Static tools never call TfL. The npm bin is `tfl` (`dist/cjs/bin/tfl.js`); `mcp` is the stdio subcommand. Setup: [docs/mcp.md](docs/mcp.md).

```json
{
  "mcpServers": {
    "tfl-ts": {
      "command": "npx",
      "args": ["-y", "tfl-ts@latest", "mcp"],
      "env": {
        "TFL_APP_KEY": "your-primary-key"
      }
    }
  }
}
```

Tools: `get_supported_modes`, `resolve_line_id`, `docs`, `resolve_stop_id`, `get_line_status`, `get_arrivals`, `plan_journey`.

No repo access, or lost your context? `npx tfl-ts docs ls` lists every bundled agent doc (this README, `CLAUDE.md`, `docs/agent.md`, `docs/mcp.md`, …). `docs cat <id>` prints one, and `docs find <query>` / `docs grep <pattern>` search across all of them — offline, no API key. The local MCP `docs` tool reads the same catalogue.

## Gotchas

- Line IDs are lowercase slugs: `'central'`, `'victoria'`, `'elizabeth'`. Not display names like `'Central'`.
- Stop IDs look like `'940GZZLUOXC'`. Resolve with `stopPoint.search()` or `place.search()`.
- Bus stops accept 5-digit NaPTAN-style codes in search (for example `'51800'`).
- Prefer static constants (`LINE_NAMES`, `STATION_SEQUENCES`, mode lists) before live calls.
- Cache status for about 30 to 60s. Do not poll arrivals faster than about 10 to 15s per stop.
- `accidentStats` and `airQuality` are deprecated.
- National Rail arrivals aren't live through TfL: `STATION_HUBS` tracks Southeastern, South Western Railway, and similar operators for topology, but `getArrivals()` returns an empty array for them, not an error.
- Tube boards use official line colours; bus boards use route-number chips. Do not mix patterns.
- Roundel trademark: placeholder unless the consumer opts in.
- A line can carry several `lineStatuses`. Do not read `[0]`. Use `getWorstCurrentStatus(line.lineStatuses)` for the operative row.
- `validityPeriods[].isNow` follows `disruption.category === 'RealTime'`. It is not a clock check. Planned engineering can be in force today with `isNow: false`.
- `validityPeriods[].toDate` is the window end, not when trains resume. Weekend engineering often ends at `00:29Z` (01:29 London, end of the traffic day). Overnight-split slices on one row are one possession; do not treat the first overlapping `toDate` as the next train.
- Severity 20 is scheduled closure (Waterloo & City weekends, end of traffic day), not an unplanned Closed (1). `sortLinesBySeverityAndOrder` ranks it after incidents.
- Circle / H&C / Met `lineId` flips along shared track. Use `withSharedTrackIdentity`. Do not rewrite raw `lineId`.

## Before and after

`getDetailedStatus()` returns shorter types and renamed fields (`severity`, `from`, `closureType`, `originName`) instead of TfL's generated shapes:

```typescript
const lines = await client.line.getDetailedStatus({
  lineIds: ['bakerloo'],
  dateRange: { startDate: '2026-08-08', endDate: '2026-08-10' },
});
const s = lines[0]?.statuses?.[0];
s?.severity;
s?.severityDescription;
s?.validityPeriods?.[0]?.from;
s?.disruption?.closureType;
```

Use `getStatus({ detail: true })` or `client.raw.line.*` for exact TfL field names.

## Station sequences

Tube, Elizabeth line, DLR, Overground, Tram, and river-bus pier topology. No credentials, no network. Identity, order, and branches only (no status or arrivals). River piers are not in `STATION_HUBS` — poll the `NaptanFerryPort` id. Live topology: `client.line.getRouteSequence()`. Also on the client: `client.line.STATION_SEQUENCES`.

```typescript
import { LINE_STATION_SEQUENCES } from 'tfl-ts/meta';

const bakerloo = LINE_STATION_SEQUENCES.bakerloo;
const outbound = bakerloo.orderedRoutes.find(
  (route) => route.direction === 'outbound' && route.serviceType === 'Regular',
);
console.log(outbound?.stationIds, bakerloo.branches);
```

## Station hubs and normalised arrivals

`STATION_HUBS` maps each physical station to its sibling StopPoint ids and the specific id that carries arrivals for each line — Liverpool Street's tube id (`940GZZLULVT`) and rail id (`910GLIVST`) both resolve to one `HUBLST` entry, with Central on the tube id and Elizabeth line on the rail id. No credentials, no network.

```typescript
import { STATION_HUBS, resolveArrivalsStopId } from 'tfl-ts/meta';

const hub = STATION_HUBS['940GZZLULVT']; // any sibling id works
const elizabethStopId = hub && resolveArrivalsStopId(hub, 'elizabeth'); // '910GLIVST'
```

`resolveArrivalsStopId` returns `undefined` when the hub doesn't carry that line, rather than the interchange id — polling the interchange id itself returns zero arrivals from TfL. Third-party National Rail operators (Southeastern, South Western Railway, c2c, and similar) show up in the hub's topology but never return live predictions: TfL's Arrivals API only covers tube, DLR, tram, Overground, and Elizabeth line.

`client.stopPoint.getNormalizedArrivals()` is `getArrivals()` plus a cleaned `destination` (falls through empty or literal `"null"` `towards` to `destinationName`, common on Elizabeth line, Overground, and some bus termini) and `platform` (compass bound, cleaned label, `isUnknown` for TfL's literal "Platform Unknown"):

```typescript
const arrivals = await client.stopPoint.getNormalizedArrivals({
  stopPointIds: ['940GZZLUOXC'],
});
arrivals[0]?.destination.name;
arrivals[0]?.platform.label;
```

`getArrivals()` and `client.raw.*` are unchanged.

`client.stopPoint.get` and `getByGeoPoint` lift Direction additional properties onto the stop: `towards`, `compassPoint`, and `compassBearingDegrees`. `smsCode` is filled from the first-class field or bag `SmsCode`. Facility keys stay in `additionalProperties` as TfL strings. `parseAdditionalPropertyValue` turns a bag `value` into `null` / boolean / number / date / text (`"yes"`, `"null"`, unix ms `InstallDate`). `client.raw.stopPoint.*` is unlifted. Do not use Prediction `bearing` (vehicle heading) as the stop flag; a painted `stopLetter` of `W` is Stop W, not west.

```typescript
const [stop] = await client.stopPoint.get({ stopPointIds: ['490013766E'] });
stop.towards; // 'Aldwych'
stop.compassPoint; // 'E'
stop.compassBearingDegrees; // 90
```

On Circle / Hammersmith & City / Metropolitan shared track, TfL assigns `lineId` per station, not per train. The same `vehicleId` can be `circle` at Victoria and `hammersmith-city` at Liverpool Street. `withSharedTrackIdentity(stopRows, lineIds, networkArrivals)` adds `sharedTrackIdentity` (`canonicalLineId` from an exclusive-segment hit, or `ambiguous` + `rawLineIds`). It does not rewrite raw `lineId`. `line.getArrivals({ lineIds })` with no `stopPointId` is the network-wide poll that evidence needs.

## Colours, severity, and detailed types

```typescript
import type {
  DetailedLine,
  DetailedLineStatus,
  DetailedDisruption,
  AffectedRoute,
  AffectedStop,
} from 'tfl-ts';
import {
  getLineColor,
  getLineInlineStyles,
  getLineCssProps,
  getLineDarkReadableStyles,
  sortLinesBySeverityAndOrder,
  getLineStatusSummary,
  getWorstCurrentStatus,
  getStatusKind,
} from 'tfl-ts/ui';

const { hex } = getLineColor('central'); // #E32017
const styles = getLineInlineStyles('central');
const cssProps = getLineCssProps('northern');

const tube = await client.line.getStatus({ modes: ['tube'] });
const sorted = sortLinesBySeverityAndOrder(tube);
const worst = getWorstCurrentStatus(sorted[0]?.lineStatuses);
getStatusKind(worst ?? 10);
```

Northern defaults to `outline` dark contrast. Pass `{ darkContrastMode: 'white' }` for white fill/text. See [CHANGELOG.md](CHANGELOG.md) (2.4.0). Raw escape hatch: `client.raw.line.statusByIds({ ids: ['central'] })`.

## Realtime

Poll arrivals with the same `app_key` as REST. No SignalR. Details: [docs/REALTIME.md](docs/REALTIME.md).

```typescript
const stop = client.realtime.pollArrivals(
  {
    stopPointIds: ['940GZZLUOXC'],
    sortBy: 'timeToStation',
    intervalMs: 30_000,
  },
  (arrivals, meta) => console.log(`[tick ${meta.tick}]`, arrivals.length),
  (error, meta) => console.error(meta.tick, error),
);
stop();
```

## Module index

| Module | Common work |
|--------|-------------|
| `client.line` | Status, detailed status, disruptions, routes, arrivals by line, static `LINE_NAMES` / `STATION_SEQUENCES` |
| `client.stopPoint` | Search, arrivals, normalised arrivals, stop metadata, static `STATION_HUBS` |
| `client.journey` | Journey planning |
| `client.mode` | Mode lists and mode arrivals |
| `client.search` / `place` / `road` / `vehicle` / `occupancy` / `bikePoint` / `cabwise` / `travelTimes` | Supporting APIs |
| `client.raw` | All 84 REST endpoints (`pnpm exec tfl list` prints JSON; `--text` is the old line form) |
| `client.realtime` | Instant-pull polling over REST arrivals |

Zero runtime dependencies (Node, browser, and edge with `fetch`).

## Deeper docs

| Resource | Purpose |
|----------|---------|
| [CLAUDE.md](CLAUDE.md) / [AGENTS.md](AGENTS.md) | Agent quick-start: static vs live |
| [docs/agent.md](docs/agent.md) | Module reference, caching, Next.js patterns |
| [.claude/skills/tfl-ts/SKILL.md](.claude/skills/tfl-ts/SKILL.md) | Usage patterns and gotchas |
| [docs/mcp.md](docs/mcp.md) | Local MCP server (includes offline `docs` tool) |
| [CHANGELOG.md](CHANGELOG.md) | Release notes |
| [docs/MIGRATION-v2.md](docs/MIGRATION-v2.md) | v1 → v2 migration |
| [examples/](examples/) | Library → UI mapping (tube + bus) |
| [docs/design/agent-friendly-cli.md](docs/design/agent-friendly-cli.md) | Why `tfl docs` and MCP `docs` exist |
| [docs/design/evals.md](docs/design/evals.md) | Why `pnpm run eval` is a rate, not a Jest gate |

All of the above ship in the npm package and are readable offline via `npx tfl-ts docs ls|cat|find|grep` or the MCP `docs` tool — no clone required.

Playground demos under `playground/demo/` are for clones, not package consumers.

## Architecture

```
OpenAPI snapshot (committed)
  → types.ts        (swagger-typescript-api, types only)
  → rawClient.ts    (owned generator, uniform object-param API)
  → client.raw.*    (public escape hatch)
  → wrappers        (line, stopPoint, …)
```

`pnpm run build` compiles TypeScript to CJS and ESM. No network, no OpenAPI regeneration.

## Contributing

```bash
git clone https://github.com/ghcpuman902/tfl-ts.git
cd tfl-ts
pnpm install
pnpm run build
pnpm run test
```

Useful scripts: `pnpm run generate`, `pnpm run check`, `pnpm run check -- --only=drift`, `pnpm exec tfl list`. See [LLM_context.md](LLM_context.md) and [.cursor/skills/tfl-ts-maintainer/SKILL.md](.cursor/skills/tfl-ts-maintainer/SKILL.md).

## License

MIT. Not affiliated with Transport for London.

| | |
|--|--|
| npm | [tfl-ts](https://www.npmjs.com/package/tfl-ts) |
| GitHub | [ghcpuman902/tfl-ts](https://github.com/ghcpuman902/tfl-ts) |
| Issues | [Report bugs](https://github.com/ghcpuman902/tfl-ts/issues) |
| Live demo | [tfl.manglekuo.com](https://tfl.manglekuo.com/) |