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
- A Docker host for PriceBuddy (any small always-on box; mine is a fanless mini PC).
- An agent framework with skills, shell access and a scheduler. Hermes Agent has all three.
- A local model for the agent. Mine is a 27B model on a separate GPU box; the agent only needs it to turn a text message into one command.
- A messaging channel the agent already uses. I use Signal.
- A way to reach the PriceBuddy web UI. Mine is LAN plus a WireGuard VPN, with no public port.
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:
- Pin images by digest, not
latest, and re-check the changelog before every bump. - Publish no ports. My reverse proxy reaches the app over an internal Docker network; the database sits on an
internal: truenetwork with no route out. - Never publish the scraper. It fetches any URL it is given and has no working authentication. Give it its own subnet and firewall that subnet to the internet only: drop traffic to private ranges, link-local, CGNAT and the host itself, allow DNS to your resolver. In Docker terms that is a small sidecar with
NET_ADMINand host networking that installs rules in theDOCKER-USERandINPUTchains on start. - Patch the start script. The stock one runs
printenvat boot, which writes the database password into the container log. Mount a copy without that line. - Skip the seeded admin. The
APP_USER_EMAIL/APP_USER_PASSWORDseeding prints the password to the log. Create the admin withphp artisan make:filament-useranduser:assign-role <email> admininstead. - Turn affiliate tags off (
AFFILIATE_ENABLED=false). - Create storage folders before the app key. On an empty bind mount the stock script crashes generating
APP_KEYand never retries, so the app returns 500s. Move themkdirblock abovekey:generate.
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-watchThe 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.

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.

Gotchas
- New skills load only in new sessions. The first time I sent a link, my long-running chat session predated the skill. The agent picked the framework's bundled generic price-monitor skill and started writing its own headless-browser scraper. Fix: start a new session after installing a skill, and disable the competing bundled skill in config (
skills.disabled). - Long sessions get slow on a local model. That session held about 115K tokens of context. The framework's command-approval check calls the same model, which evicted the conversation from the prompt cache, so the next step re-read all 115K tokens: about a minute each time. In a fresh session the same request runs in seconds.
- Variants matter. Watch the exact size or color link; used offers belong to a specific variant.
- Expect maintenance. Amazon throttles automated requests now and then and changes its markup every so often. The stale-listing warning is how you find out a rule needs updating.
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".
Comments
// Comments are reviewed before appearing. No spam. No noise.