An agent that answers questions from the live web needs two things in sequence: a way to find candidate pages, and a way to turn each page into text clean enough to reason over. This tutorial wires those two steps together through the NativePort gateway, using Serper for search and Jina Reader for page-to-markdown, and ends with a small, runnable pipeline that produces a cited source bundle: a question plus a list of {title, url, snippet, content} sources, ready to hand to whichever model you use for synthesis. None of this is tied to one model provider. The bundle is plain JSON, and turning it into a written answer is left to you.
What you’ll need
- A NativePort API key (sign up; $5 of credit is seeded automatically).
- Python 3, standard library only. No
pip install: every request in this tutorial goes throughurllib.request. - Your key exported as an environment variable, never hardcoded:
export NATIVEPORT_API_KEY="np_..."
How the two calls fit together
Both providers sit behind the same gateway, the same key, and the same Authorization: Bearer <NATIVEPORT_API_KEY> header. Only the path changes:
POST https://api.nativeport.ai/serper/search: runs a Google search and returns Serper’s own JSON, untouched. The field you want out of it is the top-levelorganicarray, each entry carrying at minimumtitle,linkandsnippet.GET https://api.nativeport.ai/jina/reader/<url>: hands back the page at<url>as clean markdown/text, with boilerplate already stripped. The target URL is simply appended to the path, unencoded, exactly as Jina’s own Reader expects it.
Neither response is reshaped by the gateway. What you get back is exactly what Serper or Jina would hand you directly, with the upstream credential injected server-side instead of held by you.
Step 1 — search with Serper
import json
import os
import urllib.error
import urllib.request
GATEWAY = "https://api.nativeport.ai"
API_KEY = os.environ["NATIVEPORT_API_KEY"]
def search(query):
body = json.dumps({"q": query}).encode()
req = urllib.request.Request(f"{GATEWAY}/serper/search", data=body, method="POST")
req.add_header("Authorization", f"Bearer {API_KEY}")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read()).get("organic", [])
organic is a list of result dicts. This tutorial only reads title, link and snippet. Serper’s response carries more (knowledge graph, related searches, and vertical-specific fields depending on which endpoint you call), but the full schema is Serper’s own to document, not the gateway’s, so treat anything beyond those three fields as extra rather than guaranteed.
Step 2 — turn a result into clean markdown with Jina Reader
def read_url(url):
req = urllib.request.Request(f"{GATEWAY}/jina/reader/{url}", method="GET")
req.add_header("Authorization", f"Bearer {API_KEY}")
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.read().decode(errors="replace")
By default this returns plain markdown text, not JSON. There’s no .get("content") to unwrap: the response body is the article.
Step 3 — the full pipeline
Put both together, handle the failure cases that actually happen in practice (see below), and cap how much of each page you keep so the bundle stays a reasonable size to pass to a model:
#!/usr/bin/env python3
"""Search with Serper, read each result with Jina Reader, and print a
cited source bundle as JSON."""
import json
import os
import sys
import urllib.error
import urllib.request
GATEWAY = "https://api.nativeport.ai"
API_KEY = os.environ["NATIVEPORT_API_KEY"]
TIMEOUT = 30
def _request(method, url, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", f"Bearer {API_KEY}")
if data is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
return resp.status, resp.read()
except urllib.error.HTTPError as e:
return e.code, e.read()
def search(query):
status, body = _request("POST", f"{GATEWAY}/serper/search", {"q": query})
if status == 402:
sys.exit("NativePort balance is at $0 — top up before retrying.")
if status != 200:
sys.exit(f"Serper search failed: HTTP {status} — {body.decode(errors='replace')[:300]}")
return json.loads(body).get("organic", [])
def read_url(url):
status, body = _request("GET", f"{GATEWAY}/jina/reader/{url}")
if status != 200:
return None
return body.decode(errors="replace")
def build_bundle(query, top_n=3, max_chars=4000):
sources = []
for result in search(query)[:top_n]:
url = result.get("link")
if not url:
continue
content = read_url(url)
if content is None:
continue # a single dead page shouldn't sink the whole bundle
sources.append({
"title": result.get("title"),
"url": url,
"snippet": result.get("snippet"),
"content": content[:max_chars],
})
return {"question": query, "sources": sources}
if __name__ == "__main__":
query = " ".join(sys.argv[1:]) or "what does NativePort's methodology page measure"
print(json.dumps(build_bundle(query), indent=2))
Running it
export NATIVEPORT_API_KEY="np_..."
python3 pipeline.py "what does NativePort's methodology page measure"
The output is a single JSON object shaped like this (values illustrative: the actual title, snippet and markdown depend on what’s live on the web when you run it):
{
"question": "what does NativePort's methodology page measure",
"sources": [
{
"title": "How we measure — NativePort",
"url": "https://nativeport.ai/methodology/",
"snippet": "Every number on this site comes from benchmark runs we operate ourselves...",
"content": "# How we measure\n\nEvery number on this site — each composite, rank..."
}
]
}
Feed sources to any model as grounding context, with instructions to cite url for each claim. The bundle already carries everything that prompt needs.
Errors you’ll actually hit
402 Payment Required: the NativePort balance is at $0. Every request answers this deterministically instead of degrading silently, so top up and retry.401 Unauthorized: theAuthorization: Bearer <NATIVEPORT_API_KEY>header is missing or malformed, or the key doesn’t resolve to an account at all (revoked key, deleted account). Check that the environment variable is actually exported in the shell running the script.403 Forbidden: the key itself is valid, but the account behind it isn’t active (for example, suspended). Retrying with the same header won’t help here, since this is an account-status problem, not a credentials problem.404on/serper/<endpoint>or/jina/<service>: the endpoint or service name doesn’t exist. Only Serper’s documented verticals (search,news,images,webpage, and the rest) and Jina’s documented services (reader,search,embeddings,rerank,classify) are admitted; anything else is refused by the gateway before it reaches the provider.- A
read_urlcall returningNone: the target page itself failed upstream at Jina (dead link, block, timeout), not a gateway problem. The pipeline above skips it and keeps going instead of aborting the whole batch.
What this costs
Usage debits your NativePort balance at each provider’s own metered price, with no markup on individual calls. Both providers here are inexpensive per call (see current entry pricing on the Serper and Jina provider pages), and sign-up seeds $5 of credit automatically. Adding credit is the only place a fee applies: 5.5%, on top-ups of $10 to $5,000, never on the calls themselves. Full breakdown: pricing.
Where to go next
- Search leaderboard and SERP verticals leaderboard: how Serper and Jina’s search endpoints measure against the rest of the field.
- Methodology: how those scores are produced.
- Swap in a different search or reader provider from the catalog. The request shape for this pipeline barely changes, since every provider sits behind the same key and base URL.