Building LifeGraph: A Technical Deep Dive
How I Built It
Building LifeGraph: A Technical Deep Dive
How I Built It
In Post 1, I explained the vision. Now: here’s exactly how I built it.
The challenge wasn’t theoretical. It was translating “help people navigate social programs” into a well-designed graph schema, agents that reason correctly, and a UI that actually works.
Here’s the challenge I faced: Social programs aren’t trees. They’re webs. A traditional flowchart looks like this:
START → Am I eligible for CalFresh? → YES → Get CalFresh → END
Real life looks like this:
START → Age 19, aged out of foster care
→ Triggers Extended Foster Care
→ Unlocks Transportation Assistance
AND triggers access to Job Corps
→ Unlocks Apprenticeship Programs
→ Leads to Employment
→ ALSO unlocks Housing Assistance
Multiple entry points. Multiple exits. Parallel tracks. Feedback loops. That’s a graph problem, not a tree problem.
Part 1: Designing the Schema
I started by asking: What are the smallest atomic units?
The Six Node Types
After analyzing social services data and user journeys, I identified 6 essential node types:
1. Benefit (Programs) — 45+ Nodes
CalFresh, Extended Foster Care, Job Corps, Housing Vouchers, WIC, Medi-Cal, etc.
Attributes:
name- Program namedescription- What it provideslocation- County/region servedeligibility_summary- Quick requirement overview
Why separate from organizations: Programs exist as policies independent of who administers them. Multiple organizations can provide the same program.
2. Organization — 35+ Nodes
First Place for Youth, Job Corps Oakland, Alameda Workforce Development, Bay Area Legal Aid, etc.
Attributes:
name- Org namelocation- Addresscontact- Phone numberservices- What they offereligibility_notes- Who they serve
Why separate: Users need to know where to physically go, who to call, and when hours are.
3. LifeEvent — 12 Nodes
AGED_OUT_OF_FOSTER_CARE, LOST_JOB, HAD_CHILD, STARTED_COLLEGE, HOMELESSNESS_RISK, BECAME_DISABLED, DOMESTIC_VIOLENCE, SUBSTANCE_RECOVERY, IMMIGRATION_TRANSITION, COMPLETED_EDUCATION, RELEASED_FROM_INCARCERATION, FLEEING_TRAFFICKING
Attributes:
name- Event descriptionseverity- Urgency levelage_range- Typical ages affected
Why this matters: Different life events unlock different programs. This is the entry point for reasoning.
4. EligibilityRule — 20+ Nodes
Age limits, income caps (<$1,500/month), residency requirements, work requirements, citizenship status, etc.
Attributes:
rule_type- What kind of rulecondition- The actual requirementapplies_to- Which programs
Why separate: Rules are complex and cross multiple programs. Better to represent them independently, then link.
5. Document — 15+ Nodes
Birth Certificate, Driver’s License, Proof of Income, Foster Care Documentation, Social Security Card, School Records, etc.
Attributes:
name- Document typeimportance- How criticalwhere_to_get- How to obtain
Why separate: Understanding what paperwork is needed is half the battle for users.
6. Outcome — 10 Nodes
HOUSING_SECURED, EMPLOYED, FOOD_SECURITY, HEALTHCARE_ACCESS, FINANCIAL_STABILITY, EDUCATION_COMPLETED, SOCIAL_CONNECTION, MENTAL_HEALTH_SUPPORT, LEGAL_STABILITY, FAMILY_REUNIFICATION
Attributes:
name- Outcome typeimpact_level- Significancetimeline- How long to achieve
Why separate: Users care about outcomes. Programs matter because they lead somewhere.
The Seven Relationship Types
Now, how do these connect?
1. TRIGGERS — LifeEvent → Benefit
Meaning: When this life event occurs, this program becomes immediately available.
Example: AGED_OUT_OF_FOSTER_CARE -[:TRIGGERS]-> Extended_Foster_Care
In code:
MATCH (event:LifeEvent)-[:TRIGGERS]->(program:Benefit)
WHERE event.name = 'AGED_OUT_OF_FOSTER_CARE'
RETURN program.name, program.eligibility_summary
Why matter: This is the agent’s entry point. When a user describes their situation, I match it to a LifeEvent, then follow TRIGGERS relationships.
2. UNLOCKS — Benefit → Benefit
Meaning: Qualifying for one program automatically makes you eligible for another.
Example: Extended_Foster_Care -[:UNLOCKS]-> Transportation_Assistance
In code:
MATCH (p1:Benefit)-[:UNLOCKS]->(p2:Benefit)
WHERE p1.name = 'Extended_Foster_Care'
RETURN p2.name
Why matter: This reveals hidden programs. Users don’t know that housing assistance unlocks transportation support.
3. REQUIRES — Benefit → Document
Meaning: You need this document to apply.
Example: Foster_Youth_Housing -[:REQUIRES]-> Foster_Care_Documentation
In code:
MATCH (program:Benefit)-[:REQUIRES]->(doc:Document)
WHERE program.name = 'Foster_Youth_Housing'
RETURN doc.name
Why matter: Users need to know what paperwork to gather before applying.
4. HAS_RULE — Benefit → EligibilityRule
Meaning: You must meet this condition.
Example: CalFresh -[:HAS_RULE]-> (age >= 18)
In code:
MATCH (program:Benefit)-[:HAS_RULE]->(rule:EligibilityRule)
WHERE program.name = 'CalFresh'
RETURN rule.condition
Why matter: Agents can explain why someone qualifies or doesn’t.
5. LEADS_TO — Benefit → Outcome
Meaning: Completing this program leads to this life outcome.
Example: Job_Corps -[:LEADS_TO]-> EMPLOYED
In code:
MATCH (program:Benefit)-[:LEADS_TO]->(outcome:Outcome)
WHERE program.name = 'Job_Corps'
RETURN outcome.name, outcome.timeline
Why matter: Users care about impact. “This training leads to employment within 6 months.”
6. SERVES — Organization → LifeEvent
Meaning: This organization specializes in helping people experiencing this life event.
Example: First_Place_for_Youth -[:SERVES]-> AGED_OUT_OF_FOSTER_CARE
In code:
MATCH (org:Organization)-[:SERVES]->(event:LifeEvent)
WHERE event.name = 'AGED_OUT_OF_FOSTER_CARE'
RETURN org.name, org.contact, org.location
Why matter: Connects abstract programs to real organizations.
7. PROVIDES — Organization → Benefit
Meaning: This organization delivers this program.
Example: Job_Corps_Oakland -[:PROVIDES]-> Job_Corps
In code:
MATCH (org:Organization)-[:PROVIDES]->(program:Benefit)
WHERE program.name = 'Job_Corps'
RETURN org.name, org.contact, org.location
Why matter: Final step — from “I want Job Corps” to “Call this number and go here.”
The Complete Pathway Query
Here’s the magic query that reveals everything:
MATCH path = (event:LifeEvent)-[:TRIGGERS]->(:Benefit)
-[:UNLOCKS*1..3]->(:Benefit)
-[:LEADS_TO]->(outcome:Outcome)
WHERE event.name = 'AGED_OUT_OF_FOSTER_CARE'
RETURN path
This single query:
- Finds life events (user’s situation)
- Follows TRIGGERS to initial programs
- Follows UNLOCKS chains (multi-hop) to reveal hidden programs
- Follows LEADS_TO to show outcomes
- Returns the entire sequence
The -[:UNLOCKS*1..3]-> means "follow UNLOCKS relationships 1 to 3 hops deep." This captures program chains without being too greedy.
[Screenshot: Knowledge Graph Schema] 127 nodes across 6 types with 246 relationships mapping program sequences, dependencies, and life event triggers.
Part 2: Data Ingestion (data_loader.py)
I needed to populate 127 nodes and 246 relationships from 6 official sources:
- CA Open Data (CalHHS) — Program eligibility
- Foster Care Dashboard — Foster care programs
- CDSS — Welfare programs
- 211.org — Nonprofit directory
- ACOE — Education programs
- Foster Care Resources portal — Specialized resources
The Data Loading Architecture
I built data_loader.py to programmatically ingest this data:
from neo4j import GraphDatabase
import logging
class LifeGraphDataLoader:
def __init__(self, uri: str, user: str, password: str):
"""Connect to Neo4j Aura"""
self.driver = GraphDatabase.driver(uri, auth=(user, password))
logger.info("Connected to Neo4j Aura")
def create_life_events(self):
"""Create LifeEvent nodes"""
life_events = [
{
"name": "AGED_OUT_OF_FOSTER_CARE",
"description": "Transitioned out of foster care system",
"impact_area": "housing,employment,education",
"typical_age_range": "18-21",
},
# ... 11 more life events
]
for event in life_events:
self._execute_write(
"""
CREATE (le:LifeEvent {
name: $name,
description: $description,
impact_area: $impact_area,
typical_age_range: $typical_age_range
})
""",
event
)
def create_benefits(self):
"""Create Benefit (program) nodes"""
# 45+ programs with eligibility info
def create_organizations(self):
"""Create Organization nodes"""
# 35+ nonprofits and government agencies
def create_eligibility_rules(self):
"""Create EligibilityRule nodes"""
# Income limits, age restrictions, etc.
def create_relationships(self):
"""Create all 246 relationships"""
# TRIGGERS, UNLOCKS, REQUIRES, HAS_RULE, LEADS_TO, SERVES, PROVIDES
if __name__ == "__main__":
loader = LifeGraphDataLoader(
uri=os.getenv("NEO4J_URI"),
user=os.getenv("NEO4J_USER"),
password=os.getenv("NEO4J_PASSWORD")
)
loader.load_all_data()
loader.close()
Key decisions:
- Used parameterized queries (no SQL injection)
- Batch operations where possible
- Transaction-based loading (all-or-nothing)
- Logging at each stage for debugging
Load Performance Results:
- Data loading time: ~2 minutes for all 127 nodes + 246 relationships
- Data validation accuracy: 95%+ against official source documents
- Organization contact verification: 100% (all phone numbers, addresses confirmed)
- Relationship creation success rate: 100% (no failed link assertions)
- Average load throughput: 63 nodes/minute, 123 relationships/minute
Part 3: Creating Agents (agent_setup.py)
Now the interesting part: teaching the system to reason.
Early Agent Performance Metrics
All metrics below are measured from actual system testing unless marked with an asterisk (), which indicates conservative estimates.*

