# RateRight Marketplace Hunter — Design Spec

> **Date:** 2026-04-28
> **Author:** Rocky + Claude Code
> **Status:** Draft — review fixes applied
> **Goal:** Get RateRight's first real hire between two strangers by actively hunting both sides of the marketplace simultaneously.

---

## 1. Problem Statement

RateRight is a two-sided construction labour marketplace with:
- 0 active workers
- 0 active contractors (304 cold leads, never converted)
- 0 organic hires (3 test hires were founder testing with friends)
- A fully built platform (signup, job posting, matching, Stripe payment) with no users

The platform works end-to-end. The problem is not product — it's distribution. Both sides of the marketplace are empty, creating a classic cold-start problem.

Previous strategy (hostel posters) was indirect and passive. New strategy: actively hunt both workers and contractors, match them, and facilitate introductions through RateRight.

---

## 2. Strategy: Hunt, Don't Hope

Four systems working together:

| System | Purpose | Runs On |
|---|---|---|
| **Job Aggregator** | Scrape real construction jobs from Seek/Indeed/Gumtree, display on RateRight | VPS (24/7) |
| **Worker Hunter** | Find people actively looking for construction work, contact them | VPS + PC |
| **Contractor Hunter** | Find companies actively hiring, pitch $50 flat fee | VPS |
| **Matchmaker** | Detect worker+contractor in same city/trade, introduce both | VPS |

Plus: manual Gumtree ad posting, Facebook group posting (headed browser on PC), and on-site recruitment.

---

## 3. System Architecture

### 3.1 Job Aggregator

**Purpose:** Scrape construction job listings from Seek, Indeed, and Gumtree daily. Display on RateRight so workers see real jobs when they land on `/go`.

**Flow:**
```
Seek/Indeed/Gumtree  →  Scraper (VPS, daily cron 06:00 AEST)
                     →  Supabase `aggregated_jobs` table
                     →  RateRight /go page shows real jobs
```

**Data scraped per listing:**
- title
- company_name (if shown)
- location / suburb
- pay_rate (if listed)
- trade_type (mapped to: Steel Fixer, Formworker, Labourer, Concreter, Carpenter, Scaffolder, Other)
- description_snippet (first 200 chars)
- source_url (original listing link)
- source (seek / indeed / gumtree)
- date_posted
- scraped_at

**Tech:**
- Playwright for Seek and Indeed (both are React SPAs requiring JS execution — Cheerio will return empty content)
- Cheerio + Axios for Gumtree (server-rendered HTML)
- PM2 cron on VPS, runs once daily at 06:00 AEST
- Deduplication by source_url (unique constraint)
- Stale listings auto-expire: `WHERE scraped_at < NOW() - INTERVAL '14 days'`

**Platform integration:**
- Aggregated jobs display on `/go` alongside RateRight-native job posts
- Badge: "Found on Seek" / "Found on Gumtree" / "Found on Indeed"
- Clicking "Apply" on aggregated job → redirects to source_url (external)
- Clicking "Apply" on RateRight-native job → in-app apply flow
- "Sign up to get matched with jobs like these" CTA on aggregated listings

**Database table: `aggregated_jobs`**
```sql
CREATE TABLE aggregated_jobs (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  title TEXT NOT NULL,
  company_name TEXT,
  location TEXT,
  suburb TEXT,
  pay_rate TEXT,
  trade_type TEXT,
  description_snippet TEXT,
  source_url TEXT UNIQUE NOT NULL,
  source TEXT NOT NULL CHECK (source IN ('seek', 'indeed', 'gumtree')),
  date_posted DATE,
  scraped_at TIMESTAMPTZ DEFAULT NOW(),
  expired BOOLEAN DEFAULT FALSE,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_aggregated_jobs_trade ON aggregated_jobs(trade_type);
CREATE INDEX idx_aggregated_jobs_location ON aggregated_jobs(location);
CREATE INDEX idx_aggregated_jobs_source ON aggregated_jobs(source);
```

---

### 3.2 Worker Hunter

**Purpose:** Find people actively looking for construction work and contact them about RateRight.

**Source 1: Gumtree "Looking for Work" → Trades & Services**

