Build a used car finder with the Agents SDK
Build a pipeline that finds the five used cars that will cost you the least over the time you keep them, within a budget and a driving distance. An agent reads every listing page before it reaches you. Each run writes a Markdown shortlist you can scan in a minute: link, price, km, distance, cost of ownership, a short abstract, warnings, and the questions to ask the seller.
Demonstrates: one call per listing (no tools: code fetches the page), structured output, a timeout that stops the wait, not the turn, and testing with a scripted model.
Run, from the example's folder with MISTRAL_API_KEY set:
uv run python -m car_finder.main --no-record
criteria.json → AutoScout24 search → filters → cost of ownership → ranked
→ for each car, cheapest first: open the page → listing-reviewer agent → shortlistSplit the work between code and the agent
Give the agent only the part code can't do: reading a listing page and judging it. Keep everything you can check in code:
- Numbers come from code. The search, the engine filters, the depreciation fit and the cost of ownership are plain Python. The report takes price, km and year from the listing, never from the model.
- Code opens the page. Python fetches each listing and puts its text in the prompt. An agent
given an
inspect_listingtool can answer without calling it, and report a live page as dead. - A rejection needs a quote. A FAIL must copy the sentence that proves it, and code checks the sentence is on the page. Without the check, a car gets rejected because its equipment list mentions "airbag en cas d'accident". An unproven worry becomes a question for the seller instead.
- Code flags fees. A page mentioning agency or broker fees gets a warning, because a cheap price with 700 € of mandatory fees is no longer cheap.
The reviewer is an agents.Agent with no tools. Its answer format is a Pydantic model, so the model
is asked for a Review and the run returns one:
class Review(BaseModel):
verdict: str = Field(description="PASS or FAIL")
why: str = Field(description="One line")
evidence: str = Field(
description="For FAIL: an exact sentence copied from page_text that proves it. Empty for PASS"
)
abstract: str = Field(description="Two or three short sentences: what it is, its history and condition, the catch")
maintenance_history: str
controle_technique: str
owners: str
questions_for_seller: list[str] = Field(description="2 to 4 questions to ask before driving there")
REVIEWER = agents.Agent(
model="mistral-medium-latest",
name="listing-reviewer",
instructions=f"""You vet one used car listing for a buyer who keeps it {BUYER["years"]} years and wants no big
repair. You get the listing data, what this model should be (target), and the text of the listing page, already
fetched. Report only what the page says; write 'not stated' otherwise. Never invent facts.
The engine and gearbox were already checked; do not judge them.
FAIL only if the page itself says: a price or km different from the listing data; the car was damaged or in an
accident, does not run, has no papers, is for export or for parts; or it looks like a scam (payment by transfer,
seller abroad, mandatory extra fees). Equipment lists ("airbag en cas d'accident", "kit de réparation",
"pare-chocs") are not evidence.
For a FAIL, copy the exact sentence that proves it into evidence. Otherwise PASS.
Write the abstract and questions in English. The engine has a timing chain unless the target says otherwise.""",
output_type=Review, # the model answers with a Review, and Completed.output is one
max_iterations=2,
)Code calls the reviewer once per listing, with the page text in the prompt, and checks the quote:
async def review(car: dict, model: dict, text: str) -> Review | None:
listing = {k: v for k, v in car.items() if k != "tco"}
prompt = json.dumps({"listing": listing, "target": model["target"], "page_text": text}, ensure_ascii=False)
for attempt in (1, 2):
match await REVIEWER.run(prompt, timeout=300):
case agents.Completed(output=Review() as rev):
pass
case agents.Running(session_id=session_id, turn_id=turn_id):
log(f"review attempt {attempt}: no answer after 300 s")
await REVIEWER.session(session_id).turn(turn_id).interrupt() # the timeout only left the wait
continue
case other: # Completed(output=InvalidOutput(...)), Failed, ...
log(f"review attempt {attempt} failed: {other!r}"[:300])
continue
# A FAIL must quote the page. An unquoted worry is kept as a question rather than dropping the car.
if rev.verdict.upper().startswith("FAIL") and _normalise(rev.evidence or "∅") not in _normalise(text):
rev.verdict = "PASS*"
rev.questions_for_seller.insert(0, f"Reviewer worried, unproven: {rev.why}")
return rev
return NoneEach outcome is a value. A run that does not answer within 300 s comes back Running: the timeout
stopped the wait, not the turn, so the code interrupts the turn before it tries again. An answer
that is not a valid Review comes back as Completed(output=InvalidOutput(...)).
The used car finder's code
The example is a project with its own pyproject.toml:
| File | What it shows |
|---|---|
criteria.json | Buyer settings, and the models, engines and jobs to consider |
car_finder/autoscout.py | Reading search results from the data AutoScout24 embeds in its pages |
car_finder/tco.py | Filters, depreciation fit, cost of ownership |
car_finder/main.py | The pipeline, the listing-reviewer agent and its checks, the report |
tests/ | The cost of ownership, and the reviewer's checks with a scripted model |
Run it
From the example's folder:
uv sync
export MISTRAL_API_KEY=...
uv run python -m car_finder.mainA run takes one to two minutes. The shortlist is printed and saved to reports/. Cars already shown
are recorded in state/shown.json and skipped next time; pass --no-record while you experiment.
The run logs each listing it opens and the reviewer's verdict. Each line shows the verdict, the cost to own the car over the period (not the price), and the listing's title:
The reviewer's quote for the FAIL was found on the page, so the code kept the FAIL; the car does not reach the shortlist.
Test it
The tests need no API key and no network. A scripted model replaces the reviewer's, so the test checks the code around it: an unquoted FAIL becomes a question for the seller, and a quoted one stands:
"""The listing reviewer with a scripted model: no API key, no network."""
import json
import pytest
from car_finder import main
from mistralai import agents
CAR = {"title": "Peugeot 108 1.0 VTi 72 Active", "price_eur": 6_900, "km": 61_000, "tco": None}
MODEL = {"target": "Peugeot 108 1.0 VTi, 68 to 73 ch, timing chain"}
PAGE = "Peugeot 108 1.0 VTi 72 Active. Carnet d'entretien complet. Contrôle technique OK."
def verdict(verdict: str, evidence: str) -> str:
return json.dumps(
{
"verdict": verdict,
"why": "Accident damage",
"evidence": evidence,
"abstract": "A 108 with a full service book.",
"maintenance_history": "full service book",
"controle_technique": "OK",
"owners": "not stated",
"questions_for_seller": ["When was the clutch changed?"],
}
)
async def test_a_fail_needs_a_quote_from_the_page(monkeypatch: pytest.MonkeyPatch) -> None:
model = agents.testing.ScriptedModel([agents.testing.reply(verdict("FAIL", "Véhicule accidenté."))])
monkeypatch.setattr(main, "REVIEWER", main.REVIEWER.override(model=model))
rev = await main.review(CAR, MODEL, PAGE)
model.assert_consumed()
assert rev is not None
assert rev.verdict == "PASS*" # the quote is not on the page: kept, with a question
assert rev.questions_for_seller[0] == "Reviewer worried, unproven: Accident damage"
async def test_a_quoted_fail_stays_a_fail(monkeypatch: pytest.MonkeyPatch) -> None:
model = agents.testing.ScriptedModel([agents.testing.reply(verdict("FAIL", "Contrôle technique OK."))])
monkeypatch.setattr(main, "REVIEWER", main.REVIEWER.override(model=model))
rev = await main.review(CAR, MODEL, PAGE)
assert rev is not None
assert rev.verdict == "FAIL"uv run pytestRank by cost of ownership
Count only what differs between two cars. Costs every car shares, like registration and insurance, change nothing in the ranking:
| Term | Where it comes from |
|---|---|
| Purchase price | The listing |
| − Resale when you sell | Each model's depreciation, fitted on its real asking prices across France per year of age and per 10,000 km, applied to the price you pay. A car bought below market is not counted as a profit, and a dealer's premium is lost when you resell privately. |
| + Servicing | servicing_per_year_eur in the criteria |
| + Likely big jobs | Each model's known jobs (clutch, water pump), weighted by how close the car gets to the mileage they usually fall due |
| + Old-car upkeep | A flat amount per year beyond 10 years old |
The filters decide which engines and gearboxes qualify before any of this runs. The Peugeot 108 shows why: its 1.0 VTi is a Toyota engine with a timing chain, its 1.2 PureTech has a belt running in oil. Both are called "108" in the listing title; the power in horsepower tells them apart.
Adapt it
Change criteria.json:
buyer: your postcode, how far you will drive, the budget, how many years and km per year.models: what you consider reliable, the power range that identifies the right engine, the words that rule a listing out, the minimum year, the maximum km and the jobs to budget for. Models that are the same car, like the Aygo, C1 and 108, share onemarket_group: one depreciation fit, and one model in the shortlist.
The defaults target petrol city cars under 10,000 € around Aix-en-Provence. They are a starting point, not sourced research: review them before trusting the ranking.
Limits
- AutoScout24 only. Leboncoin and La Centrale block automated requests, and ParuVendu shows a captcha after a few dozen.
- Check AutoScout24's terms of use before you run the finder on a schedule.
autoscout.pyreads the data embedded in the site's pages. When the site changes it, the reader breaks, and the tests do not catch it: they cover the review and the cost model, not the reader.- Models with few listings, like the Jazz and Mazda 2 under budget, use a generic depreciation curve.
- Depreciation is fitted on asking prices, not sale prices.