Writing › NaijaHealth

NaijaHealth: a graph-native health intelligence agent for Kano State

· Martyns Nwaokocha · Knowledge graphs, AI agents, public health data

Nigeria publishes an enormous amount of routine health data through DHIS2, and almost none of it is answerable in the form that health programme managers actually need. NaijaHealth is my attempt to fix that for one state. It is a knowledge graph over the National Health Management Information System data for all 44 Local Government Areas of Kano State, wrapped in an AI agent that answers questions in plain English. This post is the full technical walkthrough: the graph model, the ingestion pipeline, the nine agent tools, and the design decisions I would defend in a review.

The code is public at github.com/martynscn/NaijaHealth. It was built for the Neo4j Aura Agents and Context Building Hackathon, and I have written up the wider lessons from that cohort of 40 agents at the end.

The problem: published is not the same as queryable

Nigeria's NHMIS collects monthly returns from health facilities across the country: outpatient attendance, antenatal care visits, skilled deliveries, immunisation doses administered, malaria tests and treatments, HIV services, family planning, nutrition screening. It lands in DHIS2, the open-source platform that a large share of the global south uses for routine health information.

DHIS2 is good at what it was built for: collecting forms and rendering pre-defined pivot tables and dashboards. What it is not built for is the question a programme manager actually asks, which is almost never a single aggregate. Real questions look like this:

  • Which LGAs have the worst Penta 1 to Penta 3 dropout, and are those the same LGAs whose facilities file their monthly returns late?
  • Do private facilities in this LGA report at the same rate as public ones, and if not, how much of the LGA's apparent performance is a reporting artefact?
  • Malaria testing volume fell in this LGA last quarter. Is that a genuine decline in service delivery, or did three large facilities simply stop submitting?

Every one of those questions is relational. It joins a measurement to a place, to an indicator definition, and then to an entirely separate fact about data quality. In a flat warehouse table where a facility is a row and an indicator is a column, that last join is the one that hurts, because reporting compliance is not an attribute of the measurement. It is an attribute of the facility-period pair that produced the measurement, and it determines whether you should believe the measurement at all.

The "Graph Matters" test

The hackathon's primary judging criterion was blunt and, I think, correct: does the graph drive the intelligence, or is it just where you happen to keep the rows? I applied that test before writing any code, and NaijaHealth passes it for one specific reason.

The most valuable output of this system is not an indicator value. It is an indicator value qualified by the reporting behaviour of the facilities that produced it. That qualification is a traversal: from the indicator, through every measurement of it, to the facilities those measurements came from, to the reporting rate records for those same facilities in those same months. Expressing that as a graph pattern is three lines of Cypher. Expressing it in SQL against a star schema means a self-join on a composite grain that the schema was probably not designed to support.

The graph model

Six node labels, six relationship types. The restraint is deliberate: every label earns its place by being the target of a question someone actually asks.

NaijaHealth graph schema GEOGRAPHY State id, name LGA id, name 44 of them Facility id, name, type, level, ownership ~800, + embedding IN_STATE IN_LGA MEASUREMENT ReportingRate rate, rate_on_time, expected, actual, period, dataset the trust signal DataValue value, period, period_type reified measurement Indicator id, name, category, indicator_type + embedding FOR_INDICATOR FOR_FACILITY AT_FACILITY raw counts AT_LGA calculated rates
Figure 1. The schema. Geography forms a strict hierarchy on the right. Measurement nodes on the left attach to that hierarchy at two different grains: AT_FACILITY for raw counts, AT_LGA for pre-calculated rates. ReportingRate is the node that makes the whole thing more than a warehouse.

Decision 1: reify the measurement

A DataValue is a node, not a property on a facility. This is the classic reification call and it is worth being explicit about why it goes this way here. A measurement is an n-ary fact: it binds a value to a place, an indicator, and a period simultaneously. None of those three is subordinate to the others. If I hang the value off the facility as a property, I lose the ability to traverse from the indicator side, which is exactly the direction half the questions come from ("show me malaria testing everywhere" starts at the indicator, not the facility).

The cost is node count. The benefit is that both traversal directions are first-class and indexed, and that a measurement can carry its own properties (period, period type) without polluting either of the entities it connects.

