Skip to content
Build an Agent Harness for Repeatable Web Research
Article

Build an Agent Harness for Repeatable Web Research

AI & Machine Learning

Build a small agent harness in Python that saves job state, enforces tool and page budgets, and checks every claim against the page it came from.

By MrScraper Team 11 min read

Give a model a web tool and a question, and it can do research. Run it twice, though, and you may get two different answers. It may read 40 pages when 4 would do. It may cite a page that never said what it claims.

The fix is not a better prompt. It is a harness: the code around the model that controls what it can do, saves its progress and checks its work.

In this tutorial, you will build a small harness in Python. It runs one research task, saves state after every step, enforces budgets and only reports facts it can prove.

The example task

For each library on a list, find its latest stable release version and release date, using only that project's official website.

The input is a short list:

python
JOB_INPUT = [
    {"name": "example-lib", "domain": "docs.example-lib.org",
     "start_url": "https://docs.example-lib.org/changelog"},
    {"name": "sample-sdk", "domain": "sample-sdk.dev",
     "start_url": "https://sample-sdk.dev/releases"},
]

The output should be a version, a date, the page it came from and the exact sentence that proves it. If the harness cannot prove an answer, it should say "not found" instead of guessing.

What does the harness control?

The model decides what to try next. The harness decides everything else.

The harness controls The model controls
The job input and the output format Which allowed page to read next
Which tools exist What to extract from a page
Which domains each item may use When it thinks an item is done
Budgets for steps, pages and tokens
Saving and loading state
Checking every claim before it is reported

This split is the main idea. The model handles the parts that need judgment. Code handles the parts that need to be the same every time.

The model gets two tools:

  • read_page(url) returns a page as Markdown.
  • submit_claim(field, value, quote, url) records a fact plus the exact sentence that supports it. It can also return finish_item when it is done with the current library.

How do you persist job state?

A research job can take minutes. Networks fail, processes restart and deploys happen. If the job lives only in memory, you lose everything.

So the harness saves state after every step. If the job stops, it picks up where it left off.

Define the state

python
from dataclasses import dataclass, field, asdict
 
 
@dataclass
class ItemState:
    name: str
    domain: str
    start_url: str
    status: str = "pending"          # pending, running, done, not_found
    pages_read: list = field(default_factory=list)
    claims: list = field(default_factory=list)
 
 
@dataclass
class JobState:
    job_id: str
    items: list
    status: str = "running"          # running, verifying, done, budget_exhausted
    steps: int = 0
    pages: int = 0
    tokens: int = 0
    history: list = field(default_factory=list)

Save state and pages to SQLite

Two tables are enough. jobs holds the state as JSON. pages keeps a copy of every page the job read. You need those copies later to check quotes.

python
import json
import sqlite3
from datetime import datetime, timezone
 
db = sqlite3.connect("harness.db")
db.executescript("""
CREATE TABLE IF NOT EXISTS jobs (
    job_id TEXT PRIMARY KEY,
    state TEXT NOT NULL,
    updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS pages (
    job_id TEXT NOT NULL,
    url TEXT NOT NULL,
    fetched_at TEXT NOT NULL,
    markdown TEXT NOT NULL,
    PRIMARY KEY (job_id, url)
);
""")
 
 
def now() -> str:
    return datetime.now(timezone.utc).isoformat()
 
 
def save_state(state: JobState) -> None:
    db.execute(
        "INSERT OR REPLACE INTO jobs VALUES (?, ?, ?)",
        (state.job_id, json.dumps(asdict(state)), now()),
    )
    db.commit()
 
 
def load_state(job_id: str):
    row = db.execute("SELECT state FROM jobs WHERE job_id = ?", (job_id,)).fetchone()
    if row is None:
        return None
    data = json.loads(row[0])
    data["items"] = [ItemState(**item) for item in data["items"]]
    return JobState(**data)
 
 
def stored_page(job_id: str, url: str):
    row = db.execute(
        "SELECT markdown, fetched_at FROM pages WHERE job_id = ? AND url = ?",
        (job_id, url),
    ).fetchone()
    return {"markdown": row[0], "fetched_at": row[1]} if row else None

Make steps safe to repeat

If the job restarts in the middle of a step, it may run that step again. That is fine as long as repeating it does no harm. Here, read_page checks the pages table first. A page the job already read costs nothing the second time.

How do you enforce tool and page budgets?

Without limits, an agent can loop forever or spend far more than the task is worth. Set budgets in code and check them before every tool call.

