Skip to main content

Overview

The Shopping Agent is a real-time menu intelligence system that combines web scraping, database matching, and AI personalization into a single coherent pipeline. When a user taps Shop Now from a dispensary page, the system extracts the dispensary’s live menu, matches every product against the 16,000+ strain database, scores results against the user’s personal preference profile, and delivers ranked recommendations — all within 15–45 seconds. This page documents the complete technical architecture for developers working on the Shopping Agent pipeline.

System Architecture

Stage 1: Cache Check

Before any scraping begins, the task queries the menu_scans Supabase table for an unexpired entry matching the dispensary domain.
Cache TTL: 4 hours, shared across all users of the same dispensary. This means one user’s scan benefits every other High IQ user who visits that dispensary page within the same window. Unique constraint: The table enforces UNIQUE (website_domain, categories_hash). If the menu composition has changed (new categories), a fresh scan overwrites the old entry.

Stage 2: Menu Extraction (Firecrawl Tool-Based Architecture)

Menu extraction uses a tool-based architecture built on Firecrawl. Instead of a single extraction method, the system provides five independent tools that the pipeline orchestrator chains with cascading fallback. Each tool is independently callable and testable.

The Five Tools

All tools are exported from @tiwih/trigger and live in packages/trigger/src/lib/firecrawl-agent.ts.

Tool 1: Extract (Primary — Multi-Page)

The Extract tool uses Firecrawl’s extract() API with wildcard URL patterns to crawl an entire menu section and extract structured product data across multiple pages. This is the primary extraction method because most dispensary menus span multiple URLs (e.g., /shop/flower, /shop/edibles, /shop/vapes).
The Firecrawl SDK’s isZodSchema() detection triggers a broken tryZodV4Conversion path with Zod v4. The DISPENSARY_EXTRACT_SCHEMA is a plain JSON schema object that bypasses this entirely. Never pass Zod schemas to the Extract API.

Tool 2: Scrape+JSON (Single-Page)

For menus on a single URL, the Scrape+JSON tool combines scraping and extraction in one call — Firecrawl renders the page, then uses its built-in LLM to fill the schema.

Tool 3: Scrape+AI (Fallback)

When structured extraction returns 0 products (e.g., heavily JavaScript-rendered SPAs, age-gated sites), the pipeline falls back to scraping raw markdown and having Claude extract products from the text.

Tool 4: Sitemap Discovery

The Sitemap tool uses Firecrawl’s map() to discover all URLs on a domain, then filters for menu-related paths. This is used as a fallback when the initial scan returns 0 products — it discovers additional menu URLs to retry extraction.

Orchestrator: Cascading Fallback

The scanDispensaryMenu() orchestrator chains these tools with automatic fallback:
Each result includes a method field ("extract", "scrape-json", or "scrape-ai") indicating which tool succeeded.

Extraction Performance (Real Tests)

Why Plain JSON Schema, Not Zod

The Firecrawl JS SDK detects Zod schemas via isZodSchema() and attempts conversion through tryZodV4Conversion. With Zod v4 (used in this project), this conversion silently fails, causing Extract to return 0 products. Using a plain JSON schema object (DISPENSARY_EXTRACT_SCHEMA) bypasses the SDK’s Zod detection entirely.

Progress Reporting During Extraction

The extraction stage emits progress metadata so the mobile UI can show a live status like “Scanning menu… found 47 products so far.”

Testing the Tools

Two test scripts are available in packages/trigger/:

Stage 3: Strain Matching

Every extracted product name is run through a three-tier matching algorithm against the strains_v2 Supabase table. Matches are attempted in order; the first successful match wins.

Tier 1: Exact Match

name_canonical is a pre-computed lowercase, stripped version of the strain name stored at ingestion time. This handles the most common case: the dispensary uses the standard strain name. Confidence: high

Tier 2: Slug Match

slugify() converts a string to URL-safe format (lowercase, hyphens, no special characters). Catches cases like “OG Kush” → og-kush matching a database entry with slug og-kush. Confidence: high

Tier 3: Trigram Similarity (pg_trgm)

