Sunita is 48 and lives in Hyderabad. Her kidneys are failing, and her husband wants to give her one of his. He can't: she carries antibodies against his tissue type, so her body would reject the kidney. Worse, she's highly sensitised. Her antibodies react to most donors, so only a handful of people in any pool could ever give to her.
Somewhere in another city there's a family with the mirror-image problem. If Sunita's husband gives to their patient and their donor gives to Sunita, two people get a transplant. That's a paired exchange, a swap. With three families it becomes a ring: A's donor gives to B, B's to C, C's back to A.
We built KindRed, a paired exchange for kidney and liver across hospitals in India, on FalkorDB. This post walks through one swap breaking and the graph repairing it, the agent that runs a patient's case week by week, and the things FalkorDB taught us along the way.
Live demo: https://3o3ntcusu2.execute-api.ap-south-1.amazonaws.com (no sign-up)
Code: https://github.com/abhishiv17/Graph-hacks
Video: see below
Built for Graph Hacks 2026 (WeMakeDevs × FalkorDB), Tracks 1 and 2.
Everything in KindRed is synthetic. Sunita and Rajesh are demo personas with generated lab histories. No real patient or hospital record has been near this system. It's a software prototype, not a clinical tool, and nothing here is medical advice.
Why India needs this
India meets about 7% of its kidney transplant need and about 1.5% of its liver need (Lancet Regional Health - Southeast Asia, 2026). In 24 years the country has done 1,839 kidney exchange transplants across 65 centres, and 259 liver exchanges across 7 (Kute et al., 2025). Most of those swaps happened inside one hospital's small pool.
A bigger, shared pool finds more swaps. But finding the swap is only half of it. Each planned donation still has to pass a real lab test, a crossmatch, where the patient's blood is mixed with the donor's cells. If one test in a 3-way ring comes back positive, the whole ring fails and somebody starts again by phone.
KindRed is built around that moment: one failed match, and what happens next.

What our year-long simulation says a shared, failure-aware pool could change. Simulation numbers, not clinical ones.
Swaps are cycles
"Which donors are blood group O?" is a table question. "Who can swap with whom?" is a path question:
- A 2-way swap is a cycle of length 2: A can give to B, and B can give back to A.
- A 3-way swap is a cycle of length 3: A → B → C → A.
- "Why can't this donor give to this patient?" is a 3-hop path: donor → tissue type ← antibody ← patient.
- "Who could replace the family that just dropped out?" is a search over a neighbourhood whose size you don't know ahead of time.
In SQL each of those is another self-join. In a graph they're patterns you write down directly. So the model came out small:
| Thing | In the graph |
|---|---|
| A family (patient + willing donor) |
Pair node |
| "This donor could give to that patient" |
CAN_GIVE_TO edge, with its reasons stored on it |
| A swap | A cycle of CAN_GIVE_TO edges, plus a Loop node |
| A failed lab test | A FAILED edge, kept forever |
| A repair | A local search a couple of hops around the break |
One FalkorDB server, many graphs
FalkorDB is the only database in KindRed. One server holds many graphs, and we use them as tenants:
| Graph | What's in it |
|---|---|
exchange |
The shared pool: families, blood groups, tissue types, antibodies, CAN_GIVE_TO links, swaps, offers, crossmatches, failures, decisions, tasks |
hosp_<city> (11 of them) |
Each hospital's private data: names, documents, full tissue typing, lab results. Only de-identified facts reach exchange
|
mem_sunita, mem_rajesh
|
Each demo patient's memory: findings, beliefs, playbooks, sessions |
chat_<id> |
Each family's conversation |
<copy>__exchange, … |
A visitor's private copy of all of the above |