Decision 2: two grains, deliberately

This is the modelling choice I spent the most time on. DHIS2 gives you two fundamentally different kinds of number, and collapsing them into one grain would be a correctness error, not just an inelegance.

Why two grains, and why they must not be mixed AT_FACILITY · raw counts "Penta 3 doses given" at 3 facilities: 120 85 64 sum() is meaningful 269 Additive. Roll up facility to LGA to state freely. AT_LGA · calculated rates "Penta 3 coverage %" at 3 LGAs: 72% 64% 81% sum() is nonsense 217% Each rate has its own denominator population. Never additive.
Figure 2. Raw counts and calculated rates are different grains. Coverage and dropout percentages arrive from DHIS2 already divided by a target population, so they attach at LGA level and carry a hard constraint: never aggregate them with sum. Keeping them on a separate relationship type makes that constraint structural rather than a comment in a README.

Raw counts (doses administered, ANC visits, malaria tests) are additive. They attach via AT_FACILITY and can be rolled up the geography hierarchy with sum() safely. Coverage and dropout percentages arrive from DHIS2 already computed against a target population denominator. They are only defined at LGA level and summing them produces garbage.

By giving them distinct relationship types I make the distinction enforceable in the query rather than advisory in documentation. A Cypher template that traverses AT_LGA uses avg() or returns the raw series; one that traverses AT_FACILITY is free to sum(). An agent picking between those two templates cannot accidentally sum a percentage, because no template exists that would let it.

Decision 3: deterministic composite keys

Every measurement node gets a synthetic but fully deterministic id:

facility DataValue    {indicator_id}_{facility_id}_{period}
LGA DataValue         lga_{indicator_id}_{lga_id}_{period}
ReportingRate         rr_{dataset}_{facility_id}_{period}

Combined with a uniqueness constraint on id and MERGE rather than CREATE, this makes the entire load idempotent. I can rerun the pipeline against an overlapping period window and get updates, not duplicates. For a pipeline pulling from a live national system on a rolling twelve-month window, that property is not optional. The alternative, matching on a tuple of properties, works but is slower and depends on a composite index I would then have to maintain separately.

The ingestion pipeline

End to end architecture DHIS2 Nigeria API 4 endpoints extract chunks of 10 backoff retry load_graph UNWIND 500 MERGE only embeddings 1024 dim batch 25 Neo4j Aura 6 constraints 8 indexes 2 vector idx Aura Agent 9 tools → Streamlit UI FastMCP server 6 tools → Claude Code source idempotent ingest graph two surfaces, one graph Every stage is rerunnable. Nothing in the pipeline assumes an empty database.
Figure 3. The pipeline. Extraction, loading and embedding are three separate idempotent scripts rather than one job, because they fail for different reasons and at different rates.

Extraction

scripts/extract_dhis2.py talks to four DHIS2 endpoints: dataSets to discover the NHMIS Monthly Summary and Vaccines Utilization forms, organisationUnits to walk the hierarchy, indicators to pull calculated coverage and dropout metrics, and analytics for the actual values.

Three details are worth calling out because they are the kind of thing that decides whether a pipeline against a shared national instance works or gets you rate limited.

Dataset discovery is by name match, not hardcoded id. The script looks for a dataset whose name contains both "nhmis" and "monthly", preferring the 2019 version when several match. Hardcoding a DHIS2 uid would be faster and would break the moment the instance is re-provisioned. Kano State itself is found with level:eq:2 and its descendants with ancestors.id:eq:{kano_id} across levels 3 to 5, so the 44 LGAs and their facilities are discovered rather than enumerated.

Analytics requests are chunked at ten data elements. DHIS2 analytics will happily accept a request for every data element across every facility for twelve months, and then either time out or return something truncated. Ten at a time, with LEVEL-5 for facility grain and LEVEL-3 for LGA grain, keeps each request inside a size the server will actually serve.

Retry is asymmetric. Transient failures get exponential backoff, three attempts, ten seconds multiplied by attempt number. Permanent 4xx responses fail immediately, with 408 and 429 explicitly exempted because those two are retryable despite being 4xx. Blindly retrying a 401 against a shared government instance is how you get your access revoked.

