Nine AI agents race nine pharmacies in parallel to find your cheapest prescription price — OCR your script, live-stream every scout, and cache the winner in Postgres. Built with: react · vite · node.js · express · server-sent-events · postgresql · ghost-postgres · guild-ai · tinyfish-sdk · openai-gpt-4o-mini · multer · pg


Inspiration

Filling a single prescription in the US is a weirdly adversarial experience. The same generic drug can cost \$8 at Cost Plus, \$42 at CVS, and \$180 at Walgreens — and the only way to know is to open nine tabs, fight through captchas, and try to find a price buried behind a login wall on each pharmacy's site.

We wanted to crush that nine-tab tax into a single button press. Agents are great at browsing messy web UIs, and the expensive part of comparison shopping is exactly the thing LLMs-plus-browsers are built for: read the page, find the number, ignore the upsell.

So we built RxScout: point an agent at each pharmacy, run them in parallel, rank by cash price, done.

What it does

  1. Input — type a drug (e.g. atorvastatin 20mg), or upload a photo of your prescription.
  2. Parse — if an image was uploaded, the rx-ocr Guild agent extracts the medication text, and the rx-parser agent turns it into a structured query.
  3. Fan out — up to 9 pharmacy scout agents launch in parallel (Cost Plus, GoodRx, Walmart, Costco, CVS, Walgreens, Kroger, Rite Aid, and more). Each agent gets a pharmacy-specific prompt with program hints (e.g. Walmart's \$4 generics, Costco's non-member price, GoodRx coupon ranking).
  4. Stream — every agent pushes live progress events over SSE. The UI shows a card per pharmacy with a streaming browser view, status chips, and per-agent elapsed time.
  5. Rank — results are sorted by price; the winner is revealed with a dramatic "best price" animation.
  6. Cache — results are persisted in a Ghost-provisioned Postgres database (scout_runs + scout_cache with a 60-minute TTL) so repeat queries for the same drug/ZIP skip the live scrape.

How we built it

Architecture

┌───────────────┐      SSE       ┌──────────────────┐
│  React + Vite │ ◄──────────────┤  Express         │
│  (rxscout/)   │                │  orchestrator    │
└───────────────┘                │  (server/)       │
                                 └──┬───────────────┘
                                    │ fan-out (Promise.all)
                    ┌───────────────┼────────────────┬──────────────┐
                    ▼               ▼                ▼              ▼
              ┌──────────┐   ┌────────────┐   ┌────────────┐   ┌──────────┐
              │ TinyFish │   │ Guild:     │   │ Guild:     │   │ Ghost    │
              │ browser  │   │ rx-ocr     │   │ rx-parser  │   │ Postgres │
              │ × N      │   │ (photo→tx) │   │ (tx→med)   │   │ (cache)  │
              └──────────┘   └────────────┘   └────────────┘   └──────────┘

Orchestration — Guild AI + Node fan-out

We scrapped an earlier LangGraph supervisor in favor of a simpler pattern: Node owns the fan-out, and each "agent" is a local Guild AI agent (server/agents/pharmacy-scout, rx-ocr, rx-parser). The server spawns guild agent chat --ephemeral --mode json per invocation and parses the JSON reply — no orchestration framework, no graph, just Promise.all over the pharmacy list.

Two scrape modes are selectable from the UI:

  • tinyfish — Node calls the TinyFish SDK directly with a structured-extraction goal. Single browser run per pharmacy, structured JSON back. Fast.
  • experimental-fetch — The pharmacy-scout Guild agent fetches the page itself with guildTools. Slower but useful when we want the agent to reason about the page.

Streaming UX

Every orchestrator event (plan, pharmacy_started, pharmacy_stream, pharmacy_progress, pharmacy_done, ranked, done) is forwarded as SSE. The React client renders each pharmacy as a live card; TinyFish's streaming browser URL is embedded so you can literally watch the agent type into the pharmacy's search box.

Persistence — Ghost Postgres

Ghost provisioned a dedicated Postgres instance in seconds. Two tables:

  • scout_runs — every invocation (drug, ZIP, ranked JSON, best price, best pharmacy, timestamp).
  • scout_cache — per-pharmacy payload with expires_at = NOW() + 60 min. On a cache hit the orchestrator skips TinyFish entirely and the UI badges the result as cached.

Prescription OCR

If the user uploads a prescription photo, it's base64-embedded into the prompt for the rx-ocr agent (GPT-4o-mini via Guild). The extracted text is then fed to rx-parser, which returns a normalized medication query (atorvastatin + 20mg + 30 tablets).

Challenges we ran into

  • Pharmacy anti-bot defenses. Walmart, Costco, CVS, and Walgreens flat-out refuse datacenter IPs. We solved it by marking those pharmacies with a browserProfile: 'stealth' + US residential proxyConfig in the catalog, so TinyFish routes them through a stealth browser.
  • Agents echoing their inputs. Early on, the pharmacy-scout agent would sometimes just reflect the request payload back at us when it couldn't browse. We added an looksLikeInputEcho() guard in orchestrator.js that detects the input-shape and surfaces a clean not_found status instead of a fake result.
  • Ghost Postgres + sslmode conflict. Ghost's connection string ships with ?sslmode=require, but node-postgres wanted us to configure SSL via the ssl object. We strip sslmode from the URL in db.js and set ssl: { rejectUnauthorized: false } explicitly.
  • Stale cache eating live updates. Our first cache implementation cached across every run, which meant the first scrape result haunted demos forever. We made CACHE_ENABLED opt-in (off by default) and added a cached badge so it's obvious when a result came from Postgres vs. a fresh scrape.
  • Stale dev server port. node --watch occasionally leaves a zombie process holding :8788. Fixed by killing the stale PID; still worth a lsof -ti:8788 | xargs kill -9 one-liner in your muscle memory.

Accomplishments we're proud of

  • Nine agents, one button. Watching nine pharmacy browsers all stream side-by-side is genuinely fun, and the price spread between them is a small lesson in US healthcare economics every time you run it.
  • Sub-second cache hits. With the Ghost cache warm, repeat queries for the same drug/ZIP return in <200ms — you go from "nine agents scraping the internet" to "one SQL query" with no UI code change.
  • Prescription-photo pipeline. OCR → structured parse → live scout is a proper end-to-end flow, not a demo gimmick.

What we learned

  • Guild agents are a nice middle ground between raw SDK calls and a full orchestration framework — you get the structured-prompt-plus-tools ergonomics without adopting a runtime.
  • Real pharmacy sites are hostile. Pharmacy-specific hints (Walmart \$4 generics, Costco non-member pricing, GoodRx coupon ranking) mattered far more than any generic "find the price" prompt.
  • For streaming UIs, SSE beats WebSockets for this shape of problem — it's a one-way event stream and fetch + ReadableStream on the client is all you need.

What's next for RxScout

  • Insurance-aware prices. Right now we quote cash prices only; plugging in a SureScripts/MedImpact-style API would let us show "with your insurance" beside "cash".
  • More pharmacies. Independents, mail-order (Amazon Pharmacy, Capsule), and Canadian cross-border options.
  • Push alerts. "Atorvastatin dropped to \$6.80 at Cost Plus this week" via the cache history table.
  • A phone-friendly mode. The app already works on mobile; a PWA wrapper with camera-first onboarding would close the loop for the "I just got a script, what do I do" use case.

Built With

  • chainguard
  • ghost
  • guild.ai
  • insforge
  • tinyfish
Share this project:

Updates