Day 18 of 30 Days of Search.

TL;DR

A drug discovery research agent can investigate target–disease associations, reported compound activity, and chemical identity without claiming to discover a treatment. This tutorial builds a bounded TypeScript agent that chooses Valyu Search queries across Open Targets, ChEMBL, and PubChem, saves the returned evidence, and produces a cited research brief.

  • Working example: EGFR, non-small cell lung carcinoma, and erlotinib as a known reference compound—not a newly discovered drug.
  • Tools: Valyu Search, Vercel AI SDK 7, an OpenAI model, and Zod.
  • Access: A Valyu key with the three subscription datasets enabled, plus an OpenAI API key. Both services can incur charges.
  • Verified run: Three Search calls across the three datasets, two model steps, and matching standardized InChIKeys in the selected compound records.
  • Key design choice: The model chooses searches and follow-up questions. The application prints numerical evidence directly, preserving assay identifiers, endpoints, relation signs, and units.

Before a researcher adds a compound to a follow-up reading list, they need to answer several different questions. Is the target associated with the disease of interest? What did the compound actually do in a particular assay? Are the chemical records from two databases describing the same standardized structure?

A fluent paragraph can blur those distinctions. We'll keep them separate, attach the source records, and make the next action clear: inspect the original evidence before designing further research.

This is a developer tutorial for research use only. It is not medical advice, a treatment recommendation, a dosing tool, or a claim that an agent has discovered or validated a new drug.

What is a drug discovery research agent?

A drug discovery research agent uses tools to retrieve and organize evidence relevant to drug research. In this example, a model chooses searches, inspects returned records, and suggests follow-up questions. Application code restricts its sources, limits requests, checks identifiers, and renders a reviewable brief from the retrieved fields.

That makes it more than a script that always runs three fixed queries. It is also much narrower than an autonomous drug designer: it does not generate molecules, run docking, predict safety, validate efficacy, or select a treatment for a person.

The worked research question

We'll investigate:

What target–disease association evidence is returned for EGFR and non-small cell lung carcinoma, what EGFR assay activity is reported for erlotinib, and do the selected ChEMBL and PubChem records have the same standardized InChIKey?

The example deliberately uses known identifiers:

Entity Identifier used in this tutorial
EGFR in Open Targets ENSG00000146648
Non-small cell lung carcinoma in the returned association record MONDO_0005233
EGFR target annotation in ChEMBL CHEMBL203
Erlotinib compound in ChEMBL CHEMBL553
Erlotinib compound in PubChem CID 176870

These are identifiers for the worked example, not evidence that the agent discovered a new candidate. The validation code is scoped to them. Reusing the tutorial for another target means changing the identifier checks as well as the question.

Which data sources does the agent search?

Valyu's healthcare documentation lists all three sources as subscription datasets available through Search and Answer. The underlying Open Targets, ChEMBL, and PubChem resources have their own public interfaces; a Valyu subscription is access to the Valyu retrieval service, not a claim that those original resources are private.

Research question Valyu source What to retain
Which target–disease association is represented? valyu/valyu-open-targets Target ID, disease ID, returned association record, source link
What activity was reported for a compound? valyu/valyu-chembl Compound and activity IDs, assay ID, target annotation, endpoint, relation, value, units, assay description
Do the selected chemical records agree on identity? valyu/valyu-pubchem CID, standardized InChIKey, molecular formula, reported properties

A source's role is not exclusive. PubChem also contains bioassay information, and ChEMBL contains chemical properties. These are the roles we choose for this small workflow, not the full scope of either database.

A bounded model-driven agent chooses Valyu searches across Open Targets, ChEMBL and PubChem, retains distinct target, assay and identity records, and writes a brief with saved evidence IDs.

The model chooses searches. The application restricts sources, caps requests, saves records, and checks citation IDs before rendering the brief.

Step 1: Set up the TypeScript agent

Use Node.js 24.2 or later. The script uses import.meta.main, introduced in Node.js 24.2.0, so it can be imported for testing without running paid requests. The live example was tested on Node.js 24.12.0.

