Function Calling with Serply: Real-Time Search for OpenAI and Claude
Give GPT-4 and Claude live web search with function calling — wire Serply's Search API into OpenAI's tools and Anthropic's tool_use in a few lines.


- What Serply’s Search API actually returns
- Defining the tool schema
- Wiring it up with OpenAI
- Wiring it up with Claude
- Handling multiple tool calls and follow-up searches
- Why not just glue in a web-browsing plugin?
Large language models are frozen in time the moment training ends. Ask GPT-4 or Claude about this morning’s news, a product that launched last week, or a stock price right now, and you’ll get a guess dressed up as an answer — or, if you’re lucky, an honest “I don’t have access to real-time information.”
Function calling (OpenAI’s term) and tool use (Anthropic’s term) fix this. Instead of answering from memory, the model can pause mid-response, ask your code to run a function, and use the result to finish its answer. Wire that function up to a real search API and you’ve turned a static model into an agent that can look things up.
This post walks through wiring Serply’s Search API into both the OpenAI Chat Completions API and the Anthropic Messages API. The two providers use slightly different shapes for tool definitions and tool results, so we’ll build both side by side.
What Serply’s Search API actually returns
Before defining a tool schema, it helps to know exactly what the function will return, since that shapes how you describe it to the model.
Serply’s Search endpoint is a plain GET request. The query string gets packed into the URL path rather than passed as a normal ?q= parameter:
GET https://api.serply.io/v1/search/q=<url-encoded-query>
Every request needs an X-Api-Key header — see the authentication guide for how to get one. Two optional headers let you tune the result: X-Proxy-Location (e.g. US, GB, DE) picks the geographic vantage point the search runs from, and X-User-Agent (desktop or mobile) picks the device type. Both default to sensible values if you skip them.
The response is a small, flat JSON object:
{
"results": [
{
"title": "Result Title",
"link": "https://example.com",
"description": "Result description text..."
}
],
"total": 1840000000,
"answer": null
}
results is what you’ll almost always want — an array of title/link/description triples, already close to what an LLM needs to synthesize an answer. total is Google’s reported match count, and answer occasionally holds a direct answer-box snippet when Google surfaces one.
Here’s the Python helper both examples below will call:
import requests
SERPLY_API_KEY = "YOUR_API_KEY"
def serply_search(query: str, num_results: int = 5) -> str:
url = f"https://api.serply.io/v1/search/q={requests.utils.quote(query)}"
headers = {"X-Api-Key": SERPLY_API_KEY}
response = requests.get(url, headers=headers)
response.raise_for_status()
data = response.json()
results = data.get("results", [])[:num_results]
lines = [
f"{i+1}. {r['title']} — {r['link']}\n {r['description']}"
for i, r in enumerate(results)
]
return "\n".join(lines) or "No results found."
It returns a plain string summary rather than raw JSON — both OpenAI and Anthropic expect tool results as text (or a JSON string), and a pre-formatted summary keeps the model from having to re-parse a nested structure.
Defining the tool schema
Both providers want the same three things — a name, a description, and a JSON Schema for the arguments — but they nest them differently.
OpenAI wraps the schema inside a function object, with the parameter schema under parameters:
openai_tools = [
{
"type": "function",
"function": {
"name": "serply_search",
"description": "Search the web for current, real-time information. Use this for anything that might have changed since your training data — news, prices, recent events, current documentation.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query to run."
},
"num_results": {
"type": "integer",
"description": "How many results to return. Defaults to 5.",
}
},
"required": ["query"]
}
}
}
]
Anthropic flattens name and description to the top level and calls the schema input_schema instead of parameters:
anthropic_tools = [
{
"name": "serply_search",
"description": "Search the web for current, real-time information. Use this for anything that might have changed since your training data — news, prices, recent events, current documentation.",
"input_schema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query to run."
},
"num_results": {
"type": "integer",
"description": "How many results to return. Defaults to 5.",
}
},
"required": ["query"]
}
}
]
Same fields, different envelope. Easy to get wrong if you’re porting a tool definition from one provider to the other — worth double-checking against each SDK’s current docs if things start failing silently.
Wiring it up with OpenAI
The flow with OpenAI’s Chat Completions API is: send the message with tools attached, check whether the model asked for a tool call, run it, and send the result back as a tool role message.
from openai import OpenAI
client = OpenAI()
messages = [
{"role": "user", "content": "What's the latest news on the Mars Sample Return mission?"}
]
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=openai_tools,
)
message = response.choices[0].message
if message.tool_calls:
messages.append(message) # the assistant's tool-call turn
for tool_call in message.tool_calls:
if tool_call.function.name == "serply_search":
import json
args = json.loads(tool_call.function.arguments)
result = serply_search(
query=args["query"],
num_results=args.get("num_results", 5),
)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result,
})
# Second call: model now has the search results and writes the final answer
final_response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=openai_tools,
)
print(final_response.choices[0].message.content)
else:
print(message.content)
A few details that trip people up the first time: tool_call_id in your tool message has to match the id on the tool_call the model produced — that’s how it maps the result back to the right request. And you have to append the assistant’s own tool-call message to messages before appending the tool result, or the second call will 400 with a confusing “tool message must follow a tool call” error.
Wiring it up with Claude
Anthropic’s Messages API follows the same two-round-trip shape, but the pieces live inside a content array rather than as separate message roles.
import anthropic
client = anthropic.Anthropic()
messages = [
{"role": "user", "content": "What's the latest news on the Mars Sample Return mission?"}
]
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
tools=anthropic_tools,
messages=messages,
)
if response.stop_reason == "tool_use":
messages.append({"role": "assistant", "content": response.content})
tool_results = []
for block in response.content:
if block.type == "tool_use" and block.name == "serply_search":
result = serply_search(
query=block.input["query"],
num_results=block.input.get("num_results", 5),
)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
})
messages.append({"role": "user", "content": tool_results})
final_response = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
tools=anthropic_tools,
messages=messages,
)
print(final_response.content[0].text)
else:
print(response.content[0].text)
Two things to note. First, stop_reason == "tool_use" is how you detect that Claude wants a tool run — there’s no separate tool_calls field to check like OpenAI’s. Second, the tool result goes back as a user-role message containing tool_result content blocks, referencing tool_use_id, not as its own role the way OpenAI’s tool role works.
Handling multiple tool calls and follow-up searches
Real conversations rarely stop at one search. A user might ask a question that needs two lookups, or the model might read the first batch of results and decide it needs a follow-up search to fill a gap. Both loops above generalize naturally — wrap the “check for tool calls → run them → append results → call again” logic in a while loop that keeps going until the model returns a normal text response instead of a tool request. Just cap the number of iterations (five or so is plenty) so a model that gets stuck in a search loop doesn’t rack up API calls indefinitely.
It’s also worth trimming what you send back. serply_search above already truncates to num_results and returns a compact string instead of the full JSON payload — with results capped at 5-10 entries, you keep the tool-result message small enough that it doesn’t crowd out context on longer conversations.
Why not just glue in a web-browsing plugin?
Both OpenAI and Anthropic offer their own hosted browsing tools, and if that’s enough for your use case, use it — it’s less code. Function calling with your own search backend makes sense when you need control the hosted versions don’t give you: picking a specific geographic vantage point with X-Proxy-Location for geo-targeted results, choosing mobile vs. desktop rendering, or combining search with Serply’s other endpoints — News, Scholar, Maps, product search — as additional tools the same model can reach for depending on the question.
Once you have one tool wired up this way, adding a second is mostly copy-paste: same request pattern, same auth header, a different endpoint and a different args-to-URL mapping. From there it’s a short step to a small toolbox of real-time capabilities sitting behind a single API key, available to whichever model you’re building with — OpenAI, Anthropic, or both.