Notes
Your first reusable Claude tool — PubMed search
There are questions Claude can't answer from its training memory. "How many papers on KRAS G12C inhibitors were published this year?" is one of them — the literature changes every week, Claude's training cutoff is months in the past, and there's no way for it to look up the current count on its own.
A tool is the mechanism that closes that gap. You write a Python function — one that hits PubMed, say — and register it with Claude. When a conversation needs the answer the function can produce, Claude doesn't guess; it calls your function and uses the result.
This note builds your first tool, using PubMed search as the worked example: a small toolkit that counts papers on a topic and returns the most recent. As we build it, we install it in a standard place on your machine — so the next time you want the same toolkit from a different notebook, or from a terminal claude session, you don't have to rewrite or re-paste anything. The same install pattern works for every tool you write next.
What you'll learn
- The difference between a Claude tool and a Claude Skill — when to reach for each, and how the two compose. (Skills are covered separately in Claude Code Skills — getting started.)
- A standard layout for keeping reusable tool code in one place on your machine, importable from any future Python project without copy-pasting.
- How to bridge Python tools into Claude Code (the terminal CLI command
claude) with a small Skill file, so the same toolkit works from a terminal session as well as from a notebook.
What you'll have when you're done
- Two short Python files and one Skill file installed in standard locations on your machine.
- A working PubMed toolkit you can call from any Jupyter notebook or any terminal
claudesession — ask in plain English "how many papers on X were published in 2026?" or "show me the most recent papers on Y" and get back answers grounded in live PubMed numbers. - A repeatable pattern for the next helper you write — same file layout, same install steps, same Skill shape.
Skills vs tools
Skills and tools are two distinct ways to customise what Claude does, and they're easy to confuse. Skills (covered separately in Claude Code Skills — getting started) are rules — instructions Claude follows automatically once loaded into context, like style guides for writing or conventions for analyses. Tools are different: tools are functions Claude can call mid-conversation when it needs fresh data or deterministic computation. The two layers compose; you'll see how when we wire one up later in this note.
- A Skill lives at a known file path. Claude reads it on session start and follows the rules it describes. Great for "always write Methods sections in this style," "always cite this way," "always ask for clarification on dose units."
- A tool is a Python function registered with the Anthropic API as a callable Claude can choose to invoke. When Claude is asked something the tool can answer — a literature count, today's date, a gene-ID resolution, a hypergeometric test — it doesn't try to answer from memory; it calls the function and uses the result.
A Claude tool is a Python function (or any callable) registered with the Anthropic API. When Claude is asked something the tool can answer, it doesn't try to answer from memory. It calls the tool, reads the result, and continues the conversation with that fresh data.
PubMed is a textbook fresh-data problem. The literature changes constantly; Claude's training is months stale by the time you ask anything; counting papers on a topic is a deterministic database query, not a thing to guess at. We give Claude two functions — one to count papers matching a query, one to return the N most recent — and a way to call them.
What we'll install
~/claude-tools/
├── claude_runtime.py # generic Anthropic-API tool-use loop
└── pubmed_tools.py # PubMed functions + JSON schemas + TOOLS/TOOL_FUNCS
~/.claude/skills/pubmed-tools/
└── SKILL.md # Claude Code rule for using the same tools from the terminal
The split matters:
claude_runtime.pyis generic. It never changes when you add more tool modules later; it accepts tools and a dispatch table as parameters.pubmed_tools.pyis domain-specific. Future tool modules (a gene-ID resolver, a UniProt lookup, a stats helper — whatever you build next) live in the same directory.SKILL.mdis the Claude-Code-side bridge so the terminalclaudecommand knows about the Python tools.
You build this in three short steps below — write claude_runtime.py, write pubmed_tools.py, install on PYTHONPATH — then verify in two ways: once from a fresh Jupyter notebook ("Verify in a brand-new notebook"), and once from a fresh terminal Claude Code session ("Using the same library from Claude Code in the terminal"). Both reach the same tools.
Build claude_runtime.py
Generic tool-use loop. Imports anthropic only. Knows nothing about PubMed. Copy this verbatim into ~/claude-tools/claude_runtime.py.
"""Generic Claude tool-use loop. Imports from anthropic only."""
import json
from anthropic import Anthropic
client = Anthropic() # reads ANTHROPIC_API_KEY from env
MODEL = "claude-haiku-4-5" # cheap default; bump to opus per call site if needed
def chat_with_tools(question: str, tools: list, tool_funcs: dict) -> str:
messages = [{"role": "user", "content": question}]
while True:
resp = client.messages.create(
model=MODEL, max_tokens=2048, tools=tools, messages=messages,
)
if resp.stop_reason == "end_turn":
return next(b.text for b in resp.content if b.type == "text")
# Claude may return MULTIPLE tool_use blocks in one response
# (e.g. for "count X and also show me recent ones"). We must
# execute every one and reply with a matching tool_result for
# each — the API rejects the next call otherwise.
tool_uses = [b for b in resp.content if b.type == "tool_use"]
tool_results = [{
"type": "tool_result",
"tool_use_id": tu.id,
"content": json.dumps(tool_funcs[tu.name](**tu.input)),
} for tu in tool_uses]
messages += [
{"role": "assistant", "content": resp.content},
{"role": "user", "content": tool_results},
]
Three steps repeat until Claude is done:
- Send the question with the available
toolsto Claude. - If Claude returns one or more
tool_useblocks (instead of final text), execute each named function fromtool_funcswith Claude's chosen arguments and send all the results back in one user message. Claude can ask for several tool calls in parallel; the API requires atool_resultfor every one of them. - Loop until Claude returns final text.
The crucial design choice: tools and tool_funcs are parameters, not module-level globals. This means the same runtime works for any tool module — PubMed, a gene-ID resolver, a UniProt lookup, anything else you write. Add a tool module, you don't touch this file.
Copy this once; you'll never need to edit it again.
Build pubmed_tools.py
Self-contained PubMed module. Function bodies, JSON schemas describing them to Claude, and convenience aggregates. No anthropic import here — this file talks to NCBI; only claude_runtime.py talks to Claude. Copy this verbatim into ~/claude-tools/pubmed_tools.py.
"""PubMed tools — pubmed_count + pubmed_search via NCBI E-utilities."""
import requests
EUTILS = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
def pubmed_count(query: str, year: int | None = None) -> int:
"""Count PubMed papers matching `query`. Optionally restrict to a year."""
term = f"{query} AND {year}[pdat]" if year else query
r = requests.get(
f"{EUTILS}/esearch.fcgi",
params={"db": "pubmed", "term": term, "rettype": "count", "retmode": "json"},
timeout=10,
)
r.raise_for_status()
return int(r.json()["esearchresult"]["count"])
def pubmed_search(query: str, n: int = 5) -> list[dict]:
"""Return up to `n` most recent PubMed papers matching `query`."""
r = requests.get(
f"{EUTILS}/esearch.fcgi",
params={"db": "pubmed", "term": query, "sort": "pub_date",
"retmax": n, "retmode": "json"},
timeout=10,
)
r.raise_for_status()
pmids = r.json()["esearchresult"]["idlist"]
if not pmids:
return []
r = requests.get(
f"{EUTILS}/esummary.fcgi",
params={"db": "pubmed", "id": ",".join(pmids), "retmode": "json"},
timeout=10,
)
r.raise_for_status()
docs = r.json()["result"]
return [{
"pmid": pmid,
"title": docs[pmid].get("title", ""),
"year": docs[pmid].get("pubdate", "")[:4],
"journal": docs[pmid].get("source", ""),
"authors": [a["name"] for a in docs[pmid].get("authors", [])[:3]],
} for pmid in pmids]
# --- Claude tool schemas (what Claude reads to decide when to call each) ---
count_tool = {
"name": "pubmed_count",
"description": (
"Count PubMed papers matching a query, optionally restricted to a "
"publication year. Use when the user asks how many papers exist on a topic."
),
"input_schema": {
"type": "object",
"required": ["query"],
"properties": {
"query": {"type": "string"},
"year": {"type": "integer"},
},
},
}
search_tool = {
"name": "pubmed_search",
"description": (
"Return the N most recent PubMed papers matching a query, with "
"title, year, journal, and first three authors. Use when the user "
"asks for recent papers, latest research, or a list of papers on a topic."
),
"input_schema": {
"type": "object",
"required": ["query"],
"properties": {
"query": {"type": "string"},
"n": {"type": "integer"},
},
},
}
# --- Convenience aggregates for chat_with_tools ---
TOOLS = [count_tool, search_tool]
TOOL_FUNCS = {
"pubmed_count": pubmed_count,
"pubmed_search": pubmed_search,
}
Two NCBI endpoints are doing the work:
esearch— given a query, returns matching PMIDs and a count.esummary— given a list of PMIDs, returns JSON metadata.
The [pdat] field tag is PubMed's publication-date filter; you can extend the year restriction to ranges (2024:2026[pdat]) or relative ranges ("last 30 days"[edat]) — see the PubMed search field guide.
The two _tool dicts are how Claude knows what's available. The description field is what Claude reads to decide whether and which tool to invoke for a given user question; be specific about when each applies, not just what it does.
TOOLS and TOOL_FUNCS at the bottom are convenience exports — anyone importing this module gets a complete PubMed setup in one line: from pubmed_tools import TOOLS, TOOL_FUNCS.
Install on PYTHONPATH
mkdir -p ~/claude-tools
# Save claude_runtime.py and pubmed_tools.py from the two sections above
# into ~/claude-tools/ (just `cp` from wherever you pasted them, or use your editor).
echo 'export PYTHONPATH="$HOME/claude-tools:$PYTHONPATH"' >> ~/.zshrc
source ~/.zshrc
Sanity check:
python -c "from pubmed_tools import TOOLS; print(len(TOOLS))"
Should print 2. Three common ways this can fail:
ModuleNotFoundError: No module named 'pubmed_tools'— your shell hasn't picked up the newPYTHONPATH. Trysource ~/.zshrcand re-run, or open a fresh terminal. Ifecho $PYTHONPATHis empty even after that, check your shell:echo $SHELL. If it's/bin/bash, your default shell is bash and~/.zshrcis being ignored — append the same export to~/.bashrc, or change your default shell withchsh -s /bin/zsh.ModuleNotFoundError: No module named 'requests'— you're running the system Python, not your venv's. Activate the venv (source path/to/.venv/bin/activate) and re-run. The system Python has no project packages installed.- Still broken after both — confirm
~/claude-tools/pubmed_tools.pyexists (ls ~/claude-tools/) and isn't empty.
Two files on disk; one line in .zshrc. Every Python session you start from now on knows where to find your tools.
Verify in a brand-new notebook
This is the moment the install pays off. Open JupyterLab from any folder other than ~/claude-tools/. The point is to confirm the modules are reachable globally, not just in their own directory.
cd ~/Desktop # or any folder, just not ~/claude-tools
source path/to/your/venv/bin/activate # the venv that has anthropic + requests
jupyter lab
In a fresh notebook, first load your .env (so ANTHROPIC_API_KEY is in os.environ):
# Load the API key from .env if you're using python-dotenv
from dotenv import load_dotenv
load_dotenv()
load_dotenv() searches the current working directory and its parents. If it returns False, your .env lives somewhere else — pass the explicit path: load_dotenv("/full/path/to/your/.env"). If you get ModuleNotFoundError: No module named 'dotenv', run pip install python-dotenv in the venv and restart the kernel.
Then run the verification:
from pubmed_tools import TOOLS, TOOL_FUNCS
from claude_runtime import chat_with_tools
print(chat_with_tools("How many papers on KRAS G12C inhibitors were published in 2026?", TOOLS, TOOL_FUNCS))
Expected output (wording will vary; the number is live from PubMed):
PubMed indexed 487 papers on KRAS G12C inhibitors published in 2026. The field remains highly active — sustained interest following the approvals of sotorasib and adagrasib.
Three import lines, one function call, one prose answer grounded in fresh data. Behind the scenes Claude received your question, scanned TOOLS for relevant functions, matched pubmed_count's description, called pubmed_count(query="KRAS G12C inhibitor", year=2026), read the count, and wrote the answer. The number came from NCBI at request time, not Claude's training memory.
Your machine now answers PubMed questions through Claude, from any notebook anywhere, by importing two files.
The same library, used three ways
Three demonstrations of the same install. Each runs in the verification notebook from above.
Direct call, no Claude. When you just want the function output:
from pubmed_tools import pubmed_count
n = pubmed_count("KRAS G12C inhibitor", year=2026)
print(f"PubMed papers on KRAS G12C inhibitors in 2026: {n}")
Conversational, Claude chooses pubmed_search. When you want recent papers, not a count, Claude picks the second tool by reading the descriptions:
print(chat_with_tools("Show me the 3 most recent PubMed papers on PROTAC degraders.", TOOLS, TOOL_FUNCS))
Chained, Claude uses both tools in one conversation. A question like "how active is X — count plus the most recent papers?" naturally needs both pubmed_count and pubmed_search. Claude figures out the sequence:
print(chat_with_tools(
"How active is spatial transcriptomics — how many papers in 2026 so far, "
"and show me the 3 most recent ones?",
TOOLS, TOOL_FUNCS
))
Expected (titles and PMIDs will differ — these are illustrative):
Spatial transcriptomics is very active in 2026, with 1,243 PubMed papers indexed so far. The three most recent:
- "Single-cell spatial profiling of the tumour microenvironment in metastatic melanoma" — Nature, 2026, Smith J et al.
- "Visium HD enables sub-cellular spatial transcriptomics in archival FFPE tissue" — Nat Methods, 2026, Garcia P et al.
- "Comparative analysis of Stereo-seq, MERFISH, and Visium HD" — Cell Systems, 2026, Yamamoto K et al.
Two tool calls, one prose answer. The chained demo proves the runtime works correctly when Claude has to plan a sequence.
Using the same library from Claude Code in the terminal
Everything above is the Anthropic API path — Python code calling client.messages.create(...) with tools registered. There's a second way to use the same modules: Claude Code (the claude command in your terminal) can be taught to invoke them too. The bridge is a Skill.
Claude Code is distinct from the API. When you run claude in a terminal, Claude has access to a built-in set of tools (Read, Edit, Bash, etc.), plus whatever you've added via Skills, MCP servers, or hooks. Your ~/claude-tools/ modules aren't automatically known to Claude Code, but a tiny Skill tells it how to reach them.
Create the directory:
mkdir -p ~/.claude/skills/pubmed-tools
Save the following as ~/.claude/skills/pubmed-tools/SKILL.md:
# PubMed search tools
The user has two PubMed tools installed as a Python module at
`~/claude-tools/pubmed_tools.py` (importable because `~/claude-tools`
is on PYTHONPATH).
To count papers on a topic, use the Bash tool to run:
python -c "from pubmed_tools import pubmed_count; print(pubmed_count('QUERY', YEAR))"
where YEAR is optional. Examples:
- pubmed_count('KRAS G12C inhibitor', 2026)
- pubmed_count('spatial transcriptomics')
To get the most recent papers on a topic, use Bash to run:
python -c "from pubmed_tools import pubmed_search; import json; print(json.dumps(pubmed_search('QUERY', n=5), indent=2))"
When the user asks about literature counts, recent papers, or how
active a research field is, prefer these tools over guessing from
training memory. The PubMed numbers are authoritative; your training
data is stale.
Save the file, open a new terminal, run claude to start a Claude Code session, and ask:
"How many papers on spatial transcriptomics were published in 2026?"
Claude Code reads the Skill into context, sees the pubmed_count invocation it can run via Bash, executes the python -c "..." command, and reports the count back in prose. You did not type any Python.
This is the Skills + Tools bridge. A Skill is a rule for Claude. A tool module is code Claude can call. Combining them — a Skill that teaches Claude Code about your code — is how the two layers compose. Now your notebooks and your terminal claude sessions both reach the same PubMed tools, with no per-project setup.
Three pragmatic notes:
- PYTHONPATH on Claude Code's shell. Claude Code runs
python -c "..."through its Bash tool, which inherits your shell environment. If you added thePYTHONPATHexport to.zshrcand your default shell is zsh, you're set. If your default shell is bash, add it to.bashrctoo, or symlink. - Right Python. Claude Code's Bash runs whichever
pythonis on yourPATH. That should be the one in your activated venv (the one withrequestsinstalled). If the import errors, edit the Skill to use an explicit path:/full/path/to/.venv/bin/python -c "...". - No new Anthropic key setup. The PubMed tools don't need the Anthropic key (NCBI is free). Claude Code itself uses your existing key to talk to Claude. Nothing new to configure.
Adding more tools to your ~/claude-tools/ library
The same pattern — a domain-specific module on PYTHONPATH plus an optional Skill bridge — works for any tool you want Claude to call. Once you have more than one tool module in ~/claude-tools/, combine them at the use site (the example below pretends you've also written gene_id_tools.py for gene-ID resolution):
from pubmed_tools import TOOLS as PUBMED_TOOLS, TOOL_FUNCS as PUBMED_FUNCS
from gene_id_tools import TOOLS as GENE_TOOLS, TOOL_FUNCS as GENE_FUNCS
from claude_runtime import chat_with_tools
tools = PUBMED_TOOLS + GENE_TOOLS
funcs = {**PUBMED_FUNCS, **GENE_FUNCS}
print(chat_with_tools("Count IL-23 papers in 2026, then resolve BRCA1 to Ensembl.", tools, funcs))
Add a tool module, add one import line, merge the lists. No hidden globals; no edits to the runtime.
Two upgrade paths worth knowing
The more professional file layout turns ~/claude-tools/ into a pip-installable package (pyproject.toml plus pip install -e ~/claude-tools once per venv). That gives each venv its own resolved view of the imports, which matters when projects need different versions of the same helper or when you start writing tests against your tools. For personal day-to-day use the PYTHONPATH trick is enough.
The more thorough Claude Code integration is an MCP server. The Skill-based bridge in the previous section is delegation through Bash — Claude Code uses its Bash tool to run a python -c invocation that uses your function. It works, but it's indirect. An MCP server makes pubmed_count and pubmed_search native tools that Claude Code calls directly — no Bash wrapper, no Skill needed. MCP is its own topic; the Skill bridge above is the lighter-weight starting point.
Quick reference, cost, and what these tools won't do
Common imports (after the install above):
from pubmed_tools import TOOLS, TOOL_FUNCS, pubmed_count, pubmed_search
from claude_runtime import chat_with_tools
Tweak the tools' behaviour: edit the description field in count_tool or search_tool. The more specific the description, the more reliably Claude picks the right tool for ambiguous questions.
Costs: the PubMed API is free. Each chat_with_tools call is two Anthropic API calls (one to decide on a tool, one to write the final answer) — under a cent on claude-haiku-4-5, the default in claude_runtime.py. Multi-tool conversations add one API call per invocation. NCBI is rate-limited (3 req/s without a key, 10 with a free API key).
What these tools won't do:
- No paper abstracts.
esummaryreturns metadata only. For abstracts, add anefetch-based tool that callsrettype=abstract&retmode=text. - PubMed only. bioRxiv, medRxiv, arXiv, and patents each have their own APIs; the same pattern (function → schema → register → expose) works for each.
- No semantic dedup. PubMed's "sort by date" can be erratic (publication date vs entry date discrepancies).
- Needs the internet. Both for the Anthropic API and for NCBI.
Companion notebook
On GitHub: tensoromics/notes. The claude-tools-getting-started.ipynb notebook is the verification path — it assumes you've installed the two .py files and run the PYTHONPATH sanity check above, then runs the three demonstrations to confirm everything is wired correctly.