Create a project and install the versions used here:

mkdir drug-discovery-research-agent cd drug-discovery-research-agent npm init -y npm pkg set type=module npm install valyu-js@2.10.5 ai@7.0.143 @ai-sdk/openai@4.0.96 zod@4.6.7 

Set these environment variables on the server or in your secret manager:

Environment variable Purpose
VALYU_API_KEY Search access, with Open Targets, ChEMBL, and PubChem enabled
OPENAI_API_KEY Model calls through the OpenAI provider

The article pins AI SDK 7. Its tool-loop stop helper is isStepCount; examples written for older major versions may use different APIs. The model in this tested example is gpt-4.1-mini. Switching providers or models requires a compatible tool-calling and structured-output configuration, not just changing an arbitrary string.

Step 2: Give the model one restricted Search tool

The model gets a tool with two inputs: a dataset enum and a short query. It cannot supply arbitrary endpoints, API keys, or code to execute. The application maps the dataset name to a fixed source identifier:

const response = await valyu.search(query, { includedSources: ["valyu/valyu-chembl"], maxNumResults: 2, responseLength: "max", }); 

Two requested results are a retrieval bound, not a census. Query wording also does not guarantee an exact target or compound match. The code checks source identity, official source URLs, entity identifiers, and selected fields before a record enters the evidence ledger.

Step 3: Keep measurements out of free-form generation

During an early live test, the model attached correct citations but collapsed different assays into a potency range. The numerical values existed in the records; the summary still obscured their different contexts.

The final version does not ask the model to rewrite numerical findings. Its structured output contains only citation IDs to review, suggested questions, and proposed next research actions. The application renders the association, assay rows, and chemical identity check from the saved records.

This does not make every source correct or every suggestion useful. It prevents a specific failure: a model turning separate measurements into a more confident-looking number or conclusion than the records support.

Step 4: Build the bounded research agent

Save the complete implementation below as drug-research-agent.ts. No separate prompt file or hidden tool implementation is required.