The shared exchange graph as the How it works page draws it. The matching rules live in the graph too: antibodies REACTS_WITH the tissue types they attack, and a failed test is a FAILED edge that is never proposed again.
When a family joins, a named query (Q02 for kidney, Q21 for liver) builds its CAN_GIVE_TO edges and writes the reasons onto each one. Here's a real edge from our run:
P-0211 → P-0388 p_success 0.56 status: failed
"B → B · same blood group"
"virtual test clear: no antibody at or above 2000 MFI against this donor"
"donor DQ not typed: higher risk of a positive lab test"
Look at the third reason. Bengaluru's donor was never typed for DQ, so the virtual check couldn't see a DQ clash, and the success estimate dropped to 56% before any lab test was run. This is the edge that fails later in the story.
All the Cypher lives in 59 numbered files in backend/queries/ (Q00 to Q59). Any query without a write clause runs through GRAPH.RO_QUERY, so FalkorDB itself refuses a write from it, whoever calls it.
The multi-hop queries
The virtual crossmatch is three hops: does the patient have an antibody, at or above 2,000 MFI, against any of the donor's tissue types?
// Q03 Virtual crossmatch: donor antigen <- antibody <- patient, at or above the MFI threshold.
MATCH (:Pair {pair_id: $donor_pair})-[:HAS_DONOR]->(d:Donor),
(:Pair {pair_id: $patient_pair})-[:HAS_PATIENT]->(r:Patient)
OPTIONAL MATCH (d)-[:HAS_ANTIGEN]->(h:HLAAntigen)<-[:REACTS_WITH]-(:Antibody)<-[k:HAS_ANTIBODY]-(r)
WHERE k.mfi >= $mfi
RETURN collect({antigen: h.name, mfi: k.mfi}) AS clashes, d.untyped_loci AS untyped_loci
The family explorer draws exactly that path. On Kolkata's P-0388, the red links show why the family's own donor can't give: the patient has antibodies against DQ8 (MFI 8,800), A1 (9,800) and B8 (11,200), all carried by that donor.

Hovering one red link: "Anti-DQ8 → DQ8. This antibody attacks that tissue marker."
A 3-way swap is a 3-hop cycle over live edges, all in one program:
// Q05 All 3-way loops a -> b -> c -> a, each listed once (a has the smallest id).
MATCH (a:Pair)-[e1:CAN_GIVE_TO]->(b:Pair)-[e2:CAN_GIVE_TO]->(c:Pair)-[e3:CAN_GIVE_TO]->(a)
WHERE id(a) < id(b) AND id(a) < id(c) AND b <> c
AND a.organ = b.organ AND b.organ = c.organ AND ($organ IS NULL OR a.organ = $organ)
AND a.status = 'waiting' AND b.status = 'waiting' AND c.status = 'waiting'
AND e1.status = 'live' AND e2.status = 'live' AND e3.status = 'live'
AND ($ids IS NULL OR a.pair_id IN $ids OR b.pair_id IN $ids OR c.pair_id IN $ids)
RETURN [a.pair_id, b.pair_id, c.pair_id] AS pairs, [e1.p_success, e2.p_success, e3.p_success] AS probs
The organ check on every node is why a kidney family and a liver family can never land in the same ring. Each edge's success estimate comes back with the loop, and the optimiser multiplies them into the chance the whole swap holds.
Why bother with 3-way swaps at all? Ask KindRed answers "how far can this family's donor reach?" at any depth up to 3. For Kolkata's P-1015, the donor reached 3 waiting families in one donation and 82 within three (3 at one hop, 69 at two, 10 at three). For a hard-to-match patient, that difference is the whole case for 3-way swaps.

Answered live by gpt-oss-120b in 2.1 s, with the graph paths behind the answer and every id checked.
FalkorDB's built-in algorithms are tools the agents call: algo.WCC splits the pool into parts that can be matched independently (Q07), algo.betweenness finds bridge families whose withdrawal would break the most swaps (Q19), and algo.pageRank ranks how easy each family is to place, so the matching agent can put the hardest first (Q59).
One failed match, end to end
We ran this live in a private copy. Every number below came back from the API in that run.
The plan. The pool holds 198 synthetic families (154 kidney, 44 liver) across 11 hospitals. The matching agent finds candidate loops in the graph, and a CP-SAT optimiser (OR-Tools) picks the set with the most expected transplants. This week's plan has 32 swaps, 85 donations and 64.1 expected transplants. One of them is S-0076: Hyderabad → Bengaluru → Kolkata, with a 46% chance every link holds.
A test fails. A new family registers in Chennai, and the graph builds its compatibility links. Then the crossmatch coordinator runs S-0076's lab tests:
Hyderabad → Bengaluru negative
Bengaluru → Kolkata positive (DQ7 antibody, MFI 9000)
Kolkata → Hyderabad cancelled (the loop broke first)
That's the risky edge from earlier. The failure is written to the graph as a Failure node that BROKE the loop and a FAILED edge between the two families, so that donation is never proposed again. No rule engine needed: every future search simply doesn't walk that edge.

The FAILED edge between Bengaluru's and Kolkata's families, both IN_LOOP of S-0076, and the Failure node that BROKE it.
Repair locally. The matching agent doesn't re-plan the whole country. It reads the failure memory first (Q54), then searches only 2 hops around the three released families (Q11), keeps the swap's stored 2-way fallback in play (Q12), and ranks options with the same optimiser. In our run it searched 33 candidate loops in 55 ms and left the other 31 swaps alone.
| Option | Expected transplants | Every link holds |
|---|---|---|
| Hyderabad → Bengaluru → Chennai (recommended) | 2.29 of 3 | 56% |
| Stored 2-way fallback (S-0076-FB) | 1.46 of 2 | 73% |
| Hyderabad ⇄ Bengaluru | 1.46 of 2 | 73% |
| Bengaluru → Chennai → Ahmedabad | 1.85 of 3 | 46% |
The new Chennai family closes the ring.

