Building a LlamaIndex Research Agent with Google Scholar Search
Give a LlamaIndex agent a live Google Scholar tool via Serply so it cites real papers instead of inventing plausible-sounding references.


- Why Scholar search, specifically
- What the Scholar API actually returns
- Writing the Scholar tool
- Wiring it into an agent alongside a local index
- The point of doing it this way
Ask almost any LLM to “cite three papers on X” and it will happily do it — titles that sound right, authors that sound plausible, journals that exist. The problem is that a meaningful fraction of those citations don’t. The model isn’t looking anything up; it’s pattern-matching what a citation looks like and generating text in that shape. For a chatbot that’s a minor embarrassment. For a research agent whose whole job is to ground answers in literature, it’s disqualifying.
The fix isn’t a better prompt. It’s giving the agent an actual tool that hits actual search results, so “find me papers on X” triggers a real HTTP call instead of a completion. This post walks through wiring Serply’s Google Scholar API into a LlamaIndex agent as a FunctionTool, so the agent can decide, on its own, when to go read the literature instead of guessing.
Why Scholar search, specifically
LlamaIndex is built around indexing your own documents — PDFs, notes, internal wikis — and querying them with retrieval-augmented generation. That’s great when the answer already lives in your corpus. It’s useless when the question is “what’s the recent work on this topic,” because by definition recent work isn’t in your local index yet.
Google Scholar search closes that gap. Serply’s Scholar endpoint returns titles, links, and abstract snippets for whatever query you send it — the same result set you’d get from scholar.google.com, just as JSON instead of HTML. Combine it with a local vector index as a second tool, and the agent gets to choose per-question: “is this something I already have indexed, or do I need to go look at what’s actually been published?”
What the Scholar API actually returns
A couple of details matter for writing the tool function correctly, because they differ slightly from Serply’s general web search endpoint:
- The query goes in the URL path, not a query string:
GET https://api.serply.io/v1/scholar/q=<url-encoded query>. - Auth is a required
X-Api-Keyheader.X-Proxy-LocationandX-User-Agentare optional. - The response shape is:
{
"results": [
{
"title": "High-Frequency Trading in a Limit Order Market",
"link": "https://www.jstor.org/stable/2325066",
"description": "We analyze a limit order market where traders can submit both market and limit orders..."
}
],
"total": 1250,
"ts": 1234567890,
"device_region": "US",
"device_type": "desktop"
}
Note there’s no answer field here the way there is on Serply’s plain web search endpoint — Scholar results are just results, total, and some request metadata (ts, device_region, device_type). Keep the tool function’s return value simple: title, link, and description snippet are what an agent actually needs to reason about and cite.
Writing the Scholar tool
Here’s a plain Python function that calls the endpoint and formats the results into something readable, wrapped as a LlamaIndex FunctionTool:
import os
import requests
from urllib.parse import quote
from llama_index.core.tools import FunctionTool
SERPLY_API_KEY = os.environ["SERPLY_API_KEY"]
def search_scholar(query: str, num_results: int = 5) -> str:
"""Search Google Scholar for academic papers and return their
titles, links, and abstract snippets. Use this when the user asks
about published research, citations, or recent academic work on a
topic that might not be in the local document index.
"""
encoded_query = quote(f"q={query}")
url = f"https://api.serply.io/v1/scholar/{encoded_query}"
headers = {"X-Api-Key": SERPLY_API_KEY}
response = requests.get(url, headers=headers, timeout=15)
response.raise_for_status()
data = response.json()
results = data.get("results", [])[:num_results]
if not results:
return "No papers found for this query."
formatted = []
for i, paper in enumerate(results, start=1):
formatted.append(
f"{i}. {paper['title']}\n"
f" Link: {paper['link']}\n"
f" Abstract: {paper['description']}"
)
return "\n\n".join(formatted)
scholar_tool = FunctionTool.from_defaults(
fn=search_scholar,
name="search_google_scholar",
description=(
"Searches Google Scholar for real academic papers matching a query. "
"Returns actual titles, links, and abstracts — use this instead of "
"recalling citations from memory."
),
)
The docstring and the description passed to FunctionTool.from_defaults both matter — LlamaIndex agents decide which tool to invoke based on these descriptions, so being explicit about when to reach for this tool (recent or unindexed research) versus the local index is what actually produces the routing behavior you want.
Wiring it into an agent alongside a local index
The interesting part isn’t the Scholar tool in isolation — it’s giving the agent a choice. Suppose you already have a VectorStoreIndex built from a folder of papers you’ve personally read and want the agent to prefer when relevant:
from llama_index.core import VectorStoreIndex, SimpleDirectoryReader
from llama_index.core.tools import QueryEngineTool
from llama_index.core.agent.workflow import FunctionAgent
from llama_index.llms.openai import OpenAI
# Local index over papers you've already collected
documents = SimpleDirectoryReader("./my_papers").load_data()
local_index = VectorStoreIndex.from_documents(documents)
local_query_tool = QueryEngineTool.from_defaults(
query_engine=local_index.as_query_engine(),
name="query_local_papers",
description=(
"Searches a local collection of papers the user has already "
"read and saved. Use this first for questions about papers "
"in the user's existing collection."
),
)
agent = FunctionAgent(
tools=[local_query_tool, scholar_tool],
llm=OpenAI(model="gpt-4o"),
system_prompt=(
"You are a research assistant. Prefer the local paper index for "
"questions about papers the user already has. Use Google Scholar "
"search for anything recent, unfamiliar, or not found locally. "
"Always cite the actual title and link returned by your tools — "
"never invent a citation."
),
)
response = await agent.run(
"What's the latest work on order book imbalance as a short-term "
"price predictor? I don't think I have anything on this saved."
)
print(response)
Run this and the agent’s trace shows exactly what you’d hope: a query_local_papers call that comes back empty or thin, followed by a search_google_scholar call, followed by an answer built from the actual returned titles and links. If you ask instead about a paper you know is already in ./my_papers, the agent answers from the local index and never touches the network — which also means it’s cheaper and faster for the questions your index already covers.
The point of doing it this way
None of this makes the underlying LLM smarter. What it does is change where the facts come from. Instead of the model reconstructing a citation from patterns it saw during training — which is exactly the process that produces confident, well-formatted, nonexistent papers — every citation in the final answer traces back to a real HTTP response with a real, clickable link. If a user clicks through and the page doesn’t exist, that’s a Google Scholar indexing problem, not a hallucination.
That distinction is the whole reason to bother with tool calling in the first place. A chat model with no tools is guessing shaped like an answer. A chat model with a working Scholar search tool is actually looking things up — and for a research agent, that’s not a nice-to-have, it’s the entire value proposition.
If you want to extend this pattern, the same approach works for Serply’s other search endpoints — news, web, product — each wrapped as its own FunctionTool with a description narrow enough that the agent knows exactly when to reach for it. You can grab an API key and test the endpoint directly at api.serply.io before wiring it into an agent at all.