factcircuit
FactCircuit: an auditable claim-and-evidence verification loop with immutable provenance, staged checks, bounded retrieval, and reproducible evaluation.
Query 10 biomedical databases with natural language. Proteins, drugs, genes, variants, pathways, trials, and literature.
DataTypeScript · JavaScriptGitHub @harshk242OnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Biomedical research agent that answers cross-database questions across 10 public sources (Open Targets, UniProt, ChEMBL, ClinicalTrials.gov, PharmGKB…). It resolves identifiers, chains tool calls and cites every record. Live at tryburp.com.
Taken from the repository README
Loading the full README.
Read the full README ↗A research agent that answers biomedical questions by working across 10 public databases: it resolves identifiers, chains the right tools, and cites the records it found.
Live at tryburp.com · TypeScript · Bun · Hono · Next.js · Vercel AI SDK · MCP · Langfuse
"Which kinase inhibitors in clinical trials target proteins linked to HER2-positive breast cancer, and what pharmacogenomic variants affect their dosing?"
Answering that by hand means Open Targets → Ensembl → UniProt → ChEMBL → ClinicalTrials.gov → PharmGKB, with a different identifier scheme at every step. BURP does that loop for you and links every claim back to a source record.
Biomedical researchers already have great databases. What they lack is a way to join them. Each source has its own API and its own IDs (ENSG, UniProt accessions, CHEMBL IDs, PubChem CIDs, rsIDs, NCT numbers, PMIDs). Even the big integrators don't fully cover each other (Open Targets breaks on ClinPGx and UniProt lookups, for example). So a cross-database question turns into an afternoon of copying IDs between browser tabs.
A plain chatbot doesn't solve this. It makes up plausible identifiers, and researchers can't use an answer they can't trace. The product constraints I designed against:
The agent is a single tool-using loop that I wrote by hand on top of the Vercel AI SDK instead of using an agent framework. That gives me control over the four things that decide whether a research agent works in production: which tools the model sees, what tool output it reads, what gets cached, and what gets measured.
flowchart LR
U[Researcher] -->|SSE| API[Hono API<br/>auth · rate + cost limits]
API --> LOOP
subgraph LOOP[Agent loop · processChat]
direction TB
CTX[Context assembly<br/>system prompt · replayed transcript<br/>cache breakpoints]
LLM[LLM call<br/>Claude / Gemini]
DISP[Tool dispatch]
CTX --> LLM --> DISP --> CTX
end
DISP -->|core + discovered tools| MCP[Unified MCP server<br/>~230 tools · 10 platforms]
DISP -->|search_available_tools| HS[Hybrid tool search<br/>BM25 + embeddings · RRF]
DISP -->|inspect_artifact| ART[Artifact store<br/>jq queries]
MCP -->|large output| LO[Noise filters → artifact + schema preview]
LO --> ART
MCP --> EXT[(Open Targets · Ensembl · UniProt · ChEMBL · PubChem<br/>ClinVar · PubMed · ClinicalTrials · Reactome · ClinPGx)]
LOOP -. spans, cost, scores .-> LF[Langfuse<br/>traces · evals · feedback]
LOOP --> DB[(SQLite · Drizzle<br/>transcripts · usage ledger)]
Loading every tool schema on every call would fill a large share of the context window before the user has asked anything, and tool-choice accuracy drops as the list grows.
config.yaml (always_loaded_tools).search_available_tools system tool. That runs hybrid search: BM25 over SQLite FTS plus embedding similarity, merged with Reciprocal Rank Fusion. Keyword matches get extra weight on short queries ("clinvar variant"), and embeddings get more on descriptive ones.defer_loading with tool_reference results, so discovered tools become callable without changing the cached prefix. On other providers, matching tools are injected into the active set on the fly.→ app/server/orchestration/context/tool-catalog.ts, app/server/search/hybrid-search.ts, app/server/embeddings/
A single UniProt or ChEMBL call can return megabytes of JSON. Truncating it loses data, and passing it through as-is overflows the context. Each MCP result goes through a fixed pipeline:
| Size after step | What the model receives |
|---|---|
| Small enough | The raw output, inline |
| Too large → deterministic noise filters (jq policies per platform or tool, e.g. strip sequences and evidence arrays, cap result lists) | The reduced JSON, inline |
| Still too large | Stored as a session-scoped artifact. The model gets a schema preview (shape, sample items, sizes) plus an artifact ID |
The model then pulls exactly what it needs with an inspect_artifact tool that runs jq filters against the stored data ([.results[] | select(.phase >= 3) | {id, name}]), with timeouts and output caps. The model only sees the full data through targeted queries.
→ app/server/orchestration/large-output.ts, app/server/orchestration/tools/{noise-reduction,schema-preview,artifact-store,jq-engine}.ts
Multi-turn research is expensive if each turn re-sends the whole conversation uncached, so the context layout is deliberate:
catalog_changed warning is logged, because that silently breaks the cache.contextVersion (a hash of the system prompt + provider options). If the prompt or model changes, old history degrades gracefully instead of corrupting the request.→ app/server/orchestration/context/, app/scripts/prompt-cache-gate/
[fetched_at: <UTC>], so the model can tell that a trial's recruitment status from three turns ago may be out of date and fetch it again.id_grounding is a measured metric, not a hope: it checks that every database ID in an answer appeared in evidence the model actually had before writing it (see Evals).The loop is an AsyncGenerator of typed stream events (tool_start, tool_end, text, reasoning, usage). It's consumed directly by the SSE route and by the eval runner, so evals exercise exactly the code path users hit.
→ app/server/orchestration/chat-v2.ts, tool-dispatch.ts, cost-budget.ts, usage-meter.ts
All 10 platforms live in one unified MCP server (a stdio child process) instead of ten. Each platform is a module that exports { name, tools, handlers }, and tools are namespaced as platform__tool. Adding a database means adding a module.
Public bioinformatics APIs are flaky in different ways, so every client goes through a shared HTTP retry layer: error classification (retryable vs. not, per status/reason), exponential backoff with jitter, a total time budget per tool call, Retry-After handling for 429s, and abortable token-bucket rate limiting for the NCBI APIs (PubMed and ClinVar), which allow more requests per second when an API key is set. Retry attempts and upstream timings appear as metadata on each tool span.
→ mcp-server/src/shared/http-retry*.ts, mcp-server/src/platforms/*/
Datasets, metrics and rubrics are versioned in git and synced to Langfuse, which stores traces, experiment runs and scores.
trace every turn → run datasets as experiments → review failing traces → grow datasets from real traces
smoke (cheap, runs on every change) and deep (multi-turn target-selection cases, runs before releases). Items carry per-turn checkpoints with accepted aliases, expected tool globs and a reviewed_by field. Unreviewed, model-drafted expectations run but never count toward pass/fail.id_grounding, checkpoint_recall, expected_tool_coverage, turn_outcome, answer_truncated, tool_error_count.bun run eval --dataset smoke --matrix sonnet-effort --repeat 2 runs each arm under a shared cost cap and writes a comparison report with variance.→ app/evals/, app/server/observability/
| Area | What's there |
|---|---|
| Frontend | Next.js (static export served by the API), streaming chat with collapsible reasoning and grouped tool-call cards, tool browser, PDF export of conversations, feedback dialog, admin panel, waitlist/access requests |
| API | Hono + Zod OpenAPI (Swagger UI), Clerk JWT auth, per-user rate and token limits, SSE streaming |
| Data | SQLite (WAL) + Drizzle ORM. Migrations run on boot inside one transaction, with an automatic VACUUM INTO backup first. Legacy schema adoption, and a CI drift guard that fails if the schema changes without a migration |
| Testing | 110+ test files: unit tests for both packages, typechecks, and nightly contract tests against the live upstream APIs that upload a drift report when a database changes its response shape |
| Delivery | Multi-stage Docker build on Fly.io, deploy on merge with generated release notes, and an agent-driven workflow that tries to fix failing CI on the dev branch |
| Observability | Structured JSON logs with session/turn context, plus OpenTelemetry → Langfuse spans per generation, tool and retriever call |
| Platform | Domain | Platform | Domain |
|---|---|---|---|
| Open Targets | Target–disease–drug evidence | ClinVar | Clinical variant interpretation |
| Ensembl | Genes, transcripts, variants | PubMed | Literature |
| UniProt | Proteins & annotations | ClinicalTrials.gov | Trial registry |
| ChEMBL | Bioactivity & drug mechanisms | Reactome | Pathways |
| PubChem | Chemical structures & assays | ClinPGx (PharmGKB) | Pharmacogenomics |
app/
├── server/
│ ├── orchestration/ # agent loop, tool dispatch, context assembly, large-output pipeline, cost budget
│ ├── search/ # hybrid BM25 + embedding tool search
│ ├── embeddings/ # provider-agnostic embedder + incremental index sync
│ ├── mcp/ # MCP child-process manager and tool registry
│ ├── llm/ # provider abstraction (Anthropic, Google, OpenAI)
│ ├── observability/ # Langfuse tracing, scores, feedback sync
│ ├── db/ # Drizzle schema, migrations, queries, backup/verify CLIs
│ └── routes/ # chat (SSE), sessions, tools, usage, admin, feedback
├── evals/ # datasets, metrics, rubrics, evaluators, experiment runner
├── web/ # Next.js frontend
└── config.yaml # models, pricing, tool tiers, search weights, artifact policies
mcp-server/
└── src/
├── platforms/ # one module per database (client + tools + contract tests)
└── shared/ # HTTP retry, rate limiter, tool-call context
docs/ # architecture diagrams, codemaps, use cases, decisions
plans/ # design docs written before each major change
Requires Bun and Node 18+ (Node runs the MCP child process).
bun install
cp app/.env.example app/.env # ANTHROPIC_API_KEY, CLERK_SECRET_KEY; optional NCBI_API_KEY, OPENAI_API_KEY
cp app/web/.env.example app/web/.env.local # Clerk publishable key, API URL
bun run dev # builds the MCP server, then runs MCP + API (:3000) + web (:3001)
bun run test # unit tests for mcp-server and app
bun run eval --dataset smoke --max-cost-usd 0.5
Models, tool tiers, search weights and output-handling policies are set in app/config.yaml. The database is created and migrated on first boot. See docs/architecture.md for deployment and operational details.