"Nothing changes for any family until you decide." Each link's reasons are read from the graph.
A person approves. Nothing changes for any family until the exchange coordinator, a human, says yes. The repaired swap becomes S-0078, and the Hyderabad → Bengaluru test that already passed carries over instead of being run again. The decision is stored as Decision -[:APPROVED_BY]-> Agent {name: 'Exchange coordinator (person)'}. Approving the same task twice returns a 409, and so does asking for more tests on the broken loop.
Liver fails differently. A liver donor gives part of their liver, and the graft has to be at least 0.8% of the patient's body weight. Until a CT scan measures the lobe, the exchange only has an estimate. In L-0034 (Delhi → Chennai → Kochi), the Chennai → Kochi link looked fine on paper at 0.84%. The CT measured the right lobe at 780 ml, 0.74% of the patient's 105 kg. That link failed, and the repair (28 ms) proposed the stored 2-way fallback, Delhi ⇄ Chennai. The graph had flagged it in advance: "Liver size is only estimated and close to the limit; the CT scan will decide."
An agent that runs the case, over MCP
Two demo patients are followed week by week. Sunita's eGFR (kidney function) is falling; Rajesh's MELD score (how ill a liver patient is) is rising. Press Next week and a new lab result arrives, the health watch assesses it, and the coordinator agent decides what to do about the patient's swap.
The coordinator is the one place where a language model drives an action. Each week it reads the patient's state and memory from the graph and picks one step: search for swaps (2-way first, then 3-way), form a swap, run tests, request papers, book surgery, or wait. It's a real MCP client. KindRed runs its own MCP server (37 tools) as a child process over stdio, and the coordinator lists and calls its case tools through the protocol. The model is Groq's openai/gpt-oss-120b, falling back to gpt-oss-20b when the first one is rate limited.
We ran both patients from week 0 to transplant on the live deployment, in a fresh private copy, with Groq answering. Every tool call went over MCP.
Sunita (kidney)
| Week | Health watch saw | Coordinator did |
|---|---|---|
| 1 | eGFR 19, stage → Prepare |
find_swaps(2) → 4 found; form_swap → S-0078, Chennai → Hyderabad → Mumbai |
| 2 | eGFR 18 |
run_tests → every lab crossmatch passed |
| 3 | eGFR 17 |
request_papers → consent, ID proof and relationship proof from P-0377 |
| 4 | eGFR 16, stage → Act now |
book_surgery → 22 Nov |
| 5 | Transplant done |
Rajesh (liver)
| Week | Health watch saw | Coordinator did |
|---|---|---|
| 1 | MELD 20, stage → Prepare |
find_swaps(2) → 0, find_swaps(3) → 0, then the model's call failed and the rules ran the step: wait |
| 2 | MELD 22 |
find_swaps(2) → 0, find_swaps(3) → 2; form_swap → L-0037, Kochi → Bengaluru → Mumbai |
| 3 | MELD 25 |
run_tests(L-0037) → donor work-ups |
| 4 | MELD 26 |
request_papers → consent from P-5014, ID proof from P-0903 |
| 5 |
book_surgery → 29 Nov |
|
| 6 | MELD 27 | Transplant done, 29 Nov |
The presses of Next week we timed came back in about 2 seconds or less, model turns included.
Rajesh's week 2 is the one we like most. The 2-way search found nothing. One family further, the 3-way search found two swaps, and the coordinator formed L-0037. A 2-way-only matcher would have left him waiting.
The guardrails are code
The model never writes Cypher and never sees the raw pool. What keeps it honest is plain code:
- One action per week, enforced by the MCP server. Calls in the same week share a session, so the rule holds for any MCP client, not only ours.
-
Every tool checks the graph before it acts.
find_swapsrefuses if the patient isn't waiting;run_testsrefuses a loop that isn't this patient's. - The reason check. The model writes a one-line reason for the care team. Every id in it must appear in that week's tool result, or the reason is replaced with the result itself.
- The rules as a safety net. If no model is configured, or the model fails, the rules run the same tools and the trace says so.
allowed = set(ask.ID.findall(json.dumps(did["result"] if did else {}, default=str))) | {p["pair_id"]}
stray = sorted(set(ask.ID.findall(reason.upper().replace("‑", "-"))) - allowed) if reason else []
if stray:
note = f"The model's reason named {', '.join(stray)}, which this week's result doesn't show, so it was replaced by the result."
reason = ""
We didn't stage any failures, and the run hit two anyway. In Rajesh's week 1, Groq returned an HTTP error after the two searches, and the rules ran the step instead (nothing was found, so: wait). In Sunita's week 1, the reason check fired. The search found four 2-way swaps, but form_swap hands the final choice to the optimiser, which picked a 3-way swap with Chennai and Mumbai. The model's sentence named P-1046, which wasn't in that week's result, so the page shows the result instead of the model's words. We'll take a strict check that sometimes throws away a sentence over one that lets an invented id through.

