← All posts

How to Make Your AI Agent Cite the Sources It Actually Used

Most "AI agents with citations" are lying to you a little. The agent searches, reads a few pages, writes an answer, and then decorates it with links that look plausible. Sometimes the link is one it fetched. Sometimes it is a URL the model half-remembered from training. Sometimes the page is real but says nothing like what the agent claims.

Getting an agent to cite sources is easy. Getting it to cite the sources it actually used, in a way you can verify mechanically, takes a specific pipeline design. That is what this post covers.

Why Citations Go Wrong

A citation is only trustworthy if it survives three checks: the URL was really fetched during this run, the cited page really contains the claim, and the final output points at the right page for each claim.

Agents fail all three in predictable ways:

  • Invented URLs. If the model is allowed to write URLs freely, it will occasionally produce ones that look right and 404. This happens even when real sources are sitting in context.
  • Snippet citations. The agent cites a search result it never opened. The title matched, so it felt safe. The page might say the opposite of the claim.
  • Citation drift. The claim came from source 2, but the model attributes it to source 4. Every link resolves, every page is real, and the attribution is still wrong.

You cannot prompt your way out of all of this. "Always cite your sources" produces citations, not correct ones. The fix is structural: control what the model can cite, and verify what it cited afterward.

Step 1: Build a Source Registry, Not a Context Blob

The single most useful change is to stop concatenating fetched pages into one big context string. Instead, keep a registry: every page the agent fetches gets an ID, and the registry stores the URL, the fetch timestamp, and the full extracted content.

import requests

registry = {}

def search_and_register(query, k=3):
    r = requests.post(
        "https://api.link.sc/v1/search",
        headers={"x-api-key": "lsc_YOUR_KEY"},
        json={"q": query},
    )
    for result in r.json()["results"][:k]:
        sid = len(registry) + 1
        registry[sid] = {
            "url": result["url"],
            "title": result["title"],
            "content": result["content"],  # full-page markdown
        }
    return registry

I am using the link.sc search endpoint here because it returns full page content as markdown alongside each result, which matters for step 3: you cannot verify a quote against a page you only have a snippet of. Any fetch layer works as long as you store the complete text of what the agent read.

Two rules follow from the registry design. The model may only cite by ID, never by raw URL. And anything that was never registered cannot be cited, because there is no ID for it. Invented URLs die here.

Step 2: Make Citation Part of the Output Schema

Free-text citations are hard to parse and easy to fudge. Ask for structure instead. The reliable pattern is claim, source ID, and a supporting quote copied verbatim from the source:

prompt = f"""Answer using ONLY the numbered sources below.

Return JSON:
{{
  "answer": "...",
  "claims": [
    {{"claim": "...", "source": 2, "quote": "exact sentence copied from source 2"}}
  ]
}}

Rules:
- Every factual claim needs an entry in "claims".
- "quote" must be copied word for word from the cited source.
- If no source supports a claim, omit the claim from the answer.

Sources:
{format_sources(registry)}

Question: {question}"""

The quote field is the load-bearing part. It forces the model to anchor each claim to a specific passage, and it gives you something a program can check. A claim without an anchor is an opinion with a link attached.

If your model supports structured output or tool calling, define this as a schema instead of prompting for JSON. You get the same shape with less parsing pain.

Step 3: Verify Quotes Against the Registry

Now the payoff. Because you kept full content for every source, verification is a substring check, not an act of faith:

import re

def normalize(text):
    return re.sub(r"\s+", " ", text).strip().lower()

def verify_claims(claims, registry):
    failures = []
    for c in claims:
        src = registry.get(c["source"])
        if src is None:
            failures.append(("unknown_source", c))
        elif normalize(c["quote"]) not in normalize(src["content"]):
            failures.append(("quote_not_found", c))
    return failures

In my experience this catches the two worst failure modes almost every time they occur: citing a source that was never fetched, and attributing a claim to the wrong page. Whitespace normalization handles markdown line wrapping; if you see false failures from smart quotes or ellipses, normalize punctuation too, or fall back to a fuzzy match above a high similarity threshold.

What do you do with a failure? Depends on the stakes. For a chat product, drop the claim or strip its citation. For a research agent, feed the failures back to the model with "these quotes were not found, revise or remove the claims" and re-verify. One retry loop fixes most of them.

Step 4: Render Attribution Honestly

At output time, map source IDs back to URLs and show only sources that survived verification and are actually cited by at least one claim. Listing every fetched page as a "source" inflates apparent rigor. If the agent read six pages but the answer rests on two, cite two.

A minimal render:

def render(answer, claims, registry):
    used = sorted({c["source"] for c in claims})
    refs = "\n".join(
        f"[{sid}] {registry[sid]['title']} - {registry[sid]['url']}"
        for sid in used
    )
    return f"{answer}\n\nSources:\n{refs}"

Keep the claim-to-quote mapping in your logs even if you do not show it to users. When someone disputes an answer, you can point at the exact passage the agent relied on, from the exact page state at fetch time. That audit trail is the real product of this whole pipeline.

What This Buys You

The difference between decorative and verifiable citations shows up the first time something goes wrong:

Failure Decorative citations This pipeline
Model invents a URL Ships to the user Impossible, IDs only
Claim from unfetched page Undetectable Fails unknown_source
Wrong source attributed Undetectable Fails quote_not_found
Page contradicts claim Undetectable Quote check fails
User disputes an answer Shrug Quote plus stored page content

None of this makes the agent smarter. It makes the agent auditable, which is what people actually mean when they ask for citations. AI search engines get this wrong constantly, and the results are confidently wrong answers with official-looking links.

The prerequisite for all of it is full page content at fetch time. If your search layer only returns snippets, there is nothing to verify quotes against, and you are back to trusting the model. I covered the search-read-answer flow in more depth in real-time web search for LLMs, and how agents decide what to fetch in what is agentic search.


Want full-page markdown with every search result, so your agent has something real to cite? Get 500 free credits a month at link.sc/register.