- Scrape daily (VPS, 06:30 AEST)
- Target URL pattern: `gumtree.com.au/s-looking-for/c9302` filtered by trades subcategories
- Cities: Sydney, Melbourne, Brisbane (expand later)
- Extract: name, trade, location, contact info (phone/email from ad text), post text, listing URL
- Store in Supabase `scraped_leads` table (side: 'worker')
- Check against `contact_registry` — skip if already known
- Queue for outreach

**Source 2: Facebook Groups**

- Headed Playwright browser on Rocky's PC
- Scan target groups for posts containing keywords: "looking for work", "labourer available", "need shifts", "white card", "available for work", "seeking construction"
- Extract: poster name, group name, post text, post URL
- Store in Supabase `scraped_leads` (side: 'worker', source: 'facebook')
- Two outreach actions:
  1. Reply to their post (natural, in-group): "Hey mate, check out rateright.com.au/go — free for workers, contractors post jobs direct"
  2. DM with RateRight link (if no email/phone available)

**Worker outreach template (email):**
```
Subject: Construction work in {city}?

Hey {name},

Saw you're looking for {trade} work in {city}.

RateRight is free for workers — contractors post jobs and you apply direct. No fees, no agency cut, no middleman.

Check out current jobs: rateright.com.au/go?utm_source=gumtree&utm_medium=email&utm_campaign=hunt&city={city_slug}

Cheers,
Michael
RateRight

Unsubscribe: {unsubscribe_url}
```

**Worker outreach template (SMS via Twilio):**
```
Hey {name}, looking for {trade} work in {city}? RateRight is free for workers — contractors post jobs, you apply direct. No fees ever. rateright.com.au/go Reply STOP to opt out
```

**Rate limits:**
- Gumtree email outreach: max 20/day, 30 min gaps between sends
- Facebook DMs: max 10/day
- SMS: max 15/day

---

### 3.3 Contractor Hunter

**Purpose:** Find companies actively hiring construction workers and pitch RateRight's $50 flat fee.

**Source 1: Gumtree "Jobs" → Trades & Services**

- Scrape daily (VPS, 07:00 AEST)
- Target URL pattern: `gumtree.com.au/s-jobs/c9301` filtered by trades subcategories
- Cities: Sydney, Melbourne, Brisbane
- Extract: company/poster name, trade needed, location, contact info, job description, listing URL
- Store in Supabase `scraped_leads` (side: 'contractor')
- Cross-reference against existing 304 leads (by company name or email) — skip duplicates

**Source 2: Seek/Indeed job posters (piggyback on Job Aggregator)**

- When Job Aggregator scrapes listings, capture company_name
- Companies posting 3+ jobs = active hirers = high-value targets
- Store unique companies in `scraped_leads` (side: 'contractor', source: 'seek'/'indeed')

**Contractor outreach template (email):**
```
Subject: Hiring {trade} in {city}? $50 flat fee

Hey {name},

Saw you're hiring {trade} in {city}.

RateRight charges $50 flat per hire — no percentages, no agency markup, no ongoing fees. Post your job and we match you with workers.

Post a job: rateright.com.au/hire?utm_source=gumtree&utm_medium=email&utm_campaign=hunt&city={city_slug}

Happy to chat if you want to know more.

Cheers,
Michael
RateRight
0468 087 171

Unsubscribe: {unsubscribe_url}
```

**Legal note:** B2B cold outreach to published business contacts is permissible under Australia's Spam Act 2003 provided an unsubscribe mechanism is included. Every email includes an unsubscribe link.

**Rate limits:** Same as worker side — 20 emails/day, 30 min gaps.

---

### 3.4 Matchmaker

**Purpose:** When a worker and contractor are in the same city and trade, make the introduction through RateRight.

**Match detection:**
```sql
-- Run hourly via cron
SELECT w.id AS worker_id, c.id AS contractor_id, w.trade, w.city
FROM scraped_leads w
JOIN scraped_leads c
  ON w.city = c.city
  AND (w.trade = c.trade OR w.trade = 'labourer' OR c.trade = 'labourer')
WHERE w.side = 'worker'
  AND c.side = 'contractor'
  AND w.status IN ('replied', 'converted')  -- Worker must have engaged (privacy/consent)
  AND c.status IN ('new', 'contacted', 'replied', 'converted')  -- Contractor can be any active status
  AND w.created_at > NOW() - INTERVAL '7 days'
  AND c.created_at > NOW() - INTERVAL '7 days'
  AND NOT EXISTS (
    SELECT 1 FROM match_introductions mi
    WHERE mi.worker_lead_id = w.id AND mi.contractor_lead_id = c.id
  );
```

