Giving an AI agent web search on the same key as the model
One credential for the model and the search tool, with the exact tool definition, the fields an agent needs, and the vendor behind it named plainly.
An agent that needs to look things up usually means a second vendor: an account with a search API, another key in your environment, another invoice. On Infrai POST /v1/web/search sits on the same credential as the model itself, so the tool your agent calls and the inference that calls it are one account and one bill.
Worth saying plainly up front: that search is served by Tavily. You’re not getting a different index — you’re getting it without a second integration.
The call
curl -sS -X POST "https://api.infrai.cc/v1/web/search" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"query": "what is a capability gateway api", "max_results": 3}'
{
"ok": true,
"data": {
"query": "what is a capability gateway api",
"results": [
{
"title": "What Is an API Gateway? A Quick Learn Guide | F5",
"url": "https://www.f5.com/glossary/api-gateway",
"snippet": "An API gateway accepts API requests from a client, processes them based on defined policies, directs them to the appropriate services, and combines the responses..."
}
],
"raw_vendor_payload": null
}
}
Four request fields: query, max_results, include_domains and exclude_domains. The response gives you title, url and snippet per hit — which is exactly the shape a model needs, and small enough that three results don’t eat your context window.
raw_vendor_payload is there when you want everything the vendor returned. Leave it alone for agent use; the trimmed shape is the point.
Define it as a tool
import json
import os
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
SEARCH_TOOL = {
"type": "function",
"function": {
"name": "web_search",
"description": "Search the public web for current information. Use when the answer "
"depends on facts that change, or on anything after your training data.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "A natural-language search query."},
"max_results": {"type": "integer", "description": "1-10; default 3.", "default": 3},
"include_domains": {"type": "array", "items": {"type": "string"},
"description": "Restrict to these domains, e.g. official docs."},
},
"required": ["query"],
},
},
}
def web_search(query: str, max_results: int = 3, include_domains: list[str] | None = None) -> dict:
body = {"query": query, "max_results": max_results}
if include_domains:
body["include_domains"] = include_domains
resp = SESSION.post(f"{API}/v1/web/search", json=body, timeout=40)
payload = resp.json()
if not payload.get("ok"):
# A tool failure is a message to the model, not an exception to the user.
# The model can retry with a different query or answer without the tool.
return {"error": payload.get("error", {}).get("code", "search_failed"), "results": []}
data = payload["data"]
return {"query": data["query"],
"results": [{"title": r.get("title"), "url": r.get("url"),
"snippet": r.get("snippet")} for r in data.get("results", [])]}
def run_agent(question: str, model: str = "auto") -> str:
"""The model and the tool are the same credential, so there is no second key in
this process and no second failure mode to reason about."""
messages = [{"role": "user", "content": question}]
for _ in range(4):
resp = SESSION.post(
f"{API}/v1/chat/completions",
json={"model": model, "messages": messages, "tools": [SEARCH_TOOL]},
timeout=120,
)
resp.raise_for_status()
choice = resp.json()["choices"][0]
message = choice["message"]
messages.append(message)
calls = message.get("tool_calls") or []
if not calls:
return message.get("content", "")
for call in calls:
args = json.loads(call["function"]["arguments"] or "{}")
result = web_search(**args)
messages.append({"role": "tool", "tool_call_id": call["id"],
"content": json.dumps(result)})
return "gave up after four tool rounds"
if __name__ == "__main__":
print(run_agent("What changed in the Python 3.14 release?"))
The inference call is the OpenAI-compatible surface, so whatever agent framework you already use works unchanged — and the search tool inside it is a function on the same key rather than a separate client.
Tell the model when not to search
The most common agent failure with a search tool isn’t a bad search, it’s searching for everything. A model that looks up “what is 2+2” costs you a call and adds latency for nothing.
The description field is where you fix that: “use when the answer depends on facts that change” does more work than any amount of system-prompt instruction. Two more levers help.
include_domains narrows a lookup to sources you trust — official documentation rather than the whole web — which improves answer quality more than raising max_results does. And a low max_results keeps the context small: three good hits beat ten mediocre ones, and the model reads all of them either way.
Searching then reading
A snippet is often enough. When it isn’t, POST /v1/web/scrape turns one result into full text:
curl -sS -X POST "https://api.infrai.cc/v1/web/scrape" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"url": "https://www.f5.com/glossary/api-gateway", "format": "markdown"}'
Expose both as separate tools and let the model decide: search to find, scrape to read. Two tools, one key, and the model’s own judgement about when a snippet is insufficient.
Limitations
The surface is deliberately small — two endpoints, four request fields between them. There’s no crawl, no sitemap discovery, no scheduled re-indexing, no semantic-search mode over your own corpus, and no per-result scoring you can tune. Firecrawl’s crawl-and-extract pipeline and Exa’s embeddings-based retrieval both do things this can’t, and if web data is your product rather than a tool your agent occasionally reaches for, going direct to one of them is the better fit.
You’re also one vendor deep: search and scrape are Tavily-backed, so the index and its freshness are theirs, and using Tavily directly gets you their full parameter surface including their own answer synthesis.
What the shared credential buys is the rest of the agent. The model call, the search tool, the vector store for what you keep with POST /v1/vector/upsert, the queue for long jobs with POST /v1/queue/publish and the error capture when a tool misbehaves are one key and one GET /v1/account/usage — so per-tenant agent cost is a query rather than a reconciliation across three vendors. Search bills per call and scrape a little less, both live in GET /v1/discovery/web.search (verified 2026-09-21) and drifting downward as vendor contracts improve.