Picture 8 a.m. on a Wednesday in October. A signal fire in the Canarsie Tube stops the L between Bedford Av and 8 Av, in both directions, until further notice.

That one sentence breaks the trips of 23,445 riders an hour. A transit control room now has minutes to answer three questions: who is stuck, where will they go, and can the rest of the network carry them?

A trip planner can't help much here. It finds one rider the fastest way from A to B. It can't tell you whether the network can absorb twenty-three thousand of them an hour, because that is a flow problem.

So for the WeMakeDevs × FalkorDB Graph Hacks hackathon I built Detour: agents on a FalkorDB graph of the real New York City subway that measure what the city can still carry, and test a fix before anyone is told.

TL;DR

  • The real subway in FalkorDB, built from MTA open data: 445 station complexes, 1,459 scheduled segments with capacity, and 30,205 origin-destination flows.
  • When a line breaks, agents call typed graph tools. A MATCH finds who is hit, algo.SPpaths shows where they go, algo.maxFlow measures what the network can still carry and its min cut names the bottleneck, and GRAPH.COPY forks test shuttle plans.
  • Memory lives in the same graph, with dates, and it changes the next answer.
  • An independent NetworkX program recomputes every number.

Live demo: https://detour-orpin.vercel.app · Code: https://github.com/SaudSatopay/detour

A run of the L closure in Detour's control room: the controller's tool calls stream into the trace, the night map shows each step's evidence, and the G overload and max flow appear with their numbers.

The L closure in six questions

Question How the graph answers For the L
Whose trips cross the closure? One MATCH on demand edges 23,445 riders an hour
Where do they go on their own? algo.SPpaths, every pair in one query The G at 244%; 4,689 riders with no rail path
What can the network still carry? algo.maxFlow on a fork 17,167 of 18,432 toward Manhattan (93%)
Where is the bottleneck? The min cut 1 Av
Does a shuttle help? The same tools on a GRAPH.COPY fork Riders with no path: 4,689 → 623
Did memory change the plan? Past incidents stored in the graph 27% more buses

Putting the subway in a graph

Everything comes from MTA open data:

  • GTFS static. Weekday trains departing between 8:00 and 8:59 give trains per hour on every directed segment. Multiplied by planning loads per car, that is capacity.
  • Stations and complexes. 445 complexes, with accessibility flags.
  • The 2025 origin-destination ridership estimate. A Wednesday in October at 8 a.m.: 446,230 riders an hour. I keep the 30,205 station pairs with at least two riders, which carry 90.5% of them.

A build script assigns every pair to its fastest route (in-vehicle time, half the headway as the wait, a transfer penalty), which gives every segment a baseline load. Only one segment comes out over capacity at baseline: the 5 from Grand Central to Union Square, at 101%. That was my sign the model was sane.

The model is small on purpose:

// scheduled segments (:Station)-[:RUNS {route, capacity, load, minutes}]->(:Station) // complexes under 400 m apart (:Station)-[:WALK {meters, minutes, capacity}]->(:Station) // 8 a.m. flows; via = the segment ids of the path (:Station)-[:DEMAND {riders, via}]->(:Station) // a platform layer, so waits and transfers cost minutes (:Station)-[:BOARD]->(:Platform)-[:RIDE]->(:Platform) (:Platform)-[:ALIGHT]->(:Station) 

And each job gets its own graph:

detour:template the network and seeded memory, never changed detour:sbx:<visitor> your own copy, made with GRAPH.COPY detour:fork:<...> a throwaway copy for each what-if detour:demand the 30,205 flows, shared and read-only 

Step 1: Who is hit? One MATCH

Each DEMAND edge keeps the segment ids of its 8 a.m. path in a via property. That one property turns the first question into a single query:

MATCH (o:Station)-[d:DEMAND]->(x:Station) WHERE any(e IN d.via WHERE e IN $closed) RETURN o.id AS o, x.id AS d, d.riders AS riders 

For the L between Bedford Av and 8 Av, that is 23,445 riders an hour across 2,021 station pairs. Only 7,823 of them board at the closed stations; counting outward, 14,713 board within four stops of the closure. An alert sent only to the closed stations would miss two thirds of the people affected.

Step 2: Where do they go? Shortest paths that price a transfer