**Match introduction messages:**

To the worker:
```
Subject: {trade} job available in {city}

Hey {name},

A contractor in {suburb} is looking for a {trade} this week.

Sign up on RateRight to get connected — it's free for workers: rateright.com.au/go?utm_source=match&utm_medium=email&utm_campaign=hunt&city={city_slug}

Cheers,
Michael
RateRight

Unsubscribe: {unsubscribe_url}
```

To the contractor:
```
Subject: {trade} available in {city}

Hey {name},

We've got a {trade} available in {city} right now.

Post your job on RateRight ($50 flat fee) and we'll connect you: rateright.com.au/hire?utm_source=match&utm_medium=email&utm_campaign=hunt&city={city_slug}

Cheers,
Michael
RateRight
0468 087 171

Unsubscribe: {unsubscribe_url}
```

**Match quality scoring:**
- Same city: required (filter, not score)
- Same trade: +3 points / compatible trade: +1 / labourer wildcard: +1
- Both posted within last 3 days: +2 / within 7 days: +1
- Neither matched before: required (filter)
- Minimum score to trigger: 2

**Database table: `match_introductions`**
```sql
CREATE TABLE match_introductions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  worker_lead_id UUID REFERENCES scraped_leads(id),
  contractor_lead_id UUID REFERENCES scraped_leads(id),
  trade TEXT,
  city TEXT,
  score INTEGER,
  worker_contacted_at TIMESTAMPTZ,
  contractor_contacted_at TIMESTAMPTZ,
  worker_signed_up BOOLEAN DEFAULT FALSE,
  contractor_signed_up BOOLEAN DEFAULT FALSE,
  hire_completed BOOLEAN DEFAULT FALSE,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(worker_lead_id, contractor_lead_id)
);
```

---

## 4. Anti-Spam Failsafes

### 4.1 Central Contact Registry

Single source of truth preventing duplicate contact across all channels.

```sql
CREATE TABLE contact_registry (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email TEXT,
  phone TEXT,
  name TEXT,
  city TEXT,
  trade TEXT,
  identity_key TEXT GENERATED ALWAYS AS (
    LOWER(COALESCE(email, '') || '|' || COALESCE(phone, '') || '|' || COALESCE(name, '') || '|' || COALESCE(city, ''))
  ) STORED UNIQUE,
  side TEXT CHECK (side IN ('worker', 'contractor', 'both')),
  source TEXT CHECK (source IN ('gumtree', 'facebook', 'seek', 'indeed', 'manual', 'signup', 'match')),
  first_seen_at TIMESTAMPTZ DEFAULT NOW(),
  last_contacted_at TIMESTAMPTZ,
  contact_count INTEGER DEFAULT 0,
  max_contacts INTEGER DEFAULT 3,
  status TEXT DEFAULT 'new' CHECK (status IN ('new', 'contacted', 'replied', 'converted', 'unsubscribed', 'bounced')),
  unsubscribed_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Partial unique indexes for non-null email and phone (in addition to identity_key)
CREATE UNIQUE INDEX idx_contact_registry_email_unique ON contact_registry(email) WHERE email IS NOT NULL;
CREATE UNIQUE INDEX idx_contact_registry_phone_unique ON contact_registry(phone) WHERE phone IS NOT NULL;
CREATE INDEX idx_contact_registry_city_trade ON contact_registry(city, trade);
CREATE INDEX idx_contact_registry_status ON contact_registry(status);
CREATE INDEX idx_contact_registry_identity ON contact_registry(identity_key);
```

**Dedup note:** The `identity_key` is a generated column combining email + phone + name + city. This catches duplicates even when email/phone are NULL — e.g., "Jake from Sydney" appearing on both Gumtree and Facebook with no contact details will match on `|jake|sydney`. Partial unique indexes on email and phone catch exact matches when those fields are populated.

### 4.2 Seven Failsafes

