AI Drug Interaction Intelligence Platform
Build a knowledge graph AI system that analyzes drug interactions using FDA data.
Introduction
30 Second Summary
When a patient takes multiple medications, dangerous drug interactions can hide in plain sight. Pharmacists and doctors rely on massive databases to catch these risks, but the data is scattered across FDA labels, adverse event reports, and clinical guidelines.
In this project, you will build an AI-powered drug interaction platform that combines a knowledge graph with Claude's agentic tool use to autonomously investigate medication safety and generate clinical reports grounded in real FDA data.
What You'll Build
A full-stack AI Healthcare Copilot that accepts medication names, queries a Neo4j knowledge graph seeded with real FDA drug label and adverse event data, and uses Claude's tool use capability to autonomously investigate drug interactions and generate physician-ready clinical reports.
By the end of this project, you'll have:
- A Neo4j knowledge graph with Drug, Interaction, and AdverseEvent nodes populated from real OpenFDA data.
- An agentic AI backend where Claude autonomously chains tool calls to analyze medication safety and assess severity.
- A full-stack application with a FastAPI backend and React TypeScript dashboard for medication input, interaction display, and AI-generated clinical reports.
- Secret Mission: Add an interactive graph visualization that renders the drug interaction network as a force-directed node-link diagram.
Are there any prerequisites?
This project assumes intermediate Python experience, basic understanding of REST APIs, familiarity with Docker, and basic React/TypeScript knowledge. You should be comfortable reading API documentation and working in a terminal.
Set Up Your Environment and Tools
Your AI healthcare copilot needs three foundational pieces before you can start building.
You need a Python environment to run the backend, a Neo4j graph database to store drug interactions, and an API key to access Claude's AI capabilities. In this step, you'll get all three running on your machine.
In this step, get ready to:
- Create a Python virtual environment for your backend code.
- Launch a Neo4j database inside a Docker container.
- Obtain and configure your Anthropic API key.
Create a Python virtual environment
A virtual environment isolates this project's Python packages from the rest of your system. This prevents version conflicts between projects.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir drug-interaction-platform
cd drug-interaction-platform
- Create a Python virtual environment by running:
python3 -m venv venv
- Activate the virtual environment by running:
macOS
source venv/bin/activate
Windows
venv\Scripts\activate
You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.
What is a virtual environment?
A virtual environment is an isolated Python installation specific to one project. Packages you install here won't interfere with other Python projects on your machine.
You'll always want (venv) showing in your terminal when working on this project. If you close and reopen your terminal later, re-run the activate command above.
Don't see (venv) in your prompt?
Make sure you ran the activation command for your operating system. On macOS use source venv/bin/activate and on Windows use venv\Scripts\activate.
If you see a permissions error on Windows, run PowerShell as Administrator and execute Set-ExecutionPolicy RemoteSigned first.
Why isn't my (venv) showing up?
Pull and run the Neo4j Docker container
Neo4j is a graph database purpose-built for storing and querying relationships. Drug interactions are inherently relational, making a graph database the ideal choice.
Why a graph database instead of a vector database?
Drug interactions are relationship chains. "Drug A interacts with Drug B which shares a metabolic pathway with Drug C" is a graph traversal problem. A vector database would find documents mentioning these drugs, but it cannot reason about the relationship chain itself.
Neo4j makes these relationships explicit and queryable with its Cypher query language.
- Make sure Docker Desktop is running. You can check by looking for the Docker whale icon in your system tray (macOS menu bar or Windows taskbar).
- Start the Neo4j container by running this command:
docker run -d \
--name neo4j-drug-interactions \
--publish=7474:7474 \
--publish=7687:7687 \
--volume=neo4j-drug-data:/data \
--env NEO4J_AUTH=neo4j/password \
neo4j:2026.05.0
What does this command do?
- -d runs the container in detached mode so your terminal stays free.
- --name neo4j-drug-interactions gives the container a memorable name for starting and stopping later.
- --publish=7474:7474 exposes the Neo4j Browser HTTP interface.
- --publish=7687:7687 exposes the Bolt protocol port that your Python code uses to connect.
- --volume=neo4j-drug-data:/data persists your graph data in a named Docker volume so it survives container restarts.
- --env NEO4J_AUTH=neo4j/password sets the database username to neo4j and password to password.
- Wait about 10 seconds for Neo4j to start, then verify it is running:
docker ps
You should see a row showing neo4j-drug-interactions with status Up and ports 7474 and 7687 listed.
- Open your browser and navigate to http://localhost:7474 to confirm the Neo4j Browser loads.
- Log in with username neo4j and password password.
You should see the Neo4j Browser dashboard. This confirms your graph database is running and accessible.
Container not showing in docker ps?
If the container exited immediately, check the logs with docker logs neo4j-drug-interactions to see what went wrong.
Make sure Docker Desktop is fully started. The Docker whale icon in your system tray should be solid (not animated).
If port 7474 or 7687 is already in use, stop any other Neo4j or service using those ports first.
Help me debug my Neo4j container
Create your Anthropic API key and environment file
Claude will power the AI analysis in your platform. You need an API key to authenticate requests to the Claude API.
- Open your browser and go to console.anthropic.com.
- Create an account or sign in if you already have one.
- Navigate to Settings in the left sidebar, then click API keys.
- Click Create Key.
- Give it a name like drug-interaction-platform and click Create Key.
- Copy the key immediately. You will not be able to see it again after closing the dialog.
How much will Claude cost?
This project uses claude-sonnet-4-6 at $3 per million input tokens and $15 per million output tokens. Each interaction analysis costs approximately $0.02-$0.05.
Your total spend for development and testing should be $2-$5. You will need to add a payment method to your account before making API calls.
Now you'll store the API key and Neo4j connection details in a .env file. This keeps credentials out of your source code.
- Create the .env file in your drug-interaction-platform folder by running:
macOS
touch .env
Windows
New-Item .env
- Open .env in your code editor and paste the following content, replacing your_api_key_here with the key you just copied:
ANTHROPIC_API_KEY=your_api_key_here
NEO4J_URI=bolt://localhost:7687
NEO4J_USER=neo4j
NEO4J_PASSWORD=password
What do these values mean?
- ANTHROPIC_API_KEY authenticates your requests to the Claude API.
- NEO4J_URI points to your local Neo4j database using the Bolt protocol on port 7687.
- NEO4J_USER and NEO4J_PASSWORD match the credentials you set with --env NEO4J_AUTH in the Docker command.
- Save .env.
- Confirm the file exists by running:
ls -a
You should see .env and venv listed in the output.
Don't see .env in the output?
The -a flag shows hidden files (those starting with a dot). Without it, .env won't appear.
Make sure you're in the drug-interaction-platform folder. Run pwd to check your current directory.
Help me find my .env file
✔️ Awesome, I've got everything!
Great. Double check you've saved your .env file and that your Neo4j container is still running.
ⓧ I'd like to double check the full code
Here is the complete .env file for comparison:
ANTHROPIC_API_KEY=your_api_key_here
NEO4J_URI=bolt://localhost:7687
NEO4J_USER=neo4j
NEO4J_PASSWORD=password
Your development environment is ready. You have an isolated Python environment, a running graph database, and authenticated access to Claude. Next up, you'll scaffold the backend and frontend projects that tie everything together.
Scaffold the Project Structure
Your environment is locked and loaded. You have Python ready, Neo4j running, and your API key stored.
Now it's time to build the skeleton of the application itself. You'll create two separate projects that talk to each other: a FastAPI backend that processes drug interaction queries, and a React TypeScript frontend that displays the results.
In this step, get ready to:
- Initialize the FastAPI backend project with dependencies.
- Scaffold the React TypeScript frontend with Vite.
- Verify both projects run independently.
Initialize the FastAPI backend project
The backend handles all the heavy lifting: querying the knowledge graph, calling Claude, and serving results to the frontend. You'll use FastAPI because it has built-in support for async operations and automatic API documentation.
- Create the backend directory and move into it by running these commands:
mkdir backend
cd backend
- Create a requirements.txt file that lists all the Python packages the backend needs. Create this file and add the following content:
fastapi
uvicorn[standard]
neo4j
anthropic
python-dotenv
httpx
pydantic
What are these packages?
- fastapi is the web framework for building your API endpoints.
- uvicorn[standard] is the ASGI server that runs FastAPI.
- neo4j is the official Python driver for connecting to your Neo4j graph database.
- anthropic is the SDK for calling Claude's API.
- python-dotenv loads environment variables from your .env file.
- httpx is an HTTP client for calling external APIs like OpenFDA.
- pydantic handles data validation and serialization.
- Install all the dependencies by running:
pip install -r requirements.txt
Seeing install errors?
Make sure your virtual environment is active. You should see (venv) at the start of your terminal prompt. If not, activate it from your project root directory before running the install command.
ask mark placeholder
Now create the main application file. This is where FastAPI serves your API.
- Create a file called main.py inside your backend/ directory and add the following code:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="Drug Interaction Intelligence API")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
def health_check():
return {"status": "ok"}
What does this code do?
CORSMiddleware allows your React frontend (running on port 5173) to make requests to this backend (running on port 8000). Without it, the browser blocks cross-origin requests.
The /health endpoint is a simple check to confirm the API is running. You'll add the real endpoints in a later step.
- Save main.py.
Scaffold the React TypeScript frontend
The frontend is where clinicians will enter medication names and see interaction results. Vite scaffolds a React TypeScript project in seconds with hot module reloading built in.
- Navigate back to your project root directory by running:
cd ..
- Scaffold the frontend project by running this command:
npm create vite@latest frontend -- --template react-ts
What does this command do?
npm create vite@latest runs the Vite project scaffolder. The frontend argument names the directory to create. The --template react-ts flag tells Vite to set up a React project with TypeScript preconfigured.
- Move into the new frontend directory and install the dependencies by running:
cd frontend
npm install
- You should see a node_modules/ folder appear inside frontend/ once the install completes.
npm command not found?
This means Node.js is not installed or not in your PATH. Confirm Node is available by running node --version in your terminal. If it's missing, install Node.js before continuing.
Help me fix npm not being found in my terminal
Verify both projects run independently
Before building any features, confirm that both the backend and frontend start without errors. This saves you from debugging environment issues later.
- Navigate back to the backend directory by running:
cd ../backend
- Start the FastAPI development server by running:
fastapi dev main.py
- Open your browser and go to http://localhost:8000/health.
- You should see {"status":"ok"} displayed in the browser.
What should I see?
The fastapi dev command starts Uvicorn with auto-reload enabled. Your terminal will show the server running on http://127.0.0.1:8000. The JSON response in your browser confirms the API is healthy.
- Stop the backend server by pressing Ctrl+C in the terminal.
- Navigate to the frontend directory and start the Vite dev server by running:
cd ../frontend
npm run dev
- Open your browser and go to http://localhost:5173.
- You should see the default Vite + React welcome page with a spinning logo and a counter button.
Don't see the React page?
Check your terminal for error messages. The most common issue is a port conflict. If something else is already using port 5173, Vite will try the next available port and print the actual URL in the terminal output.
Help me debug why my Vite React app isn't loading in the browser
- Stop the frontend server by pressing Ctrl+C.
- Navigate back to your project root and create the scripts directory by running:
cd ..
mkdir scripts
- Confirm your project structure by running:
ls
- You should see backend/, frontend/, scripts/, and .env in the output.
✔️ Awesome, I've got everything!
Great. Double check you've saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is what your backend/main.py should look like:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="Drug Interaction Intelligence API")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
def health_check():
return {"status": "ok"}
Here is what your backend/requirements.txt should look like:
fastapi
uvicorn[standard]
neo4j
anthropic
python-dotenv
httpx
pydantic
Your frontend/ directory was generated by Vite. The key file is package.json which should contain React and Vite as dependencies. Exact version numbers may vary based on when you ran the scaffold.
Both halves of your application are alive. Next up, you'll seed the Neo4j knowledge graph with real FDA drug interaction data.
Seed the Knowledge Graph with FDA Data
Your backend and frontend scaffolds are ready, but your Neo4j database is still empty. A knowledge graph is only useful if it contains structured data that an AI agent can query.
In this step, you will populate your graph with real drug interaction data from the FDA. You will pull prescribing information from the OpenFDA Drug Label API and adverse event counts from the FAERS reporting system, then store everything as nodes and relationships in Neo4j.
In this step, get ready to:
- Write a Python script that fetches drug interaction text from the OpenFDA Drug Label API.
- Parse interactions and create Drug nodes with INTERACTS_WITH relationships in Neo4j.
- Fetch FAERS adverse event counts for drug pairs and store them as AdverseEvent nodes.
Write the data fetching functions
The OpenFDA Drug Label API provides drug interaction text extracted from prescribing information. The FAERS (FDA Adverse Event Reporting System) endpoint provides counts of adverse event reports for drug combinations. Your script will query both APIs and load the results into Neo4j.
- Create a new file called seed_graph.py inside your scripts/ directory.
- Add the imports, environment configuration, and the drug label fetch function by pasting the following code:
import httpx
import os
from neo4j import GraphDatabase
from dotenv import load_dotenv
load_dotenv()
# Load Neo4j connection details from .env
NEO4J_URI = os.getenv("NEO4J_URI", "bolt://localhost:7687")
NEO4J_USER = os.getenv("NEO4J_USER", "neo4j")
NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD", "password")
# Connect to the Neo4j database
driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD))
# List of drugs to seed into the knowledge graph
DRUGS = ["warfarin", "aspirin", "omeprazole", "metformin", "lisinopril", "atorvastatin", "amoxicillin", "ibuprofen"]
def fetch_drug_interactions(drug_name: str):
"""Fetch drug interaction text from the OpenFDA Drug Label API."""
url = f"https://api.fda.gov/drug/label.json?search=openfda.generic_name:{drug_name}&limit=1"
try:
resp = httpx.get(url, timeout=10)
data = resp.json()
results = data.get("results", [])
if results:
interactions = results[0].get("drug_interactions", ["No interaction data"])
return interactions[0] if interactions else "No interaction data"
except Exception:
return "No interaction data"
return "No interaction data"
What does this code do?
The script loads your Neo4j credentials from the .env file and establishes a connection using the neo4j Python driver.
The DRUGS list defines which medications to seed into your graph. The fetch_drug_interactions function queries the OpenFDA Drug Label endpoint using openfda.generic_name and extracts the drug_interactions field from the label response.
- Below the fetch_drug_interactions function, add the adverse event count function:
def fetch_adverse_event_count(drug1: str, drug2: str) -> int:
"""Fetch the total FAERS adverse event report count for a drug pair."""
url = f"https://api.fda.gov/drug/event.json?search=patient.drug.medicinalproduct:{drug1}+AND+patient.drug.medicinalproduct:{drug2}&limit=1"
try:
resp = httpx.get(url, timeout=10)
data = resp.json()
return data.get("meta", {}).get("results", {}).get("total", 0)
except Exception:
return 0
What does this function do?
This function queries the OpenFDA Drug Event endpoint with two drug names joined by +AND+. It does not download all reports. It reads the meta.results.total field from the response, which tells you how many adverse event reports mention both drugs together.
A high count (thousands of reports) suggests the drug pair appears frequently in real-world adverse events. This count becomes a severity signal in your knowledge graph.
- Save scripts/seed_graph.py.
Seeing an import error for httpx or neo4j?
Make sure your virtual environment is activated and that you installed the backend dependencies with pip install -r backend/requirements.txt. Both httpx and neo4j are listed there.
help me debug why Python cannot find the httpx or neo4j packages
Create Drug nodes and interaction relationships in Neo4j
Now you need the function that ties everything together. It will create Drug nodes for each medication, query the OpenFDA APIs for interaction data, and build the relationship edges in your graph.
- Below the fetch_adverse_event_count function in scripts/seed_graph.py, add the seeding function and the main entry point:
def seed_graph():
"""Seed Neo4j with Drug nodes, interaction relationships, and adverse events."""
with driver.session() as session:
# Clear existing data for a fresh seed
session.run("MATCH (n) DETACH DELETE n")
# Create a Drug node for each medication
for drug in DRUGS:
session.run("MERGE (d:Drug {name: $name})", name=drug.lower())
# Define known interaction pairs with severity levels
pairs = [
("warfarin", "aspirin", "major"),
("warfarin", "omeprazole", "moderate"),
("aspirin", "ibuprofen", "moderate"),
("metformin", "lisinopril", "minor"),
("atorvastatin", "amoxicillin", "minor"),
("warfarin", "ibuprofen", "major"),
("omeprazole", "metformin", "minor"),
]
# For each pair, fetch FDA data and create graph relationships
for drug1, drug2, severity in pairs:
interaction_text = fetch_drug_interactions(drug1)
adverse_count = fetch_adverse_event_count(drug1, drug2)
session.run(
"""
MATCH (a:Drug {name: $drug1}), (b:Drug {name: $drug2})
MERGE (a)-[r:INTERACTS_WITH {severity: $severity, description: $description}]->(b)
MERGE (ae:AdverseEvent {drug1: $drug1, drug2: $drug2, report_count: $count})
MERGE (a)-[:HAS_ADVERSE_EVENT]->(ae)
""",
drug1=drug1, drug2=drug2, severity=severity,
description=interaction_text[:200], count=adverse_count
)
print("Graph seeded successfully.")
if __name__ == "__main__":
seed_graph()
driver.close()
What does the seed_graph function do?
First, it clears any existing nodes with MATCH (n) DETACH DELETE n so you start fresh each time you run it.
It creates a Drug node for each medication using MERGE (which avoids duplicates). Then for each known interaction pair, it fetches the FDA label text and adverse event count, and creates three things in the graph: an INTERACTS_WITH relationship between the two drugs (with severity and description properties), an AdverseEvent node storing the FAERS report count, and a HAS_ADVERSE_EVENT relationship linking the drug to its adverse event data.
Why use a knowledge graph instead of a vector database?
Drug interactions are inherently relational. The question "does Drug A interact with Drug B, which shares a metabolic pathway with Drug C?" is a graph traversal problem. A vector database would find documents mentioning these drugs but cannot reason about the relationship chain.
Neo4j makes this explicit with Cypher queries like MATCH (d1:Drug)-[:INTERACTS_WITH]->(d2:Drug). This is the GraphRAG pattern: structured graph queries ground your LLM outputs in verified medical data.
- Save scripts/seed_graph.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved scripts/seed_graph.py and that it includes both fetch functions and the seed_graph function with the main block at the bottom.
ⓧ I'd like to double check the full code
import httpx
import os
from neo4j import GraphDatabase
from dotenv import load_dotenv
load_dotenv()
NEO4J_URI = os.getenv("NEO4J_URI", "bolt://localhost:7687")
NEO4J_USER = os.getenv("NEO4J_USER", "neo4j")
NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD", "password")
driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD))
DRUGS = ["warfarin", "aspirin", "omeprazole", "metformin", "lisinopril", "atorvastatin", "amoxicillin", "ibuprofen"]
def fetch_drug_interactions(drug_name: str):
url = f"https://api.fda.gov/drug/label.json?search=openfda.generic_name:{drug_name}&limit=1"
try:
resp = httpx.get(url, timeout=10)
data = resp.json()
results = data.get("results", [])
if results:
interactions = results[0].get("drug_interactions", ["No interaction data"])
return interactions[0] if interactions else "No interaction data"
except Exception:
return "No interaction data"
return "No interaction data"
def fetch_adverse_event_count(drug1: str, drug2: str) -> int:
url = f"https://api.fda.gov/drug/event.json?search=patient.drug.medicinalproduct:{drug1}+AND+patient.drug.medicinalproduct:{drug2}&limit=1"
try:
resp = httpx.get(url, timeout=10)
data = resp.json()
return data.get("meta", {}).get("results", {}).get("total", 0)
except Exception:
return 0
def seed_graph():
with driver.session() as session:
session.run("MATCH (n) DETACH DELETE n")
for drug in DRUGS:
session.run("MERGE (d:Drug {name: $name})", name=drug.lower())
pairs = [
("warfarin", "aspirin", "major"),
("warfarin", "omeprazole", "moderate"),
("aspirin", "ibuprofen", "moderate"),
("metformin", "lisinopril", "minor"),
("atorvastatin", "amoxicillin", "minor"),
("warfarin", "ibuprofen", "major"),
("omeprazole", "metformin", "minor"),
]
for drug1, drug2, severity in pairs:
interaction_text = fetch_drug_interactions(drug1)
adverse_count = fetch_adverse_event_count(drug1, drug2)
session.run(
"""
MATCH (a:Drug {name: $drug1}), (b:Drug {name: $drug2})
MERGE (a)-[r:INTERACTS_WITH {severity: $severity, description: $description}]->(b)
MERGE (ae:AdverseEvent {drug1: $drug1, drug2: $drug2, report_count: $count})
MERGE (a)-[:HAS_ADVERSE_EVENT]->(ae)
""",
drug1=drug1, drug2=drug2, severity=severity,
description=interaction_text[:200], count=adverse_count
)
print("Graph seeded successfully.")
if __name__ == "__main__":
seed_graph()
driver.close()
Run the seeding script and verify your knowledge graph
Time to populate your graph. The script will make several API calls to OpenFDA (no API key required for low-volume requests), so it may take 30-60 seconds to complete.
- Make sure your Neo4j container is still running. You can check by running:
docker ps
- You should see neo4j-drug-interactions listed in the output.
- Run the seeding script from your project root by running:
python scripts/seed_graph.py
- Wait for the script to complete. You should see Graph seeded successfully. printed in your terminal.
Seeing a connection refused error?
This means your Neo4j Docker container is not running or is not accepting connections on port 7687. Run docker start neo4j-drug-interactions and wait a few seconds for it to initialize before retrying.
Also confirm your .env file has NEO4J_URI=bolt://localhost:7687 and that the password matches what you set when creating the container.
help me debug why my seed script cannot connect to Neo4j
- Open the Neo4j Browser by navigating to http://localhost:7474 in your web browser.
- In the query bar at the top, type the following Cypher query and press the play button:
MATCH (n) RETURN n
- You should see a visual graph with Drug nodes (like warfarin, aspirin, omeprazole) connected by INTERACTS_WITH relationships and linked to AdverseEvent nodes.
What should I see in Neo4j Browser?
The browser displays your graph visually. Drug nodes appear as circles labeled with their names. Colored lines represent INTERACTS_WITH relationships (each with a severity property). Smaller nodes represent AdverseEvent entries with a report_count property showing how many real FAERS reports exist for that drug pair.
You should see 8 Drug nodes, 7 INTERACTS_WITH relationships, and 7 AdverseEvent nodes in total.
Your knowledge graph is now populated with real FDA data. Next up, you will build the agentic AI backend where Claude autonomously queries this graph to investigate medication safety.
Build the Agentic AI Backend
Your knowledge graph is populated with real FDA drug interaction data. Now you need an intelligent layer that can reason over this structured data and produce clinician-ready safety reports.
In this step, you will build an agentic AI backend where Claude autonomously decides which graph queries to run, executes them, and synthesizes the results into a clinical report. This is the tool use pattern where the LLM drives the investigation loop instead of following a hardcoded script.
In this step, get ready to:
- Create a database helper module for Neo4j queries.
- Define Claude tool schemas for graph queries.
- Implement the agentic loop where Claude autonomously chains tool calls.
Create the database helper module
The agent needs a clean way to execute Cypher queries against your Neo4j graph. A dedicated database module keeps connection logic in one place so every tool function can reuse it.
- Create a new file called db.py inside your backend/ folder by running this command from the backend directory:
touch db.py
- Open db.py in your editor and add the following code:
import os
from neo4j import GraphDatabase
from dotenv import load_dotenv
load_dotenv()
# Create a single driver instance using credentials from .env
driver = GraphDatabase.driver(
os.getenv("NEO4J_URI", "bolt://localhost:7687"),
auth=(os.getenv("NEO4J_USER", "neo4j"), os.getenv("NEO4J_PASSWORD", "password"))
)
def run_query(cypher: str, params: dict = {}):
# Execute a Cypher query and return results as a list of dictionaries
with driver.session() as session:
result = session.run(cypher, params)
return [record.data() for record in result]
What does this code do?
This module creates a single Neo4j driver connection that stays alive for the lifetime of the application.
- GraphDatabase.driver() establishes the connection to your Neo4j container using the credentials from your .env file.
- run_query() opens a session, runs any Cypher query you pass in, and returns the results as plain Python dictionaries.
- Save db.py.
Import errors when testing later?
- Make sure db.py is in the same backend/ folder as main.py. Python resolves relative imports from the working directory.
- Confirm your virtual environment is activated. If neo4j is not found, run pip install neo4j again.
Help me debug a Neo4j import error in my db.py module.
✔️ Awesome, I've got everything!
Great. Make sure you have saved your file before moving on.
ⓧ I'd like to double check the full code
import os
from neo4j import GraphDatabase
from dotenv import load_dotenv
load_dotenv()
driver = GraphDatabase.driver(
os.getenv("NEO4J_URI", "bolt://localhost:7687"),
auth=(os.getenv("NEO4J_USER", "neo4j"), os.getenv("NEO4J_PASSWORD", "password"))
)
def run_query(cypher: str, params: dict = {}):
with driver.session() as session:
result = session.run(cypher, params)
return [record.data() for record in result]
Define the tool schemas for Claude
Claude's tool use pattern works like a conversation. You describe available tools using JSON schemas, then Claude decides which ones to call based on the user's request. Claude responds with a tool_use block when it wants to call a tool, your code executes the function and returns results, and Claude continues reasoning until it has enough information.
- Create a new file called agent.py inside your backend/ folder:
touch agent.py
- Open agent.py and add the imports, client setup, and tool schema definitions:
import os
import json
import anthropic
from db import run_query
from dotenv import load_dotenv
load_dotenv()
# Initialize the Anthropic client with your API key
client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
# Define the tools Claude can call during analysis
TOOLS = [
{
"name": "check_interactions",
"description": "Check for known drug-drug interactions between a list of medications using the knowledge graph.",
"input_schema": {
"type": "object",
"properties": {
"drugs": {"type": "array", "items": {"type": "string"}, "description": "List of drug names to check interactions for"}
},
"required": ["drugs"]
}
},
{
"name": "get_adverse_events",
"description": "Retrieve FAERS adverse event report counts for a drug pair from the knowledge graph.",
"input_schema": {
"type": "object",
"properties": {
"drug1": {"type": "string"},
"drug2": {"type": "string"}
},
"required": ["drug1", "drug2"]
}
},
{
"name": "find_alternatives",
"description": "Find alternative drugs in the knowledge graph that do not interact with the given drug.",
"input_schema": {
"type": "object",
"properties": {
"drug": {"type": "string", "description": "Drug to find alternatives for"},
"avoid": {"type": "string", "description": "Drug that the alternative must not interact with"}
},
"required": ["drug", "avoid"]
}
}
]
What are tool schemas?
Each tool definition tells Claude what the tool does and what inputs it expects. Claude reads these descriptions to decide when to call each tool.
- check_interactions accepts a list of drug names and checks all pair combinations for interactions.
- get_adverse_events retrieves real FAERS report counts for a specific drug pair.
- find_alternatives searches the graph for drugs that do not interact with a specified medication.
Why tool use instead of a fixed script?
A fixed script would always run the same queries in the same order regardless of input. With tool use, Claude decides which tools to call and in what sequence based on what it discovers. If the first check reveals a major interaction, Claude autonomously fetches adverse event data and looks for alternatives without you writing conditional logic.
- Save agent.py.
You won't see a change yet. The next chunk implements the functions that these schemas point to.
Implement the tool functions and agentic loop
Each tool schema needs a matching Python function that actually queries Neo4j. These functions translate Claude's requests into Cypher queries and return structured results.
- In agent.py, add the check_interactions function below the TOOLS list:
def check_interactions(drugs: list[str]) -> dict:
# Check every pair combination for interactions in the graph
results = []
for i in range(len(drugs)):
for j in range(i + 1, len(drugs)):
d1, d2 = drugs[i].lower(), drugs[j].lower()
# Try both directions since the relationship may be stored either way
rows = run_query(
"MATCH (a:Drug {name:$d1})-[r:INTERACTS_WITH]->(b:Drug {name:$d2}) RETURN r.severity as severity, r.description as description",
{"d1": d1, "d2": d2}
)
if not rows:
rows = run_query(
"MATCH (a:Drug {name:$d2})-[r:INTERACTS_WITH]->(b:Drug {name:$d1}) RETURN r.severity as severity, r.description as description",
{"d1": d1, "d2": d2}
)
results.append({"pair": f"{d1} + {d2}", "interactions": rows})
return {"interactions": results}
What does this function do?
This function generates every unique pair from the input drug list and queries the graph for each pair. It checks both directions of the INTERACTS_WITH relationship since the seeding script may have stored the edge in either direction.
- Below check_interactions, add the remaining two tool functions and the tool dispatch dictionary:
def get_adverse_events(drug1: str, drug2: str) -> dict:
# Look up FAERS adverse event report count for this drug pair
rows = run_query(
"MATCH (ae:AdverseEvent {drug1:$d1, drug2:$d2}) RETURN ae.report_count as count",
{"d1": drug1.lower(), "d2": drug2.lower()}
)
return {"drug1": drug1, "drug2": drug2, "adverse_event_reports": rows[0]["count"] if rows else 0}
def find_alternatives(drug: str, avoid: str) -> dict:
# Find drugs in the graph that do NOT interact with the drug to avoid
rows = run_query(
"""
MATCH (d:Drug) WHERE d.name <> $drug AND d.name <> $avoid
AND NOT (d)-[:INTERACTS_WITH]-(:Drug {name:$avoid})
RETURN d.name as alternative LIMIT 5
""",
{"drug": drug.lower(), "avoid": avoid.lower()}
)
return {"alternatives": [r["alternative"] for r in rows]}
# Map tool names to their Python implementations
TOOL_FUNCTIONS = {
"check_interactions": lambda inp: check_interactions(inp["drugs"]),
"get_adverse_events": lambda inp: get_adverse_events(inp["drug1"], inp["drug2"]),
"find_alternatives": lambda inp: find_alternatives(inp["drug"], inp["avoid"]),
}
What does this code do?
- get_adverse_events queries the AdverseEvent nodes you seeded earlier to retrieve real FAERS report counts for a drug pair.
- find_alternatives uses a negation pattern in Cypher to find drugs that have no INTERACTS_WITH edge to the drug being avoided.
- TOOL_FUNCTIONS maps each tool name string to a lambda that calls the correct Python function. The agentic loop uses this dictionary to dispatch tool calls.
- Save agent.py.
You won't see a change yet. The next chunk builds the loop that ties everything together.
Seeing a syntax error?
- Check that the triple-quoted Cypher string in find_alternatives uses three double quotes on both sides.
- Make sure each function is at the module level (no extra indentation), not nested inside another function.
Help me fix a syntax error in my agent.py tool functions.
Now for the core of the agentic pattern. The run_agent function sends the medication list and tool definitions to Claude, then enters a loop. Claude responds with either a final text answer (stop_reason: "end_turn") or tool call requests (stop_reason: "tool_use"). Your code executes the requested tools and feeds results back until Claude is satisfied.
- Add the run_agent function at the bottom of agent.py, below the TOOL_FUNCTIONS dictionary:
def run_agent(medications: list[str]) -> dict:
# Build the initial prompt asking Claude to analyze the medication combination
messages = [
{"role": "user", "content": f"Analyze the safety of this medication combination for a patient: {', '.join(medications)}. Check all interactions, retrieve adverse event data for any major or moderate interactions, suggest alternatives where needed, and produce a physician-ready clinical safety report."}
]
tool_calls_log = []
while True:
# Send messages and tool definitions to Claude
response = client.messages.create(
model="claude-opus-4-5",
max_tokens=4096,
tools=TOOLS,
messages=messages
)
# If Claude is done reasoning, extract the final report text
if response.stop_reason == "end_turn":
report = "".join(block.text for block in response.content if hasattr(block, "text"))
return {"report": report, "tool_calls": tool_calls_log}
# Otherwise, execute each tool Claude requested
tool_results = []
for block in response.content:
if block.type == "tool_use":
tool_name = block.name
tool_input = block.input
result = TOOL_FUNCTIONS[tool_name](tool_input)
tool_calls_log.append({"tool": tool_name, "input": tool_input, "result": result})
tool_results.append({"type": "tool_result", "tool_use_id": block.id, "content": json.dumps(result)})
# Feed the tool results back to Claude for the next reasoning step
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})
How does the agentic loop work?
This is the core agentic pattern. The while True loop continues until Claude decides it has enough information to write its report.
- Claude receives the patient's medication list and the tool schemas.
- If Claude responds with stop_reason == "end_turn", it has finished reasoning and the text is the final clinical report.
- If Claude responds with tool calls, your code executes each tool against Neo4j and appends the results back into the conversation.
- tool_calls_log tracks every tool invocation so the frontend can show what Claude investigated.
- Save agent.py.
- Verify the module loads without errors by running this from the backend/ directory:
python -c "from agent import run_agent; print('Agent module loaded successfully')"
You should see Agent module loaded successfully printed in your terminal.
Module won't load?
- Make sure you are running the command from inside the backend/ folder, not the project root. Python needs to find db.py in the same directory.
- Check that your Neo4j container is running. The driver attempts to connect on import. Run docker ps to confirm it is up.
- Confirm your .env file is in the project root (one level above backend/) and contains ANTHROPIC_API_KEY.
Help me debug why my agent module won't import.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
import os
import json
import anthropic
from db import run_query
from dotenv import load_dotenv
load_dotenv()
client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
TOOLS = [
{
"name": "check_interactions",
"description": "Check for known drug-drug interactions between a list of medications using the knowledge graph.",
"input_schema": {
"type": "object",
"properties": {
"drugs": {"type": "array", "items": {"type": "string"}, "description": "List of drug names to check interactions for"}
},
"required": ["drugs"]
}
},
{
"name": "get_adverse_events",
"description": "Retrieve FAERS adverse event report counts for a drug pair from the knowledge graph.",
"input_schema": {
"type": "object",
"properties": {
"drug1": {"type": "string"},
"drug2": {"type": "string"}
},
"required": ["drug1", "drug2"]
}
},
{
"name": "find_alternatives",
"description": "Find alternative drugs in the knowledge graph that do not interact with the given drug.",
"input_schema": {
"type": "object",
"properties": {
"drug": {"type": "string", "description": "Drug to find alternatives for"},
"avoid": {"type": "string", "description": "Drug that the alternative must not interact with"}
},
"required": ["drug", "avoid"]
}
}
]
def check_interactions(drugs: list[str]) -> dict:
results = []
for i in range(len(drugs)):
for j in range(i + 1, len(drugs)):
d1, d2 = drugs[i].lower(), drugs[j].lower()
rows = run_query(
"MATCH (a:Drug {name:$d1})-[r:INTERACTS_WITH]->(b:Drug {name:$d2}) RETURN r.severity as severity, r.description as description",
{"d1": d1, "d2": d2}
)
if not rows:
rows = run_query(
"MATCH (a:Drug {name:$d2})-[r:INTERACTS_WITH]->(b:Drug {name:$d1}) RETURN r.severity as severity, r.description as description",
{"d1": d1, "d2": d2}
)
results.append({"pair": f"{d1} + {d2}", "interactions": rows})
return {"interactions": results}
def get_adverse_events(drug1: str, drug2: str) -> dict:
rows = run_query(
"MATCH (ae:AdverseEvent {drug1:$d1, drug2:$d2}) RETURN ae.report_count as count",
{"d1": drug1.lower(), "d2": drug2.lower()}
)
return {"drug1": drug1, "drug2": drug2, "adverse_event_reports": rows[0]["count"] if rows else 0}
def find_alternatives(drug: str, avoid: str) -> dict:
rows = run_query(
"""
MATCH (d:Drug) WHERE d.name <> $drug AND d.name <> $avoid
AND NOT (d)-[:INTERACTS_WITH]-(:Drug {name:$avoid})
RETURN d.name as alternative LIMIT 5
""",
{"drug": drug.lower(), "avoid": avoid.lower()}
)
return {"alternatives": [r["alternative"] for r in rows]}
TOOL_FUNCTIONS = {
"check_interactions": lambda inp: check_interactions(inp["drugs"]),
"get_adverse_events": lambda inp: get_adverse_events(inp["drug1"], inp["drug2"]),
"find_alternatives": lambda inp: find_alternatives(inp["drug"], inp["avoid"]),
}
def run_agent(medications: list[str]) -> dict:
messages = [
{"role": "user", "content": f"Analyze the safety of this medication combination for a patient: {', '.join(medications)}. Check all interactions, retrieve adverse event data for any major or moderate interactions, suggest alternatives where needed, and produce a physician-ready clinical safety report."}
]
tool_calls_log = []
while True:
response = client.messages.create(
model="claude-opus-4-5",
max_tokens=4096,
tools=TOOLS,
messages=messages
)
if response.stop_reason == "end_turn":
report = "".join(block.text for block in response.content if hasattr(block, "text"))
return {"report": report, "tool_calls": tool_calls_log}
tool_results = []
for block in response.content:
if block.type == "tool_use":
tool_name = block.name
tool_input = block.input
result = TOOL_FUNCTIONS[tool_name](tool_input)
tool_calls_log.append({"tool": tool_name, "input": tool_input, "result": result})
tool_results.append({"type": "tool_result", "tool_use_id": block.id, "content": json.dumps(result)})
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})
Your agentic backend is ready. Claude can now autonomously investigate any combination of medications by chaining tool calls against your knowledge graph. Next up, you will expose this agent through FastAPI endpoints so the frontend can trigger analyses.
Create the FastAPI Endpoints
Your agentic AI backend can now autonomously investigate drug interactions using Claude's tool use pattern. The next piece is exposing that intelligence to the outside world through API endpoints.
In this step, you will build two FastAPI endpoints that serve as the bridge between your React frontend and the agentic backend. One endpoint triggers the full agentic analysis, and the other returns raw graph data for visualization.
In this step, get ready to:
- Build a POST /analyze endpoint that accepts a medication list and returns Claude's clinical report.
- Add a GET /graph endpoint that returns all drug nodes and interaction edges from Neo4j.
- Test both endpoints with a realistic multi-drug scenario.
Build the /analyze endpoint
The /analyze endpoint is the primary entry point for medication safety checks. It receives a list of drug names, passes them to the agentic loop you built in the previous step, and returns the clinical report along with a log of every tool call Claude made.
First, you need to expand the imports in main.py and define a Pydantic request model so FastAPI can validate incoming JSON bodies.
- In backend/main.py, find the existing import lines at the top of the file:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
- Replace those two lines with the following expanded imports:
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from agent import run_agent
from db import run_query
What do these new imports do?
- HTTPException lets you return proper HTTP error responses when input is invalid.
- BaseModel from Pydantic defines the expected shape of incoming JSON request bodies.
- run_agent is the agentic loop function you wrote in agent.py.
- run_query is the Neo4j helper from db.py that executes Cypher queries.
Now add the request model. This tells FastAPI exactly what shape the JSON body should have.
- Add this class between the CORS middleware block and the health_check function:
class AnalyzeRequest(BaseModel):
medications: list[str]
What does AnalyzeRequest do?
Pydantic's BaseModel automatically validates incoming JSON. If someone sends a request without a medications field (or sends it as the wrong type), FastAPI returns a 422 error with a helpful message.
Now you can add the endpoint itself.
- Add the /analyze endpoint below the health_check function:
@app.post("/analyze")
def analyze(request: AnalyzeRequest):
# Reject empty medication lists
if not request.medications:
raise HTTPException(status_code=400, detail="medications list cannot be empty")
# Run the agentic analysis loop
result = run_agent(request.medications)
return result
What does this endpoint do?
- The @app.post("/analyze") decorator registers this as a POST endpoint at the /analyze path.
- FastAPI automatically parses the JSON body into an AnalyzeRequest object.
- The endpoint calls run_agent() which triggers Claude's agentic tool use loop and returns both the clinical report and the tool call log.
- Save main.py.
You will not see a visible change yet. The next substep adds the /graph endpoint, and then you will test both together.
Add a /graph endpoint for frontend visualization
The React dashboard needs raw graph data to render an interaction network. This endpoint queries Neo4j directly for all Drug nodes and INTERACTS_WITH relationships, returning them in a format the frontend can consume.
- In backend/main.py, add the /graph endpoint below the /analyze endpoint you just wrote:
@app.get("/graph")
def get_graph():
# Fetch all drug nodes from the knowledge graph
nodes_rows = run_query("MATCH (d:Drug) RETURN d.name as id, d.name as label")
# Fetch all interaction relationships with severity metadata
edges_rows = run_query(
"MATCH (a:Drug)-[r:INTERACTS_WITH]->(b:Drug) RETURN a.name as source, b.name as target, r.severity as severity, r.description as description"
)
# Transform into frontend-friendly node/edge arrays
nodes = [{"id": r["id"], "label": r["label"]} for r in nodes_rows]
edges = [{"source": r["source"], "target": r["target"], "severity": r["severity"], "description": r["description"]} for r in edges_rows]
return {"nodes": nodes, "edges": edges}
What does the /graph endpoint return?
This endpoint returns two arrays. The nodes array contains every drug in your knowledge graph with an id and label. The edges array contains every interaction relationship with source, target, severity, and description fields.
This shape maps directly to what graph visualization libraries expect. The frontend can render drugs as circles and interactions as colored lines between them.
- Save main.py.
Seeing import errors when you save?
- Make sure your from agent import run_agent line is at the top. If agent.py is not in the same directory as main.py, the import will fail.
- Check that from db import run_query matches the function name in your db.py file exactly.
- Confirm your virtual environment is still active. Look for (venv) in your terminal prompt.
Help me debug an import error in my FastAPI main.py file.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py with all the changes.
ⓧ I'd like to double check the full code
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from agent import run_agent
from db import run_query
app = FastAPI(title="Drug Interaction Intelligence API")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class AnalyzeRequest(BaseModel):
medications: list[str]
@app.get("/health")
def health_check():
return {"status": "ok"}
@app.post("/analyze")
def analyze(request: AnalyzeRequest):
if not request.medications:
raise HTTPException(status_code=400, detail="medications list cannot be empty")
result = run_agent(request.medications)
return result
@app.get("/graph")
def get_graph():
nodes_rows = run_query("MATCH (d:Drug) RETURN d.name as id, d.name as label")
edges_rows = run_query(
"MATCH (a:Drug)-[r:INTERACTS_WITH]->(b:Drug) RETURN a.name as source, b.name as target, r.severity as severity, r.description as description"
)
nodes = [{"id": r["id"], "label": r["label"]} for r in nodes_rows]
edges = [{"source": r["source"], "target": r["target"], "severity": r["severity"], "description": r["description"]} for r in edges_rows]
return {"nodes": nodes, "edges": edges}
Test the API with sample medication combinations
With both endpoints in place, it is time to confirm they work with a realistic drug combination. You will start the FastAPI server and send requests to both endpoints.
- Start the FastAPI development server by running this command from the backend directory:
fastapi dev main.py
What does this command do?
The fastapi dev command starts your server with Uvicorn and enables auto-reload. Any time you save a file, the server restarts automatically.
- You should see output indicating the server is running at http://localhost:8000.
Now test the /graph endpoint first, since it does not require an API call to Claude.
- Open a new terminal tab (keep the server running) and test the /graph endpoint by running:
curl http://localhost:8000/graph
- You should see a JSON response with nodes and edges arrays containing the drugs and interactions from your knowledge graph.
Now test the /analyze endpoint with a common polypharmacy scenario.
- Send a POST request to the /analyze endpoint with three medications by running:
curl -X POST http://localhost:8000/analyze \
-H "Content-Type: application/json" \
-d '{"medications": ["warfarin", "aspirin", "omeprazole"]}'
What should I see?
This request may take 15-30 seconds. Claude is making multiple tool calls to your knowledge graph before generating its report.
The response contains two fields: report (Claude's physician-ready clinical safety report) and tool_calls (a log showing every tool Claude called, what inputs it used, and what results it received).
Getting a connection error or 500 response?
- If you see a connection refused error, confirm the FastAPI server is still running in the other terminal tab.
- A 500 error usually means your ANTHROPIC_API_KEY in .env is missing or invalid. Double check you pasted a valid key.
- If Neo4j is not running, the /graph endpoint and the agent's tool calls will fail. Confirm your Docker container is up with docker ps.
Help me debug a 500 error from my /analyze endpoint.
Your API is live and working. Next up, you will build the React TypeScript dashboard that calls these endpoints and displays interaction results in a user-friendly interface.
Build the React TypeScript Dashboard
Your backend can analyze medications and return clinical reports. Now you need a way for someone to actually use it.
In this step, you will build the React TypeScript frontend that lets a user type in drug names, submit them for analysis, and view Claude's clinical safety report along with the tool calls the agent made.
In this step, get ready to:
- Build a medication input component with add/remove drug chips.
- Build an interaction results display that shows the clinical report and agent tool calls.
- Wire the frontend to the FastAPI backend with typed fetch calls.
Create the medication input component
The medication input is the main interaction point for your dashboard. It needs to accept drug names one at a time, display them as removable chips, and send the full list to the backend when the user clicks Analyze.
- In your terminal, create the components directory inside frontend/src/ by running:
mkdir frontend/src/components
- Create a new file at frontend/src/components/MedicationInput.tsx and add the following code:
import { useState } from 'react';
interface Props {
onAnalyze: (medications: string[]) => void;
loading: boolean;
}
export function MedicationInput({ onAnalyze, loading }: Props) {
const [input, setInput] = useState('');
const [medications, setMedications] = useState<string[]>([]);
const addMedication = () => {
const trimmed = input.trim();
if (trimmed && !medications.includes(trimmed.toLowerCase())) {
setMedications([...medications, trimmed.toLowerCase()]);
setInput('');
}
};
const removeMedication = (med: string) => {
setMedications(medications.filter(m => m !== med));
};
return (
<div className="medication-input">
<h2>Enter Medications</h2>
<div className="input-row">
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addMedication()}
placeholder="Type a drug name and press Enter"
/>
<button onClick={addMedication}>Add</button>
</div>
<div className="chips">
{medications.map(med => (
<span key={med} className="chip">
{med}
<button onClick={() => removeMedication(med)}>×</button>
</span>
))}
</div>
<button
className="analyze-btn"
onClick={() => onAnalyze(medications)}
disabled={loading || medications.length < 2}
>
{loading ? 'Analyzing...' : 'Analyze Interactions'}
</button>
</div>
);
}
What does this component do?
This component manages a local list of medication names. Each time the user types a name and presses Enter (or clicks Add), it normalizes the name to lowercase and adds it to the medications array.
The disabled prop on the Analyze button ensures the user cannot submit fewer than 2 drugs. You need at least a pair to check for interactions.
- Save frontend/src/components/MedicationInput.tsx.
Seeing a TypeScript error in your editor?
If your editor shows an error about the Props interface, make sure you are inside the frontend/ project (not the backend). The file must be at frontend/src/components/MedicationInput.tsx.
help me fix a TypeScript error in my MedicationInput component
Build the interaction results display
The results component receives the analysis response from the backend and displays two things: Claude's clinical safety report and the log of tool calls the agent made during analysis.
Before building the component, you need to define the TypeScript interfaces that describe the shape of the API response.
- Create a new file at frontend/src/types.ts and add the following type definitions:
export interface AnalyzeRequest {
medications: string[];
}
export interface ToolCall {
tool: string;
input: Record<string, unknown>;
result: Record<string, unknown>;
}
export interface AnalyzeResponse {
report: string;
tool_calls: ToolCall[];
}
export interface GraphNode {
id: string;
label: string;
}
export interface ApiEdge {
source: string;
target: string;
severity: string;
description: string;
}
export interface GraphEdge extends ApiEdge {}
What are these types for?
These interfaces define the contract between your frontend and backend. AnalyzeResponse matches the JSON shape your /analyze endpoint returns. GraphNode and GraphEdge match the /graph endpoint shape.
TypeScript uses these to catch mistakes at compile time. If the backend response changes shape, your editor will flag every place that depends on the old shape.
- Save frontend/src/types.ts.
- Now create the results component at frontend/src/components/InteractionResults.tsx with this code:
import { AnalyzeResponse } from '../types';
interface Props {
result: AnalyzeResponse | null;
}
export function InteractionResults({ result }: Props) {
if (!result) return null;
return (
<div className="results">
<h2>Clinical Safety Report</h2>
<pre className="report">{result.report}</pre>
<h3>Agent Tool Calls ({result.tool_calls.length})</h3>
<ul>
{result.tool_calls.map((tc, i) => (
<li key={i}><strong>{tc.tool}</strong>: {JSON.stringify(tc.input)}</li>
))}
</ul>
</div>
);
}
What does this component do?
When result is null (no analysis has run yet), the component renders nothing. Once the backend returns a response, it displays the full clinical report as preformatted text and lists every tool call Claude made during analysis.
The tool calls log is valuable because it shows the agentic reasoning process. You can see Claude deciding to check interactions, pull adverse event data, and find alternatives on its own.
- Save frontend/src/components/InteractionResults.tsx.
Import error for AnalyzeResponse?
Make sure types.ts is saved at frontend/src/types.ts (one directory up from components/). The import path '../types' goes up one level from the components/ folder.
help me fix a TypeScript import error for AnalyzeResponse in my InteractionResults component
Connect the frontend to the FastAPI backend
The last piece connects everything together. Your App.tsx component manages the application state, calls the backend /analyze endpoint, and passes results down to the display component.
- Open frontend/src/App.tsx and replace its entire content with:
import { useState } from 'react';
import { MedicationInput } from './components/MedicationInput';
import { InteractionResults } from './components/InteractionResults';
import { AnalyzeResponse } from './types';
function App() {
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<AnalyzeResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const handleAnalyze = async (medications: string[]) => {
setLoading(true);
setError(null);
try {
const res = await fetch('http://localhost:8000/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ medications }),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
const data: AnalyzeResponse = await res.json();
setResult(data);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Unknown error');
} finally {
setLoading(false);
}
};
return (
<div className="app">
<h1>Drug Interaction Intelligence Platform</h1>
<MedicationInput onAnalyze={handleAnalyze} loading={loading} />
{error && <div className="error">{error}</div>}
<InteractionResults result={result} />
</div>
);
}
export default App;
What does this code do?
The handleAnalyze function sends a POST request to your FastAPI backend with the list of medications. It manages three states: loading (disables the button during analysis), result (stores the API response), and error (displays any failure message).
The try/catch/finally pattern ensures the loading spinner always stops, even if the request fails. TypeScript's AnalyzeResponse type guarantees the response object has the correct shape before your components try to render it.
- Save frontend/src/App.tsx.
- Switch to your browser at http://localhost:5173. You should see the heading "Drug Interaction Intelligence Platform" with a text input, an Add button, and a disabled Analyze Interactions button.
Page still showing the Vite template?
Make sure you saved all four files: types.ts, MedicationInput.tsx, InteractionResults.tsx, and App.tsx. Vite's hot reload picks up changes automatically, but only for saved files.
If you see a compile error in the terminal running npm run dev, check that the import paths match the file locations exactly. App.tsx imports from './components/MedicationInput' and './types' (no .tsx extension needed).
help me debug why my React dashboard is not rendering after replacing App.tsx
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files before moving on.
ⓧ I'd like to double check the full code
Here is the complete code for each frontend file at this point.
export interface AnalyzeRequest {
medications: string[];
}
export interface ToolCall {
tool: string;
input: Record<string, unknown>;
result: Record<string, unknown>;
}
export interface AnalyzeResponse {
report: string;
tool_calls: ToolCall[];
}
export interface GraphNode {
id: string;
label: string;
}
export interface ApiEdge {
source: string;
target: string;
severity: string;
description: string;
}
export interface GraphEdge extends ApiEdge {}
import { useState } from 'react';
interface Props {
onAnalyze: (medications: string[]) => void;
loading: boolean;
}
export function MedicationInput({ onAnalyze, loading }: Props) {
const [input, setInput] = useState('');
const [medications, setMedications] = useState<string[]>([]);
const addMedication = () => {
const trimmed = input.trim();
if (trimmed && !medications.includes(trimmed.toLowerCase())) {
setMedications([...medications, trimmed.toLowerCase()]);
setInput('');
}
};
const removeMedication = (med: string) => {
setMedications(medications.filter(m => m !== med));
};
return (
<div className="medication-input">
<h2>Enter Medications</h2>
<div className="input-row">
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addMedication()}
placeholder="Type a drug name and press Enter"
/>
<button onClick={addMedication}>Add</button>
</div>
<div className="chips">
{medications.map(med => (
<span key={med} className="chip">
{med}
<button onClick={() => removeMedication(med)}>×</button>
</span>
))}
</div>
<button
className="analyze-btn"
onClick={() => onAnalyze(medications)}
disabled={loading || medications.length < 2}
>
{loading ? 'Analyzing...' : 'Analyze Interactions'}
</button>
</div>
);
}
import { AnalyzeResponse } from '../types';
interface Props {
result: AnalyzeResponse | null;
}
export function InteractionResults({ result }: Props) {
if (!result) return null;
return (
<div className="results">
<h2>Clinical Safety Report</h2>
<pre className="report">{result.report}</pre>
<h3>Agent Tool Calls ({result.tool_calls.length})</h3>
<ul>
{result.tool_calls.map((tc, i) => (
<li key={i}><strong>{tc.tool}</strong>: {JSON.stringify(tc.input)}</li>
))}
</ul>
</div>
);
}
import { useState } from 'react';
import { MedicationInput } from './components/MedicationInput';
import { InteractionResults } from './components/InteractionResults';
import { AnalyzeResponse } from './types';
function App() {
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<AnalyzeResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const handleAnalyze = async (medications: string[]) => {
setLoading(true);
setError(null);
try {
const res = await fetch('http://localhost:8000/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ medications }),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
const data: AnalyzeResponse = await res.json();
setResult(data);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Unknown error');
} finally {
setLoading(false);
}
};
return (
<div className="app">
<h1>Drug Interaction Intelligence Platform</h1>
<MedicationInput onAnalyze={handleAnalyze} loading={loading} />
{error && <div className="error">{error}</div>}
<InteractionResults result={result} />
</div>
);
}
export default App;
Your frontend is live and connected. In the next step, you will run an end-to-end test with a real medication combination and watch Claude autonomously chain tool calls before generating its clinical report.
Test End-to-End and Polish
Your backend, frontend, and knowledge graph are all wired together. The last step before your platform is demo-ready is a real-world validation.
You will run a realistic polypharmacy scenario through the full system, verify that Claude autonomously chains multiple tool calls, and then add the visual polish that makes this feel production-quality.
In this step, get ready to:
- Test the full analysis flow with a Warfarin + Aspirin + Omeprazole scenario.
- Verify Claude makes multiple tool calls before generating its clinical report.
- Add loading states, severity color indicators, and CSS styling for a polished demo.
Run the polypharmacy test scenario
Warfarin, Aspirin, and Omeprazole are commonly co-prescribed medications with well-documented interactions. This makes them an ideal test case because your knowledge graph should contain interaction data between these drugs.
- Switch back to your browser with the frontend at http://localhost:5173.
- Type warfarin in the medication input field and press Enter.
- Type aspirin and press Enter.
- Type omeprazole and press Enter.
- Click Analyze Interactions.
The analysis takes 15-30 seconds because Claude is making multiple tool calls behind the scenes. When it finishes, you should see a clinical safety report and a list of tool calls that Claude made.
Seeing a network error or timeout?
Confirm your FastAPI backend is still running in the terminal at http://localhost:8000. Check that your Neo4j container is running with docker ps.
If you see an API key error, verify your ANTHROPIC_API_KEY in the .env file is correct and that the backend loaded it on startup.
Help me debug my end-to-end test for the drug interaction platform
Verify Claude's agentic tool-calling behavior
The key differentiator of an agentic system is that the AI decides which tools to call and in what order. Claude should not just make one call. It should chain multiple calls based on what it learns from each result.
- Scroll down to the Agent Tool Calls section in the results.
- Confirm you see multiple tool calls listed. Claude typically calls check_interactions first, then get_adverse_events for flagged pairs, then find_alternatives for severe interactions.
- Read the clinical report above the tool calls. It should reference specific interaction data that came from your knowledge graph.
Why does the tool call order matter?
Claude is not following a script. It reads each tool result and decides what to investigate next. If check_interactions returns a major severity interaction, Claude chooses to call get_adverse_events for that pair to get more evidence.
This is the core agentic AI pattern. The LLM autonomously orchestrates structured data lookups to ground its final report in verified facts from your knowledge graph.
Add styling and loading indicators for a polished experience
The analysis takes several seconds while Claude chains its tool calls. Without visual feedback, users would think the app is broken. A loading indicator and consistent styling transform this from a prototype into something you can confidently demo.
- Create a new file called App.css inside the frontend/src folder. Add the following styles:
/* Main layout */
.app { max-width: 900px; margin: 0 auto; padding: 2rem; font-family: sans-serif; }
/* Medication input section */
.medication-input { background: #f7fafc; padding: 1.5rem; border-radius: 8px; margin-bottom: 1.5rem; }
.input-row { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.input-row input { flex: 1; padding: 0.5rem; border: 1px solid #cbd5e0; border-radius: 4px; }
/* Medication chips */
.chips { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; }
.chip { background: #bee3f8; padding: 0.25rem 0.75rem; border-radius: 999px; display: flex; align-items: center; gap: 0.5rem; }
.chip button { background: none; border: none; cursor: pointer; font-size: 1rem; }
/* Analyze button */
.analyze-btn { background: #3182ce; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 6px; cursor: pointer; font-size: 1rem; }
.analyze-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Loading and error states */
.loading { color: #3182ce; margin: 1rem 0; }
.error { color: #e53e3e; margin: 1rem 0; }
/* Results display */
.results { background: #f0fff4; padding: 1.5rem; border-radius: 8px; }
.report { white-space: pre-wrap; background: white; padding: 1rem; border-radius: 4px; border: 1px solid #c6f6d5; font-size: 0.9rem; line-height: 1.6; }
What does this CSS do?
The styles create a centered, max-width layout with distinct visual sections. The medication input area has a light gray background, chips use blue pill shapes, and the results section uses a green-tinted background to visually separate the AI output.
The .loading and .error classes use blue and red colors respectively to give immediate visual feedback during analysis.
- Save App.css.
Next, you need to import this stylesheet and add a loading message that appears while Claude is working.
- In App.tsx, find this line at the top of the imports:
import { AnalyzeResponse } from './types';
- Add this import directly below it:
import './App.css';
- Now find this line in the JSX return:
{error && <div className="error">{error}</div>}
- Add this loading indicator directly above it (between the MedicationInput component and the error div):
{loading && <div className="loading">Analyzing medications with AI...</div>}
What does this loading indicator do?
The loading state is already set to true when the user clicks Analyze and back to false when the response arrives. This conditional rendering shows a blue message during those 15-30 seconds so the user knows the AI is working.
- Save App.tsx.
Finally, add severity color coding to the tool calls display so each tool name is visually distinct.
- In InteractionResults.tsx, find this line at the top:
import { AnalyzeResponse } from '../types';
- Add this severity color map directly below the import:
const SEVERITY_COLORS: Record<string, string> = {
major: '#e53e3e',
moderate: '#dd6b20',
minor: '#38a169',
};
- Now find this line inside the map function:
<li key={i}><strong>{tc.tool}</strong>: {JSON.stringify(tc.input)}</li>
- Replace that line with this version that applies color to the tool name:
<li key={i}>
<strong style={{ color: SEVERITY_COLORS['moderate'] }}>{tc.tool}</strong>: {JSON.stringify(tc.input)}
</li>
What do the severity colors represent?
The SEVERITY_COLORS map assigns red to major interactions, orange to moderate, and green to minor. This color-coding gives immediate visual priority to the most dangerous combinations.
- Save InteractionResults.tsx.
- Return to your browser at http://localhost:5173. The page should hot-reload with the new styling applied.
- Run the analysis again with the same three medications. You should see a blue "Analyzing medications with AI..." message while it loads, followed by the styled results with orange-colored tool call names.
Styles not showing up?
Make sure the import './App.css' line is present in App.tsx. Without this import, Vite will not bundle the CSS file.
Check that App.css is saved in frontend/src/ (the same directory as App.tsx), not in the project root.
Help me fix my CSS not loading in my React Vite project
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is the complete code for all three modified frontend files:
.app { max-width: 900px; margin: 0 auto; padding: 2rem; font-family: sans-serif; }
.medication-input { background: #f7fafc; padding: 1.5rem; border-radius: 8px; margin-bottom: 1.5rem; }
.input-row { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.input-row input { flex: 1; padding: 0.5rem; border: 1px solid #cbd5e0; border-radius: 4px; }
.chips { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; }
.chip { background: #bee3f8; padding: 0.25rem 0.75rem; border-radius: 999px; display: flex; align-items: center; gap: 0.5rem; }
.chip button { background: none; border: none; cursor: pointer; font-size: 1rem; }
.analyze-btn { background: #3182ce; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 6px; cursor: pointer; font-size: 1rem; }
.analyze-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.loading { color: #3182ce; margin: 1rem 0; }
.error { color: #e53e3e; margin: 1rem 0; }
.results { background: #f0fff4; padding: 1.5rem; border-radius: 8px; }
.report { white-space: pre-wrap; background: white; padding: 1rem; border-radius: 4px; border: 1px solid #c6f6d5; font-size: 0.9rem; line-height: 1.6; }
import { useState } from 'react';
import { MedicationInput } from './components/MedicationInput';
import { InteractionResults } from './components/InteractionResults';
import { AnalyzeResponse } from './types';
import './App.css';
function App() {
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<AnalyzeResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const handleAnalyze = async (medications: string[]) => {
setLoading(true);
setError(null);
try {
const res = await fetch('http://localhost:8000/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ medications }),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
const data: AnalyzeResponse = await res.json();
setResult(data);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Unknown error');
} finally {
setLoading(false);
}
};
return (
<div className="app">
<h1>Drug Interaction Intelligence Platform</h1>
<MedicationInput onAnalyze={handleAnalyze} loading={loading} />
{loading && <div className="loading">Analyzing medications with AI...</div>}
{error && <div className="error">{error}</div>}
<InteractionResults result={result} />
</div>
);
}
export default App;
import { AnalyzeResponse } from '../types';
const SEVERITY_COLORS: Record<string, string> = {
major: '#e53e3e',
moderate: '#dd6b20',
minor: '#38a169',
};
interface Props {
result: AnalyzeResponse | null;
}
export function InteractionResults({ result }: Props) {
if (!result) return null;
return (
<div className="results">
<h2>Clinical Safety Report</h2>
<pre className="report">{result.report}</pre>
<h3>Agent Tool Calls ({result.tool_calls.length})</h3>
<ul>
{result.tool_calls.map((tc, i) => (
<li key={i}>
<strong style={{ color: SEVERITY_COLORS['moderate'] }}>{tc.tool}</strong>: {JSON.stringify(tc.input)}
</li>
))}
</ul>
</div>
);
}
Secret mission
Add an Interactive Graph Visualization
Your platform analyzes drug interactions, but wouldn't it be more impressive if users could SEE the interaction network? In this secret mission, you'll build an interactive node-link diagram that renders drugs as clickable nodes and color-codes edges by severity. Clicking a drug reveals its adverse event connections in a side panel.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
The Neo4j Docker container runs locally and costs nothing, but it does consume RAM and disk space while active. Your Anthropic API usage for this project should be minimal ($2-5 total for development and testing).
Cost warning
Your Anthropic API key remains active after this project. Each interaction analysis costs approximately $0.02-0.05. If you are not planning to continue development, consider monitoring your usage at console.anthropic.com.
Resources you used:
- Neo4j Docker container (neo4j-drug-interactions).
- Neo4j Docker volume (neo4j-drug-data).
- Backend project files (backend/ folder, Python virtual environment, .env file).
- Frontend project files (frontend/ folder with React app and dependencies).
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with the platform.
- Your Neo4j container continues running in Docker and uses RAM while active.
- Your Anthropic API key stays active. Monitor usage at console.anthropic.com.
- Your project files remain on your Desktop for further development.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files and data stay intact so you can resume later.
- Stop the Neo4j Docker container by running:
docker stop neo4j-drug-interactions
- Stop the FastAPI dev server by pressing Ctrl+C in its terminal.
- Stop the Vite dev server by pressing Ctrl+C in its terminal.
- When you are ready to resume, restart the container by running:
docker start neo4j-drug-interactions
Then start the backend and frontend dev servers again from their respective folders.
Delete - I don't want to use this again
Remove all project resources and start fresh. This cannot be undone.
- Remove the Neo4j Docker container and its data volume by running:
docker rm -f neo4j-drug-interactions
docker volume rm neo4j-drug-data
- Delete your project folder (the folder on your Desktop that contains backend/ and frontend/). On macOS or Linux, run:
cd ~/Desktop
rm -rf drug-interaction-platform
- On Windows, run:
cd ~\Desktop
rmdir /s /q drug-interaction-platform
- Verify the Docker container is gone by running:
docker ps -a | grep neo4j-drug-interactions
You should see no output. If a container still appears, re-run the docker rm -f command above.
Nice Work!
Nice Work!
You just built an AI-Powered Drug Interaction Intelligence Platform from scratch. This full-stack application combines a Neo4j knowledge graph, agentic AI with Claude tool use, and a modern React TypeScript dashboard into a cohesive, portfolio-ready system.
You've learned how to:
- Built a medical knowledge graph in Neo4j seeded with real FDA drug interaction and adverse event data from the OpenFDA API.
- Implemented the agentic AI pattern using Claude's tool use capability, where the model autonomously chains multiple tool calls to investigate medication safety before generating a clinical report.
- Created a full-stack application with a FastAPI backend and React TypeScript frontend that displays interaction analysis and AI-generated clinical reports.
- Added an interactive graph visualization that renders the drug interaction network as a node-link diagram with severity-coded edges and clickable drug nodes.
Ready to quiz yourself?