-
Notifications
You must be signed in to change notification settings - Fork 0
HTTP API
Everything the browser frontend does, it does over this API — there is nothing privileged it can
reach that a script cannot. It is plain JSON over the JDK's com.sun.net.httpserver, plus one
server-sent-events stream. No WebSocket.
Base URL is whatever the server binds to (Web Bind Address:Web Port, default
http://127.0.0.1:8455), or your reverse proxy's Web Public Url.
This API is not versioned yet and is written for the bundled frontend. Treat it as unstable across mod versions.
Sessions are a signed cookie:
dispatcher_session=…; Path=/; HttpOnly; SameSite=Lax; Max-Age=<Web Session Hours>[; Secure]
Secure is set only when Web Public Url starts with https://. Two ways to get one:
-
GET /auth/token/<token>— the one-time link minted by/dispatcher web session <tier>. Consumes the token, sets the cookie, redirects to/. -
GET /auth/login→ Discord OAuth →GET /auth/callback— sets the cookie, redirects to/.
For scripting, the practical route is: mint a session link in-game, follow it once with a cookie jar, then reuse the cookie.
# one-time link from /dispatcher web session deployer
curl -c jar.txt -L 'http://127.0.0.1:8455/auth/token/<token>'
curl -b jar.txt 'http://127.0.0.1:8455/api/me'| Rule | Detail |
|---|---|
| No cookie / bad cookie | 401 {"error":"unauthorized"} |
| Tier too low | 403 {"error":"forbidden","detail":"requires planner, you are viewer"} |
Any mutating method (not GET/HEAD/OPTIONS) |
must send an X-Dispatcher-Csrf header — any value. Missing → 403 csrf
|
Mutating request with an Origin header |
must equal Web Public Url when that is set. Otherwise → 403 bad_origin
|
Always the same envelope:
{ "error": "graph_too_large", "detail": "120000 micro nodes over Web Graph Node Cap" }Common keys: unauthorized, forbidden, csrf, bad_origin, not_found, bad_body,
bad_graph, graph_too_large, graph_changed, rate_limited, server_not_ready,
method_not_allowed, internal_error.
Fixed, not configurable. Fixed 60-second windows, keyed by session (falling back to client IP,
respecting X-Forwarded-For):
| Bucket | Limit / minute | Applies to |
|---|---|---|
| auth | 20 (per IP) | /auth/* |
| graph, corridor, sim, audit, debug | 60 | the heavy read endpoints |
| write | 60 | mutations on presets, plans, folders |
| deploy | 10 | POST /api/deploy |
Over the limit → 429 {"error":"rate_limited"}.
- Large payloads (graphs, replays, sim results) are stored gzipped and streamed as-is when you send
Accept-Encoding: gzip; otherwise they are inflated for you. - Graphs and corridor-actual responses carry an
ETag; sendIf-None-Matchto get304. - Endpoints that take
?graph=<uuid>also accept?v=<version>to pin the graph version — a mismatch is409 graph_changedinstead of silently answering about a different network.
Tier is the minimum required.
| Method | Path | Tier | Notes |
|---|---|---|---|
GET |
/api/me |
any session | {discordId, username, tier} |
GET |
/api/status |
viewer | mod version, uptime, train count, SSE client count, active notifications, and the limits the UI needs |
POST |
/auth/logout |
— | requires X-Dispatcher-Csrf; clears the cookie, 204
|
| Method | Path | Tier | Notes |
|---|---|---|---|
GET |
/api/graphs |
viewer | index of rail networks: id, version, node/edge/station counts, dimensions, bounding boxes, tooLarge
|
GET |
/api/graphs/<uuid> |
viewer | the full graph payload. ETag; 409 graph_too_large over Web Graph Node Cap
|
GET |
/api/trains |
viewer | the train roster and its rosterVersion
|
GET |
/api/live/positions |
viewer | the latest position frame for every train |
GET |
/api/stations?graph=<uuid>[&v=] |
viewer | logical station groups → platforms with map coordinates |
GET |
/api/station-tags |
viewer | CRN station tags — {tags: [{tag, stations: [platform…]}]}, grouped across every network |
GET |
/api/notifications |
viewer | active notifications (SIGNAL_WAIT, DEADLOCK, DETOUR) |
GET |
/api/replays |
viewer | replay index |
GET |
/api/replays/<id> |
viewer | one replay in full |
Each roster row carries line and category — the CRN travel section the train is currently inside
("" without CRN or outside a section) — along with the existing owner, destination,
currentStation and state fields.
Both need ?graph=<uuid>&from=<station group>&to=<station group>, with distinct from/to;
optional &v=<graphVersion>.
| Method | Path | Tier | Notes |
|---|---|---|---|
GET |
/api/corridor/actual |
viewer | observed movement along the corridor. Extra ?sinceTick=; ETag
|
GET |
/api/corridor/plan |
viewer | the simulator's projection for the same corridor |
404 route_not_found when there is no route between those two station groups on that graph.
| Method | Path | Notes |
|---|---|---|
GET |
/api/presets |
summaries of every preset |
GET |
/api/presets?id=<uuid> |
one preset, with its decoded schedule |
POST |
/api/presets |
{trainId, name?} — snapshot a train's current schedule; or {sourceId, name?} to duplicate a preset |
PATCH |
/api/presets |
{id, name} rename · {id, folder} move · {id, entry, target, col, row, key, value} edit one whitelisted schedule value |
DELETE |
/api/presets?id=<uuid> |
delete |
Preset errors: preset_full, preset_invalid, preset_empty, bad_name, bad_folder,
not_found, preset_corrupt.
| Method | Path | Notes |
|---|---|---|
GET |
/api/plans |
summaries · ?id=<uuid> for one in full |
POST |
/api/plans |
save; body with an id overwrites, without one creates |
DELETE |
/api/plans?id=<uuid> |
delete |
GET |
/api/train-folders |
{folders: {trainUuid: "folder/path"}} — viewer may read this one |
PATCH |
/api/train-folders |
{trainId, folder} files one train (blank unfiles) · {from, to} re-files a whole folder subtree · {autoSort: true, overwrite?} (see below) |
{autoSort: true} re-files the whole roster from each train's CRN category then line
("Category/Line", trains without a category left unfiled) — deployer-only, the one folder
call that is not planner-gated. overwrite: true replaces every existing folder (manual filings
included); false (default) only files currently-unfiled trains. Returns {changed: n}.
| Method | Path | Notes |
|---|---|---|
POST |
/api/sims |
queue a run → 202 {simId}
|
GET |
/api/sims |
every job with its state |
GET |
/api/sims/<simId> |
one job's status |
DELETE |
/api/sims/<simId> |
cancel — owner only, unless you are deployer |
GET |
/api/sims/<simId>/result |
the finished result (gzipped). 409 not_done while running |
GET |
/api/sims/<simId>/diagram?from=&to= |
corridor diagram for that run. 409 graph_changed if the network was rebuilt since |
Submit body:
A train may appear at most once, and may not be both assigned and removed, or both kept and
removed. valueOverrides go through the same whitelist as preset edits and the same code path as
deploy — you cannot simulate an edit deploy would refuse, or vice versa.
| Method | Path | Notes |
|---|---|---|
POST |
/api/deploy |
{assignments: […same shape as /api/sims…], mode: "IMMEDIATE" | "IDLE_ONLY"} → per-train results. Unknown/missing mode = IDLE_ONLY
|
GET |
/api/audit?limit=<1..500> |
the deploy journal, newest first (default 100) |
A successful deploy also broadcasts a deployed SSE event.
Field-debugging aids for plan-vs-actual mismatches. Shapes may change without notice.
| Method | Path | Notes |
|---|---|---|
GET |
/api/debug/simedges?graph=<uuid>&ids=<edgeId,…> |
the simulator's own view of those graph edges |
GET |
/api/debug/schedule?train=<uuid> |
one train's raw schedule: instruction/condition ids and their NBT data, plus throttle, speed, paused, current entry and live Tramways limits |
GET |
/api/debug/simtrain?graph=<uuid>&train=<id> |
one train in the current projection: station visits, events, notices, the edges it actually drove, and its final plan |
GET /api/events (viewer)
One multiplexed stream. Web Max Sse Clients (20) connections max — over that, 503 sse_full.
- The stream opens with
retry: 3000and a per-connectionhelloevent carrying{serverTick, serverWallMs, dayTime, dayTimeRate, rosterVersion, tier, graphs: {id: version}}— everything a fresh client needs to know what to fetch. - Every broadcast event carries a monotonic
id:. Reconnect withLast-Event-IDto replay what you missed; if the gap is too large the server sendsresetand you should refetch your snapshots. - A
:comment heartbeat is written after 15 s of silence, so proxies keep the connection open. The client reconnects on its own after 25 s of silence.
| Event | Meaning |
|---|---|
hello |
per-connection preamble (no id) |
reset |
your Last-Event-ID was too old — refetch everything |
trains |
a live position frame (full or delta) |
trainMeta |
the roster changed — refetch /api/trains
|
graph |
one network was rebuilt: {id, version}
|
graphIndex |
the set of networks changed |
notify |
a notification was raised, updated or cleared |
replay |
a replay finished capturing |
presets, plans, trainFolders
|
that store changed — refetch it |
sim |
a simulation job changed state |
deployed |
{user, mode, applied, skipped} |
Everything not under /api or /auth is served from the frontend bundled in the jar
(assets/createdispatcher/web/), with SPA fallback to index.html.
Create Dispatcher · MC 1.20.1 · Forge & Fabric · MIT · source
Players
Server admins
Developers
Help
{ "graphId": "…", // required "assignments": [ // preset → train { "trainId": "…", "presetId": "…", "valueOverrides": [ { "entry": 0, "target": "…", "col": 0, "row": 0, "key": "…", "value": "…" } ] } ], "removals": ["trainUuid"], // simulate without these trains "keeps": ["trainUuid"], // keep these exactly as they are "removeScheduled": true, // default: drop scheduled-but-unassigned trains "startDayTime": null, // in-game day time, null = now "horizonHours": 12, // capped by Web Sim Max Horizon Hours "headwaySeconds": null // null/-1 = the configured default }