| # | Failsafe | Implementation |
|---|----------|---------------|
| 1 | **Single source of truth** | Every scraper checks `contact_registry` BEFORE queueing outreach. If person exists → skip. |
| 2 | **Hard contact cap** | `max_contacts = 3`. Initial outreach + 1 follow-up + 1 match notification. Then stop forever. |
| 3 | **Cooldown period** | `WHERE last_contacted_at < NOW() - INTERVAL '7 days'`. Minimum 7 days between messages. |
| 4 | **Cross-source dedup** | Match on `identity_key` (generated from email+phone+name+city). "Jake, Sydney, labourer" on Gumtree AND Facebook = one contact. Partial unique indexes catch exact email/phone matches too. |
| 5 | **Unsubscribe** | Every email has unsubscribe link. Sets `unsubscribed_at`, enforced at query level: `WHERE unsubscribed_at IS NULL`. |
| 6 | **Bounce handling** | Bounced emails set `status = 'bounced'`. Never retried. Reuses hostel campaign monitor logic. |
| 7 | **Daily send caps** | Hard limits enforced in code: 20 emails/day, 15 SMS/day, 10 Facebook DMs/day. Even in full auto mode. |

### 4.3 Pre-Send Check (every message)

```
Before sending any outreach:
  1. SELECT FROM contact_registry WHERE email = ? OR phone = ? OR identity_key = ?
  2. IF unsubscribed_at IS NOT NULL → SKIP
  3. IF status = 'bounced' → SKIP
  4. IF contact_count >= max_contacts → SKIP
  5. IF last_contacted_at > NOW() - INTERVAL '7 days' → SKIP
  6. Compute daily_send_count:
     SELECT COUNT(*) FROM scraped_leads
     WHERE outreach_sent_at >= CURRENT_DATE AND outreach_channel = ?
     IF daily_send_count >= daily_cap → DEFER to tomorrow
  7. ELSE → SEND, UPDATE contact_count + 1, last_contacted_at = NOW()
```

---

## 5. Scraped Leads Table

Central storage for all scraped leads before they enter the contact registry.

```sql
CREATE TABLE scraped_leads (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  source TEXT NOT NULL CHECK (source IN ('gumtree', 'facebook', 'seek', 'indeed', 'manual')),
  source_url TEXT,
  side TEXT NOT NULL CHECK (side IN ('worker', 'contractor')),
  name TEXT,
  email TEXT,
  phone TEXT,
  trade TEXT,
  city TEXT,
  suburb TEXT,
  description TEXT,
  pay_rate TEXT,
  post_date DATE,
  status TEXT DEFAULT 'new' CHECK (status IN ('new', 'queued', 'approved', 'sent', 'replied', 'converted', 'rejected', 'skipped')),
  contact_registry_id UUID REFERENCES contact_registry(id),
  outreach_channel TEXT CHECK (outreach_channel IN ('email', 'sms', 'facebook_dm', 'facebook_reply', 'gumtree_message')),
  outreach_sent_at TIMESTAMPTZ,
  outreach_message_id TEXT,
  scraped_at TIMESTAMPTZ DEFAULT NOW(),
  created_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(source, source_url)  -- Works for Gumtree/Seek/Indeed (stable URLs)
);

-- Facebook URLs are unstable (session tokens change). Use poster identity instead.
CREATE UNIQUE INDEX idx_scraped_leads_facebook_dedup
  ON scraped_leads(source, name, city, post_date)
  WHERE source = 'facebook';

CREATE INDEX idx_scraped_leads_side ON scraped_leads(side);
CREATE INDEX idx_scraped_leads_city_trade ON scraped_leads(city, trade);
CREATE INDEX idx_scraped_leads_status ON scraped_leads(status);
CREATE INDEX idx_scraped_leads_source ON scraped_leads(source);
```

---

## 6. Deployment Architecture

### 6.1 Where Things Run

| Component | Runs On | Why |
|---|---|---|
| Job Aggregator scraper | VPS (PM2 cron) | 24/7, no browser needed |
| Gumtree worker/contractor scraper | VPS (PM2 cron) | 24/7, no browser needed |
| Facebook group scraper | Rocky's PC (headed Playwright) | Needs personal profile, residential IP |
| Facebook group poster | Rocky's PC (headed Playwright) | Same reason |
| Email/SMS outreach sender | VPS (PM2) | 24/7, reuses existing Gmail API + Twilio |
| Reply monitor | VPS (PM2) | 24/7, reuses hostel monitor pattern |
| Matchmaker | VPS (PM2 cron) | Hourly check, lightweight |
| Notion summary generator | VPS | Triggers at 08:00 AEST daily |

