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.
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.
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
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.
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?"
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
Periodnode withNEXTrelationships 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
- NaijaHealth source code on GitHub
- My hackathon submission thread on the Neo4j community forum
- Neo4j: What 40 AI Agents Revealed About the Future of Graph Intelligence
- Neo4j Aura Agent documentation
- GraphAcademy: Building Agents in Neo4j Aura
- DHIS2 Nigeria, the NHMIS data source
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.