"The model's reason named P-1046, which this week's result doesn't show, so it was replaced by the result."
Ask KindRed: tools, not generated Cypher
Coordinators can ask questions in plain words: "What breaks if Bengaluru's P-0211 withdraws?" or "Which waiting families are hardest to match?". A model picks from 15 read-only graph tools, each a wrapper around named queries. Ids are resolved against the graph first, every answer shows its tool trace and the graph paths it rests on, and if the draft names an id no query returned, the raw results replace it.
Asking about a family that doesn't exist gets the honest answer instead of "no impact":

The model picked withdrawal_impact; the id lookup (Q36) found no such family, so nothing else ran.
Testing it live also turned up a bug. The model sometimes writes ids with a non-breaking hyphen, P‑0433 (U+2011) instead of P-0433. Ask KindRed's id pattern only matched the ASCII hyphen, so for that answer nothing got checked. The coordinator already normalised that character; Ask KindRed didn't. Our checks had only been tested with friendly input.
Memory is more nodes
Agent memory in KindRed isn't a separate store. It's more nodes and edges in the same FalkorDB server, one graph per patient:
-
What happened: each week the health watch writes a
FindinglinkedBASED_ONthe readings it used. In the exchange, failed tests areFailurenodes andFAILEDedges. -
What the agents believe: stage, trend and swap are
Factnodes. A new belief doesn't overwrite the old one. ItSUPERSEDESit, and the old one stays with its date. -
How to act: each stage has a
Playbookof orderedStepnodes, which the health watch reads from the graph before handing work out.
// Q46 Replace one belief. The old fact stays, marked not current, behind a SUPERSEDES edge.
MATCH (me:Person {persona_id: $persona_id})
OPTIONAL MATCH (me)-[:KNOWS]->(old:Fact {key: $key, current: true})
WITH me, old
WHERE old IS NULL OR old.value <> $value
CREATE (me)-[:KNOWS]->(f:Fact {fact_id: $fact_id, key: $key, value: $value, text: $text, at: $at, current: true})
FOREACH (x IN CASE WHEN old IS NULL THEN [] ELSE [old] END | SET x.current = false CREATE (f)-[:SUPERSEDES]->(x))
RETURN f.fact_id AS fact_id

Sunita's beliefs in mem_sunita: each newer Fact is linked to the one it replaced by a SUPERSEDES edge.
Agents never call each other. When the health watch sees a more urgent stage, it writes one Task per playbook step, ASSIGNED_TO the scheduler, the committee clerk or the patient liaison, and they pick the tasks up on their next turn. Every agent step also stores what it read first and who wrote it. Before searching, the matching agent read one failed-link memory ("P-0211 → P-0388 (positive_xm)") and skipped that link. The Agents page draws all of this live from FalkorDB, and no line on it joins two agents directly.

