In late September 2019, days of heavy rain left large parts of Patna under water. In Rajendra Nagar and Kankarbagh, people waited on rooftops while help requests moved around as WhatsApp forwards, phone calls and social posts. Most were in Hinglish. Few had an address beyond "near the water tanki". The same family often asked three times through three relatives.
The result was the failure every relief volunteer knows. One house got three boats and the next lane got none. Nobody could say who had already been helped, or why one family went before another.
ReliefNet is my attempt at the coordination layer that was missing. Seven AI agents turn messy messages into households, decide who goes where, and remember what worked. The only thing they share is a FalkorDB graph. I built it for Graph Hacks (WeMakeDevs × FalkorDB), in the Agent Memory and Coordination track.

The system at a glance

Messages arrive from four places: a Telegram bot, a public help form, coordinators in the ops room, and volunteers in the field app. The FastAPI server does very little with them. It takes the organisation from the verified sign-in, writes a Message node, and returns. Everything after that is done by agent workers that read and write the graph.
FalkorDB holds three things. Each organisation has its own graph, with its messages, requests, households, teams and lessons. One shared region graph holds the road network from OpenStreetMap, plus landmarks, wards and shelters, because a flooded road is flooded for everyone. The Redis side of the same server carries the live event stream for the dashboard and the field chat history. There is no second database.
Model calls go through one module with two settings, a fast model and a smart model, so the provider can change. Every model path also has a rule-based path. With no API key, the whole system still runs on parsing rules, scoring rules and rule-based lessons. That matters in a disaster, where a cloud bill or an outage should not stop the response.
Why the agents never call each other
The usual shape of a multi-agent system is a chain or an orchestrator: Intake calls Dedupe, Dedupe calls Triage, or a supervisor passes messages between them. I chose the opposite. In ReliefNet, no agent knows another exists. Each agent watches one request status in the graph, claims one request, does one job, and writes the result back.

Three things made this worth it.
Crashes stop mattering. In a call chain, an agent that dies mid-call loses or duplicates the work. Here, the state of every request lives on its node. If an agent stops, the request is still there, with its status and its history.
Scaling is just more copies. To clear a backlog, I start more Dispatch workers. There is no routing table and no queue to configure, because the graph is the queue.
The coordination is the memory. The write that hands a request to the next agent is the same write that records why. So the history that explains a decision is never a separate log that can drift out of date.
The cost is a little latency, because agents poll for work instead of being pushed it. For flood relief, a second does not matter. A lost or doubled request does.
A handoff is one Cypher write
An agent claims work with a single atomic query. A request is free when nobody holds it, or when the holder's lease has run out.
memory.claim_nextMATCH (r:Request {status:$status}) WHERE (r.claimed_by IS NULL OR r.lease_until < $now) AND coalesce(r.not_before, 0) <= $now WITH r ORDER BY r.priority DESC LIMIT 1 WITH r, r.claimed_by AS prev SET r.claimed_by = $me, r.lease_until = $now + $lease RETURN properties(r) AS item, prev
The finish is guarded by the same lease. It moves the request to the next status, releases the lease, and adds a PROCESSED_BY edge with the agent's reason, all in one query. If the agent lost its lease in the meantime, the MATCH finds nothing and nothing is written.
memory.finish_stepMATCH (r:Request {id:$id}) WHERE r.claimed_by = $me SET r += $props, r.status = $next, r.claimed_by = NULL, r.lease_until = NULL WITH r MERGE (a:Agent {name:$me}) CREATE (r)-[:PROCESSED_BY {at:$now, from:$from, to:$next, reason:$reason}]->(a) RETURN r.id

A test fires 30 claims at the same moment and checks that no request is taken twice. In the demo, you can stop a Dispatch agent in the middle of a job and watch another copy reclaim the request when the lease expires. The ops room counts "households sent two teams", and the number stays at zero.
Three kinds of memory in one graph