Riders don't spread out politely. They take their fastest open path. To make waits and transfers cost something, the graph has a platform layer: a Platform node per station and route, BOARD edges that cost a three-minute transfer penalty plus half the headway, RIDE edges along the scheduled segments, and free ALIGHT edges. Then every displaced pair is rerouted in one query:

UNWIND $pairs AS p MATCH (a:Station {id: p.o}), (b:Station {id: p.d}) CALL algo.SPpaths({sourceNode: a, targetNode: b, relTypes: ['BOARD', 'RIDE', 'ALIGHT', 'WALK'], weightProp: 'minutes'}) YIELD path, pathWeight RETURN p.o AS o, p.d AS d, pathWeight AS minutes 

All 2,021 pairs reroute in under a second. Left to themselves, L riders pile onto the G: four G segments run at 190 to 244% of capacity, and 4,689 riders an hour have no open rail path at all.

Detour's home page: the G runs at 244% of capacity, and the night map shows displaced riders' fastest paths overloading the G in red.

Step 3: What can the network still carry? Max flow and the min cut

Fastest paths show what riders will do. Max flow shows what the network could do if they spread out perfectly.

Each call forks the visitor's graph with GRAPH.COPY, deletes the closed segments, frees the capacity the displaced riders were using, and wires a super source and a super sink to every station by its balance of displaced riders, once per direction of travel. Then:

MATCH (src:Hub {id: $src}), (snk:Hub {id: $snk}) CALL algo.maxFlow({sourceNodes: [src], targetNodes: [snk], relationshipTypes: ['FLOW'], capacityProperty: 'spare'}) YIELD maxFlow, edges, edgeFlows RETURN maxFlow, edges, edgeFlows 

algo.maxFlow takes a single relationship type and no parallel edges, and a subway corridor is full of parallel edges: the 4, 5 and 6 run between the same stations, plus walks. So the fork sums the spare capacity per station pair into one FLOW edge and keeps the ids of the segments behind it. That maps the flow and the cut back to real lines.

The answer for the L: 17,167 of the 18,432 riders an hour headed into Manhattan can still move. That's 93%. The min cut sits at 1 Av. Its only rail links are the closed L, so no spare capacity anywhere else can reach its riders. That is exactly where a shuttle bus should start.

Step 4: Test the fix on a fork before anyone is told

GRAPH.COPY makes a what-if cheap. The controller looks for shuttle bridges within 2 km of the stations the network can't clear. Then it copies the closure fork once per plan, adds the bus legs and runs the same tools again:

  • 1 Av ↔ Astor Pl at 25 buses an hour: riders with no path drop from 4,689 to 623, and the max-flow shortfall from 1,266 to 315.
  • A two-leg plan does no better, with 18 more buses. One leg wins.

One result surprised me. With the shuttle, fastest-path overflow rises, from 24,078 to 28,762, because 4,066 riders who had no way through can now travel. So the plan also needs the rider desk to steer people onto lines with room. And for the other two closures in the demo, the Lexington express and the Cranberry Street Tunnel, the open network absorbs everyone, so the controller proposes steering riders and no buses at all.

Agents that never write Cypher

The model's job is to choose tools and explain the results, not to invent queries or numbers. The agents pick from a fixed catalogue of 13 typed tools, backed by 83 queries. Each tool returns one of four answer shapes (answered, from memory, not in the graph, unknown name), a one-line summary, the data, and the exact Cypher it ran with its timings. The control room shows all of it, step by step.

dispatcher | v controller agent --- 13 typed tools, 83 queries ---> FalkorDB | v a person approves | HANDED_OFF v rider desk: one agent per remembered rider 

Nothing reaches riders until a person approves a plan. Then the controller hands off to the rider desk, a HANDED_OFF edge records it, and one rider agent per remembered rider writes a new route, step-free where that rider needs it.

After approval: two shuttle plans tested on forked graphs, the recommended one published to riders, and a rider agent routing Tomás around the closure.

Detour runs on free model tiers, and Gemini's free tier allows only a few full runs a day. So I also wrote the controller's procedure as code. When the quota runs out, the same tools run in the same order on the visitor's graph, and the run is labeled scripted. When the database is unreachable, a run recorded on FalkorDB plays back, labeled as a recording. The same tools are also served over MCP, so other agents can use them.