### 6.2 Agent Configuration

| Agent | Role | Model | Runs On |
|---|---|---|---|
| **Rivet** | Orchestrator — triggers scrapers, matchmaker, generates Notion summaries | DeepSeek | VPS |
| **Susan** | Outreach sender — processes queue, sends messages, monitors replies | DeepSeek | VPS |

All other agents (Builder, Harper, Sentinel, Radar, Herald, Cog) are parked.

### 6.3 Rivet Daily Schedule

```
06:00 AEST — Run Job Aggregator (Seek/Indeed/Gumtree job listings)
06:30 AEST — Run Worker Hunter (Gumtree looking-for-work)
07:00 AEST — Run Contractor Hunter (Gumtree job posters + Seek/Indeed companies)
07:30 AEST — Run Matchmaker (detect city+trade pairs)
08:00 AEST — Generate Notion summary:
              "Found X workers, Y contractors. Z matches. N outreach queued."
19:00 AEST — If auto-mode enabled: trigger Susan to process outreach queue
```

### 6.4 Susan — Role Boundary

**Important distinction:** "Susan" is two things:

1. **Susan (Node.js PM2 scripts)** — mechanical work that runs 24/7:
   - `outreach-sender.js` — processes queue, sends email/SMS, respects rate limits
   - `reply-monitor.js` — polls Gmail every 30 min, detects replies, classifies obvious cases (bounce, unsubscribe, "not interested")
   - These are PM2 processes, not LLM invocations. They run continuously and cost nothing.

2. **Susan (LLM agent via OpenClaw)** — invoked only when needed:
   - Composing non-template responses to questions ("How does RateRight work?")
   - Classifying ambiguous replies that the keyword classifier can't handle
   - Generating personalized outreach for high-value matches
   - Invoked by Rivet on-demand, not running continuously. Keeps API costs near zero.

**Daily cycle (PM2 scripts):**
```
Continuous (PM2):
  - outreach-sender.js: check queue every 30 min → send approved items → update DB
  - reply-monitor.js: poll Gmail every 30 min → classify replies → update contact_registry

On-demand (LLM, triggered by Rivet):
  - Ambiguous reply needs human-quality response → Susan composes reply
  - High-value match detected → Susan writes personalized intro
```

---

## 7. Approval Flow

### Phase 1: Manual Review (first ~20 leads)

```
Scrapers run → leads land in Supabase (status: 'new')
    ↓
Notion summary sent to Rocky at 08:00 AEST
    ↓
Rocky reviews during the Window (19:00-20:30)
    ↓
Rocky runs /matchmaker approve → status changes to 'approved'
    ↓
Susan sends approved outreach
```

### Phase 2: Auto with Daily Summary

Once Rocky is happy with messaging quality:

```
Scrapers run → leads land in Supabase (status: 'new')
    ↓
Susan auto-sends at 19:00 AEST (no approval needed)
    ↓
Notion summary sent next morning: "Sent X messages yesterday.
  Y replies received. Z new signups."
    ↓
Rocky reviews summary, can /matchmaker pause if anything looks wrong
```

Transition from Phase 1 → Phase 2 is triggered by Rocky running `/matchmaker auto-enable`.

---

## 8. Notion Integration

Daily summary posted to a dedicated Notion page via the Notion MCP integration.

**Summary format:**
```
# RateRight Daily Hunt — {date}

## Scrape Results
- Workers found: {count} ({cities breakdown})
- Contractors found: {count} ({cities breakdown})
- Jobs aggregated: {count} from Seek/Indeed/Gumtree

## Matches
- New matches detected: {count}
- Match details: {list of city + trade pairs}

## Outreach
- Queued: {count} (Phase 1) / Sent: {count} (Phase 2)
- Channel breakdown: {email: X, sms: Y, facebook: Z}

## Replies (last 24h)
- Positive: {count} — {names/details}
- Negative: {count}
- Questions: {count} — {summary}

## Platform Activity
- New worker signups: {count}
- New contractor signups: {count}
- Jobs posted: {count}
- Matches made: {count}
- Hires completed: {count}

## Issues
- {any errors, bounces, rate limit hits, agent problems}
```

---

## 9. Skills

