Building a Job-Search Agent That Never Hallucinates Listings

Why job-search agents built on structured job APIs alone tend to invent postings, and how to ground one in real search results and scraped pages instead.

Profile picture of Serply
Serply
Diagram of a job-search agent pipeline moving from search results to scraped job postings

Ask most LLM-backed “job search assistants” for openings at a specific company and you’ll get a suspiciously tidy list: a job title, a salary range, a couple of bullet points on requirements, and a link that either 404s or leads somewhere completely unrelated. The model isn’t lying to you on purpose — it’s doing what language models do when they don’t have real data in front of them. It’s pattern-matching what a job posting usually looks like and filling in the blanks.

That’s fine for a demo. It’s useless for a tool anyone would actually rely on to find a job.

The fix isn’t a smarter prompt. It’s an agent architecture that never lets the model describe a listing it hasn’t actually read. In this post we’ll build exactly that: a two-step pipeline that finds real postings through web search, then scrapes each candidate page for its actual content, so every detail the agent reports back — title, employer, requirements, link — traces back to a real page that really exists.

Why structured job APIs aren’t the whole answer

Serply exposes a purpose-built Google Jobs endpoint, GET /v1/job/search/{query}, which returns a nicely structured jobs array — position, employer, location, and a link to the listing, all parsed out for you. For a lot of use cases that’s exactly what you want, and it’s worth reaching for first.

The catch: job board result pages change layout often, and coverage varies by query — solid for some searches and thin or missing for others. That’s not a dealbreaker, but it does mean you shouldn’t build a “never hallucinates” guarantee on top of it alone. If the endpoint returns nothing for a query, an agent that only trusts it has two bad options: report nothing, or start inventing.

There’s a more robust pattern available, and it’s one you already have the pieces for: plain web search finds real postings just fine, because job boards are some of the most heavily SEO’d content on the internet. Indeed, LinkedIn, ZipRecruiter, Glassdoor, and company career pages all rank for the searches a job-seeker would actually type. Combine that with a scraper that pulls the full text of whatever page search points you to, and you get an agent that’s grounded in real, currently-live pages every single time — with the structured endpoint as a nice-to-have supplement, not a single point of failure.

The two-step pipeline

Here’s the shape of it:

  1. SearchGET /v1/search/{query} against something like data analyst remote jobs or a more targeted site: query. This returns real organic results, which for job-related searches are dominated by actual job board listing pages.
  2. Scrape — for each promising result, POST /v1/request with response_type: "markdown" against that specific URL. This pulls the full page content — not just the two-line snippet search gave you — converted to clean markdown, ready for an LLM to summarize accurately.

Every fact the agent surfaces in step 3 (the summary) comes from text that was actually scraped in step 2, from a URL that was actually returned in step 1. There’s no step where the model gets to fill in gaps from its training data.

Step 1: search for candidate postings

import requests

SERPLY_API_KEY = "YOUR_API_KEY"
SEARCH_HEADERS = {"X-Api-Key": SERPLY_API_KEY}

def search_job_postings(role: str, location: str = "", num_candidates: int = 5):
    query = f"{role} {location} jobs".strip()
    # Serply packs the query string into the URL path, not as ?params
    encoded_query = requests.utils.quote(f"q={query}", safe="=&")
    url = f"https://api.serply.io/v1/search/{encoded_query}"

    response = requests.get(url, headers=SEARCH_HEADERS)
    response.raise_for_status()
    data = response.json()

    # Prefer results that look like actual job board or ATS listing pages
    job_board_hosts = ("indeed.com", "linkedin.com/jobs", "glassdoor.com",
                        "ziprecruiter.com", "greenhouse.io", "lever.co")

    candidates = [
        r for r in data.get("results", [])
        if any(host in r["link"] for host in job_board_hosts)
    ]

    # Fall back to whatever search returned if none matched the known hosts
    if not candidates:
        candidates = data.get("results", [])

    return candidates[:num_candidates]

The response shape here is exactly what Google Search documents: {"results": [{"title", "link", "description"}], "total", "answer"}. Nothing exotic — just real search results, filtered down to the URLs most likely to be actual postings.

Step 2: scrape each candidate for the real content

REQUEST_HEADERS = {
    "Content-Type": "application/json",
    "X-Api-Key": SERPLY_API_KEY,
}

def scrape_posting(url: str) -> str:
    response = requests.post(
        "https://api.serply.io/v1/request",
        headers=REQUEST_HEADERS,
        json={"url": url, "response_type": "markdown"},
    )
    response.raise_for_status()
    # markdown response_type returns plain text directly -- NOT JSON
    return response.text

This is the detail that trips people up: unlike almost every other Serply endpoint, /v1/request with response_type: "markdown" does not wrap its output in a JSON envelope. The response body is the markdown, served with Content-Type: text/html; charset=utf-8. Call .json() on it and you’ll get a parse error. Call .text (or response.text() in JavaScript) and you get exactly what you want — clean article-style text with the posting’s real requirements, salary line, and description intact, stripped of nav bars and ad clutter.

Step 3: assemble a grounded summary

def find_real_jobs(role: str, location: str = "", num_candidates: int = 5):
    candidates = search_job_postings(role, location, num_candidates)

    results = []
    for candidate in candidates:
        try:
            markdown = scrape_posting(candidate["link"])
        except requests.HTTPError:
            continue  # page unreachable -- skip it, don't invent a summary

        results.append({
            "title": candidate["title"],
            "url": candidate["link"],
            "search_snippet": candidate["description"],
            "full_text": markdown,
        })

    return results


if __name__ == "__main__":
    postings = find_real_jobs("data analyst", "remote")
    for p in postings:
        print(f"\n## {p['title']}")
        print(p["url"])
        print(p["full_text"][:500], "...")

Feed each posting’s full_text into your LLM one at a time with a prompt like “summarize the requirements, salary, and application process described in this text — do not add any detail not present in the text below,” and you get summaries that are anchored, sentence by sentence, in a page that genuinely exists. If a candidate URL 404s or the scrape comes back empty, the pipeline just drops it — a smaller, honest result set beats a padded, invented one.

Where the structured endpoint still helps

None of this means /v1/job/search is worth ignoring. For queries where it comes back populated, it hands you position, employer, and location pre-parsed, which is faster than scraping and summarizing for the same information. A reasonable production agent tries the structured endpoint first as a fast path, and falls back to the search-and-scrape pipeline above whenever the structured response is empty or thin. That way you get speed when the endpoint cooperates and a reliability floor when it doesn’t.

The takeaway

An agent’s outputs are only as trustworthy as the data it’s actually allowed to see. The moment you let a model describe something it hasn’t been shown — a listing, a price, a spec sheet — you’ve opened the door to hallucination, no matter how good the prompt is. Search-then-scrape isn’t a workaround; it’s the discipline of forcing every claim the agent makes back through a real, fetchable, checkable source.

If you’re building anything similar — a research agent, a price tracker, a news summarizer — the same two-step pattern applies: use Serply’s search endpoints to find candidate pages, then /v1/request to pull their real content before you ever let a model talk about what’s on them. Grab an API key at serply.io and check the authentication guide to get started.