Each box is an agent and each cylinder is a FalkorDB graph. Lines are writes, reads and Task hand-offs.
Many judges, one demo
During judging, two people might press Next week on the same patient. So "Make my own copy" rebuilds the whole demo in the visitor's own graphs (<copy>__exchange, <copy>__mem_sunita, …). The copy id travels in a header, and a context variable routes every query to that copy's graphs:
_world: contextvars.ContextVar[str] = contextvars.ContextVar("world", default="")
def real(graph: str) -> str:
"""The real FalkorDB graph name for a logical one ("exchange", "hosp_hyd", "mem_sunita") in the current world."""
w = _world.get()
return f"{w}__{graph}" if w else graph
Isolation doesn't depend on remembering a filter in every query. The query just runs against a different graph.
We also load tested it. Every family write is version-checked (Q01). With 16 agents updating one family at the same moment (160 updates), the check refused 1,498 stale writes, which were retried, and lost 0 updates. The same run without the check lost 138.
Through the whole API, using Artillery on one laptop, our first run managed 28 requests a second with a p95 of 25.6 s, and 575 of 1,590 visitors timed out. Profiling showed most of the time going to parsing FalkorDB replies in pure Python, and every visitor re-running the same whole-pool traversals. Adding hiredis and a small read cache that any write to the exchange graph clears fixed both:
| Run | Requests/s | p95 | Visitors timed out |
|---|---|---|---|
| First | 28 | 25.6 s | 575 of 1,590 |
| With hiredis + cache | 51.6 | 3.7 s | 0 |
Afterwards, all 1,046 chat messages were in the graph exactly once. When 20 people pressed Next week on one patient in the same second, 1 moved the journey and 19 were refused.
We stopped FalkorDB to check it's doing the work
"The graph does the work" is a claim, so we stopped only the FalkorDB container and called the API. Health, pool, agents, patients, the family explorer and Ask KindRed all returned 500. No stale answers, and no model answering without the graph. The web app falls back to a clearly labelled recording of a real run, and its buttons say nothing can be rebuilt.
When we started FalkorDB again, the API recovered by itself in about 16 seconds. All 57 graphs were still there, including the private copy and its two FAILED edges, because FalkorDB wrote its snapshot on shutdown.
Things FalkorDB taught us the hard way
None of these threw an error, which is what made them expensive.
-
Results are capped at 10,000 rows by default. A big pool has more 3-way loops than that, and the extras just weren't returned. One line in schema setup fixes it:
db().config_set("RESULTSET_SIZE", -1). -
A variable-length path can't take a parameter.
[:CAN_GIVE_TO*1..$hops]isn't accepted, so Q33 uses*1..3and filters withWHERE length(path) <= $hops. -
shortestPathonly works inRETURNorWITH. Q34 computes the route inside the return expression. -
An edge alias you never reference didn't get the constraints we expected. In cycle patterns, Q57 now references every edge alias in
WHERE. -
A
MATCHstraight after aWHEREcould drop that filter. The liver integrity checks put aWITHafter every filteredMATCH. -
Chained
OPTIONAL MATCHaggregations miscounted rows. We split link counts into their own query and gather antibodies before the other optional matches.
The lesson: test graph queries against answers you already know, not just for "no exception".
What we'd tell another team
-
Store the reason on the edge. Putting "why this donation works" on
CAN_GIVE_TOgave us the hover explanations, the step-by-step walkthroughs, the decision trail and the risk flag that predicted the DQ7 failure. -
Remember failures as structure. A
FAILEDedge is cheaper than any rule engine. - Search locally. A 2-hop repair took 55 ms and left 31 swaps alone. Re-planning the pool would have reshuffled families who were fine.
- Constrain the action space, not just the prompt. One action per week on the server, preconditions in every tool and an id check on the model's reason caught real failures. Prompt wording didn't.
Limits
- Synthetic data only. The generator reproduces published match rates (Saidman et al., 2006), but this isn't a clinical tool.
- One backend process. The load test shows where that ends.
- Free Groq keys hit rate limits quickly. KindRed falls back to a second model, then to the rules.
- In our yearly simulation, failure-aware matching places fewer highly sensitised patients than a failure-blind matcher (39% vs 43%), because their links fail more often. Sunita is exactly that patient, and we haven't solved it.
Try it
Open the live demo and press Make my own copy in the top bar first, so your Next week presses don't move anyone else's patient. Then open Demo patients and walk Sunita or Rajesh to transplant, or open the Live map for the failed-swap story.
To run it yourself you need Docker Desktop:
git clone https://github.com/abhishiv17/Graph-hacks.git kindred
cd kindred
docker compose up --build -d
The app is on http://localhost:3001, FalkorDB Browser on http://localhost:3000 (try MATCH (a:Pair)-[f:FAILED]->(b:Pair) RETURN a, f, b on the exchange graph), and the API docs on http://localhost:8000/docs. The coordinator and Ask KindRed need a model: set GROQ_API_KEY and KINDRED_ASK_PROVIDER=groq in .env, or point it at a local Ollama model. Everything else runs without a key.
Stack: FalkorDB · Python 3.11 + FastAPI · OR-Tools CP-SAT · MCP Python SDK · Groq (gpt-oss-120b, gpt-oss-20b) · Sarvam for voice · Next.js + Tailwind · Docker Compose · AWS (EC2, API Gateway, VPC link) · Artillery · 79 pytest tests
Built by Team KindRed: Abhishek M Shivanagoudar, Afreen Hossain and Akshay R, for Graph Hacks 2026 by WeMakeDevs and FalkorDB.


Top comments (0)