Set the budgets

python
BUDGET = {
    "max_steps": 30,            # Total model turns for the job
    "max_pages_total": 12,      # Total pages fetched for the job
    "max_pages_per_item": 4,    # Pages fetched per library
    "max_tokens": 200,          # Total MrScraper tokens for the job
    "token_cap_per_page": 20,   # Passed to MrScraper as tokenCap
}
 
 
class BudgetExceeded(Exception):
    pass

Pick numbers that fit your task. Start small. You can raise a limit once you see that the job needs it.

Fetch pages through the harness

read_page is the only way the model can reach the web. It checks the domain, checks the budgets, calls MrScraper and records the cost.

The call uses the MrScraper Scrape Markdown endpoint. The tokenCap field limits how many tokens one page and its retries can use. The response reports the actual token_usage, which the harness adds to the job total.

python
import os
import requests
from urllib.parse import urlparse
 
MRSCRAPER_URL = "https://api.mrscraper.com/"
TOKEN = os.environ["MRSCRAPER_API_TOKEN"]
 
 
def fetch_markdown(url: str) -> dict:
    response = requests.post(
        MRSCRAPER_URL,
        params={"token": TOKEN, "markdown": "true"},
        headers={"x-api-token": TOKEN, "Content-Type": "application/json"},
        json={"url": url, "maxRetries": 2, "tokenCap": BUDGET["token_cap_per_page"]},
        timeout=330,
    )
    response.raise_for_status()
    return response.json()
 
 
def read_page(state: JobState, item: ItemState, url: str) -> str:
    if urlparse(url).netloc != item.domain:
        return f"Refused: {url} is outside {item.domain}."
 
    cached = stored_page(state.job_id, url)
    if cached:
        return cached["markdown"]
 
    if len(item.pages_read) >= BUDGET["max_pages_per_item"]:
        return "Refused: page budget for this item is used up. Submit claims or finish."
    if state.pages >= BUDGET["max_pages_total"]:
        raise BudgetExceeded("job page budget")
    if state.tokens >= BUDGET["max_tokens"]:
        raise BudgetExceeded("job token budget")
 
    result = fetch_markdown(url)
    markdown = result.get("markdown", "")
 
    db.execute("INSERT OR REPLACE INTO pages VALUES (?, ?, ?, ?)",
               (state.job_id, url, now(), markdown))
    db.commit()
 
    state.pages += 1
    state.tokens += result.get("token_usage") or 0
    item.pages_read.append(url)
    return markdown

Notice the two kinds of limits. Hitting an item limit sends a message back to the model, so it can wrap up that item. Hitting a job limit stops the whole job, because there is nothing left to spend.

Run the loop

The loop asks the model for one action at a time. next_action is your own model call. It sends the task, the current item, recent history and the tool descriptions, then returns a dict like {"tool": "read_page", "args": {"url": "..."}}.

python
def run_job(job_id: str, next_action) -> JobState:
    state = load_state(job_id) or JobState(
        job_id=job_id, items=[ItemState(**i) for i in JOB_INPUT]
    )
 
    try:
        for item in state.items:
            if item.status in ("done", "not_found"):
                continue
            item.status = "running"
 
            while True:
                if state.steps >= BUDGET["max_steps"]:
                    raise BudgetExceeded("step budget")
                state.steps += 1
 
                action = next_action(task="Find the latest stable release version and date.",
                                     item=item, history=state.history[-6:])
                tool, args = action.get("tool"), action.get("args", {})
 
                if tool == "read_page":
                    observation = read_page(state, item, args.get("url", ""))
                elif tool == "submit_claim":
                    item.claims.append({**args, "item": item.name})
                    observation = "Claim recorded. It will be checked before the report."
                elif tool == "finish_item":
                    item.status = "done"
                    observation = "Item finished."
                else:
                    observation = f"Unknown tool {tool}."
 
                state.history.append({"item": item.name, "tool": tool,
                                      "args": args, "observation": observation[:500]})
                save_state(state)
 
                if item.status == "done":
                    break
 
    except BudgetExceeded as e:
        state.status = "budget_exhausted"
        state.history.append({"stopped": str(e)})
        save_state(state)
        return state
 
    state.status = "verifying"
    save_state(state)
    return state

Each step saves state before the next one starts. If the process dies after step 7, the next run_job call with the same job_id resumes at step 8.

How do you verify the final answer?

