Writing

ReliefNet: coordinating flood relief with AI agents that share one graph

Seven agents, one FalkorDB graph, no direct calls. How ReliefNet turns messy flood messages into one household, one team, and a recorded reason for every decision.

Info

Category
Building
Published
18 Oct 2026
Reading time
8 min
Flooded rooftops from the air

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 live ops room during a flood drill
The ops room during a flood drill. Every queue row links to the edges behind it.

The system at a glance

ReliefNet architecture: channels, API, agent workers and FalkorDB
Everything that matters lives in FalkorDB. The API writes the incoming message; the agents do the rest through the graph.

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.

The request pipeline as a state machine
The pipeline is a state machine on the Request node. An agent owns one transition.

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_next
MATCH (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_step
MATCH (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
Timeline of a lease expiring and another agent reclaiming the request
A crash costs one lease period, not a lost request.

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

The graph data model with episodic, semantic and procedural memory
One organisation's graph, coloured by the kind of memory each node holds.

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.

The organisation graph in the FalkorDB Browser
One organisation's graph in the FalkorDB Browser, around a team: assignments, deliveries and the agents that touched them.

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_requests
MATCH (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.

The redesigned map with households, teams, routes and flooded roads
The map draws its own names from the graph, so every name on it is one that 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 Ask why sheet with the household's path through the graph
Ask why: the answer, the timeline, and the read-only Cypher that built 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.

The public help form
The public help form. No sign-in; Hindi, Hinglish or English.
The volunteer field app with the next job
The field app: the next job, the route, and a chat with the Field agent.

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.

More writing