Memory that changes the answer

Memory is not a log beside the graph. It hangs off the same nodes the agents already query:

(:Agent)-[:WROTE]->(:Finding {text, validFromMs, validToMs}) (:Finding)-[:ABOUT]->(:Station | :Route) (:Finding)-[:SUPERSEDES]->(:Finding) 

Here are two moments where it changes what the agents do:

  • A plan sized by experience. A past incident remembers that the June bus bridge carried 79% of its planned riders, so the controller sizes the new shuttle 27% larger.
  • A route that follows the facts. Aisha uses a wheelchair. Memory says the elevator to the L platform at Myrtle-Wyckoff Avs is out, so her step-free route takes the M: 33 minutes. Report it fixed and the old fact gets an end date (it is not deleted), and a new fact supersedes it. Her next route takes the L: 24 minutes.

Aisha's step-free route: the L in 24 minutes now, and the M in 33 minutes before the memory changed.

Because closed facts stay in the graph, the memory page can draw every fact over its validity window, next to incidents and agent steps.

Memory over time: each fact as a bar over its validity window, grouped by line, with past incidents and a closed elevator fact.

Don't trust the graph: check it

I didn't want to trust a number just because a database produced it. A separate Python program built on NetworkX reads the same data files, follows the same written spec, and recomputes everything. FalkorDB and NetworkX agree on the max flow in all six direction groups of the three closures, and on the displaced-rider counts at every depth. The proof page shows them side by side.

Every visitor also gets their own sandbox graph, so I load-tested the multi-tenancy. 48 reader agents and 6 writers across 6 tenant graphs sustained 7,195 reads and 719 writes a second, with zero errors, and a closure in one tenant never showed up in another.

The proof page: FalkorDB and NetworkX side by side for every closure.

What I learned

  1. Choose a problem where the algorithm is the product. "Can the network absorb the L's riders?" is literally a max flow, and "why not?" is a min cut. A language model can't fake either one, and that kept the agents honest.
  2. Put the expensive question in a property. Storing each flow's path on its DEMAND edge turned "who is affected?" into one MATCH.
  3. Walk a frontier instead of a variable-length pattern. -[:RUNS*0..8]-> exploded on corridors with many parallel routes. One hop per query, carrying a frontier, takes milliseconds.
  4. Read the algorithm's contract. algo.maxFlow wants one relationship type and no parallel edges, so I build FLOW edges and keep their provenance. And algo.SSpaths returns k paths in total, not one per target; one SPpaths call per pair under UNWIND was what I needed.
  5. Forks make what-ifs and tenancy simple. GRAPH.COPY gave me a sandbox per visitor and a throwaway graph per plan, with no locking logic.
  6. Close facts; don't delete them. Validity windows plus SUPERSEDES let the next answer read only what is true now, while keeping the history.
  7. Free tiers shape the architecture. Model quotas run out, so the procedure also exists as code, and every run says which mode it used. On FalkorDB Cloud's free tier, my database user could run graph commands but not plain Redis ones (SET and INCR came back NOPERM). So rate limits, the seed lock and the sandbox registry moved into a small detour:meta graph, with MERGE doing the locking.
  8. Test the live stack on a phone. An end-to-end test on a phone viewport caught a race: a new visitor's first page load could mint two visitor ids, so an approval could land in a different sandbox from the run.

What I'd love to see in FalkorDB

  • algo.maxFlow with several relationship types, parallel edges and the min-cut edges in its output.
  • A bounded breadth-first procedure that returns hop distances.
  • Copy-on-write forks, so a fork per what-if would be nearly free.

Try it

  • Live: https://detour-orpin.vercel.app. Pick Break the L train, open any step to see its evidence on the map, and approve the plan. Then go to the riders page and report Aisha's elevator fixed. With the L closed in your sandbox her route stays on the M whatever memory says, so press Reset my sandbox in the control room first.
  • Code, the graph model and every query: https://github.com/SaudSatopay/detour
  • Every number in this post is on the proof page, next to NetworkX's.

Built with

Next.js 16, FalkorDB 4.22 on FalkorDB Cloud, the Vercel AI SDK with free Gemini and Groq models, and MTA open data. I built Detour with Claude Code as a pair programmer, and it helped me draft this post. The graph model, the queries and every decision are documented in the repo.