Episodic memory is what happened. Every message, step, merge, handoff and delivery is a node or an edge with a time and a reason: BECAME, PROCESSED_BY, DUPLICATE_OF, HANDED_OFF, REROUTED, SERVED.
Semantic memory is what is true now: households and their open needs, teams and their supplies, and the roads and places of the region.
Procedural memory is what works. The Learner reads deliveries and road reports and writes Lesson nodes, for example "Team North is fastest with medicine" or "Ward 14 is a hotspot". Each lesson links to its evidence with DERIVED_FROM. When Triage or Dispatch uses a lesson, it writes a USED_LESSON edge, so you can see every decision a lesson changed. A lesson acts only at 50% confidence or more, and a coordinator can apply or dismiss one by hand.
Because all of this persists, memory works across sessions. When the night shift starts, the Shift Coordinator writes the handover briefing from the graph, not from someone's notes.

Finding the same family twice
Duplicates are the most expensive mistake in relief, because each one can send a second team. Dedupe works in two steps. First it asks FalkorDB's vector index for the nearest earlier requests by meaning:
memory.similar_requestsMATCH (me:Request {id:$id}) CALL db.idx.vector.queryNodes('Request', 'embedding', $k, me.embedding) YIELD node, score MATCH (node)-[:FOR]->(f:Family) RETURN node.id, f.id AS family, 1.0 - score AS similarity
Then it checks the graph for evidence that text cannot give: the distance between the two locations, the same landmark or ward, the size and make-up of the family, and conflicting names. If the evidence agrees, it links the new request to the household with a DUPLICATE_OF edge that keeps the score and the evidence, for example "similarity 0.903 · 38 m · same landmark · 7 vs 7 people". The flood drill knows the true households, so it can measure this: merges stayed above 90% precise in my runs.
Roads are a graph too
Dispatch routes on the OpenStreetMap road network inside the region graph, with FalkorDB's shortest-path algorithm, algo.SPpaths, weighted by a cost on each road. When two different teams report a road flooded, or one coordinator confirms it, the road's cost jumps. Every active route that used it is sent back to Dispatch with a REROUTED edge, and the next route avoids it.
Location turned out to be the hardest part. People write "near Budha Hospital", "Road No. 6" or just a neighbourhood name, so Intake scores every clue together: the landmark, the road, the neighbourhood and the ward. To make that reliable, the map no longer shows Google's place names. It draws its own names from our graph, so every name a coordinator or volunteer sees is a name Intake can match.

Every decision has a receipt
A graph that explains itself is only useful if people can see the explanation. So every queue row has a receipt that lists the household's edges with their reasons. The live log shows the edge each event wrote. Ask why answers a coordinator's question in plain words, and its Graph query tab shows the exact read-only Cypher behind the answer. A Memory layer on the map draws one household's graph neighbours around it.

The people at the edges
Households ask for help through Telegram or a short web form that needs no sign-in. The form uses the person's own phone number to find their household, so a second message does not send a second team. Volunteers get a field app with their next job, the route and a chat with the Field agent, which writes their reports back into the graph.


What I learned
A shared graph is a good coordination layer
Putting state on the node gave me crash safety almost for free, and putting reasons on the edges gave me explainability almost for free. One graph per organisation, inside one FalkorDB instance, gave me tenant separation without extra infrastructure.
Real data is messier than the model
OpenStreetMap has only three neighbourhoods inside my area, so road names carry most of the location signal. My wards are still simple grid zones, while people write their real municipal ward numbers. Real ward outlines are the next fix.
Visibility is a feature
A graph browser shows a snapshot, not a live view, and a large graph only shows in part. Building receipts and the Memory layer into the product itself mattered more than any external tool.
Next: real municipal ward outlines, live satellite flood extents from Copernicus EMS and ISRO Bhuvan, a WhatsApp channel, and an offline map for when the network fails.
The code is open source on GitHub, with the data model and the key Cypher queries in the README. I built ReliefNet with Claude Code as a coding assistant.