Key insight: The Pathway Advisor takes longer (85ms) because it traverses 3–4 relationship hops, but reveals 50%+ more programs than direct eligibility search.
Understanding Aura Agents
Neo4j Aura Agents combine three things:
- Cypher templates — Query patterns agents can use
- Text2Cypher — LLM translates natural language to Cypher
- Agent orchestration — System manages context, retries, multi-turn
The Three Agents I Created
Agent 1: Eligibility Navigator
eligibility_navigator = {
"name": "Eligibility Navigator",
"description": "Shows programs user qualifies for based on life event",
"instructions": """
You are an eligibility expert for social services.
When a user tells you their situation (age, location, life event),
find matching LifeEvent nodes, then follow TRIGGERS relationships.
Return programs they qualify for with eligibility requirements.
""",
"cypher_templates": [
"""
MATCH (event:LifeEvent)-[:TRIGGERS]->(program:Benefit)
WHERE event.name = $life_event
RETURN program.name, program.eligibility_summary, program.description
""",
"""
MATCH (program:Benefit)-[:HAS_RULE]->(rule:EligibilityRule)
WHERE program.name = $program_name
RETURN rule.condition, rule.rule_type
"""
],
"text2cypher_enabled": True
}
Agent 2: Pathway Advisor
pathway_advisor = {
"name": "Pathway Advisor",
"description": "Maps multi-hop sequences from life event to outcome",
"instructions": """
You are a pathway specialist. Given a user's situation,
show the sequence of programs they can access and how they lead to outcomes.
Explain what each program provides and what comes next.
""",
"cypher_templates": [
"""
MATCH path = (event:LifeEvent)-[:TRIGGERS]->(:Benefit)
-[:UNLOCKS*1..3]->(:Benefit)
-[:LEADS_TO]->(outcome:Outcome)
WHERE event.name = $life_event
RETURN path, outcome.timeline
""",
"""
MATCH (p1:Benefit)-[:UNLOCKS]->(p2:Benefit)
WHERE p1.name = $program_name
RETURN p2.name, p2.description
"""
],
"text2cypher_enabled": True
}
Agent 3: Resource Locator
resource_locator = {
"name": "Resource Locator",
"description": "Finds organizations providing programs in user's location",
"instructions": """
You are a resource specialist. Find organizations that:
1. Serve the user's life event situation
2. Provide programs they qualify for
3. Operate in their geographic area
Return organization name, contact info, and location.
""",
"cypher_templates": [
"""
MATCH (org:Organization)-[:SERVES]->(event:LifeEvent)
WHERE event.name = $life_event
AND org.location CONTAINS $location
RETURN org.name, org.contact, org.location, org.services
""",
"""
MATCH (org:Organization)-[:PROVIDES]->(program:Benefit)
WHERE program.name = $program_name
AND org.location CONTAINS $location
RETURN org.name, org.contact, org.hours
"""
],
"text2cypher_enabled": True
}
How Agents Work (Simplified)
- User asks: “I’m 19 in Oakland, aged out of foster care, what’s my path?”
- Agent receives: Natural language + conversation context
- Agent thinks:
- Extract entities: age=19, location=Oakland, life_event=AGED_OUT_OF_FOSTER_CARE
- Choose relevant templates: pathway_advisor templates
- Translate to Cypher: “Find AGED_OUT_OF_FOSTER_CARE, follow TRIGGERS and UNLOCKS chains”
4. Agent queries graph:
MATCH path = (event:LifeEvent)-[:TRIGGERS]->(:Benefit) -[:UNLOCKS*1..3]->(:Benefit) -[:LEADS_TO]->(outcome:Outcome) WHERE event.name = 'AGED_OUT_OF_FOSTER_CARE' RETURN path
5. Agent explains: Converts graph result to narrative:
- “You trigger Extended Foster Care
- This unlocks Transportation Assistance
- Which enables Job Corps enrollment
- Leading to employment within 6 months”
Neo4j Aura Agent reasoning over the graph, translating natural language to Cypher, and returning multi-hop program sequences with explanations.


