Skip to content

New AI assistant answers: ChatGPT, Gemini, Copilot and AI Mode by API

SerpKite
Get API key

Cited web answers for RAG agents: a streaming tutorial

Ground an agent in live Google results with /v1/answer: fast or standard depth, SSE streaming in TypeScript and Python, and [n] citations as links.

SerpKite team 6 min read

On this page

A retrieval step for an agent usually goes like this: search the web, pick a few results, fetch the pages, cut them into passages, rank the passages, put the best ones in a prompt, ask a model to answer with sources, then check that the sources it cites exist. Each step is simple. Together they are a few hundred lines of code you have to keep working.

SerpKite’s /v1/answer does that whole step in one call and returns an answer whose claims cite numbered sources from live Google results. This tutorial builds it into an agent: a one-shot call first, then streaming to a terminal and to a browser, then turning [n] markers into links, and finally exposing it as a tool.

When to use it, and when not to

Use /v1/answer when you want the finished answer: a chat reply, a fact an agent needs before its next step, a “what’s the latest version of X” lookup. Claude Haiku 5.5 writes it from the sources only, and you get the citations to show or verify.

If your own model should write the answer, for example because it also sees the conversation or private documents, use search with include_content and highlights instead. That returns the results plus the passages most relevant to the query, ranked without a model, and you build the prompt yourself. The RAG pipeline guide covers that route.

Setup

npm install serpkite        # TypeScript, 0.7.0 or later
pip install -U serpkite     # Python, 0.7.0 or later
export SERPKITE_API_KEY=skt_live_...

Both clients read SERPKITE_API_KEY from the environment. Get a key from the dashboard; new accounts get free credits.

Step 1: one cited answer

import { SerpKite } from "serpkite";

const sk = new SerpKite();
const res = await sk.answer({ q: "what is retrieval-augmented generation" });

console.log(res.answer);
for (const c of res.citations) console.log(`[${c.index}] ${c.title} ${c.link}`);
console.log(res.meta.credits); // { search, answer }

The response has four parts you will use:

Field What it holds
answer Markdown with [n] markers, or null when there was nothing to answer from
citations[] The sources the answer cites (index, title, link, domain), in first-cited order
results[] Every source the model was given, numbered the same way, with a date when known
meta credits_used, credits (split into search and answer), llm (provider, model, tokens), warnings

A marker that doesn’t match a source is removed before the response is sent, so every [n] in answer has an entry in citations.

Step 2: pick a depth

depth Answers from Credits
fast The results page: answer box, knowledge panel and snippets 3
standard (default) Also the top num_sources pages (default 3, up to 5), as query-ranked passages 8 with 3 pages, 6–10 for 1–5