Output is seven JSON files on disk. Keeping extraction and loading as separate stages with a file boundary between them means I can reload the graph a dozen times while iterating on the model without hitting the DHIS2 API once.

Loading

scripts/load_graph.py uses a single pattern throughout: chunk the records, UNWIND the chunk as rows, MERGE the node, then MATCH the parent and MERGE the relationship.

UNWIND $rows AS row
MERGE (dv:DataValue {id: row.id})
SET dv.value = row.value,
    dv.period = row.period,
    dv.period_type = row.period_type
WITH dv, row
MATCH (i:Indicator {id: row.indicator_id})
MERGE (dv)-[:FOR_INDICATOR]->(i)
WITH dv, row
MATCH (f:Facility {id: row.facility_id})
MERGE (dv)-[:AT_FACILITY]->(f)

Batch size is 500. The MATCH on the parent rather than MERGE is intentional: if the facility does not already exist, I want the row to be skipped, not to silently create a phantom facility node with nothing but an id. Load order therefore matters, and the script runs geography first, then indicators, then measurements.

Two normalisations happen here rather than at query time. DHIS2 periods arrive as YYYYMM and are rewritten to YYYY-MM, which makes STARTS WITH $year work as a cheap range filter. Facility type and ownership are derived from DHIS2 organisation unit group names by keyword match into Public, Private or Unknown. Pushing that classification into the load means the agent's templates can filter on f.ownership directly instead of every template carrying its own string matching.

Constraints, indexes and vector indexes

// 6 uniqueness constraints, one per label
CREATE CONSTRAINT facility_id IF NOT EXISTS
  FOR (f:Facility) REQUIRE f.id IS UNIQUE;

// 8 secondary indexes on the properties templates filter on
CREATE INDEX datavalue_period IF NOT EXISTS
  FOR (d:DataValue) ON (d.period);

// 2 vector indexes for the similaritySearch tools
CREATE VECTOR INDEX indicator_embeddings IF NOT EXISTS
  FOR (i:Indicator) ON i.embedding
  OPTIONS {indexConfig: {
    `vector.dimensions`: 1024,
    `vector.similarity_function`: 'cosine'
  }};

The uniqueness constraints do double duty: they guarantee the idempotency of MERGE and they provide the backing index that makes the MATCH on parent ids in the load fast. The secondary indexes map one-to-one onto the properties that appear in agent template WHERE clauses, which is the only defensible basis for choosing them.

Embeddings: resolving vocabulary, not answering questions

Both Facility and Indicator carry a 1024-dimension embedding from OpenAI's text-embedding-3-small, reduced from its native 1536 via the dimensions parameter. The number 1024 is not arbitrary and not a quality judgement: it has to match the vector index definition and the Aura Agent's similarity search configuration exactly, so it is fixed at the schema layer and everything downstream conforms to it.

What gets embedded matters more than the model choice. For a facility I compose a descriptive sentence out of name, level, type, ownership and parent LGA, so that the embedding captures the facility's role rather than just its name string. For an indicator I concatenate name, description, category and type.

The purpose of these embeddings is narrow and worth stating plainly, because it is the most commonly over-scoped component in agent architectures: vector search here resolves vocabulary, it does not answer questions. A user asks about "child immunisation". No indicator is called that. Similarity search maps the phrase onto the actual indicator names for BCG, Penta and Measles doses, and those resolved names then become parameters to a Cypher template that does the real work. Vector search is the entry point into the traversal, not a substitute for it.

The embedding script processes in batches of 25 with a half second pause, and skips any node where embedding IS NULL is false, so reruns are cheap and only fill gaps.

The agent: nine tools across three types

Aura Agents compose from three tool types, and the interesting engineering is entirely in how you allocate work between them. NaijaHealth uses six cypherTemplate tools, two similaritySearch tools, and exactly one text2cypher tool.