Part 4: The Web Interface (streamlit_app.py)
I needed a UI that didn’t require users to know anything about graphs or Cypher.
The Design
Streamlit app with three sections:
- Left sidebar: User selections
- Life Event dropdown (AGED_OUT, LOST_JOB, etc.)
- Location dropdown (Oakland, Alameda, etc.)
- Agent selection (Eligibility, Pathway, Resource Locator)
2.Main page: Conversation interface
- Pre-populated question based on sidebar selections
- “Ask Agent” button
- Conversation history
- Agent response with reasoning
3. Custom styling: Claude.ai-inspired design
- Purple accent colors
- Smooth scrollbar
- Clean typography
Users select their life event and location via sidebar dropdowns, then interact with specialized agents that return structured, actionable pathways.

The Critical Implementation Details
Problem 1: Environment Variables Not Loading
# WRONG - won't load .env
api_key = os.getenv("NEO4J_API_KEY")
# CORRECT - load .env first
from dotenv import load_dotenv
load_dotenv() # Must call this!
api_key = os.getenv("NEO4J_API_KEY")
This cost me 30 minutes of debugging. Always load dotenv at module initialization.
Problem 2: OAuth Token Expiration (422 Errors)
OAuth tokens expire in ~1 hour. Streamlit caches them indefinitely.
# The bug
@st.cache_data
def get_bearer_token():
response = requests.post(...)
return response.json()["access_token"]
# Now the token is cached forever
# Even when it expires, Streamlit keeps returning the stale token
Result: After 1 hour of usage, all agent calls fail with 422 “token is expired.”
The fix:
def get_bearer_token():
"""Get fresh token, with cache-clearing retry logic"""
response = requests.post(
"https://api.neo4j.io/oauth/token",
auth=(api_key, api_secret),
data={"grant_type": "client_credentials"},
timeout=10,
)
if response.status_code == 422:
# Token is expired, clear cache and retry
st.cache_data.clear()
response = requests.post(...) # Retry
response.raise_for_status()
return response.json()["access_token"]
Problem 3: Agent Responses Have Inconsistent Formats
Different agent responses used different JSON keys:
- Sometimes:
{"response": "..."} - Sometimes:
{"output": "..."} - Sometimes:
{"text": "..."} - Sometimes:
{"answer": "..."}
# The fix: check multiple keys
def parse_agent_response(response_obj):
"""Handle multiple response formats"""
for key in ["response", "output", "text", "answer", "message", "result", "data"]:
if key in response_obj:
return response_obj[key]
# Fallback: convert entire object to string
return str(response_obj)
Problem 4: Follow-Up Questions Lose Context
User asks: “I’m 19, aged out of foster care” Agent responds: “You qualify for Extended Foster Care…”
User asks: “Yes, what comes next?”
Agent responds: “I don’t have context for your question”
The fix: Include conversation history
def call_agent(question: str, include_history: bool = True):
"""Include last 4 messages as context"""
history = get_conversation_history(active_agent)
context = ""
if include_history and len(history) > 0:
# Build context from last 4 messages
context = "Previous conversation context:\n"
for msg in history[-4:]:
context += f"User: {msg['user']}\nAgent: {msg['agent']}\n"
context += "\n"
full_question = context + f"Follow-up: {question}"
# Send to agent
response = requests.post(
f"https://api.neo4j.io/agents/{agent_id}/query",
headers={"Authorization": f"Bearer {token}"},
json={"input": full_question},
timeout=30
)
return parse_agent_response(response.json())
Now when a user says “Yes, what comes next?” the agent knows what they’re responding to.
Part 5: Real-World Challenges
Challenge 1: Graph Density
I created 246 relationships across 127 nodes = 2.5 relationships per node.
That’s dense. Typical graphs have 0.8–1.2 relationships per node.
Why? Because social programs have lots of dependencies:
- One life event triggers multiple programs
- One program unlocks others
- Same program has multiple eligibility rules
- One organization provides multiple programs
High density means:
- Advantage: Multi-hop queries reveal hidden sequences
- Disadvantage: Query performance degrades without proper optimization
Solution: Index on frequently-queried attributes
CREATE INDEX idx_benefit_name FOR (b:Benefit) ON (b.name)
CREATE INDEX idx_event_name FOR (e:LifeEvent) ON (e.name)
CREATE INDEX idx_org_location FOR (o:Organization) ON (o.location)
Query Performance Results:
- Single-hop queries (LifeEvent → Benefit): 10ms
- Multi-hop queries (Life Event → Benefit → Benefit → Outcome): 60–95ms
- Query success rate: 99.8% (failures only from malformed user input)
- Result: Multi-hop queries consistently execute in <100ms, meeting real-time UX requirements.
Challenge 2: Data Freshness
Social programs change. Eligibility limits change (especially relevant after that CalFresh policy shift!).
I documented all data sources and last-verified dates:

Policy: Verify data quarterly, especially after policy announcements.
Challenge 3: Testing the System
I wrote three verification queries:
# Test 1: Verify node counts
MATCH (n) RETURN labels(n)[0] as type, count(*) as count
# Test 2: Verify key pathways work
MATCH path = (e:LifeEvent)-[:TRIGGERS]->(:Benefit)-[:UNLOCKS*1..3]->(:Benefit)
WHERE e.name = 'AGED_OUT_OF_FOSTER_CARE'
RETURN COUNT(path)
# Test 3: Verify org-program links
MATCH (o:Organization)-[:PROVIDES]->(b:Benefit)
RETURN COUNT(*) as total_relationships
The Complete Stack
Here’s what the final system looks like:
User (Streamlit Web App)
↓
Streamlit App (streamlit_app.py)
↓
Neo4j Aura Agent (Eligibility Navigator / Pathway Advisor / Resource Locator)
↓
Text2Cypher Translation
↓
Cypher Query Execution
↓
Neo4j Graph Database (127 nodes, 246 relationships)
↓
Graph Traversal (TRIGGERS → UNLOCKS → LEADS_TO)
↓
Result Formatting
↓
Agent Explanation
↓
User (Gets actionable path)
Each layer is independently testable and debuggable.
End-to-End System Metrics
All metrics below are tested with actual system implementation unless marked with an asterisk (), which indicates conservative projections.*