fast returns in a few seconds and is enough for definitions, dates, versions and other facts Google shows on the results page. Use standard when the answer needs detail that only the pages have: how a feature works, why something changed, what a document says. A decision model (OpenAI's Decisions API) checks which sources help and sends easy questions to a faster setting.

Scope the sources with the usual search options. For a question about a library, answering from its own documentation avoids blog posts that are out of date:

const res = await sk.answer({
  q: "how do I cancel an asyncio task",
  depth: "standard",
  num_sources: 2,
  include_domains: ["docs.python.org"],
});

time: "week" keeps the answer to recent coverage, and country, language and location localise the search.

Step 3: stream the answer

Waiting for a whole answer is fine inside an agent loop. In a chat UI, people want to see text arrive. With streaming, /v1/answer returns Server-Sent Events in this order:

Event Data
results {request, results}: the numbered sources, sent before any text
delta {text}: the next piece of the answer, repeated
done {answer, citations, meta}: the final answer with checked markers, and the total credits
error {error}: sent instead of done when the model failed; the model part is refunded

In TypeScript, answerStream turns the stream into an async iterator, and an error event is thrown as a SerpKiteError:

import { SerpKite, SerpKiteError } from "serpkite";

const sk = new SerpKite();

try {
  for await (const ev of sk.answerStream({ q: "what changed in the latest Node.js LTS" })) {
    if (ev.event === "results") console.error(`${ev.data.results.length} sources`);
    if (ev.event === "delta") process.stdout.write(ev.data.text);
    if (ev.event === "done") console.log("\n", ev.data.citations, ev.data.meta.credits_used);
  }
} catch (err) {
  if (err instanceof SerpKiteError && err.code === "ai_unavailable") {
    console.error("The model failed; only the search was billed.");
  } else {
    throw err;
  }
}

In Python, answer_stream yields typed events. Match on their classes:

from serpkite import SerpKite
from serpkite.types import AnswerStreamDelta, AnswerStreamDone, AnswerStreamResults

with SerpKite() as sk:
    for ev in sk.answer_stream("what changed in the latest Node.js LTS", depth="standard"):
        if isinstance(ev, AnswerStreamResults):
            print(f"{len(ev.results)} sources")
        elif isinstance(ev, AnswerStreamDelta):
            print(ev.text, end="", flush=True)
        elif isinstance(ev, AnswerStreamDone):
            print("\n", [c.link for c in ev.citations], ev.meta.credits_used)

Two details matter here. First, the credit headers on a streamed response are sent before the answer exists, so they carry only the search part; the total is in the done event’s meta.credits_used. Second, if you stop reading early (the user navigates away, your code breaks out of the loop), the connection closes and the model part is not charged.

Step 4: relay the stream to a browser

Keep the API key on your server. A small endpoint can call SerpKite and pass the events through. Here is one with FastAPI and the async client:

import json

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from serpkite import AsyncSerpKite, SerpKiteError
from serpkite.types import AnswerStreamDelta, AnswerStreamDone, AnswerStreamResults

app = FastAPI()
sk = AsyncSerpKite()


def sse(event: str, data: object) -> str:
    return f"event: {event}\ndata: {json.dumps(data)}\n\n"


@app.get("/ask")
async def ask(q: str) -> StreamingResponse:
    async def events():
        try:
            async for ev in sk.answer_stream(q, depth="standard"):
                if isinstance(ev, AnswerStreamResults):
                    yield sse("sources", [s.model_dump() for s in ev.results])
                elif isinstance(ev, AnswerStreamDelta):
                    yield sse("delta", {"text": ev.text})
                elif isinstance(ev, AnswerStreamDone):
                    yield sse("done", ev.model_dump(mode="json"))
        except SerpKiteError as err:
            yield sse("error", {"code": err.code})

    return StreamingResponse(events(), media_type="text/event-stream")

The browser can read it with EventSource("/ask?q=…"), appending delta text as it arrives and showing the sources from the first event while the answer is still being written.

The streamed text and the final answer use the same [n] markers. Show the deltas as plain text while they arrive, then replace the text with the done event’s answer and link the markers. The final answer is the one whose markers were checked against the sources.

import type { AnswerStreamDone } from "serpkite";

function linkCitations({ answer, citations }: Pick<AnswerStreamDone, "answer" | "citations">) {
  const links = new Map(citations.map((c) => [c.index, c.link]));
  return answer.replace(/\[(\d+)\]/g, (marker, n: string) => {
    const link = links.get(Number(n));
    return link ? `[${marker}](${link})` : marker;
  });
}

The result is Markdown with each [1] turned into a link to its source, which any Markdown renderer can display. If you prefer footnotes, list citations under the answer in the order they appear.

Step 6: give it to an agent as a tool

The same call works as a tool, so the model decides when it needs a cited answer and when a plain search is enough.

With LangChain, SerpKiteAnswer (in langchain-serpkite 0.3.0) returns the answer followed by its numbered sources, and the full response as the tool artifact:

from langchain_serpkite import SerpKiteAnswer, SerpKiteSearch

tools = [SerpKiteAnswer(depth="fast"), SerpKiteSearch()]

With the Vercel AI SDK, @serpkite/ai-sdk 0.4.0 has an answer tool among its 21:

import { openai } from "@ai-sdk/openai";
import { serpkiteTools } from "@serpkite/ai-sdk";
import { generateText, stepCountIs } from "ai";

const { text } = await generateText({
  model: openai("gpt-5-mini"),
  tools: serpkiteTools({ only: ["answer", "search", "webpage"] }),
  stopWhen: stepCountIs(5),
  prompt: "Which Python version added the asyncio TaskGroup? Cite sources.",
});

In Claude, Cursor and other MCP clients, the remote MCP server has an answer tool with depth, num_sources and domain filters. There is nothing to install.

Handling the edge cases

  • Nothing to answer from. If the search finds no usable sources, answer is null, meta.warnings says why, and only the search is billed. Treat it as “I don’t know” rather than an error.
  • The model fails. The call returns 503 ai_unavailable (or an error event on a stream). The model part is refunded and the message says what the search part cost. Retrying later is safe.
  • You need fields, not prose. Pass output_schema (a JSON Schema, up to 16 KB) and the answer comes back as JSON in output, with [n] markers kept in the fields that hold claims. It can’t be combined with streaming.
  • Untrusted pages. Text inside a fetched page is treated as data, and instructions in it are ignored. The model is told to say when the sources don’t answer the question.

A quick alternative: answers on search calls

If your agent already calls /v1/search or /v1/news, add include_answer: true instead of making a second call. You get the normal response plus an answer object with text and citations, for +2 credits (+4 when include_content read the pages). It doesn’t stream, but it’s the cheapest way to add a summary to results you already fetch.

Further reading

Written by the SerpKite team. Questions or corrections: support@serpkite.com. Subscribe via RSS.

Related posts

Start building

Get your API key in 30 seconds

2,500 free credits, then 1,000 every month. No credit card.