### 9.1 Skill: `/hunt`

**Purpose:** Manually trigger a scrape + outreach cycle on demand.

**Commands:**
```
/hunt                     → full cycle: all sources, both sides, all cities
/hunt workers             → scrape workers only (all cities)
/hunt contractors         → scrape contractors only (all cities)
/hunt workers sydney      → scrape workers in Sydney only
/hunt contractors melbourne → scrape contractors in Melbourne only
/hunt jobs                → run Job Aggregator only
/hunt --dry-run           → scrape and report what was found, don't queue outreach
/hunt --status            → show today's scrape stats
```

**Implementation:**
- Invokes scraper scripts on VPS via SSH or LocalCC bridge
- Parses output and displays summary
- In dry-run mode: shows leads found but doesn't insert into outreach queue
- Respects all rate limits and dedup rules

**File:** `C:\Users\mclou\.claude\skills\hunt.md`

### 9.2 Skill: `/matchmaker`

**Purpose:** Run matching engine and manage outreach queue.

**Commands:**
```
/matchmaker               → run match detection, show today's matches
/matchmaker approve       → approve all pending outreach
/matchmaker approve 5     → approve first 5 in queue
/matchmaker reject {id}   → reject a specific lead
/matchmaker stats         → conversion funnel: scraped → contacted → replied → signed up → hired
/matchmaker pause         → pause all automated outreach
/matchmaker resume        → resume automated outreach
/matchmaker auto-enable   → switch from Phase 1 (manual) to Phase 2 (auto with summary)
/matchmaker auto-disable  → switch back to Phase 1
/matchmaker queue         → show current outreach queue with details
```

**Implementation:**
- Queries Supabase directly for match detection and queue management
- Updates lead statuses in `scraped_leads` table
- Stats query joins `scraped_leads`, `contact_registry`, and platform tables (`profiles`, `worker_profiles`, `companies`, `jobs`, `matches`, `payments`)
- Pause/resume sets a flag in Supabase that Susan checks before sending

**File:** `C:\Users\mclou\.claude\skills\matchmaker.md`

### 9.3 Skill: `/pulse`

**Purpose:** One-command health check of entire system.

**Commands:**
```
/pulse                    → full status: platform, scrapers, outreach, agents
/pulse workers            → worker signup count by city and trade
/pulse contractors        → contractor count, active jobs
/pulse funnel             → full conversion funnel with percentages
/pulse agents             → Rivet and Susan status, last run times, errors
/pulse today              → today's activity only
```

**Implementation:**
- Queries Supabase for platform data (profiles, jobs, matches, payments)
- Queries `scraped_leads` and `contact_registry` for campaign data
- SSHs to VPS for agent status (pm2 status, last log entries)
- Formats as compact table output

**File:** `C:\Users\mclou\.claude\skills\pulse.md`

### 9.4 Skill: `/post`

**Purpose:** Facebook group posting via headed browser.

**Commands:**
```
/post                     → show today's posting schedule
/post compose             → generate individualized posts for today's target groups
/post go                  → execute posts via headed Playwright browser
/post groups              → list all groups, last posted, engagement
/post add "Group Name"    → add group to rotation
/post remove "Group Name" → remove group from rotation
/post history             → posting history: what, where, when
/post schedule            → show/edit weekly posting schedule
```

**Implementation:**
- Group config stored in `scripts/facebook/groups.json`:
  ```json
  {
    "groups": [
      {
        "name": "Working Holiday Australia 2026",
        "url": "https://facebook.com/groups/...",
        "city": "national",
        "last_posted": "2026-04-25",
        "posts_this_week": 1,
        "rules_notes": "No promo except Fridays"
      }
    ]
  }
  ```
- Content templates in `scripts/facebook/templates/` — varied by type (question, tip, story, job link)
- Each post individualized per group: different opening, city reference, group context
- Headed Playwright browser opens on Rocky's PC
- Timing: ±5 min randomization from scheduled time
- Between-group gap: 15-20 minutes (randomized)
- Safety: max 5-10 groups per day, max 1-2 posts per group per week
- Logging: every post tracked in Supabase `facebook_post_log` table (not local JSON — survives machine changes)

**File:** `C:\Users\mclou\.claude\skills\post.md`

---

## 10. Platform Changes Required

### 10.1 /go Page Updates

