Build a Medication Lookup Agent with Claude and FHIRfly
Build a TypeScript agent that uses Claude's tool-use protocol to query verified medication data from FDA and NLM sources — no hallucinated drug codes, no invented interactions.
Build a Medication Lookup Agent with Claude and FHIRfly

AI agents that touch clinical data need more than a good prompt — they need authoritative sources at runtime. This tutorial walks through building a TypeScript agent that uses Claude's tool-use capabilities to query real FDA and NLM drug data through FHIRfly's terminology APIs.
No hallucinated NDC codes. No invented drug interactions. Just verified data, returned in structured responses.
Why Agents Hallucinate Clinical Data
Large language models are trained on medical literature, but they don't have a live connection to the FDA's drug database. Ask an LLM to look up an NDC code from memory and you'll get something that looks plausible but doesn't correspond to any real product. The model isn't lying — it doesn't have access to the source of truth.
The fix is architectural: give the agent tools that call authoritative APIs at inference time. Claude's tool-use protocol lets you define typed functions that the model can invoke when it needs factual data, instead of generating it from parametric memory.
What We're Building
A conversational agent that can:
- Search for medications by name, brand, or ingredient
- Retrieve full drug details — active ingredients, DEA schedule, pharmacological class
- Pull FDA-approved label sections — warnings, interactions, dosing
The agent decides which tools to call based on the user's question. All clinical data comes from FHIRfly's APIs, sourced daily from FDA and NLM databases.
Setup
npm install @anthropic-ai/sdk @fhirfly-io/terminology zod
import Anthropic from "@anthropic-ai/sdk";
import { betaZodTool } from "@anthropic-ai/sdk/helpers/beta/zod";
import { Fhirfly } from "@fhirfly-io/terminology";
import { z } from "zod";
const anthropic = new Anthropic();
const fhirfly = new Fhirfly({ apiKey: process.env.FHIRFLY_API_KEY! });
Two clients, two API keys. The Anthropic SDK reads ANTHROPIC_API_KEY from the environment by default.
Defining the Tools
Each tool wraps a FHIRfly SDK method with a Zod schema that tells Claude what parameters it can pass. The betaZodTool helper handles schema generation and type validation automatically — Claude can't pass a number where a string is expected.
Search medications
const searchMedications = betaZodTool({
name: "search_medications",
description:
"Search for medications by name, ingredient, or brand. " +
"Returns matching drug products with NDC codes and clinical details.",
inputSchema: z.object({
query: z.string().describe("Drug name, brand, or active ingredient"),
product_type: z
.enum(["rx", "otc", "all"])
.optional()
.describe("Filter by prescription or over-the-counter"),
}),
run: async ({ query, product_type }) => {
const results = await fhirfly.ndc.search(
{ q: query, product_type, is_active: true },
{ shape: "standard", limit: 5 }
);
return JSON.stringify(
results.items.map((drug) => ({
ndc: drug.ndc,
brand: drug.brand_name,
generic: drug.generic_name,
form: drug.dosage_form,
strength: drug.strength,
manufacturer: drug.labeler_name,
rxcui: drug.rxcui,
}))
);
},
});
The shape: "standard" response includes RxCUI cross-references — useful if the agent needs to pivot from an NDC to an RxNorm concept later. The is_active: true filter excludes discontinued products so the agent never returns data for drugs no longer on the market.
Get full drug details
const getDrugDetails = betaZodTool({
name: "get_drug_details",
description:
"Get comprehensive details for a specific drug by NDC code, " +
"including active ingredients, DEA schedule, and pharmacological class.",
inputSchema: z.object({
ndc: z.string().describe("The NDC (National Drug Code) to look up"),
}),
run: async ({ ndc }) => {
const { data } = await fhirfly.ndc.lookup(ndc, { shape: "full" });
return JSON.stringify({
ndc: data.ndc,
brand: data.brand_name,
generic: data.generic_name,
ingredients: data.active_ingredients,
dea_schedule: data.dea_schedule,
pharm_class: data.pharm_class,
route: data.route,
form: data.dosage_form,
strength: data.strength,
});
},
});
The shape: "full" response returns everything the FDA publishes for this product — active ingredients with individual strengths and units, pharmacological classification, and DEA scheduling.
Retrieve FDA label sections
const getDrugLabel = betaZodTool({
name: "get_drug_label",
description:
"Retrieve FDA-approved label sections for a drug. " +
"Use bundles for common groupings: 'safety' (warnings + contraindications), " +
"'interactions' (drug interactions + clinical pharmacology), " +
"'dosing' (dosage, administration, forms).",
inputSchema: z.object({
ndc: z.string().describe("NDC code for the medication"),
bundle: z
.enum(["safety", "dosing", "interactions", "pregnancy", "ingredients"])
.describe("Pre-defined group of related label sections"),
}),
run: async ({ ndc, bundle }) => {
const { data } = await fhirfly.fdaLabels.lookup(ndc, { bundle });
return JSON.stringify({
brand: data.metadata.brand_name,
generic: data.metadata.generic_name,
sections: data.sections,
});
},
});
The bundle parameter groups related FDA label sections — "safety" pulls warnings, boxed warnings, and contraindications in one call instead of specifying each section name individually.
Wiring Up the Agent
The Anthropic SDK's tool runner handles the agentic loop: it sends the user's question to Claude, detects tool-use requests in the response, executes the matching function, feeds the result back, and repeats until Claude has enough data to answer.
async function ask(question: string): Promise<string> {
const response = await anthropic.beta.messages.toolRunner({
model: "claude-sonnet-4-6",
max_tokens: 4096,
system:
"You are a clinical data assistant. Use the provided tools to look up " +
"verified medication data from FDA and NLM sources. Never guess drug " +
"codes, ingredients, or interactions — always use a tool. " +
"Cite the data source in your responses.",
tools: [searchMedications, getDrugDetails, getDrugLabel],
messages: [{ role: "user", content: question }],
});
const text = response.content.find((block) => block.type === "text");
return text?.text ?? "";
}
That's the entire agent. No manual loop, no result parsing, no retry logic. The tool runner and the FHIRfly SDK handle it.
A note on model choice: Sonnet is a strong default for production agents — fast, capable, and cost-effective for multi-tool workflows. Swap in claude-opus-4-6 when the clinical reasoning requires deeper analysis, like synthesizing multiple drug interactions or interpreting complex label language.
Running It
const answer = await ask(
"What are the active ingredients in Tylenol Extra Strength, " +
"and are there any drug interaction warnings I should know about?"
);
console.log(answer);
Behind the scenes, Claude will:
- Call
search_medications with { query: "Tylenol Extra Strength" }
- Pick the matching NDC from the results
- Call
get_drug_details to retrieve the active ingredients
- Call
get_drug_label with bundle: "interactions" for interaction data
- Synthesize everything into a clear, sourced response
Every fact in the final answer traces back to an API call against FDA data — not the model's training corpus.
What the Agent Returns
Here's what the output looks like:
Tylenol Extra Strength (NDC 50580-0488) contains one active ingredient:
- Acetaminophen — 500 mg per caplet
Drug Interaction Warnings (FDA Label):
- Acetaminophen is hepatotoxic at high doses. Do not exceed 3,000 mg in 24 hours.
- Concurrent use with warfarin may increase INR and bleeding risk.
- Alcohol consumption (3+ drinks/day) significantly increases the risk of liver damage.
- Use caution with other acetaminophen-containing products to avoid unintentional overdose.
Sources: FDA NDC Directory, FDA Structured Product Labeling
Compare this to an ungrounded model response: plausible-sounding but potentially outdated or fabricated clinical details, with no way to verify the source. The difference matters when downstream systems — or patients — depend on accuracy.
Adding Error Handling
In production, tools should handle failures gracefully. The FHIRfly SDK throws typed errors you can catch and surface to Claude as informative tool results:
run: async ({ ndc }) => {
try {
const { data } = await fhirfly.ndc.lookup(ndc, { shape: "full" });
return JSON.stringify(data);
} catch (error) {
if (error instanceof NotFoundError) {
return JSON.stringify({
error: `NDC ${ndc} not found in the FDA database. ` +
"Try searching by drug name instead.",
});
}
throw error;
}
},
When a tool returns an error message, Claude adapts — it might try a different search term or ask the user for clarification instead of failing silently.
Key Takeaways
- Tool use turns LLMs into data consumers, not data generators. Claude's role shifts from recalling facts to orchestrating lookups and synthesizing verified results.
- Typed schemas prevent garbage-in. Zod validates tool inputs before they hit the API. Invalid parameters are caught at the SDK layer, not in your database.
- Bundles reduce round trips. FHIRfly's label bundles group related FDA sections, so the agent gets complete safety or dosing information in a single call.
- The data stays current. FHIRfly ingests from FDA and NLM daily. The agent inherits that freshness — no retraining, no re-embedding, no stale vector stores.