PostgreSQL pg_trgm breaks strings into trigrams (3-character substrings) and computes a similarity score from 0.0 to 1.0. A threshold of 0.4 is permissive enough to catch common dispensary name variations while filtering out genuinely unrelated strings. Confidence: medium if similarity > 0.6, low if 0.4–0.6.

Unmatched Products

Products that pass through all three tiers without a match are added to the discoveries array. These represent real strains available locally that are not yet in the High IQ database.

Parallelism

Matching runs concurrently for all extracted products using Promise.all() batched in groups of 20 to avoid overwhelming the Supabase connection pool.

Stage 4: AI Personalization

Once all products are matched, Claude Sonnet generates personalized recommendations. The personalization step has two parts: deterministic tag assignment and AI recommendation generation.

Deterministic Tag Assignment

Tags are assigned by comparing the matched strain IDs against the user’s profile data passed in the request. This is pure logic — no AI involved.
isSimilarToFavorite() uses the High IQ strain similarity scores (pre-computed and stored in Supabase) to find products with similar terpene and effect profiles to the user’s favorites.

AI Recommendation Generation

After tags are assigned, the full product list (with tags and strain data) is passed to Claude Sonnet. The AI selects the top 3–5 picks and writes a plain-English reason for each.
The generateObject() call uses AI SDK 7 with schema for structured output, ensuring the recommendations are always valid JSON.

Stage 5: Cache Save

Results are upserted into the menu_scans table with a 4-hour expiry.
The categories_hash is an MD5 of the sorted category list. If the dispensary adds a new product category (e.g., starts selling topicals), the hash changes, triggering a fresh scan on the next request.

Stage 6: Complete

The task returns the full output payload, which Trigger.dev delivers to the mobile app via WebSocket. The useRealtimeTaskTrigger hook in the app receives the completed run and triggers a state update to show the results screen.

De-duplication

The Hono API endpoint checks for active Trigger.dev runs before triggering a new one.
This prevents two users opening the same dispensary page at the same time from triggering two concurrent scans.

Mobile App Integration

Screens

Real-Time Hook

The mobile app uses useRealtimeTaskTrigger from @trigger.dev/react-hooks to subscribe to run progress and output without polling.

Discovery Queue: Queueing Unmatched Strains for Research

When a user taps “Add to Research Queue” in the DiscoveryScreen, the app submits the unmatched strain names directly to the Hono API — no Convex middleman involved.

Why Direct API, Not Convex

Convex is the source of truth for user-owned data: orders, stash, favorites, and dispensaries. Strain research is a platform-level concern — the data ends up in Supabase (strains_v2) and benefits all users, not just the submitter. Routing it through Convex would violate the data layer boundary and add unnecessary latency.

Data Flow

Source Type

The /queue-batch endpoint accepts a source field that identifies how the strain was discovered. The shopping_discovery value was added alongside the existing order_upload and manual values specifically for this flow.

Implementation Details

The useRef guard prevents the user from double-submitting if they tap the button quickly while the request is in flight. Unlike useState, a ref update does not trigger a re-render, so the button can remain visually enabled for the next valid submission without a flash of disabled state.
The Trigger.dev strain research pipeline processes queued strains asynchronously. Full strain profiles (genetics, terpenes, effects, images) are typically available within a few hours of submission.

Configuration

Environment Variables

The following environment variables are required in apps/api/.env and must be set in the Vercel project settings for production.

Trigger.dev Package Setup

The shopping-menu-scan task lives in the @tiwih/trigger package at packages/trigger/src/tasks/shopping-menu-scan.ts. It is deployed to the Trigger.dev cloud alongside the other pipeline tasks.

Adjustable Limits

Performance Characteristics

The Supabase strains_v2 table has a GIN index on name_canonical for trigram searches: CREATE INDEX idx_strains_name_trgm ON strains_v2 USING GIN (name_canonical gin_trgm_ops). Without this index, trigram matching on 16,000 rows would be too slow for the pipeline.

Observability

All pipeline stages emit structured logs via @tiwih/logger with the shopping category. Trigger.dev’s dashboard shows per-run stage durations, metadata snapshots, and task output — making it straightforward to identify where time is spent on any given scan.