← All posts

How to Fact-Check With AI and Web Sources

Quick answer: AI fact checking works by turning a statement into checkable claims, searching the live web for each one, fetching the source pages as clean text, and asking a model to compare the claim against what the sources actually say. The model never decides truth on its own. It reasons over evidence you retrieved, cites where each judgment came from, and flags anything it cannot verify. That last part matters more than the verdict.

An LLM by itself is not a truth oracle. It has a training cutoff, it does not know what happened this week, and it will state a wrong fact with the same confidence as a right one. The fix is not a smarter model. It is grounding the model in sources it can read at the moment of the check.

The Pipeline

A useful fact-check flow has five steps. Each one is boring on its own, which is exactly why it works.

Step What it does Why it matters
Extract claims Break the input into atomic, checkable statements A paragraph has many claims; you check them one at a time
Search Query the live web for each claim Gets you current, real URLs instead of memorized guesses
Fetch Pull the full source text, not snippets Snippets lie by omission; you need the surrounding context
Compare Ask the model: does this source support, contradict, or not address the claim? This is where the actual checking happens
Report Cite sources, assign a verdict, flag uncertainty An honest "I could not verify this" is a valid output

The two web steps are where link.sc fits. Search returns real results for a query, and fetch converts any URL into clean markdown the model can read. You could stitch this together with a headless browser and an HTML parser, but you would spend most of your time on proxies and boilerplate instead of the logic that matters.

Step 1: Extract the Claims

Give the model a statement and ask for a list of independently checkable claims. Structured output keeps this clean.

import os
import json
import requests
from anthropic import Anthropic

LINKSC = "https://api.link.sc/v1"
HEADERS = {
    "x-api-key": os.environ["LINKSC_API_KEY"],
    "content-type": "application/json",
}
claude = Anthropic()  # reads ANTHROPIC_API_KEY from the environment


def extract_claims(text: str) -> list[str]:
    resp = claude.messages.create(
        model="claude-opus-4-8",
        max_tokens=1024,
        thinking={"type": "adaptive"},
        messages=[{
            "role": "user",
            "content": (
                "Break the following into a JSON array of atomic, "
                "verifiable factual claims. One fact per string.\n\n"
                f"{text}"
            ),
        }],
    )
    body = next(b.text for b in resp.content if b.type == "text")
    return json.loads(body)

A claim like "the company was founded in 2009 and has 400 employees" becomes two claims. You want them separate because one can be true while the other is false.

Step 2 and 3: Search and Fetch

For each claim, search the web and fetch the top results. Note the contract: search uses q, fetch uses url and format, and both go to api.link.sc/v1 with an x-api-key header.

def search(query: str) -> dict:
    r = requests.post(
        f"{LINKSC}/search",
        headers=HEADERS,
        json={"q": query, "engine": "google"},
    )
    r.raise_for_status()
    return r.json()["serpData"]


def fetch(url: str) -> str:
    r = requests.post(
        f"{LINKSC}/fetch",
        headers=HEADERS,
        json={"url": url, "format": "markdown"},
    )
    r.raise_for_status()
    return r.json()["content"]


def gather_evidence(claim: str, limit: int = 3) -> list[dict]:
    serp = search(claim)
    results = serp.get("organic", [])[:limit]
    evidence = []
    for item in results:
        try:
            content = fetch(item["link"])
        except requests.HTTPError:
            continue
        evidence.append({"url": item["link"], "content": content[:6000]})
    return evidence

Fetching the full page instead of trusting the search snippet is the difference between real checking and theater. A snippet might read "revenue fell 12%" when the page says "revenue fell 12% in the prior year but recovered." Reading the page catches that.

Step 4 and 5: Compare and Report

Now hand the model the claim and the evidence together, and ask it to judge only from what it was given. This is the part people skip, and it is the part that keeps the model honest.

def check_claim(claim: str, evidence: list[dict]) -> dict:
    sources = "\n\n".join(
        f"SOURCE {i} ({e['url']}):\n{e['content']}"
        for i, e in enumerate(evidence)
    )
    resp = claude.messages.create(
        model="claude-opus-4-8",
        max_tokens=1024,
        thinking={"type": "adaptive"},
        system=(
            "You are a fact-checker. Judge the claim ONLY from the sources "
            "provided. Do not use prior knowledge. If the sources do not "
            "address the claim, say so. Return JSON with keys: verdict "
            "(supported | contradicted | unverified), confidence (0-1), "
            "reasoning, and citations (list of source URLs used)."
        ),
        messages=[{
            "role": "user",
            "content": f"CLAIM: {claim}\n\n{sources}",
        }],
    )
    body = next(b.text for b in resp.content if b.type == "text")
    return json.loads(body)

The system prompt does the heavy lifting. "Judge only from the sources provided" and "if the sources do not address the claim, say so" are the two instructions that turn a confident guesser into a checker. An unverified verdict is not a failure. It is the system telling you the web did not have the answer, which is honest and useful.

Tie it together:

def fact_check(text: str) -> list[dict]:
    report = []
    for claim in extract_claims(text):
        evidence = gather_evidence(claim)
        if not evidence:
            report.append({"claim": claim, "verdict": "unverified",
                           "reasoning": "No sources retrieved."})
            continue
        result = check_claim(claim, evidence)
        result["claim"] = claim
        report.append(result)
    return report

Honest Limits

Build this and you have a genuinely useful tool. You do not have a lie detector. Keep these limits in view:

  • Sources can be wrong. The web is full of confident, incorrect pages. Weight authoritative sources higher and show the user the citations so they can judge for themselves.
  • The model can misread. It can mark a source as "supporting" when it only tangentially relates. Lower the confidence threshold you trust, and surface the reasoning so a human can audit it.
  • Fresh events are hard. For something that happened an hour ago, sources may disagree or not exist yet. unverified is the right answer there.
  • Framing is not fact. "The best phone of the year" is an opinion, not a claim. Your extraction step should skip or flag these rather than pretend to check them.

The goal is not to replace human judgment. It is to do the tedious retrieval and comparison at scale, then hand a human a cited, structured summary they can act on. That is a much better use of an LLM than asking it to recall facts from memory.

For a deeper look at why retrieval beats recall, see grounding LLM answers in live web sources. The same pattern powers search, research agents, and anything else where being current matters. The link.sc docs cover the search and fetch endpoints in full.


Ready to ground your AI in real sources? Get started with link.sc for free and give your fact-checker a live view of the web.