DEV Community

Cover image for "KindRed: when a kidney swap breaks, the graph finds another way (built on FalkorDB)"
Abhishek.M. Shivanagoudar
Abhishek.M. Shivanagoudar

Posted on

"KindRed: when a kidney swap breaks, the graph finds another way (built on FalkorDB)"

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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_swaps refuses if the patient isn't waiting; run_tests refuses 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 = ""
Enter fullscreen mode Exit fullscreen mode

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 Finding linked BASED_ON the readings it used. In the exchange, failed tests are Failure nodes and FAILED edges.
  • What the agents believe: stage, trend and swap are Fact nodes. A new belief doesn't overwrite the old one. It SUPERSEDES it, and the old one stays with its date.
  • How to act: each stage has a Playbook of ordered Step nodes, 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
Enter fullscreen mode Exit fullscreen mode


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
Enter fullscreen mode Exit fullscreen mode

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.

  1. 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).
  2. A variable-length path can't take a parameter. [:CAN_GIVE_TO*1..$hops] isn't accepted, so Q33 uses *1..3 and filters with WHERE length(path) <= $hops.
  3. shortestPath only works in RETURN or WITH. Q34 computes the route inside the return expression.
  4. An edge alias you never reference didn't get the constraints we expected. In cycle patterns, Q57 now references every edge alias in WHERE.
  5. A MATCH straight after a WHERE could drop that filter. The liver integrity checks put a WITH after every filtered MATCH.
  6. Chained OPTIONAL MATCH aggregations 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_TO gave 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 FAILED edge 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
Enter fullscreen mode Exit fullscreen mode

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)