Current: Worker landing page → signup CTA
Change: Add aggregated job listings section

```
/go page layout:
  Hero: "Construction Work in {City}. $35-45/hr. Zero Fees."
  ↓
  Section: "Jobs Available Now" — pulls from aggregated_jobs table
    - Each card: title, company, suburb, pay rate, trade, source badge
    - Aggregated jobs: "View on Seek →" button (external link)
    - Native jobs: "Apply Now" button (in-app flow)
  ↓
  CTA: "Sign up free to get matched" → /worker/signup
```

### 10.2 Unsubscribe Endpoint

New API route: `GET /api/unsubscribe?token={jwt}`
- Decodes JWT containing contact_registry.id
- Sets `unsubscribed_at = NOW()` in contact_registry
- Returns simple "You've been unsubscribed" page
- JWT prevents unauthorized unsubscribes

### 10.3 UTM Tracking

All outreach links include UTM parameters:
```
rateright.com.au/go?utm_source={source}&utm_medium={channel}&utm_campaign=hunt&city={city}
rateright.com.au/hire?utm_source={source}&utm_medium={channel}&utm_campaign=hunt&city={city}
```

Sources: `gumtree`, `facebook`, `seek`, `indeed`, `match`, `gumtree_ad`
Channels: `email`, `sms`, `facebook_dm`, `facebook_reply`, `organic`

---

## 11. File Structure

```
rateright-growth-deploy/
├── scripts/
│   ├── hunt/
│   │   ├── job-aggregator.js        # Scrapes Seek/Indeed/Gumtree jobs
│   │   ├── worker-hunter.js         # Scrapes Gumtree looking-for-work
│   │   ├── contractor-hunter.js     # Scrapes Gumtree job posters + Seek/Indeed companies
│   │   ├── matchmaker.js            # Detects city+trade matches
│   │   ├── outreach-sender.js       # Sends email/SMS from approved queue
│   │   ├── reply-monitor.js         # Monitors replies, classifies, updates registry
│   │   ├── notion-summary.js        # Generates daily Notion summary
│   │   ├── unsubscribe-handler.js   # Processes unsubscribe requests
│   │   └── config.js                # Cities, trade mappings, rate limits, templates
│   ├── facebook/
│   │   ├── group-scraper.js         # Headed browser — scrapes looking-for-work posts
│   │   ├── group-poster.js          # Headed browser — posts to groups
│   │   ├── groups.json              # Group config (name, URL, schedule, rules)
│   │   ├── templates/               # Post templates by type
│   │   │   ├── question.json
│   │   │   ├── tip.json
│   │   │   ├── story.json
│   │   │   └── job-link.json
│   │   └── post-log.json            # Local backup (primary log in Supabase facebook_post_log)
│   └── campaign/                    # Existing hostel campaign (kept for reference)
│       ├── gmail-api.js             # REUSED — email sending
│       ├── gmail-auth.js            # REUSED — OAuth tokens
│       └── sheets-api.js            # REUSED — Google Sheets access
├── supabase/
│   └── migrations/
│       └── 20260428000000_marketplace_hunter.sql  # New tables
└── docs/
    └── superpowers/
        └── specs/
            └── 2026-04-28-marketplace-hunter-design.md  # This file
```

---

## 12. Reused Infrastructure

| Component | Source | What We Reuse |
|---|---|---|
| Gmail API (send/receive) | `scripts/campaign/gmail-api.js` | Email outreach + reply monitoring |
| Gmail OAuth | `scripts/campaign/gmail-auth.js` | Authentication |
| Twilio SMS | `src/services/twilio.js` | SMS outreach |
| Supabase database | `src/config/database.js` | Lead storage, contact registry |
| Playwright | `@playwright/test` (already installed) | Facebook scraping + posting |
| PM2 process management | Already running on VPS | Cron scheduling, process monitoring |
| Express.js server | `src/index.js` | Unsubscribe endpoint, API routes |

---

## 13. Build Phases

### Phase 1 — This Week: Job Aggregator + Gumtree Ad Templates
- Build and deploy Job Aggregator (Seek/Indeed/Gumtree scraper)
- Update `/go` page to display aggregated jobs
- Create Gumtree ad posting templates for Rocky's helper
- Database migration for new tables
- `/pulse` skill (need visibility immediately)