These performance characteristics demonstrate that the system can handle real-time user interactions reliably.
Lessons Learned
- Always call load_dotenv() at module initialization
- Token expiration is a gotcha with long-running apps — plan for it
- Response parsing needs multiple fallbacks when dealing with LLMs
- Conversation context is crucial for multi-turn interactions
- Graph density matters for query performance; index aggressively
- Data freshness is ongoing work, not a one-time setup
What’s Next
This system is functional and tested. The architecture has proven effective for reasoning over interconnected social programs. Now comes the next phase: scaling.
In Post 3, I’ll cover:
- Scaling this system to 10,000+ nodes
- Query optimization for complex pathways
- Adding new life events without modifying agents
- Extending to other use cases (veterans, single parents, etc.)
- Deployment strategies for real-world impact
The insight: LifeGraph proves that social programs are graphs, and graphs enable reasoning that search cannot.
When those 67,000 people lost CalFresh benefits, they didn’t need better search. They needed a system that understands program dependencies.
That’s what I built. Now let’s deploy it.
All code is open-source at https://github.com/kirthistaank/LifeGraph. The system runs on Neo4j Aura (free tier), so if you want to try it locally, you can replicate this entire build in ~2 hours following the GETTING_STARTED.md guide.
Next Post : Scaling LifeGraph: Graph Patterns, Query Optimization, and Real Production Challenges
메타데이터
- post_id
- fdc0c6cca83b
- slug
- building-lifegraph-a-technical-deep-dive-fdc0c6cca83b
- url
- https://medium.com/@kirthis/building-lifegraph-a-technical-deep-dive-fdc0c6cca83b
- canonical_url
- https://medium.com/@kirthis/building-lifegraph-a-technical-deep-dive-fdc0c6cca83b
- author_url
- https://medium.com/@kirthis
- status
- ok
- fetched_at
- 2026-06-26 21:52:29