Tool allocation and routing natural language question similaritySearch × 2 resolve vocabulary find_similar_indicators find_similar_facilities "child immunisation" ↓ "Penta 3 doses given" feeds a template as a param cypherTemplate × 6 the primary path: reviewed logic lga_health_overview compare_lgas_by_indicator reporting_rates coverage_rates_by_lga penta_dropout_by_lga reporting_by_ownership text2cypher × 1 last resort only health_data_query Generates Cypher at runtime from the live schema. Flexible but unverified. Constrained by a narrow name and description so the agent reaches for it last. entry point deterministic core escape hatch
Figure 4. Six reviewed templates carry the load, two similarity tools resolve user vocabulary into real graph values, and a single text2cypher tool exists as an escape hatch with a deliberately narrow description so the agent reaches for it last.

That ratio is the whole architectural argument. Text2cypher is seductive in a demo because it appears to answer anything. In anything resembling production it is the component most likely to produce a confidently wrong number, because an LLM writing a sum() over a percentage column will not error, it will just be wrong by a factor nobody can see.

So for every question I already know how to answer, the agent does not write Cypher. It selects from queries I wrote, reviewed and tested. The agent's job is parameter extraction and tool selection, both of which LLMs are genuinely good at. Query authorship, which they are less reliably good at, stays with me.

The wildcard parameter pattern

Several templates carry this shape:

WHERE ($lga_name = '' OR toLower(l.name) CONTAINS toLower($lga_name))
  AND r.period STARTS WITH $year
  AND ($dataset = '' OR r.dataset = $dataset)

An empty string means "no filter". One template therefore serves both "reporting rates across all of Kano" and "reporting rates in Dala LGA for the Vaccines dataset" without me needing four near-identical tools. This matters more than it looks: every additional tool is another option the agent can choose wrongly, so collapsing the filter dimensions into optional parameters directly reduces the tool-selection error surface.

Entity resolution lives in the prompt

The single most practically important line in the agent's system instructions is a note that Kano's Nasarawa LGA is spelled with one "s". This looks like trivia. It is not.

DHIS2 prefixes Kano LGA names with kn , so the stored value is kn Nasarawa, while a user types "Nassarawa" or "Nasarawa LGA" or just "Nasarawa". Every template therefore matches with toLower(...) CONTAINS toLower($param) rather than equality, and the system prompt carries the specific spelling traps. There is also a state called Nasarawa in Nigeria, distinct from this LGA inside Kano, which is exactly the kind of collision that produces a plausible empty result set and a confused user.

The general principle: in a graph-backed agent, the system prompt is not decoration. It is where domain knowledge that cannot be expressed in the schema gets encoded. Mine also carries the interpretation rules, for instance that a low reporting rate signals a data quality problem while a high dropout rate signals a supply chain or follow-up problem, and that all values need toFloat() before aggregation because DHIS2 analytics returns them as strings.

The query a flat table cannot answer

Here is the traversal that justifies the whole design. The question is: "is this LGA's malaria testing decline real, or is it a reporting artefact?"

Joining a measurement to its own trustworthiness Indicator malaria FOR_IND DataValue value, period AT_FAC Facility the pivot IN_LGA LGA ← FOR_FACILITY ReportingRate same facility, same period verdict value fell AND rate fell = artefact value fell rate steady = real The Facility node is where the measurement path and the data-quality path meet. That meeting is the product.
Figure 5. The traversal that makes the graph worth building. The measurement path and the data quality path are independent chains that intersect at the Facility node, letting the agent qualify every number with the reporting behaviour of the facility that produced it.

Two independent chains meet at Facility. One carries the clinical measurement, the other carries the administrative fact of whether that facility filed its return that month. If testing volume fell and reporting rate fell together, the decline is probably an artefact of missing submissions. If volume fell while reporting held steady, something real happened and it deserves a programmatic response.

This is the distinction between a number and an insight, and it is structurally available in the graph in a way it simply is not in a facility-by-indicator table.

Two surfaces over one graph

NaijaHealth exposes the same graph twice, which was a deliberate decision rather than duplicated effort.

The Aura Agent with its nine tools is the product surface, consumed by a Streamlit chat UI intended for a health programme officer who will never write Cypher. The FastMCP server exposes six tools over the Model Context Protocol so the same graph is directly queryable from Claude Code or Claude Desktop. That is the analyst surface: I use it when I am exploring, and it means the graph is useful to a technical user without going through the agent's guardrails at all.