### Phase 2 — Next Week: Worker + Contractor Hunters
- Build Gumtree worker scraper
- Build Gumtree contractor scraper
- Build outreach sender (email + SMS)
- Build reply monitor (adapt from hostel campaign)
- Contact registry + dedup logic
- Notion integration for daily summaries
- `/hunt` and `/matchmaker` skills
- Reconfigure Rivet and Susan for new roles

### Phase 3 — Week After: Matchmaker + Facebook + Auto Mode
- Build match detection engine
- Build match introduction sender
- Facebook group scraper (headed browser)
- Facebook group poster (headed browser)
- `/post` skill
- Auto-mode toggle (Phase 1 → Phase 2 approval flow)
- End-to-end testing: scrape → match → outreach → signup → hire → payment

---

## 14. Success Metrics

| Milestone | Target | Timeframe |
|---|---|---|
| First worker signup from outreach | 1 | Week 2 |
| First contractor signup from outreach | 1 | Week 2 |
| 10 workers on platform | 10 | Week 3 |
| 10 contractors on platform | 10 | Week 3 |
| First real match made | 1 | Week 3 |
| **First real hire between strangers** | **1** | **Week 4** |
| 50 workers on platform | 50 | Week 6 |
| First $50 payment collected | 1 | Week 6 |
| Repeatable: 5+ hires/month | 5 | Month 3 |

---

## 15. Risks and Mitigations

| Risk | Impact | Mitigation |
|---|---|---|
| Gumtree blocks scraping | Lose worker/contractor lead source | Rotate user agents, respect rate limits, add delays. Fallback: manual Gumtree browsing |
| Facebook account banned | Lose Facebook channel entirely | Conservative limits (10 DMs/day, 5-10 group posts/day), headed browser, residential IP, individualized content |
| Low reply rates | Outreach not converting | A/B test templates, try SMS vs email, adjust targeting. Matchmaker intros should convert better than cold outreach |
| Workers sign up but no jobs | Workers leave and don't return | Job Aggregator ensures they always see real listings. Push notifications when native jobs posted |
| Contractors sign up but no workers | Contractors leave | Prioritize worker acquisition. Show contractors "X workers in your area" even before they post |
| Spam complaints | Domain reputation damaged, legal risk | 7 failsafes (Section 4), unsubscribe in every message, hard contact caps |
| API costs too high | Budget drain (learned from previous fleet) | DeepSeek for agents (cheapest), no LLM needed for scraping/sending, Notion API is free |

---

## 16. Additional Tables (from spec review)

### 16.1 Facebook Post Log (in Supabase, not local JSON)

```sql
CREATE TABLE facebook_post_log (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  group_name TEXT NOT NULL,
  group_url TEXT,
  post_type TEXT CHECK (post_type IN ('question', 'tip', 'story', 'job_link')),
  post_content TEXT,
  city TEXT,
  posted_at TIMESTAMPTZ DEFAULT NOW(),
  engagement_likes INTEGER DEFAULT 0,
  engagement_comments INTEGER DEFAULT 0,
  removed_by_admin BOOLEAN DEFAULT FALSE,
  notes TEXT
);

CREATE INDEX idx_facebook_post_log_group ON facebook_post_log(group_name);
CREATE INDEX idx_facebook_post_log_date ON facebook_post_log(posted_at);
```

### 16.2 Import Existing 304 Leads

The 304 existing contractor leads must be imported into `contact_registry` before the Contractor Hunter goes live. Otherwise the scraper will contact companies Rocky has already spoken to.

**Migration task (Phase 1):**
1. Identify where the 304 leads currently live (Growth Engine Supabase `leads` table, Apollo export, or Google Sheet)
2. Export: name, email, phone, company, city
3. Insert into `contact_registry` with `source = 'manual'`, `side = 'contractor'`, `status = 'contacted'`
4. This prevents duplicate outreach to known leads

### 16.3 Matchmaker Privacy Safeguard

Match introductions only fire when the **worker** has `status = 'replied'` or `'converted'` — meaning they have affirmatively engaged with RateRight. This prevents sharing a scraped person's details (name, trade, city) with a third party without their awareness, which would risk breaching Australia's Privacy Act 1988.

Contractors can be introduced at any active status, as they are businesses with publicly posted contact details (B2B context under the Spam Act 2003).
