We built an agent swarm that reprices a market every night on Google Cloud. The part worth writing about is not what the model does. It is where the model is not allowed to go.
This project started with an advertisement. A good-looking monthly price for a car I might actually want, and a catch I only found by clicking through to the provider. The most common catch was the mileage: the advertised price assumed 5,000 kilometres a year and the longest available term. Drive a normal 10,000 and the figure moves. Sometimes the advertised price was not even current any more, and the provider's own page quoted considerably more.
Every one of those numbers is defensible on its own. Together they make an offer look like a price when it is really a price under conditions, and the conditions are the part that gets left out.
Plinkie is a comparison site built around that observation. Two design decisions follow from it directly. Every offer is stored per mileage tier rather than as a single headline figure, so a car appears at the mileage you actually drive instead of at the one that photographs best. And every price carries the moment we last saw it confirmed at the source: after 72 hours without confirmation, the site labels it as unconfirmed rather than showing it as fact.
What runs at night
At 02:00 CET, Cloud Scheduler starts a Cloud Run Job. The job reads a register of sources from Firestore, applies a refresh policy per source, and fans the work out over Pub/Sub, where a failed task lands in a dead-letter topic after three attempts. Scrapers fetch each offer page and store it in Cloud Storage with its SHA-256, so we can always show which bytes a claim came from. The run finishes with a single transactional swap: until the whole set is ready, yesterday's data stays live.
A recent run took 24 minutes across four sources. Nobody was watching it, which is the entire point of building it this way.
Where the model actually sits
The nightly run makes three model calls. Not one of them reads a price off a page.
Extraction is ordinary code: one adapter module per source, tested against fragments cut from the real page in the real element order. Gemini enters afterwards, to check that code rather than to replace it. Blind verification sends the stored HTML snapshot to Gemini 3.7 Flash in a batch job, without showing it what the parser concluded, and only a genuine contradiction reaches a human review queue. Deduplication uses gemini-embedding-001 to find candidate pairs across providers, after which deterministic rules decide them. The terms-and-conditions harvest turns provider PDFs into checkable clauses.
The pricing engine itself carries no model at all, and it never will. We publish the formula on the site, versioned, with a changelog, so a visitor can redo the sum by hand. A formula that a model might compute differently next Tuesday is not a formula.
What each Google service actually solved
The stack was not chosen for a diagram. Each service closed a specific problem, and here is what each one did for us.
| Service | What it solved |
|---|---|
| Cloud Run Jobs | A swarm that runs once a night and scales to zero for the other 23 hours. We pay for 24 minutes, not for a day. |
| Batch on the Agent Platform | Half the token price for work where nobody is waiting. The whole night, models included, stays under $2. |
| Cloud DLP | Personal data removed from an uploaded contract before any model sees the file. The ordering is the promise, and DLP is what lets us make it. |
| Google ADK |
LongRunningFunctionTool gave us an agent that pauses on a human judgement and resumes where it stopped, without us writing a state machine for it. |
| Firestore | A home for the two collections that change outside the run window, which keeps the nightly job the only writer of the source of truth. |
| Pub/Sub | A dead-letter topic, so one broken source degrades the run instead of ending it. |
The agent that cannot tell us a number
One question defeats a pipeline: where is the answer? Every provider puts "what does an extra kilometre cost" somewhere else on their own site, and moves it when they redesign. We tried a fixed crawl and it broke on contact with reality.
So we built a scout with Google ADK. It browses the provider's own site, decides whether to go a level deeper, and offers the sentence it believes carries the answer. It runs on Gemini 3.7 Flash in realtime rather than in batch, and that exception is reasoned: a search decides its next request from the previous answer, which a batch job cannot do.
Here is the part we would like you to look at. The tool the scout calls has this shape:
leg_vondst_vast(url: str, citaat: str) A URL and a quote. There is no value parameter. The scout is structurally incapable of telling us that an extra kilometre costs 6 cents. It can only point at a page and at a sentence on it.
Deterministic code does the rest. It reads the number out of the quote, checks the unit against the one the question demands, checks the range, refuses a quote containing two candidate numbers, and refuses any quote it cannot find word for word on the page we fetched ourselves. Eight locks in total. Each one exists because a dry run walked around the previous seven, and each one is now a regression test.
The result is that a model picking the wrong sentence produces a rejection rather than a claim. 3,692 offers on the site now carry a warning about excess, early termination or extra kilometres, and every one of them shows the provider's URL, the literal sentence, and the date we read it. A reader can check any of it at the source in one click.
One choice that went against the grain
We started on Cloud SQL and took it out again. Four outages in a row turned out to share one cause: a cold start of 307 to 412 seconds, against a timeout we had set eight seconds too short. The start time is not tunable.
The honest reading is that we had picked the wrong shape, not a bad product. A managed instance is built to stay warm for traffic that arrives all day. Our writer wakes up once a night, works for 24 minutes, and wants to disappear again. We replaced it with a DuckDB file in Cloud Storage, opened in-process by the job that owns it, and object generation enforces that there is exactly one writer. That removed the VPC peering, the auth-proxy sidecar, the start and stop logic, and the last password in the project.
If you are building something that genuinely scales to zero, check whether your database does too.
What it cost to learn
One failure is worth repeating, because it was quiet. The terms harvest reused a result reader from a neighbouring service, and the two marked their batch lines differently. Every key parsed as an empty string, every line failed to find its document, and the run reported a clean harvest of zero rows after a batch we had already paid for.
The fix took ten minutes. The lasting fix was a counter in the run report that says how many lines found no document, with the cause printed next to it as soon as the number leaves zero. A system that can fail silently will eventually do it while you are asleep, and 02:00 is exactly when nobody is reading.
Who built this
Plinkie is built by Business Data Solutions, a Dutch consulting and training firm that helps organisations put their data to work. This system was built by one person in ten days, runs in two fully separate Google Cloud projects, and costs under $2 per night to operate. The infrastructure is OpenTofu, the services are Python 3.13, and the site is Next.js.
If you are weighing up where an agent belongs in your own systems, or where it does not, we are happy to think along. You can reach us through the contact form.
Links
- Plinkie, running on Google Cloud (the interface is Dutch, because the market is Dutch)
- The published pricing formula
- The repository, with the architecture diagram and setup instructions
We wrote this piece for the purposes of entering the All Things Agentic Hackathon run by Google Cloud and Devpost.
Top comments (2)
One thing that tripped us up with the embedding-only approach before we added rules: two companies with "Holding GmbH" in the name scored high similarity even when one was automotive and the other was real estate, because German corporate naming conventions make subsidiaries look like clones. We didn't catch it for a while because the merge rate didn't set off any obvious alarms. Do you pre-filter by source category before the embedding step, or does the confidence threshold alone handle that?
The advertised figure is the part I stopped trusting too. Leftover daily capacity on PZERO dies at UTC midnight. A model id still on the page is not a price.
I do not let a model read the charge. I quote the live row before I queue. Thin book, I shrink the job. Same idea as your 72-hour unconfirmed label.