diff --git a/plugins/omi-pubmed-app/Procfile b/plugins/omi-pubmed-app/Procfile
new file mode 100644
index 00000000000..0e048402efc
--- /dev/null
+++ b/plugins/omi-pubmed-app/Procfile
@@ -0,0 +1 @@
+web: uvicorn main:app --host 0.0.0.0 --port $PORT
diff --git a/plugins/omi-pubmed-app/README.md b/plugins/omi-pubmed-app/README.md
new file mode 100644
index 00000000000..0ce7fbc9d7b
--- /dev/null
+++ b/plugins/omi-pubmed-app/README.md
@@ -0,0 +1,20 @@
+# Omi PubMed App
+
+PubMed chat tools integration for Omi. This app uses the public NCBI E-utilities API (no OAuth/API key required).
+
+## Tools
+- `search_pubmed`: Search PubMed by query and return top matches.
+- `get_pubmed_article`: Fetch article details for a PubMed ID.
+- `get_related_pubmed`: Get related articles from a PubMed ID.
+
+## Run locally
+```bash
+pip install -r requirements.txt
+uvicorn main:app --reload --port 8080
+```
+
+## Omi manifest URL
+`/.well-known/omi-tools.json`
+
+## Health check
+`/health`
diff --git a/plugins/omi-pubmed-app/main.py b/plugins/omi-pubmed-app/main.py
new file mode 100644
index 00000000000..802e9331316
--- /dev/null
+++ b/plugins/omi-pubmed-app/main.py
@@ -0,0 +1,273 @@
+import html
+from typing import Any
+
+import httpx
+from fastapi import FastAPI, Request
+from fastapi.responses import HTMLResponse
+
+from models import ChatToolResponse
+
+app = FastAPI(
+ title="Omi PubMed App",
+ description="PubMed chat tools for Omi",
+ version="1.0.1",
+)
+
+EUTILS = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
+TIMEOUT = 20.0
+
+
+def _safe(value: Any) -> str:
+ return html.unescape(str(value)) if value is not None else ""
+
+
+def _is_valid_pmid(pmid: str) -> bool:
+ return pmid.isdigit() and len(pmid) <= 12
+
+
+def _clamp_max_results(value: Any, default: int = 5) -> int:
+ try:
+ parsed = int(value)
+ except (TypeError, ValueError):
+ return default
+ return max(1, min(parsed, 10))
+
+
+def _extract_article_fields(record: dict) -> dict:
+ title = _safe(record.get("title", "Untitled"))
+ pubdate = _safe(record.get("pubdate", ""))
+ source = _safe(record.get("source", ""))
+ doi = _safe(record.get("elocationid", ""))
+ authors = []
+ for author in record.get("authors", [])[:8]:
+ name = _safe(author.get("name"))
+ if name:
+ authors.append(name)
+
+ abstract = ""
+ if isinstance(record.get("abstract"), list):
+ abstract = " ".join(_safe(x) for x in record["abstract"] if x)
+ elif record.get("abstract"):
+ abstract = _safe(record["abstract"])
+
+ return {
+ "title": title,
+ "pubdate": pubdate,
+ "source": source,
+ "doi": doi,
+ "authors": authors,
+ "abstract": abstract,
+ }
+
+
+async def _fetch_json(client: httpx.AsyncClient, endpoint: str, params: dict) -> dict:
+ resp = await client.get(f"{EUTILS}/{endpoint}", params=params)
+ resp.raise_for_status()
+ return resp.json()
+
+
+async def _search_ids(client: httpx.AsyncClient, query: str, retmax: int = 5) -> list[str]:
+ data = await _fetch_json(
+ client,
+ "esearch.fcgi",
+ {
+ "db": "pubmed",
+ "term": query,
+ "retmode": "json",
+ "retmax": _clamp_max_results(retmax),
+ "sort": "relevance",
+ },
+ )
+ return data.get("esearchresult", {}).get("idlist", [])
+
+
+async def _fetch_summaries(client: httpx.AsyncClient, ids: list[str]) -> dict:
+ if not ids:
+ return {}
+ data = await _fetch_json(
+ client,
+ "esummary.fcgi",
+ {"db": "pubmed", "id": ",".join(ids), "retmode": "json"},
+ )
+ return data.get("result", {})
+
+
+@app.get("/health")
+async def health():
+ return {"status": "ok"}
+
+
+@app.get("/")
+async def home():
+ return HTMLResponse(
+ """
+
+ Omi PubMed App
+ Use PubMed search and article lookup from Omi chat.
+ Manifest: /.well-known/omi-tools.json
+
+ """
+ )
+
+
+@app.get("/.well-known/omi-tools.json")
+async def manifest():
+ return {
+ "tools": [
+ {
+ "name": "search_pubmed",
+ "description": "Search PubMed by keywords and return relevant papers.",
+ "endpoint": "/tools/search_pubmed",
+ "method": "POST",
+ "parameters": {
+ "properties": {
+ "query": {"type": "string", "description": "Search query"},
+ "max_results": {"type": "integer", "description": "1-10, default 5"},
+ },
+ "required": ["query"],
+ },
+ "auth_required": False,
+ "status_message": "Searching PubMed...",
+ },
+ {
+ "name": "get_pubmed_article",
+ "description": "Get detailed citation and abstract for a PubMed ID.",
+ "endpoint": "/tools/get_pubmed_article",
+ "method": "POST",
+ "parameters": {
+ "properties": {
+ "pmid": {"type": "string", "description": "PubMed ID (numeric)"},
+ },
+ "required": ["pmid"],
+ },
+ "auth_required": False,
+ "status_message": "Fetching PubMed article...",
+ },
+ {
+ "name": "get_related_pubmed",
+ "description": "Find related PubMed articles from a PubMed ID.",
+ "endpoint": "/tools/get_related_pubmed",
+ "method": "POST",
+ "parameters": {
+ "properties": {
+ "pmid": {"type": "string", "description": "PubMed ID (numeric)"},
+ "max_results": {"type": "integer", "description": "1-10, default 5"},
+ },
+ "required": ["pmid"],
+ },
+ "auth_required": False,
+ "status_message": "Finding related PubMed articles...",
+ },
+ ]
+ }
+
+
+@app.get("/manifest.json")
+async def manifest_alias():
+ return await manifest()
+
+
+@app.post("/tools/search_pubmed", response_model=ChatToolResponse, tags=["chat_tools"])
+async def search_pubmed(request: Request):
+ try:
+ body = await request.json()
+ query = (body.get("query") or "").strip()
+ max_results = _clamp_max_results(body.get("max_results", 5))
+ if not query:
+ return ChatToolResponse(error="query is required")
+
+ async with httpx.AsyncClient(timeout=TIMEOUT) as client:
+ ids = await _search_ids(client, query, max_results)
+ if not ids:
+ return ChatToolResponse(result=f"No PubMed results found for: {query}")
+ summaries = await _fetch_summaries(client, ids)
+
+ lines = [f"Top PubMed results for: {query}"]
+ for idx, result_pmid in enumerate(ids, start=1):
+ row = summaries.get(result_pmid, {})
+ title = _safe(row.get("title", "Untitled"))
+ journal = _safe(row.get("fulljournalname", row.get("source", "")))
+ date = _safe(row.get("pubdate", ""))
+ lines.append(f"{idx}. PMID {result_pmid}: {title} ({journal}, {date})")
+ return ChatToolResponse(result="\n".join(lines))
+ except Exception as e:
+ return ChatToolResponse(error=f"PubMed search failed: {e}")
+
+
+@app.post("/tools/get_pubmed_article", response_model=ChatToolResponse, tags=["chat_tools"])
+async def get_pubmed_article(request: Request):
+ try:
+ body = await request.json()
+ pmid = (body.get("pmid") or "").strip()
+ if not pmid:
+ return ChatToolResponse(error="pmid is required")
+ if not _is_valid_pmid(pmid):
+ return ChatToolResponse(error="pmid must be a numeric PubMed ID")
+
+ async with httpx.AsyncClient(timeout=TIMEOUT) as client:
+ summaries = await _fetch_summaries(client, [pmid])
+
+ if pmid not in summaries:
+ return ChatToolResponse(error=f"No PubMed record found for PMID {pmid}")
+
+ record = _extract_article_fields(summaries[pmid])
+ lines = [
+ f"PMID {pmid}",
+ f"Title: {record['title']}",
+ f"Authors: {', '.join(record['authors']) if record['authors'] else 'N/A'}",
+ f"Journal/Date: {record['source']} ({record['pubdate']})",
+ f"DOI/Location: {record['doi'] or 'N/A'}",
+ ]
+ if record["abstract"]:
+ lines.append(f"Abstract: {record['abstract'][:1800]}")
+ return ChatToolResponse(result="\n".join(lines))
+ except Exception as e:
+ return ChatToolResponse(error=f"Failed to fetch PubMed article: {e}")
+
+
+@app.post("/tools/get_related_pubmed", response_model=ChatToolResponse, tags=["chat_tools"])
+async def get_related_pubmed(request: Request):
+ try:
+ body = await request.json()
+ pmid = (body.get("pmid") or "").strip()
+ max_results = _clamp_max_results(body.get("max_results", 5))
+ if not pmid:
+ return ChatToolResponse(error="pmid is required")
+ if not _is_valid_pmid(pmid):
+ return ChatToolResponse(error="pmid must be a numeric PubMed ID")
+
+ async with httpx.AsyncClient(timeout=TIMEOUT) as client:
+ data = await _fetch_json(
+ client,
+ "elink.fcgi",
+ {
+ "dbfrom": "pubmed",
+ "db": "pubmed",
+ "id": pmid,
+ "linkname": "pubmed_pubmed",
+ "retmode": "json",
+ },
+ )
+
+ linksets = data.get("linksets", [])
+ related = []
+ if linksets:
+ dbs = linksets[0].get("linksetdbs", [])
+ if dbs:
+ related = [str(x) for x in dbs[0].get("links", [])[:max_results]]
+
+ if not related:
+ return ChatToolResponse(result=f"No related articles found for PMID {pmid}")
+
+ summaries = await _fetch_summaries(client, related)
+
+ lines = [f"Related PubMed articles for PMID {pmid}:"]
+ for idx, related_pmid in enumerate(related, start=1):
+ row = summaries.get(related_pmid, {})
+ title = _safe(row.get("title", "Untitled"))
+ journal = _safe(row.get("fulljournalname", row.get("source", "")))
+ date = _safe(row.get("pubdate", ""))
+ lines.append(f"{idx}. PMID {related_pmid}: {title} ({journal}, {date})")
+ return ChatToolResponse(result="\n".join(lines))
+ except Exception as e:
+ return ChatToolResponse(error=f"Failed to fetch related PubMed articles: {e}")
diff --git a/plugins/omi-pubmed-app/models.py b/plugins/omi-pubmed-app/models.py
new file mode 100644
index 00000000000..26d0fe21bec
--- /dev/null
+++ b/plugins/omi-pubmed-app/models.py
@@ -0,0 +1,7 @@
+from typing import Optional
+from pydantic import BaseModel
+
+
+class ChatToolResponse(BaseModel):
+ result: Optional[str] = None
+ error: Optional[str] = None
diff --git a/plugins/omi-pubmed-app/railway.toml b/plugins/omi-pubmed-app/railway.toml
new file mode 100644
index 00000000000..6573527d5b3
--- /dev/null
+++ b/plugins/omi-pubmed-app/railway.toml
@@ -0,0 +1,7 @@
+[build]
+builder = "NIXPACKS"
+
+[deploy]
+startCommand = "uvicorn main:app --host 0.0.0.0 --port $PORT"
+restartPolicyType = "ON_FAILURE"
+restartPolicyMaxRetries = 10
diff --git a/plugins/omi-pubmed-app/requirements.txt b/plugins/omi-pubmed-app/requirements.txt
new file mode 100644
index 00000000000..0461ccbacd3
--- /dev/null
+++ b/plugins/omi-pubmed-app/requirements.txt
@@ -0,0 +1,4 @@
+fastapi==0.104.1
+uvicorn==0.24.0
+httpx==0.27.2
+pydantic==2.5.2