import { generateText, isStepCount, Output, tool } from "ai"; import { createOpenAI } from "@ai-sdk/openai"; import { z } from "zod"; import { Valyu } from "valyu-js"; import { writeFile } from "node:fs/promises"; export const SOURCES = { targets: "valyu/valyu-open-targets", chembl: "valyu/valyu-chembl", pubchem: "valyu/valyu-pubchem", } as const; export const QUESTION = "Investigate EGFR in non-small cell lung carcinoma. " + "Use erlotinib as a known reference compound, not a newly discovered drug. " + "Retrieve target-disease association evidence, reported EGFR assay activity, " + "and a cross-source chemical identity check. Identify what remains unknown."; type Dataset = keyof typeof SOURCES; type Data = Record<string, unknown>; type Result = { source?: unknown; url?: unknown; content?: unknown }; type Search = (query: string, options: { searchType: "proprietary"; includedSources: string[]; maxNumResults: number; responseLength: "max"; }) => Promise<{ success: boolean; results?: Result[] }>; export type Evidence = { id: string; dataset: Dataset; url: string; retrieved_at: string; record: Data; }; const object = (value: unknown): Data | null => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Data : null; const text = (value: unknown): string | null => typeof value === "string" && value.trim() && value !== "N/A" ? value : null; const rows = (value: unknown): Data[] => Array.isArray(value) ? value.map(object).filter((value): value is Data => value !== null) : []; export function parse(value: unknown): Data | null { try { return object(typeof value === "string" ? JSON.parse(value) : value); } catch { return null; } } export function normalize(dataset: Dataset, result: Result): Omit<Evidence, "id"> | null { if (result.source !== SOURCES[dataset]) return null; const record = parse(result.content); if (!record || typeof result.url !== "string") return null; let url: URL; try { url = new URL(result.url); } catch { return null; } if (url.protocol !== "https:" || url.username || url.password || url.port) return null; let selected: Data; if (dataset === "targets") { if (record.id !== "ENSG00000146648" || record.symbol !== "EGFR" || url.hostname !== "platform.opentargets.org" || url.pathname !== "/target/ENSG00000146648") return null; const association = rows(object(record.associated_diseases)?.rows) .find(row => object(row.disease)?.id === "MONDO_0005233"); selected = { target_id: record.id, symbol: record.symbol, disease_association: association ?? null, interpretation: "Association score is a heuristic, not confidence or treatment probability." }; } else if (dataset === "chembl") { if (record.molecule_chembl_id !== "CHEMBL553" || url.hostname !== "www.ebi.ac.uk" || url.pathname.replace(/\/$/, "") !== "/chembl/compound_report_card/CHEMBL553") return null; const activities = rows(object(record.bioactivity_data)?.activities) .filter(row => row.target_chembl_id === "CHEMBL203" && row.target_organism === "Homo sapiens") .slice(0, 4).map(row => Object.fromEntries([ "activity_id", "target_chembl_id", "target_pref_name", "target_organism", "standard_type", "standard_relation", "standard_value", "standard_units", "assay_chembl_id", "assay_type", "assay_description", "data_validity_comment", "potential_duplicate", "confidence_score", ].map(key => [key, row[key] === undefined || row[key] === "N/A" ? null : row[key]]))); selected = { molecule_id: record.molecule_chembl_id, name: text(record.pref_name), inchikey: text(record.standard_inchi_key), activities, interpretation: "First four matching returned rows, not a potency ranking. Target organism is an annotation; inspect the assay description." }; } else { if (record.cid !== 176870 || url.hostname !== "pubchem.ncbi.nlm.nih.gov" || url.pathname !== "/compound/176870") return null; selected = { cid: record.cid, inchikey: text(record.inchikey), molecular_formula: text(record.molecular_formula), molecular_weight: record.molecular_weight === "N/A" ? null : record.molecular_weight ?? null }; } return { dataset, url: url.origin + url.pathname, retrieved_at: new Date().toISOString(), record: selected }; } export function identityCheck(evidence: Evidence[]): "MATCH" | "MISMATCH" | "UNKNOWN" { const left = evidence.find(item => item.dataset === "chembl")?.record.inchikey; const right = evidence.find(item => item.dataset === "pubchem")?.record.inchikey; if (typeof left !== "string" || typeof right !== "string" || !/^[A-Z]{14}-[A-Z]{10}-[A-Z]$/.test(left) || !/^[A-Z]{14}-[A-Z]{10}-[A-Z]$/.test(right)) return "UNKNOWN"; return left === right ? "MATCH" : "MISMATCH"; } export function createRetrieval(search: Search) { const evidence: Evidence[] = []; const calls: { dataset: Dataset; query: string; status: string }[] = []; async function retrieve(dataset: Dataset, query: string) { if (!Object.hasOwn(SOURCES, dataset)) throw new Error("Dataset is not allowed."); if (calls.length >= 6) return { status: "budget_exhausted", records: [] }; const call = { dataset, query, status: "pending" }; calls.push(call); try { const response = await search(query, { searchType: "proprietary", includedSources: [SOURCES[dataset]], maxNumResults: 2, responseLength: "max" }); if (!response.success || !Array.isArray(response.results)) { call.status = "failed"; return { status: "failed", records: [] }; } const accepted: Evidence[] = []; for (const result of response.results) { const normalized = normalize(dataset, result); if (!normalized) continue; let item = evidence.find(item => item.dataset === dataset && item.url === normalized.url); if (!item) { item = { id: `E${evidence.length + 1}`, ...normalized }; evidence.push(item); } accepted.push(item); } call.status = accepted.length ? "ok" : "no_accepted_records"; return { status: call.status, records: accepted, identity_check: identityCheck(evidence) }; } catch { call.status = "failed"; return { status: "failed", records: [] }; } } return { evidence, calls, retrieve }; } export const Brief = z.object({ citationsToReview: z.array(z.string()).min(1).max(6), openQuestions: z.array(z.string()).max(8), nextSteps: z.array(z.Source:https://dev.to/valyuai/how-to-build-a-drug-discovery-research-agent-27mf

Related posts