A paper price tag with a small e-ink price graph hanging from a frosted pine branch above a misty forested valley at dawn

A Self-Hosted Personal Shopper: PriceBuddy Price Alerts Through a Local AI Agent

Text an agent a product link and a target price; a phone alert arrives when it hits, including the lowest used offer on Amazon. PriceBuddy self-hosted and hardened, a small helper the agent calls, and an alert job that uses no model at all.

I text my home agent a product link and a price: "watch this, tell me when it's under $300, even used." A few seconds later it replies with the product name, today's price and the target. From then on the page is checked every 30 minutes, and a message lands on my phone the moment the cheapest in-stock offer reaches the target. For Amazon it tracks two prices side by side: the regular price and the lowest used offer from any seller.

Everything runs at home. The price tracker is PriceBuddy, the agent is Hermes Agent on a local 27B model, and the alert job does not touch a model at all. This post is the build: what runs where, the settings that matter, and the gotchas with their fixes.

What you need

1. Run PriceBuddy, but not with the stock compose file

PriceBuddy is a Laravel app with a MySQL database and a separate scraper service (a headless Chrome behind a small HTTP API). Before installing anything I had the code audited. The verdict: no telemetry or phone-home, but the shipped defaults need work. What I changed in my compose file:

2. Give the agent a narrow API token

PriceBuddy has a REST API with per-token abilities. Mint one for the agent with only what it needs: product:create, product:update, product:detail, product:pagination, product:delete, store:pagination and meta-extraction:extract. Store it in a 0600 file on the agent machine. The agent never reads or prints it; the helper does.

3. Write a helper so the model does not drive the API

The agent does not call the API itself. It runs one small Python script (standard library only) and relays the output:

pb.py add "<link>" --target 80 [--every 30] [--used] [--instock]
pb.py list
pb.py show <id>
pb.py target <id> 70 | none
pb.py every <id> 15
pb.py pause|resume|remove <id>

The script handles everything a model gets wrong under pressure. It cleans Amazon links (including amzn.to short links) down to /dp/<ASIN>. It also re-sends the fields PriceBuddy's update endpoint requires on every call, rounds the check interval to the values PriceBuddy accepts (15 minutes minimum, so stores do not block you), and strips "Amazon.com:" from product titles.

This split is what makes a local model enough. Its whole job is mapping "tell me when it's under eighty bucks, used is fine" to add "<link>" --target 80 --used. Requests like that come back in a few seconds.

4. Teach the agent with a skill file

Hermes skills are a folder with a SKILL.md (front matter plus instructions) and optional scripts. Mine holds a table of phrasings mapped to commands, plus a short rules section:

- This skill is THE way to watch prices. Do not use any other price-watch skill,
  the browser tools, or write your own scraper or cron job, even if `add` fails.
  If the helper fails, tell the owner what it said and stop.
- Make one helper call per request.

Those rules exist because of the first real request, covered in the gotchas below.

5. Send alerts without a model

Hermes has a built-in scheduler with a no-agent mode: it runs a plain script and delivers whatever the script prints. An empty output is a silent run. That turns alerts into zero-token, deterministic code:

hermes cron create "every 15m" --no-agent --script pricebuddy-check.py \
  --deliver signal --name pricebuddy-watch

The script reads every product from the API and alerts when the cheapest in-stock listing is at or below the target. It keeps a small state file so each hit alerts once, again only if the price drops further, and re-arms when the price goes back up. It also ignores listings whose last good check is too old, so a stale price can never fire an alert. If a product stops updating it warns at most once a day, and if PriceBuddy itself is unreachable at most once every six hours.

A compact mini PC on a plywood shelf in a rustic workshop at dusk, with a phone on the workbench lit up by a new message
The alert is just a message on the phone. Illustration.

6. Making Amazon work

Amazon is where most deals are and where the stock PriceBuddy config struggled.

The regular price. Amazon's current markup often leaves the element PriceBuddy reads, .a-price > .a-offscreen, empty. The visible price sits in a sibling with class aok-offscreen, and on discounted items that text reads "$24.99 with 5 percent savings", which PriceBuddy's number parser turns into 25. The fix is an XPath rule that tries the cleanest sources first: the hidden input twister-plus-price-data-price, then attach-base-product-price, then an aok-offscreen price that does not contain "percent".

Use XPath for this, not regex. Amazon pages run 2 to 3 MB, and lazy regex scans hit PHP's backtracking limit and return nothing.

The lowest used offer. The "New & Used from $X" figure on the product page is the cheapest offer of any condition, which may be new. For used only, PriceBuddy fetches Amazon's all-offers panel with a used-only filter:

https://amazon.com/gp/product/ajax/aodAjaxMain/?asin=<ASIN>&pc=dp
  &experienceId=aodAjaxMain&filters=<used-only filter JSON>

The panel lists used offers cheapest first. The first non-pinned #aod-offer (the pinned one is the new Buy Box) is the lowest used price. PriceBuddy picks a store config by hostname, so the bare amazon.com host gets a second store config with this rule. The regular www.amazon.com store stays as it was. With --used, the helper puts both listings on one product and the alert names which one hit. If Amazon has no used offers at the moment, the helper says so and still watches the regular price.

Two automatic wristwatches on a weathered wooden table, one new in a presentation box and one lightly worn on a cloth, with a blank paper tag between them
One product, two listings: the regular price and the lowest used offer. Illustration.

Gotchas

How much of this is local

All of the day-to-day running. Parsing my messages, adding and changing watches, and listing prices go through the local 27B model. The alerts use no model, scraping runs on my own box, and nothing about what I watch leaves the house except the store page requests themselves.

Building it was different. The code audit, the egress firewall, and working out Amazon's markup were done with a frontier model in a terminal session. Narrower maintenance is a fair job for the local agent, given the same probe scripts: "this listing stopped updating, find the new price element".

Fringe Tech Self-Hosting AI

Comments

// Comments are reviewed before appearing. No spam. No noise.