The model can say anything. The harness only reports what it can prove. Every claim must pass four checks before it reaches the report:

  1. The page was read in this job. The URL must be in the pages table for this job_id.
  2. The quote is real. The quote must appear, word for word, in the stored page text.
  3. The value is in the quote. The version or date must appear inside the quoted sentence.
  4. The domain is allowed. The URL must belong to the item's official domain.
python
def normalize(text: str) -> str:
    return " ".join(text.split()).lower()
 
 
def verify_claim(state: JobState, item: ItemState, claim: dict):
    url, quote, value = claim.get("url", ""), claim.get("quote", ""), str(claim.get("value", ""))
 
    if urlparse(url).netloc != item.domain:
        return False, "outside allowed domain"
    page = stored_page(state.job_id, url)
    if page is None:
        return False, "page was not read in this job"
    if not quote or normalize(quote) not in normalize(page["markdown"]):
        return False, "quote not found on page"
    if normalize(value) not in normalize(quote):
        return False, "value not found in quote"
    return True, page["fetched_at"]

Build the report from verified claims only

python
FIELDS = ("version", "release_date")
 
 
def build_report(state: JobState) -> list[dict]:
    report = []
    for item in state.items:
        row = {"library": item.name, "status": "not_found", "rejected": []}
        for claim in item.claims:
            ok, detail = verify_claim(state, item, claim)
            if ok and claim.get("field") in FIELDS:
                row[claim["field"]] = {
                    "value": claim["value"],
                    "quote": claim["quote"],
                    "url": claim["url"],
                    "retrieved_at": detail,
                }
            else:
                row["rejected"].append({"claim": claim, "reason": detail})
        if all(f in row for f in FIELDS):
            row["status"] = "verified"
        elif any(f in row for f in FIELDS):
            row["status"] = "partial"
        report.append(row)
 
    if state.status == "verifying":
        state.status = "done"
    save_state(state)
    return report

A verified row looks like this:

json
{
  "library": "example-lib",
  "status": "verified",
  "version": {
    "value": "4.2.1",
    "quote": "Version 4.2.1 was released on 2026-09-30.",
    "url": "https://docs.example-lib.org/changelog",
    "retrieved_at": "2026-10-15T06:42:10+00:00"
  },
  "release_date": {
    "value": "2026-09-30",
    "quote": "Version 4.2.1 was released on 2026-09-30.",
    "url": "https://docs.example-lib.org/changelog",
    "retrieved_at": "2026-10-15T06:42:10+00:00"
  },
  "rejected": []
}

Keep the rejected list. It shows where the model made claims it could not back up. That is useful data when you tune your prompts or tools.

These checks catch made-up quotes and wrong pages. They do not prove that a quote means what the model says it means. For example, a changelog might list a pre-release above the stable release. For important jobs, send partial rows and a sample of verified rows to a person for review.

Run it, stop it, resume it

python
state = run_job("release-check-2026-10-15", next_action=my_model_step)
 
if state.status == "verifying":
    report = build_report(state)
elif state.status == "budget_exhausted":
    print("Stopped early:", state.history[-1])
    report = build_report(state)  # Still report what was verified

To resume after a crash, call run_job again with the same job ID. Finished items are skipped, pages already read come from the cache and the step count picks up where it stopped.

To repeat the job next week, use a new job ID. The new run fetches fresh pages and keeps its own evidence, so you can compare the two reports.

FAQs

What does the harness control?

The harness controls the job input, the tools, the allowed domains, the budgets, saving and loading state, and the checks on every claim. The model only chooses the next action inside those limits.

How do you verify the final answer?

Check every claim against the stored copy of the page it cites. The page must have been read in this job, the quote must appear word for word, the value must appear in the quote and the URL must be on an allowed domain. Report only claims that pass, and send partial results to a person for review.

Which parts does MrScraper provide, and which parts must my application implement?

MrScraper is a developer-focused web data API. It fetches pages, can capture content that loads in the browser and returns clean Markdown or HTML. You can cap tokens and retries per request, and each response reports token usage and runtime.

Your application builds the harness. That includes the job state, the database, the loop, the model calls, the budgets, the domain rules, claim checks and the final report. Your team also decides which sources it has permission to collect from.

Summarize this post

Open it in your assistant of choice with the prompt ready to send.

Take a Taste of Easy Scraping!

Your choices

Cookie preferences

Necessary cookies keep your selection. Optional categories are disabled until you switch them on.

Strictly necessary

Remembers your privacy selection and keeps the site working.

Always on