How this was built
This is a Bay Area apartment finder built on Parallel's FindAll and Task APIs. You describe a place in plain language and get back individual listings you can actually open, each checked against your criteria. The whole thing is one Next.js app on Vercel. There is no backend server and no database.
One FindAll call does the hard part
A search does not send a keyword query. It sends an objective plus a few match conditions, and FindAll discovers candidates across the web and checks each one. The parts that matter: match conditions decide whether a candidate is kept (booleans like "is this one real listing?" and "is it under budget?"), and enrichments are the 18 structured fields pulled back for each match (rent, beds, address, and so on). Enrichments run on the Task API, one task per match.
POST /v1beta/findall/runs
{
"objective": "Find 2 bedroom apartments for rent under
4600 dollars per month in San Francisco, CA",
"entity_type": "apartment rental listings",
"match_conditions": [
{ "name": "is_rental_listing",
"description": "One individual unit's listing page with
its own street address. Not a search or
category page." },
{ "name": "fits_budget",
"description": "Asking rent <= $4600. If no rent shown,
treat as matched." }
],
"enrichments": [
{ "name": "monthly_rent_usd", "description": "..." },
{ "name": "bedrooms", "description": "..." },
{ "name": "street_address", "description": "..." }
// 15 more: bathrooms, sqft, pet_policy, parking, ...
],
"generator": "base",
"match_limit": 10
}Bedroom count is intentionally not a match condition. Strict conditions cause zero-match runs when the API cannot verify them from page text, so beds come back as an enrichment and get filtered in code.
The app is a stateless client driving the run
FindAll runs are asynchronous, so the browser drives each search through short serverless calls. The server holds no state between them. The only thing that persists is the user's saved shortlist, kept in their own browser via localStorage.
1. POST /api/search create the FindAll run
2. GET /api/search/{id} poll: status + matched candidates
3. POST /api/search/{id}/enrich run the 18-field enrichment
4. GET /api/search/{id} poll until enrichment settles
5. GET /api/search/{id}/finalize geocode + score, return listingsVerified does not mean openable
This was the part that took the most work. FindAll verifying a candidate means the page matched the conditions. It does not guarantee a link a person can click and rent from. The gap between those two is most of the application:
Category and search pages match "describes rentals" but you cannot rent them, so the match condition rejects index pages and a URL guard filters search or category paths. A few aggregators ( zillow.com, yelp.com, loopnet.com, crexi.com) bot-wall the real listing, so they are blocked rather than sending someone to a dead link. And when a candidate carries several URLs, the app picks the most specific individual one instead of a browse page.
Discovery is variable, so plan for thin runs
A thin query sometimes comes back mostly category pages and finalizes near-empty, while the same query a minute later returns six. Two guards handle it. If discovery never fills its match limit (a rare or over-constrained query), the app proceeds to enrichment once it has run long enough with at least one match, rather than waiting forever. And if a run that actually completed still finalizes with almost nothing, it runs one more fresh pass and keeps whichever found more.
Discovery plus per-listing enrichment is inherently a multi-minute operation, so the UI shows a timer and streams results in as they verify. generator: base is the right tier for a broad city-wide query; core and pro search harder for rarer, more specific ones.
A second, targeted check with the Task API
For listings from untrusted sources, a fraud check runs on the Task API directly (the same API that powers the enrichments). The schema asks for concrete signals rather than a vague "is this a scam" score, and the signals are weighted in code.
POST /v1/tasks/runs
{
"input": { "title": "...", "body": "...", "price": 2200,
"address": "...", "source": "craigslist.org" },
"task_spec": { "output_schema": { "type": "json",
"json_schema": { /* 5 fact-based scam signals */ } } },
"processor": "base"
}Scoring
Each listing gets a 0 to 100 score from price fit and proximity to a reference point (with a bedroom-fit penalty used only for ranking). Because results are fetched fresh on every search, staleness is handled separately: listings the API reports inactive, or past a freshness window, are flagged and hidden by default.
Live configuration
Everything is env-driven. These are the values this instance is running with right now:
Loading live configuration...