The MCP tools wrap the same vector search primitives:

CALL db.index.vector.queryNodes('facility_embeddings', $top_k, $embedding)
YIELD node AS f, score
MATCH (f)-[:IN_LGA]->(l:LGA)
RETURN f.name AS facility, f.type AS type, l.name AS lga,
       round(score * 1000) / 1000 AS score
ORDER BY score DESC

Note that the vector hit is immediately joined back into the geography hierarchy. A similarity score on its own is not useful to the caller; a matched facility with its LGA attached is. This is the pattern I would push in any graph plus vector system: never return a bare vector result, always traverse one hop out from it so the answer carries context.

The application layer

The Streamlit app authenticates with OAuth2 client credentials against api.neo4j.io/oauth/token, then invokes the agent at /v2beta1/organizations/{org}/projects/{project}/agents/{agent}/invoke.

One small detail I am glad I got right: Aura issues a 60 minute token, and the app caches it for 58. Caching for the full lifetime is the standard way to produce intermittent 401s in production, because the token can expire in flight between your check and the server's. A two minute safety margin costs nothing.

The response renderer handles structured content blocks rather than flattening to text: thinking blocks collapse into expanders, and tool use is paired with its tool result so the user can open a panel and see exactly which of the nine tools fired, with what parameters, and what came back.

That last part is not a nicety. It is the explainability dividend of the whole architecture. When the answer is produced by a named, reviewed Cypher template with visible parameters, a sceptical health officer can audit the reasoning. When it is produced by an opaque generated query, they cannot, and in public health they are right not to trust it.

What I would change

Three things, stated plainly, because a write-up with no self-criticism is marketing.

  • There is no temporal model. Period is a string property and trend questions are answered by returning a series and letting the LLM eyeball it. A proper Period node with NEXT relationships would make "three consecutive months of decline" a graph pattern instead of a judgement call.
  • Facility classification is keyword based. Deriving ownership from organisation unit group names by string match is fragile and will misclassify anything unusually named. The Unknown bucket is a silent accuracy ceiling on every ownership comparison the agent makes.
  • The data window is fixed at twelve months. Fine for a hackathon, wrong for anything operational. Multi-year data would enable seasonality adjustment, which matters enormously for malaria in northern Nigeria and is currently absent.

What 40 agents showed

NaijaHealth was one of 40 functional agent submissions in the hackathon cohort, from 201 course completions and 141 developers who requested Aura credits. Neo4j's write-up, What 40 AI Agents Revealed About the Future of Graph Intelligence, is worth reading in full, and the pattern it identifies matches my own experience building this.

The winning projects shared one property: they modelled problems that were intrinsically about relationships. First place went to Jonas Koenner's ConspiracyGraph, mapping claims, narrative frames, entities, publishers and fact-checks across 1,282 nodes and 2,436 relationships, which one judge said every media outlet should be considering. Second was Dhiraj Pattra's RegulatoryRisk GraphAgent, connecting regulations to obligations to processes so that counterparty contagion paths surface instantly. Third was the Korca Triage Agent, which modelled people, skills, workload and relationships to lift support ticket routing accuracy from 72% to 93.4%.

None of those are graph problems because someone chose a graph database. They are graph problems because the question being asked is about how things connect, and the database followed from the question. The judges' criterion, "Graph Matters", was really asking whether the graph was load bearing or decorative.

Konrad Hippius, summarising the cohort on LinkedIn, put the conclusion better than I would have: LLMs generate answers while knowledge graphs provide context, and together they produce systems that reason rather than merely respond. As agentic systems multiply, the limiting factor will not be model capability. It will be data architecture.

I believe that, and NaijaHealth is my evidence for it. The model in this system is replaceable. The thing that makes it able to tell a real decline in malaria testing apart from a facility that simply stopped filing returns is not the model. It is a ReportingRate node with an edge pointing at a Facility.

References

If you are working on a knowledge graph, a graph-backed agent, or a public health data problem and want to talk it through, get in touch.

Ready to start?

Tell me what you are trying to build. I will reply within one business day.

Get in touch