See the shortage behind the shortage.
When a drug goes short, a hospital pharmacy usually finds out one product at a time. Fontrel follows the shortage to the plant listed on the drug's FDA label, finds the other products on your formulary made at that same plant, and lists candidate alternatives with no plant in common. A pharmacist approves or rejects each one, and Fontrel remembers that decision the next time the same drug is short.
It runs on a FalkorDB graph built from public FDA data.
Live: fontrel.midelabs.xyz. The hospitals in it are synthetic, and the FDA data is real.
On October 17, 2026, the FDA shortage list included Eugia US bupivacaine injection (NDC 55150-172). Its FDA label lists one manufacturing plant: Eugia Pharma Specialities Limited (DUNS 650498244). Out of 292 plants in the graph, that one ranks first by PageRank.
For the synthetic Mercy General Hospital, a trace on that shortage finds:
- 69 formulary products made at the same plant, 31 of them with no shortage notice. 11 are sold under other company names, including Aurobindo ketorolac and McKesson SKY heparin. A shortage list shows you the bupivacaine. The graph shows you the ketorolac and heparin too. That's shared exposure worth a look, not proof they'll run short.
- Four alternatives worth a first look: NorthStar Rx 72603-700 and 72603-800, and OneSource 83270-164 and 83270-167. All four are on formulary, have no shortage notice, and list their own plants.
- The hospital's memory: in July, Mercy approved Hospira 0409-1165 for this drug. That product is now in shortage itself, so it's listed below the four above.
You can check the plant yourself: open the Aurobindo ketorolac label on DailyMed and look at the establishment section.
- Trace the plant. Reads the shortage, then walks from the product to the plant listed on its FDA label.
- Find what else is exposed. Finds every product on the hospital's formulary made at that plant, flagging the ones that have no shortage notice yet.
- List candidate alternatives. Finds products from other companies with the same ingredients at the same strengths, the same form and the same route. Each candidate shows that evidence and its label link, and falls into one of three groups: no shared plant on record, shares a plant, or plant overlap unknown (when either label lists no plant). These are candidates for review, not confirmed therapeutic equivalents.
- Remember decisions. A pharmacist approves or rejects a candidate. The decision is saved in that hospital's own graph. On the next trace, candidates the team rejected before are listed lower, past approvals are shown with their outcome, and the agent recalls them.
- Show its work. Every step lists the Cypher query it ran, how many rows came back, and how long it took.
The app has four screens: a shortage inbox, the trace for one shortage, the hospital's decision memory, and a ranking of the plants that the most products depend on.
- Agents That Act on Connected Data
- Agent Memory and Coordination
- One graph per hospital. Each hospital's formulary, decisions and saved traces live in their own FalkorDB graph (
tenant_mercy,tenant_lakeside). The sharedsupplygraph holds public FDA data, and the two join on product NDC. Separating hospitals takes no filtering code, and no query can reach across them. - Multi-hop paths are the product. Shortage, product, plant, then every other product at that plant is one Cypher pattern. In a table database it's a chain of joins across 4,476 products and 7,149 label-to-plant links.
- Graph algorithms in the database.
algo.pageRankranks plants by how many products depend on them, andalgo.WCCgroups plants that share products. Both run inside FalkorDB under its 1,000 ms query timeout. - Every step is visible. The trace screen shows each Cypher query, its row count and its time in FalkorDB.
flowchart LR
user["Pharmacist<br/>(browser)"] --> web["Next.js app<br/>Vercel"]
web -- "/api/* over HTTPS" --> api["FastAPI<br/>trace stream (SSE)"]
api --> agent["Agent loop"]
agent <-- "tool calls" --> llm["Groq<br/>gpt-oss-120b"]
agent --> tools["Read-only<br/>graph tools"]
api -- "approve / reject<br/>(the only write)" --> tenant
subgraph falkor["FalkorDB"]
supply[("supply graph<br/>products, plants,<br/>shortages, suppliers")]
tenant[("tenant_<hospital> graphs<br/>formulary, decisions,<br/>saved traces")]
end
tools --> supply
tools --> tenant
subgraph sources["Public data (ingest)"]
ofda["openFDA<br/>shortages + NDC"]
dm["DailyMed<br/>label plants"]
dmf["FDA Drug<br/>Master Files"]
end
ofda --> supply
dm --> supply
dmf --> supply
- Backend: Python and FastAPI. Traces stream to the browser step by step as server-sent events.
- Agent: a plain tool-calling loop on an OpenAI-compatible API, using Groq's
openai/gpt-oss-120b. The model chooses which graph tools to call. The tools are read-only, and the hospital is bound on the server, so the model can't query another hospital's graph. - Required steps: the exposure, candidate and memory queries always run. If the model answers early, the server runs whatever it skipped before the trace is complete.
- Answer checks: each list in the model's answer is checked against the query that owns it. An exposed product must come from the exposure query and a candidate from the candidate query, and a candidate's status always comes from the graph, never from the model. Anything else is dropped. The written summary is labeled as the model's and isn't checked fact by fact, so the app asks you to check it against the lists.
- No model? No problem: if the model is unavailable, the same queries run in a fixed order and the answer is built straight from the graph.
- Cache: complete traces are saved in the hospital's graph and replayed until that hospital's decisions change or the data is reloaded.
- Limits: at most two fresh traces run at once (
MAX_FRESH_TRACES), never two for the same hospital and shortage. If the browser leaves, the trace stops before its next query or model call. - Frontend: Next.js and Tailwind.
Fontrel uses one shared graph for public data and one graph per hospital. The hospital graphs never query each other. They join to the shared graph on product NDC.
| Node | Key properties | Source |
|---|---|---|
Product |
product_ndc, generic_name, labeler, dosage_form, route |
openFDA NDC directory |
Ingredient |
name |
openFDA NDC directory |
Labeler |
name |
openFDA NDC directory |
Site |
duns, name |
DailyMed label establishments |
Supplier |
name |
FDA Drug Master File list |
Shortage |
id, generic_name, company, availability, updated |
openFDA drug shortages |
IngestRun |
source, records, fetched, at |
When each source was downloaded and when the graph was built from it |
| Relationship | From → To | Meaning |
|---|---|---|
CONTAINS |
Product → Ingredient | Active ingredient |
LABELED_BY |
Product → Labeler | Company on the label |
MADE_AT |
Product → Site | Plant on the FDA label, with operation (manufacture, pack, label and so on) and the label setid |
MANUFACTURED_AT |
Product → Site | The MADE_AT links where the operation is manufacture. Used by the graph algorithms. |
AFFECTS |
Shortage → Product | The shortage notice covers this product |
EQUIVALENT_TO |
Product ↔ Product | Same ingredients at the same strength each, same form and route, different company. Products with any missing strength are never paired. |
CANDIDATE_SUPPLIER |
Supplier → Ingredient | A Drug Master File for the ingredient, with dmf number and weight 0.3. This is a registration, not confirmed supply. |
| Node | Meaning |
|---|---|
Hospital |
The hospital |
FormularyItem |
A product the hospital stocks, keyed by product_ndc |
Episode |
One shortage the hospital dealt with |
Decision |
A pharmacist's approve or reject, with reason, name and date |
Trace |
A saved trace, replayed while the hospital's decisions are unchanged |
Relationships: (Episode)-[:FOR]->(FormularyItem), (Decision)-[:IN]->(Episode) and (Decision)-[:CHOSE]->(FormularyItem).
These are the queries the product depends on, copied from backend/fontrel/tools.py. All of them are parameterised and read-only.
Q1. Products made at the same plant as the shortage product (what else is exposed)
MATCH (s:Shortage {id: $sid})-[:AFFECTS]->(p:Product)-[m1:MADE_AT {operation: 'MANUFACTURE'}]->(site:Site)
<-[m2:MADE_AT {operation: 'MANUFACTURE'}]-(other:Product)
WHERE other.product_ndc IN $formulary AND other.product_ndc <> p.product_ndc
OPTIONAL MATCH (other)<-[:AFFECTS]-(os:Shortage)
RETURN DISTINCT other.product_ndc AS product_ndc, other.generic_name AS product, other.labeler AS labeler,
site.name AS via_site, site.duns AS duns, m1.setid AS shortage_label_setid,
m2.setid AS exposed_label_setid, count(DISTINCT os) > 0 AS already_in_shortage,
[p.product_ndc, site.name, other.product_ndc] AS path
ORDER BY already_in_shortage, labelerQ3. Candidate alternatives, compared with the shortage product's plants
MATCH (s:Shortage {id: $sid})-[:AFFECTS]->(p:Product)-[:EQUIVALENT_TO]-(alt:Product)
OPTIONAL MATCH (p)-[:MANUFACTURED_AT]->(bad:Site)<-[:MANUFACTURED_AT]-(alt)
OPTIONAL MATCH (alt)-[am:MADE_AT {operation: 'MANUFACTURE'}]->(own:Site)
OPTIONAL MATCH (alt)<-[:AFFECTS]-(altsh:Shortage)
OPTIONAL MATCH (alt)-[ac:CONTAINS]->(ai:Ingredient)
RETURN DISTINCT alt.product_ndc AS product_ndc, alt.generic_name AS product, alt.labeler AS labeler,
alt.dosage_form AS dosage_form, alt.route AS route, alt.spl_set_id AS label_setid,
collect(DISTINCT {name: ai.name, strength: ac.strength}) AS ingredients,
collect(DISTINCT bad.name) AS shared_sites,
collect(DISTINCT {site: own.name, setid: am.setid}) AS own_sites,
count(DISTINCT altsh) > 0 AS in_shortageTwo small follow-up queries supply the rest. One counts the shortage product's recorded plants. The other reads the hospital's last decision on each candidate:
// tenant graph
MATCH (d:Decision)
WITH d.substitute_ndc AS substitute_ndc, d ORDER BY d.at DESC
WITH substitute_ndc, collect(d)[0] AS last
RETURN substitute_ndc, last.action AS action, last.reason AS reason, last.outcome AS outcome,
last.outcome_days AS outcome_days, last.at AS atEach candidate is then labeled shares a plant (a shared plant was found), no shared plant on record (both products list manufacturing plants and none overlap) or plant overlap unknown (either label lists no plant). Within each group, candidates the hospital rejected before are listed lower.
Q2, Q4, Q5 and Q6: shared ingredients, plant ranking, plant groups and past decisions
Q2. Formulary products sharing an ingredient, with registered suppliers
MATCH (s:Shortage {id: $sid})-[:AFFECTS]->(p:Product)-[:CONTAINS]->(i:Ingredient)
OPTIONAL MATCH (i)<-[c:CANDIDATE_SUPPLIER]-(sup:Supplier)
WITH p, i, count(DISTINCT sup) AS supplier_count, collect(DISTINCT c.dmf)[0..5] AS sample_dmfs
MATCH (i)<-[:CONTAINS]-(other:Product)
WHERE other.product_ndc IN $formulary AND other.product_ndc <> p.product_ndc
RETURN i.name AS ingredient, supplier_count, sample_dmfs,
collect(DISTINCT {product_ndc: other.product_ndc, product: other.generic_name,
labeler: other.labeler}) AS formulary_productsQ4. Plants ranked by dependence (PageRank)
CALL algo.pageRank(null, 'MANUFACTURED_AT') YIELD node, score
WITH node, score WHERE node:Site
MATCH (node)<-[:MANUFACTURED_AT]-(p:Product)
OPTIONAL MATCH (p)<-[:AFFECTS]-(sh:Shortage)
RETURN node.name AS site, node.duns AS duns, score AS pagerank,
count(DISTINCT p) AS products, count(DISTINCT p.labeler) AS labelers,
count(DISTINCT sh) AS shortages
ORDER BY pagerank DESC LIMIT $limitQ5. Plant groups touching a formulary (weakly connected components)
CALL algo.WCC({nodeLabels: ['Product', 'Site'], relationshipTypes: ['MANUFACTURED_AT']})
YIELD node, componentId
WITH componentId, collect(node) AS members
WITH componentId, [n IN members WHERE n:Site | n.name] AS sites,
[n IN members WHERE n:Product AND n.product_ndc IN $formulary | n.product_ndc] AS formulary_products
WHERE size(sites) > 1 AND size(formulary_products) > 0
RETURN componentId AS cluster, size(sites) AS site_count, sites[0..6] AS sites,
size(formulary_products) AS formulary_count, formulary_products[0..12] AS formulary_sample
ORDER BY formulary_count DESC LIMIT $limitQ6. A hospital's past decisions for the same ingredient (two steps: shared graph, then hospital graph)
// supply graph
MATCH (s:Shortage {id: $sid})-[:AFFECTS]->(:Product)-[:CONTAINS]->(i:Ingredient)<-[:CONTAINS]-(r:Product)
RETURN collect(DISTINCT r.product_ndc) AS ndcs
// tenant graph
MATCH (e:Episode)-[:FOR]->(f:FormularyItem) WHERE f.product_ndc IN $ndcs
MATCH (d:Decision)-[:IN]->(e)
OPTIONAL MATCH (d)-[:CHOSE]->(sub:FormularyItem)
RETURN e.id AS episode, e.shortage_label AS episode_label, e.opened AS opened, e.closed AS closed,
d.id AS decision, d.action AS action, d.substitute_ndc AS substitute_ndc,
sub.generic_name AS substitute, sub.labeler AS substitute_labeler,
d.reason AS reason, d.outcome AS outcome, d.outcome_days AS outcome_days,
d.synthetic AS synthetic, d.by AS by, d.at AS at
ORDER BY e.opened DESC, d.idThe only write in the product is a pharmacist's decision, POST /api/decisions. It uses MERGE, so saving the same decision twice updates one node instead of creating two. To clear decisions made through the app and return both hospitals to their seeded memory, run python -m ingest.reset_demo. Saved traces stay valid after a reset.
| Source | Used for | Notes |
|---|---|---|
| openFDA drug shortages | Current shortages | Public, no key needed |
| openFDA NDC directory | Products, ingredients, labelers | Public |
| DailyMed | Manufacturing plants from each label's establishment section | Public. Not every label lists its plants. |
| FDA Drug Master File list | Registered ingredient suppliers | Public. Registrations, not confirmed supply, and the list has no country column. |
Fontrel tracks 38 ingredients that hospitals often see in shortage, including bupivacaine, lidocaine and heparin. The list is in backend/ingest/drugs.yaml. The live deployment was loaded on October 17, 2026:
| Count | |
|---|---|
| Current FDA shortage records | 974 |
| Products | 4,476 |
| DailyMed labels read | 7,030 |
| Manufacturing plants | 292 |
| Product pairs with matching ingredients, strength, form and route | 32,755 |
| Drug Master File supplier registrations | 476, from 244 suppliers |
Counts change slightly whenever the data is reloaded.
The two hospitals, Mercy General (150 formulary items, 113 shortage records) and Lakeside Community (80 items, 52 records), are synthetic. Their formularies and past decisions were generated for this project and are labeled synthetic in the app. No patient or hospital data is used.
- Fontrel supports a pharmacist's decision. It doesn't choose a drug or a dose, and a candidate alternative is not a confirmed therapeutic equivalent. The FDA Orange Book sets that bar.
- A shared plant means shared exposure to review. It doesn't prove the plant caused the shortage or that other products will run short.
- The public demo is shared, so a decision one visitor saves is visible to the next. The live server clears app-made decisions every night with
ingest.reset_demo. - There is no sign-in. Each hospital's data lives in its own graph and the agent only reads the hospital it was started for, but anyone using this demo can switch hospitals. A real deployment needs sign-in before each hospital's decisions are private.
- It doesn't forecast shortages. It works from what is filed today.
- Plant data is only as complete as the FDA labels. Products with no listed plant are shown as "no site on file", never as safe.
- Drug Master File suppliers are registrations. Fontrel doesn't claim anyone is actively supplying.
- It doesn't rate countries or regions, because the public data has no reliable country field.
- Fontrel records approve and reject decisions. It doesn't record outcomes or close an episode yet, so outcomes like "held 42 days" appear only on the synthetic seed records.
- Groq's free tier allows about one fresh trace per minute. Saved traces replay instantly.
npm auditreports a high-severity advisory inbraces, used only by the ESLint tooling. No patched version exists yet, and the shipped app has no reported vulnerabilities (npm audit --omit=dev).- The API allows browser calls only from
http://localhost:3100by default. SetCORS_ORIGINSwhen you deploy it elsewhere.
Fontrel works at a different layer from supply maps such as the USP Medicine Supply Map. It takes the plant-level picture down to one hospital's formulary, proposes an action, and remembers what that hospital decided.
| Method | Path | What it returns |
|---|---|---|
| GET | /api/health |
Database status, node counts, and when each source was downloaded and loaded |
| GET | /api/tenants |
The hospitals |
| GET | /api/shortages?tenant= |
Current FDA shortages on that hospital's formulary |
| POST | /api/trace |
A trace, streamed step by step |
| GET | /api/graph/subgraph?tenant=&shortage_id= |
Up to 25 nodes for the graph view |
| POST | /api/decisions |
Saves an approve or reject |
| GET | /api/recall?tenant=&shortage_id= |
The past decisions a trace would recall |
| GET | /api/memory?tenant= |
Every episode and decision for a hospital |
| GET | /api/sources?tenant= |
The PageRank plant ranking and the plant groups |
| GET | /api/ingredients |
Shortage and supplier counts per ingredient |
| GET | /api/anchor?tenant= |
A current shortage with exposure across companies |
backend/
fontrel/ API, agent loop, graph tools, trace cache
ingest/ data loaders for openFDA, DailyMed and the DMF list, plus the synthetic hospitals
tests/ 68 tests, most against a live FalkorDB
frontend/
src/app/ landing page and the app screens
src/components/ shared header, logo and landing sections
docker-compose.yml FalkorDB with append-only persistence, bound to localhost
This takes about 10 minutes on a fresh machine. Most of that is the first data download.
| Tool | Version | Get it |
|---|---|---|
| Docker | Any recent version, with Docker Compose | docker.com/get-started |
| Python | 3.11 or newer | python.org/downloads |
| Node.js | 20 or newer | nodejs.org |
| Groq API key | Optional, free | console.groq.com/keys |
Without a Groq key the app still works. Every list, path and graph comes from FalkorDB. You just won't get the short written summary at the top of each trace.
Run these from a terminal. On Windows, use WSL or Git Bash. The one Windows-specific difference is noted in step 3.
1. Get the code and start FalkorDB
git clone https://github.com/mystiquemide/fontrel.git
cd fontrel
docker compose up -dFalkorDB now runs on port 6379, reachable only from your machine. Its browser UI is at http://localhost:3000 if you want to look at the graph directly.
2. Add your settings
cp .env.example .envOpen .env and paste your Groq key after GROQ_API_KEY=. Leave it empty to run without the written summary.
3. Install the backend
python3 -m venv .venv
source .venv/bin/activate
pip install -r backend/requirements.txtOn Windows PowerShell, activate with .venv\Scripts\Activate.ps1 instead.
4. Load the data
cd backend
python -m ingest.run
python -m ingest.synthingest.run downloads current FDA data and builds the supply graph. It takes about two minutes and caches the files in data/raw, so later runs are faster. To pull fresh data later, run python -m ingest.run --refresh. Without it, the load reuses the downloaded files. Each load replaces what the graph held: shortages, products, ingredients, plants and suppliers that left the source are removed. ingest.synth creates the two synthetic hospitals.
5. Start the API
uvicorn fontrel.api:app --port 8000Leave this running. Check it at http://localhost:8000/api/health.
6. Start the app (in a second terminal)
cd fontrel/frontend
npm install
npm run devOpen http://localhost:3100. Select Open the app, then select Trace on any shortage.
| What you see | What to do |
|---|---|
Cannot connect to the Docker daemon |
Start Docker Desktop, or run sudo systemctl start docker on Linux, then run docker compose up -d again. |
The API says falkordb: false |
FalkorDB isn't running. Run docker compose up -d from the project folder. |
| The app shows "Fontrel cannot reach its graph database" | The API isn't running. Start it as in step 5. |
| The inbox shows no shortages | The data isn't loaded. Run step 4. |
| Traces say "The AI model is busy" | No Groq key, or you hit Groq's free rate limit. The lists still come straight from the graph. Wait a minute and select Run a fresh trace. |
Port 8000 is already in use |
Start the API on another port, for example --port 8001, and start the app with BACKEND_URL=http://localhost:8001 npm run dev. |
cd backend
python -m pytest -q68 tests. Most run against a live FalkorDB loaded with real FDA data, and the agent tests replace the AI model with a scripted stand-in. They cover data loading and reloading, strength matching, every query, tenant separation, the agent's guardrails, cancellation, the trace cache and the API. Because they use live FDA data, a test can fail if a record it relies on leaves the FDA list.
The frontend runs on Vercel. The backend (FalkorDB, the API and Caddy for HTTPS) runs on any Linux server with Docker, such as a $5 to $12 VPS with 2 GB of RAM.
You need a hostname for the API. If you don't have a domain, <server-ip>.sslip.io works, for example 203.0.113.7.sslip.io. Open ports 80 and 443.
git clone https://github.com/mystiquemide/fontrel.git && cd fontrel
cp .env.example .env # add GROQ_API_KEY, API_DOMAIN and CORS_ORIGINS (see below)
docker compose -f deploy/docker-compose.yml --env-file .env up -d --build
docker compose -f deploy/docker-compose.yml exec api sh -c "python -m ingest.run && python -m ingest.synth"
curl https://$API_DOMAIN/api/healthSettings for the server's .env:
| Variable | Example |
|---|---|
GROQ_API_KEY |
your key |
API_DOMAIN |
203.0.113.7.sslip.io |
CORS_ORIGINS |
https://your-app.vercel.app |
FalkorDB and the API are only reachable inside Docker. Caddy is the only public entry point and gets the HTTPS certificate on its own.
fda.gov blocks some server IPs. If the load prints skipped the FDA Drug Master File list, download the file in a browser from the link it prints, copy it to the server, and load it:
docker compose -f deploy/docker-compose.yml cp dmf-list.xlsx api:/tmp/dmf.xlsx
docker compose -f deploy/docker-compose.yml exec api python -m ingest.dmf_file /tmp/dmf.xlsxIf the server already runs Coolify, its Traefik proxy holds ports 80 and 443. Use the Coolify file instead, which skips Caddy and lets Traefik route API_DOMAIN to the API and issue the certificate:
docker compose -f deploy/docker-compose.coolify.yml --env-file .env up -d --build
docker compose -f deploy/docker-compose.coolify.yml exec api sh -c "python -m ingest.run && python -m ingest.synth"For a public demo, clear visitor decisions nightly with a cron entry on the server (run crontab -e, use the compose file you deployed with):
0 3 * * * cd /root/fontrel && docker compose -f deploy/docker-compose.yml exec -T api python -m ingest.reset_demo
Import the repository in Vercel, set the root directory to frontend, and add these environment variables:
| Variable | Value |
|---|---|
BACKEND_URL |
https://<API_DOMAIN> |
SITE_URL |
your Vercel URL, used for link previews |
The app forwards /api/* to BACKEND_URL, so the browser only ever talks to Vercel.
AI coding assistants were used to write and review parts of the code and documentation, under my direction. The graph model, the queries, the data checks and the product decisions are mine, and every query result shown here was checked against the live data.
At runtime, Fontrel calls openai/gpt-oss-120b on Groq to choose tools and write the short summary. The model never writes to the graph, and its answers are checked against graph results before they are shown.
Photos from Unsplash by National Cancer Institute, Árpád Czapp and Diana Polekhina. Fonts are Newsreader, Instrument Sans and Fragment Mono from Google Fonts.


