AI Candidate Finder for Hard-to-Fill Positions
The Problem
Hiring licensed techs is the number one pain point. You spend 5-10 hours per week on Indeed. Good candidates get 8 offers before you even review their resume. This automation scrapes job boards daily, scores candidates by fit, and drafts personalized outreach.
How It Works
Job description with must-haves (certs, experience, location radius)
AI scrapes job boards daily, scores by fit, drafts outreach. Alerts on strong matches.
Daily candidate shortlist with fit scores and pre-written outreach.
PRD
# Product Requirements Document
Recipe 026 -- AI Candidate Finder for Hard-to-Fill Positions
THE AI TRADES Platform
---
Recipe Slug: `ai-candidate-finder`
Recipe Number: 026
Rank: 25 | Tier: 3
Difficulty: Replit Build
Time Estimate: Full day
Category: Internal Operations
Spend Replaces: Recruiter ($5,000-15,000 per hire)
Roles: Owner, HR
Trades: HVAC, Plumbing, Electrical
Software: Indeed, ZipRecruiter, LinkedIn
Principles: Replace the Boring 80%
---
Reusable Modules Referenced:
- Module 0: UX Philosophy (`modules/ux-philosophy-module.md`)
- Module 8: Analytics Dashboard (`modules/analytics-dashboard-module.md`)
- Module 10: Settings Panel (`modules/settings-panel-module.md`)
- Module 11: Notification / Toast (`modules/notification-toast-module.md`)
- Module 14: Headless Browser Automation (`modules/headless-browser-automation-module.md`)
Integration Docs (include with build):
- PostgreSQL Database -- database, auth, storage
- Email Sending (`integrations/email-sending.md`) -- Resend for outreach delivery
- OpenAI/Anthropic (`integrations/openai-anthropic.md`) -- resume parsing, scoring, outreach drafting
---
Table of Contents
3. Job Requirements Configuration
4. Candidate Scoring Algorithm
5. AI Resume/Profile Analysis Prompt
9. Data Model
11. Example: Licensed HVAC Tech in Dallas
---
1. Recipe Overview
Hiring licensed techs is the number one pain point in home services. Not the work itself. Not the customers. Finding people. You spend 5-10 hours per week scrolling through Indeed, reading resumes that are 60% irrelevant, and composing messages to candidates who already have 8 offers by the time you get to them. The best candidates are off the market in 48 hours. Your manual search cadence of "check Indeed on Tuesday afternoon" means you are always late.
This is not a staffing shortage problem. It is a speed and filtering problem. The candidates exist. They post resumes at 6am on a Monday. By Wednesday, they have three interviews scheduled. If you are checking job boards twice a week and spending 30 minutes per candidate evaluating fit, you are structurally unable to compete with companies that have full-time recruiters.
This recipe scrapes job boards daily, extracts structured data from resumes and profiles, scores every candidate against your specific requirements, and drafts personalized outreach messages for the ones worth pursuing. You wake up to a shortlist: "3 strong matches found overnight. Outreach drafted. Review and send."
Input: Job description with must-haves (certifications, experience, location radius, license types, trade specialties).
Transformation: AI scrapes job boards daily, parses resumes into structured data, scores each candidate by weighted fit criteria, and drafts personalized outreach. Alerts on strong matches.
Output: Daily candidate shortlist with fit scores (0-100) and pre-written outreach messages ready to send or edit.
---
2. Strategy Brief
The Business Villain
"The 5-10 Hours You Spend Every Week Losing to Faster Companies"
A plumbing company owner in Dallas needs a licensed journeyman plumber. He posts the job on Indeed and ZipRecruiter. He checks both platforms Tuesday and Thursday. On Monday at 7am, a journeyman with 8 years of experience and a Texas state license posts his resume on Indeed. By the time the owner logs in Tuesday afternoon, that candidate has received messages from two staffing agencies, one national chain, and three other local shops. The owner writes a message Wednesday morning. The candidate replies Thursday: "Thanks, but I already accepted an offer."
This happens every week. Not because the owner is bad at hiring. Because the owner is also running jobs, handling estimates, managing three trucks, and trying to collect on a $14K invoice. Hiring is a part-time activity competing with full-time demands.
What the manual hiring process costs:
| Problem | Impact |
|---|---|
| Owner spends 5-10 hours/week browsing job boards | 250-500 hours/year of owner time on candidate search |
| Resume review is manual and inconsistent | Good candidates missed because the resume format was bad |
| Outreach messages are generic copy-paste | Response rate under 15% |
| 48-72 hour delay between candidate posting and owner contact | Best candidates gone before first contact |
| No scoring system for fit | Owner relies on gut feeling, wastes time on poor matches |
| Each bad hire costs $8,000-$15,000 in training, lost productivity, callbacks | One wrong hire per year wipes out a month of profit |
Why this keeps happening: Hiring is not the owner's core competency. They know what a good tech looks like in the field. They do not know how to efficiently source one from a pool of 200 resumes. The tools (Indeed, ZipRecruiter) are designed for volume, not precision. They surface everyone, and the owner has to do all the filtering.
The cost of doing nothing: A position stays open for 45-90 days. During that time, you turn away work or overload your existing crew. Overloaded crews cut corners. Callbacks increase. Morale drops. Your best tech quits because he is tired of covering for the empty slot. Now you have two positions to fill.
The Behavioral Solution
"From Searching for Candidates to Reviewing Pre-Scored Shortlists"
| Before (Manual Hiring) | After (AI Candidate Finder) |
|---|---|
| Check Indeed 2-3x/week | System checks every board daily at 5am |
| Read 20-30 resumes per session | AI reads every resume, scores all of them |
| Guess at fit based on skim-reading | Weighted score 0-100 based on your exact requirements |
| Write generic outreach: "We saw your resume..." | AI drafts personalized message referencing specific qualifications |
| Best candidates contacted 48-72 hours late | Strong matches (90+) get same-day outreach |
| No system, no tracking, no data | Full pipeline: found, scored, contacted, responded, hired |
| Spend $5,000-$15,000 per hire on a recruiter | Spend $50-$100/month on API costs |
The trust ladder:
1. Day 1: Owner defines the job. System scrapes overnight. Morning shortlist has 4 candidates, scored 92, 85, 78, 71. Owner reads the 92 score profile and thinks "Yeah, that is exactly what I need." Sends the outreach with one edit.
2. Week 1: Two candidates respond. Owner schedules interviews. Realizes he reviewed 15 candidates in 10 minutes instead of 10 hours.
3. Week 2: Hires a licensed journeyman who was on the market for 3 days. Would have missed him entirely under the old process.
4. Month 1: Opens a second job listing for an apprentice. Copy, adjust requirements, run. The system is now his hiring department.
Core Loop
```
[Job Requirements Defined]
-> [Daily 5am Scrape: Indeed, ZipRecruiter, LinkedIn]
-> [AI Parses Resumes/Profiles into Structured Data]
-> [Scoring Engine: Weighted Match 0-100]
-> [Filter: Score >= 70 enters shortlist]
-> [AI Drafts Outreach per Score Tier]
-> [Daily Digest: Shortlist + Scores + Outreach]
-> [Owner Reviews, Edits, Sends]
-> [Track: Sent, Responded, Interviewed, Hired]
```
---
3. Job Requirements Configuration
The owner defines what they need once. The system uses this configuration as the scoring rubric for every candidate it finds. Requirements are split into two categories: must-haves (deal-breakers) and nice-to-haves (bonus points).
Must-Haves vs Nice-to-Haves
A must-have is a hard filter. If the candidate does not have it, they do not appear on the shortlist regardless of how strong they are in other areas. A nice-to-have adds points but does not disqualify.
Must-haves are binary. Either the candidate has an EPA 608 Universal certification or they do not. Either they are within 30 miles of the job location or they are not. There is no partial credit on must-haves.
Nice-to-haves are weighted. A candidate with 10 years of experience scores higher than one with 5 years, but both are viable.
Configuration Fields
| Field | Type | Category | Example |
|---|---|---|---|
| Job Title | Text | Core | "Licensed HVAC Service Technician" |
| Trade | Select | Core | HVAC, Plumbing, Electrical |
| Job Location | Address/ZIP | Core | "75201" (Dallas, TX) |
| Location Radius | Miles (slider, 5-100) | Must-Have | 30 miles |
| Required Certifications | Multi-select + custom | Must-Have | EPA 608 Universal, NATE Core |
| Required License Types | Multi-select + custom | Must-Have | Texas HVAC Technician License, Journeyman |
| Minimum Experience (years) | Number | Must-Have | 3 years |
| Trade Specialties | Multi-select | Nice-to-Have | Residential, Commercial, New Construction, Service/Repair |
| Preferred Certifications | Multi-select + custom | Nice-to-Have | R-410A Safety, OSHA 10/30, First Aid/CPR |
| Preferred Experience (years) | Number | Nice-to-Have | 5+ years (scores higher) |
| Equipment/Brand Experience | Multi-select + custom | Nice-to-Have | Carrier, Trane, Lennox, Rheem |
| Driver's License | Boolean | Must-Have or Nice-to-Have (configurable) | Required |
| Own Tools | Boolean | Nice-to-Have | Preferred |
| Availability | Select | Nice-to-Have | Immediately, Within 2 weeks, Within 30 days |
| Pay Range | Min/Max | Reference | $28-$38/hour |
| Job Description (free text) | Textarea | Reference | Full description for context and outreach personalization |
Certification Presets by Trade
To save setup time, the system pre-populates common certifications and licenses based on the selected trade.
HVAC:
- EPA 608 (Type I, Type II, Type III, Universal)
- NATE (Core, Specialty: AC Install, AC Service, Heat Pump, Gas Furnace, Air Distribution)
- R-410A Safety Certification
- State HVAC License (varies by state)
Plumbing:
- Journeyman Plumber License (state-specific)
- Master Plumber License (state-specific)
- Backflow Prevention Certification
- Medical Gas Installer Certification
- OSHA certifications
Electrical:
- Journeyman Electrician License (state-specific)
- Master Electrician License (state-specific)
- Low Voltage License
- Fire Alarm License
- OSHA certifications
The owner can add custom certifications not on the preset list. Custom entries are saved to a `custom_certifications` array for reuse across future job postings.
Location Radius Logic
The system uses the job's ZIP code as the center point and calculates straight-line distance to the candidate's listed location. The formula uses the Haversine equation on ZIP code centroids.
| Radius Setting | Typical Use Case |
|---|---|
| 5-15 miles | Dense metro area, plenty of candidates |
| 15-30 miles | Suburban, standard commute range |
| 30-50 miles | Rural or hard-to-fill, willing to accept longer commute |
| 50-100 miles | Desperate. Willing to offer relocation or remote start. |
If the candidate's profile does not include a location, the system flags them as "Location Unknown" rather than excluding them. The owner can choose to include or exclude unknowns via a toggle.
---
4. Candidate Scoring Algorithm
Every candidate gets a score from 0 to 100. The score is the weighted sum of six dimensions. Must-have failures override the score entirely: a candidate who fails any must-have gets a score of 0 and is excluded from the shortlist.
Scoring Dimensions
| Dimension | Weight | Max Points | What It Measures |
|---|---|---|---|
| Certification Match | 25 | 25 | How many required and preferred certs the candidate holds |
| Experience Years | 20 | 20 | Years of relevant trade experience |
| Location Proximity | 20 | 20 | Distance from job site |
| License Status | 15 | 15 | Active, valid license of the required type |
| Availability | 10 | 10 | How soon the candidate can start |
| Trade Specialty Match | 10 | 10 | Alignment with preferred specialties (residential, commercial, etc.) |
| Total | 100 | 100 |
Scoring Logic Per Dimension
Certification Match (25 points):
```
required_certs_met = count of required certs found / total required certs
preferred_certs_met = count of preferred certs found / total preferred certs
If required_certs_met < 1.0:
-> Must-have failure. Score = 0. Candidate excluded.
cert_score = (required_certs_met 15) + (preferred_certs_met 10)
```
All required certs must be present. Preferred certs earn partial credit.
Experience Years (20 points):
```
If candidate_years < minimum_required:
-> Must-have failure. Score = 0. Candidate excluded.
If candidate_years >= preferred_years:
experience_score = 20
If candidate_years >= minimum_required AND < preferred_years:
experience_score = 12 + ((candidate_years - minimum_required) / (preferred_years - minimum_required)) * 8
```
Meeting the minimum gets 12 points. Meeting or exceeding preferred gets the full 20. Everything between scales linearly.
Location Proximity (20 points):
```
If candidate_distance > location_radius:
-> Must-have failure. Score = 0. Candidate excluded.
If candidate_distance <= 10 miles:
location_score = 20
If candidate_distance > 10 AND <= location_radius:
location_score = 20 - ((candidate_distance - 10) / (location_radius - 10)) * 15
```
Within 10 miles is a perfect score. Beyond 10 miles, the score tapers. Beyond the radius, excluded.
License Status (15 points):
```
If license_required AND license_not_found:
-> Must-have failure. Score = 0. Candidate excluded.
If license_found AND license_active:
license_score = 15
If license_found AND license_status_unknown:
license_score = 10
If license_not_required:
license_score = 15 (not penalized)
```
Availability (10 points):
```
If availability == "immediately" or "within_1_week":
availability_score = 10
If availability == "within_2_weeks":
availability_score = 8
If availability == "within_30_days":
availability_score = 5
If availability == "unknown":
availability_score = 3
```
Trade Specialty Match (10 points):
```
matching_specialties = intersection of candidate specialties and preferred specialties
specialty_score = (matching_specialties / total_preferred_specialties) * 10
```
Score Tiers
| Tier | Score Range | Label | Action |
|---|---|---|---|
| A | 90-100 | Strong Match | Personalized outreach drafted. Owner alerted immediately. |
| B | 70-89 | Good Fit | Standard outreach drafted. Included in daily digest. |
| C | 50-69 | Marginal | Logged but no outreach. Visible in "All Candidates" view. |
| D | 0-49 | Poor Match | Logged for record-keeping. Hidden by default. |
| X | 0 (must-have fail) | Disqualified | Not shown unless owner toggles "Show Disqualified." |
---
5. AI Resume/Profile Analysis Prompt
This prompt takes raw resume text or a job board profile and extracts structured data. The output feeds directly into the scoring engine.
```
You are a recruiting data extraction assistant for a home service contractor.
You extract structured candidate information from resumes and job board
profiles. Your output must be machine-readable JSON. Do not guess or infer
information that is not present. If a field cannot be determined from the
text, use null.
CANDIDATE PROFILE TEXT:
{raw_resume_or_profile_text}
JOB CONTEXT (for matching purposes only, do not copy into output):
- Trade: {trade_type}
- Required Certifications: {required_certs_list}
- Required Licenses: {required_licenses_list}
- Preferred Certifications: {preferred_certs_list}
- Location: {job_zip_code}
EXTRACTION RULES:
1. Extract ONLY what is explicitly stated in the text. Do not infer
certifications from job titles. "HVAC Technician" does not imply
EPA 608. It must be listed.
2. For experience years, calculate from the earliest relevant role to
the most recent. Overlapping roles do not double-count.
3. For certifications, normalize names to standard forms:
- "608 cert" -> "EPA 608"
- "608 universal" -> "EPA 608 Universal"
- "NATE certified" -> "NATE Core" (unless specific specialty listed)
- "journeyman license" -> "Journeyman [Trade] License"
4. For location, extract city, state, and ZIP if present. If only a city
and state are listed, geocode to the city center ZIP.
5. For availability, look for phrases like "available immediately,"
"2 weeks notice," "start date flexible," etc. If not stated, use null.
6. For trade specialties, look for descriptions of work types:
residential, commercial, new construction, service/repair, maintenance,
industrial. Extract what is described, not assumed.
7. For equipment/brand experience, look for specific brand names mentioned
in work history or skills sections.
8. For license status, if a license is mentioned without "expired" or
"inactive," assume "active." If the resume says "in progress" or
"expected," mark as "pending."
OUTPUT FORMAT (JSON):
{
"candidate_name": "string",
"email": "string or null",
"phone": "string or null",
"location": {
"city": "string or null",
"state": "string or null",
"zip": "string or null"
},
"experience_years": number or null,
"certifications": [
{
"name": "string",
"normalized": "string",
"matches_required": boolean,
"matches_preferred": boolean
}
],
"licenses": [
{
"name": "string",
"type": "journeyman | master | apprentice | specialty | other",
"state": "string or null",
"status": "active | pending | expired | unknown"
}
],
"trade_specialties": ["string"],
"equipment_brands": ["string"],
"availability": "immediately | within_1_week | within_2_weeks | within_30_days | unknown",
"education": [
{
"institution": "string",
"credential": "string",
"year": number or null
}
],
"work_history": [
{
"company": "string",
"title": "string",
"start_year": number or null,
"end_year": number or null,
"is_current": boolean,
"description_summary": "string (1-2 sentences)"
}
],
"has_drivers_license": boolean or null,
"has_own_tools": boolean or null,
"languages": ["string"],
"notable_skills": ["string"],
"red_flags": ["string"],
"confidence_notes": "string (any caveats about data quality)"
}
RED FLAG DETECTION:
- Job-hopping: 3+ employers in 2 years with no explanation
- Gaps: 6+ month employment gaps without education or explanation
- Downward trajectory: moving from higher to lower responsibility roles
- Location mismatch: candidate is in a different state with no mention
of willingness to relocate
- License issues: expired or revoked licenses mentioned
Do not editorialize. Extract and flag. The owner makes the judgment call.
```
Model configuration: Claude or GPT-4o. Temperature 0.1. The extraction must be deterministic. Higher temperatures introduce hallucinated certifications.
---
6. Outreach Message Templates
Three message tiers based on candidate score. The tone is contractor-to-contractor. Not HR speak. Not corporate. You are a shop owner reaching out to a tech, not a recruiter sending a form letter.
Tier 1: Strong Match (Score 90+)
These candidates are rare. The message must be specific, reference their actual qualifications, and communicate urgency without desperation. This is your best shot at a top-tier tech. Make it count.
```
Subject: {candidate_first_name} -- saw your profile, think you'd be a fit
Hey {candidate_first_name},
I'm {owner_name}, owner of {company_name} in {city}. We're a
{company_size}-truck {trade} shop focused on {specialty_focus}.
Came across your profile and a few things stood out: {specific_match_1}
and {specific_match_2}. We don't see that combination often.
We're looking for {job_title_short} right now. {pay_range_sentence}
{one_line_about_culture_or_differentiator}.
If you're open to a conversation, I'd like to set up a quick call this
week. No pressure, no recruiter games. Just two {trade} guys talking shop.
{owner_name}
{company_name}
{owner_phone}
```
Merge fields:
- `{specific_match_1}`: Pulled from scoring data. Example: "your EPA 608 Universal and 8 years on residential systems"
- `{specific_match_2}`: Second standout. Example: "Carrier and Trane experience"
- `{pay_range_sentence}`: "We're in the $32-38/hour range, depending on experience." Only included if pay range is configured.
- `{one_line_about_culture_or_differentiator}`: From company profile. Example: "We run clean trucks, keep our guys under 50 hours, and the work-life balance here is real."
Tier 2: Good Fit (Score 70-89)
Solid candidates who may need a closer look. The message is professional but less personalized. Still references one specific qualification to show you actually read their profile.
```
Subject: {trade} position in {city} -- {company_name}
Hey {candidate_first_name},
{owner_name} here from {company_name}. We're hiring a {job_title_short}
and your background caught my eye, especially {one_specific_match}.
A little about us: {company_size} trucks, {specialty_focus}, based in
{city}. {pay_range_sentence_if_configured}
If you're exploring options, I'd like to chat. Drop me a text or call
at {owner_phone}, or just reply here.
{owner_name}
{company_name}
```
Tier 3: Below 70 (Auto-Archive, No Outreach)
Candidates scoring below 70 do not receive outreach. They are logged in the system for reference but do not appear in the daily digest unless the owner specifically toggles "Show all candidates."
No rejection message is sent. These candidates never knew you were looking at them. Sending a "thanks but no thanks" to someone who never applied to your company would be confusing and unprofessional.
AI Outreach Generation Prompt
```
You are writing an outreach message from a home service business owner to
a potential job candidate. The owner is not an HR professional. They are
a contractor who runs trucks and gets their hands dirty.
RULES:
1. Keep the message under 150 words. Techs scan messages on their phone.
2. Reference 1-2 specific things from the candidate's profile. Never
write a message that could have been sent to anyone.
3. Tone: casual, direct, confident. Like a text from a fellow tradesman
who happens to own a shop. No corporate language. No "exciting
opportunity." No "we'd love to have you on our team."
4. Include the pay range ONLY if the owner has configured one. Never
invent a pay range.
5. Do not use exclamation marks. Do not use em dashes. Do not start
with "I hope this finds you well."
6. End with a clear, low-pressure call to action: call, text, or reply.
7. Sign with the owner's first name and company. Not "Best regards" or
"Sincerely."
CANDIDATE DATA:
{candidate_json}
JOB DATA:
{job_config_json}
COMPANY DATA:
{company_profile_json}
SCORE TIER: {tier} (90+ = strong match, 70-89 = good fit)
Write the outreach message. Output ONLY the subject line and message body.
No preamble. No explanation.
```
---
7. Daily Digest Format
The daily digest arrives at 6am via email (or SMS summary with email link for full details). It is the owner's morning hiring briefing. One glance tells them: how many new candidates, who is worth reaching out to, and what is the status of previous outreach.
Digest Structure
```
CANDIDATE FINDER DAILY DIGEST
{date}
Job: {job_title} | {city}, {state} | Active {days_active} days
=============================================
STRONG MATCHES (Score 90+): {count}
---------------------------------------------
1. {candidate_name} -- Score: {score}
{experience_years} yrs | {key_certs} | {city}, {state} ({distance} mi)
Outreach: [DRAFTED - Review & Send]
2. {candidate_name} -- Score: {score}
{experience_years} yrs | {key_certs} | {city}, {state} ({distance} mi)
Outreach: [DRAFTED - Review & Send]
GOOD FITS (Score 70-89): {count}
---------------------------------------------
3. {candidate_name} -- Score: {score}
{experience_years} yrs | {key_certs} | {city}, {state} ({distance} mi)
Outreach: [DRAFTED - Review & Send]
4. {candidate_name} -- Score: {score}
{experience_years} yrs | {key_certs} | {city}, {state} ({distance} mi)
Outreach: [DRAFTED - Review & Send]
PIPELINE STATUS
---------------------------------------------
Outreach sent (awaiting reply): {sent_count}
Replies received: {reply_count}
Interviews scheduled: {interview_count}
Total candidates found (all time): {total_count}
[View Full Shortlist in App]
=============================================
```
Digest Rules
1. Order: Strong matches first, then good fits. Within each tier, sort by score descending.
2. Max entries: Show up to 5 strong matches and 5 good fits. If more exist, show the count and link to the app.
3. Key certs: Show the top 2 certifications that matched. Do not list all of them.
4. No digest on empty days: If zero new candidates scored 70+ overnight, send a one-liner: "No new matches for {job_title} today. {total_active_candidates} candidates in pipeline."
5. Pipeline status: Always include the pipeline counts so the owner knows where things stand without opening the app.
6. Character limit for SMS summary: If the owner prefers SMS, send a condensed version: "{count} new candidates for {job_title}. Top score: {top_score}. Check your email for the full list." The full digest goes to email regardless.
---
8. Feature Specs with VTCR
Feature 1: Job Requirements Setup
---
#### VTCR 1.1 -- Create New Job Listing
Visual:
The main Candidate Finder page shows a "Your Job Listings" section. If no jobs exist, a centered empty state reads: "No active job listings. Create one to start finding candidates." with a primary button: "Create Job Listing." If jobs exist, they appear as cards with the job title, trade badge, location, days active, and candidate count. A "+ New Job" button sits in the top right.
Trigger:
The owner clicks "Create Job Listing" or "+ New Job."
Condition:
- The owner is authenticated.
Result:
- A full-page form opens with sections for: Job Basics (title, trade, location, radius), Must-Haves (certifications, licenses, min experience), Nice-to-Haves (preferred certs, preferred experience, specialties, equipment brands), and Job Details (pay range, description, availability preference).
- When the owner selects a trade, the certification and license multi-selects pre-populate with common options for that trade (Section 3 presets).
- The location radius defaults to 30 miles with a slider from 5 to 100.
- The form saves as a draft on every field change (debounced 2 seconds). No "Save" button anxiety.
- On completion, the owner clicks "Activate Job" at the bottom.
---
#### VTCR 1.2 -- Activate Job Listing and First Scrape
Visual:
When the owner clicks "Activate Job," a confirmation modal appears: "This will start searching Indeed, ZipRecruiter, and LinkedIn for candidates matching your requirements. The first scan runs now. After that, it runs daily at 5am. Ready?" with "Start Searching" and "Go Back" buttons. After confirmation, the job card shows a green "Active" badge and a progress indicator: "First scan in progress..."
Trigger:
The owner clicks "Start Searching" in the confirmation modal.
Condition:
- All must-have fields are populated (trade, location, at least one certification or license requirement, minimum experience).
- The job listing is in draft state.
Result:
- The job listing status changes to `active` in `job_listings`.
- The first scrape job is queued immediately (does not wait for the 5am cron).
- The job card updates to show "Scanning..." with a spinner.
- When the first scan completes (typically 2-5 minutes), the card updates: "{count} candidates found. {shortlist_count} scored 70+. [View Shortlist]."
- A toast notification fires: "First scan complete. {shortlist_count} candidates match your requirements."
---
#### VTCR 1.3 -- Edit Job Requirements
Visual:
Clicking a job card opens its detail view. A "Requirements" tab shows the current configuration in an editable form identical to the creation flow. Changed fields highlight with a subtle blue left border. A banner at the top warns: "Changing requirements will re-score all existing candidates."
Trigger:
The owner modifies any requirement field and the form auto-saves (debounce 2 seconds).
Condition:
- The job listing exists.
Result:
- The `job_listings` record is updated with new requirements.
- All existing candidates for this job are re-scored using the new weights and must-haves.
- Candidates who previously passed must-haves but now fail are moved to "Disqualified."
- Candidates who previously failed but now pass are moved back to the active shortlist.
- The shortlist view refreshes. A toast confirms: "Requirements updated. {count} candidates re-scored."
---
Feature 2: Candidate Scraping and Parsing
---
#### VTCR 2.1 -- Daily Automated Scrape
Visual:
On the job detail page, a "Scrape History" section shows a log of past scrapes with: date, source (Indeed/ZipRecruiter/LinkedIn), candidates found, new candidates (not previously seen), and status (completed/failed/partial). The most recent scrape is at the top with a green checkmark or red X.
Trigger:
The 5am daily cron job fires for each active job listing.
Condition:
- The job listing status is `active`.
- The job listing has not been paused or archived.
- At least one scrape source is enabled in settings.
Result:
- The scraper queries each enabled job board for profiles matching the job's trade, location, and experience range.
- Raw profile data is saved to `raw_candidate_profiles`.
- Each profile is deduplicated against existing candidates by name + location + phone/email hash.
- New profiles are sent through the AI resume analysis prompt (Section 5).
- Parsed structured data is saved to `candidates`.
- The scoring engine runs on all new candidates.
- Candidates scoring 90+ trigger an immediate alert to the owner (push notification or SMS): "Strong match found: {name}, score {score}. [View Profile]."
- A `scrape_log` entry records the results.
---
#### VTCR 2.2 -- Manual Candidate Import
Visual:
On the job detail page, a "Add Candidate" button in the top right opens a modal with two options: "Paste Resume Text" (a large textarea) and "Upload Resume File" (a file drop zone accepting .pdf, .docx, .txt). Below both, a "Parse & Score" button.
Trigger:
The owner pastes resume text or uploads a file and clicks "Parse & Score."
Condition:
- The textarea is not empty OR a valid file is uploaded.
- The file is under 5MB.
Result:
- If a file is uploaded, it is converted to plain text (PDF via pdf-parse, DOCX via mammoth).
- The text is sent through the AI resume analysis prompt.
- The structured data is saved to `candidates` with source "manual."
- The scoring engine runs.
- The candidate card appears immediately in the shortlist at its scored position.
- If the candidate scores 70+, outreach is auto-drafted.
- A toast confirms: "{candidate_name} added. Score: {score}."
---
#### VTCR 2.3 -- Duplicate Candidate Detection
Visual:
If the system detects a potential duplicate (same name + similar location, or matching email/phone), a yellow warning banner appears on the candidate card: "Possible duplicate of {existing_candidate_name} (added {date}). [Merge] [Keep Separate]."
Trigger:
The deduplication check runs automatically after each parse (scrape or manual import).
Condition:
- A candidate with a matching name (fuzzy, Levenshtein distance <= 2) and location (same city or within 5 miles) already exists for this job.
- OR a candidate with a matching email or phone already exists.
Result:
- If the owner clicks "Merge," the newer profile data is merged into the existing candidate. The system keeps the higher-detail version of each field. The candidate is re-scored.
- If the owner clicks "Keep Separate," both records remain. The warning is dismissed and does not appear again for this pair.
- Automatic scrapes auto-skip exact duplicates (same source URL or identical email+name) without prompting the owner.
---
Feature 3: Candidate Review and Outreach
---
#### VTCR 3.1 -- Shortlist View
Visual:
The shortlist is a list of candidate cards, sorted by score descending. Each card shows: candidate name, score badge (color-coded by tier: green 90+, blue 70-89, yellow 50-69), experience years, top 2 matching certifications as chips, city/state, distance in miles, availability, and an outreach status indicator (Not Sent, Drafted, Sent, Replied, Interview Scheduled). A filter bar at the top allows filtering by score range, outreach status, and availability.
Trigger:
The owner navigates to a job listing's detail page. The shortlist is the default tab.
Condition:
- At least one candidate has been scored for this job.
Result:
- The shortlist loads showing all candidates scoring 70+ by default.
- Candidates with score below 70 are hidden. A toggle at the bottom: "Show {count} candidates below 70" reveals them.
- Clicking a candidate card expands the full profile (Section 3.2).
---
#### VTCR 3.2 -- Candidate Detail View
Visual:
Clicking a candidate card expands it into a full detail panel (slide-over from the right on desktop, full page on mobile). The panel shows: the score breakdown as a horizontal bar chart (each of the 6 dimensions with their contribution), the full parsed profile (experience, certifications, licenses, work history, red flags), the raw resume/profile text in a collapsible section, and the drafted outreach message in an editable text area. Action buttons at the bottom: "Send Outreach," "Skip," "Archive."
Trigger:
The owner clicks a candidate card in the shortlist.
Condition:
- The candidate record exists.
Result:
- The full candidate detail loads with the score breakdown visualization.
- If outreach has been drafted but not sent, the message appears in the editable text area.
- If outreach has not been drafted (manual import with no auto-draft), the "Generate Outreach" button triggers the AI prompt (Section 6).
- Red flags from the AI analysis are shown in a red-bordered section at the bottom of the profile, each with the AI's note.
---
#### VTCR 3.3 -- Send Outreach Message
Visual:
The outreach section of the candidate detail shows the drafted message in an editable textarea. Above the textarea, the subject line is editable. Below, two buttons: "Send via Email" (primary) and "Copy to Clipboard" (secondary, for sending through Indeed/ZipRecruiter messaging manually). After sending, the textarea becomes read-only with a green "Sent" badge and timestamp.
Trigger:
The owner clicks "Send via Email" or "Copy to Clipboard."
Condition:
- For email: the candidate has an email address on file AND the owner's Resend integration is configured.
- For clipboard: no conditions (always available).
Result:
- Email: The message is sent via Resend from the owner's configured sender address. The `outreach_messages` record is updated with `sent_at` and `channel: 'email'`. The candidate card in the shortlist updates to show "Sent" status.
- Clipboard: The subject line and message body are copied to the clipboard. A toast confirms: "Copied to clipboard." The status updates to "Sent (Manual)" after the owner confirms they sent it via a quick modal: "Did you send this message? [Yes] [Not Yet]."
- In both cases, the outreach is logged in `outreach_messages` for pipeline tracking.
---
#### VTCR 3.4 -- Track Candidate Responses
Visual:
Each candidate card in the shortlist shows an outreach status. When the owner receives a reply (outside the system, via email or job board), they manually update the status. The candidate detail panel includes a "Status" dropdown: Not Contacted, Outreach Sent, Replied, Interview Scheduled, Offer Made, Hired, Declined, No Response. A notes textarea below allows the owner to log context.
Trigger:
The owner changes the status dropdown or adds notes on a candidate.
Condition:
- The candidate record exists and outreach has been sent (or the owner overrides to a later status).
Result:
- The `candidates.pipeline_status` field is updated.
- If status changes to "Interview Scheduled," a date picker appears to log the interview date.
- If status changes to "Hired," the system asks: "Mark this job listing as filled?" If yes, the job status changes to `filled`, scraping stops, and remaining candidates are archived.
- Pipeline counts in the daily digest and dashboard update in real time.
---
Feature 4: Dashboard and Analytics
---
#### VTCR 4.1 -- Hiring Pipeline Dashboard
Visual:
The main Candidate Finder page shows a pipeline summary at the top for each active job. The pipeline is a horizontal funnel with counts: Found (total) > Shortlisted (70+) > Contacted > Replied > Interviewing > Hired. Below the funnel, key metrics: average score of shortlisted candidates, average response rate, days-to-fill (if hired), and total candidates scanned.
Trigger:
The owner navigates to the Candidate Finder page.
Condition:
- At least one job listing exists.
Result:
- Pipeline metrics are calculated from `candidates` and `outreach_messages` tables.
- Each stage of the funnel is clickable, filtering the shortlist to show candidates in that stage.
- If multiple jobs are active, a job selector dropdown filters the dashboard.
---
#### VTCR 4.2 -- Strong Match Alert
Visual:
When the daily scrape finds a candidate scoring 90+, an SMS is sent to the owner immediately (not waiting for the 6am digest): "Strong match for {job_title}: {candidate_name}, score {score}. {key_cert} + {experience_years} yrs. Check your candidate finder." If the owner has the app open, a slide-in notification appears at the top right.
Trigger:
The scoring engine scores a new candidate at 90 or above.
Condition:
- The job listing is active.
- The candidate is new (not previously scored).
- The owner has not disabled immediate alerts in settings.
Result:
- SMS sent via Twilio (if configured) or email via Resend.
- The candidate is flagged with `is_strong_match: true` in the database.
- The candidate appears first in the next digest with a star indicator.
---
9. Data Model
```sql
-- ============================================================================
-- Recipe 026: AI Candidate Finder for Hard-to-Fill Positions
-- Migration Script
-- Version: 1.0.0
-- ============================================================================
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ============================================================================
-- ENUM TYPES
-- ============================================================================
CREATE TYPE trade_type_enum AS ENUM (
'hvac',
'plumbing',
'electrical'
);
CREATE TYPE job_status_enum AS ENUM (
'draft',
'active',
'paused',
'filled',
'archived'
);
CREATE TYPE candidate_source_enum AS ENUM (
'indeed',
'ziprecruiter',
'linkedin',
'manual'
);
CREATE TYPE pipeline_status_enum AS ENUM (
'new',
'shortlisted',
'outreach_drafted',
'outreach_sent',
'replied',
'interview_scheduled',
'offer_made',
'hired',
'declined',
'no_response',
'archived'
);
CREATE TYPE score_tier_enum AS ENUM (
'strong_match',
'good_fit',
'marginal',
'poor_match',
'disqualified'
);
CREATE TYPE availability_enum AS ENUM (
'immediately',
'within_1_week',
'within_2_weeks',
'within_30_days',
'unknown'
);
CREATE TYPE license_status_enum AS ENUM (
'active',
'pending',
'expired',
'unknown'
);
CREATE TYPE outreach_channel_enum AS ENUM (
'email',
'indeed_message',
'ziprecruiter_message',
'linkedin_message',
'manual'
);
CREATE TYPE scrape_status_enum AS ENUM (
'running',
'completed',
'partial',
'failed'
);
-- ============================================================================
-- TABLE: job_listings
-- Each open position the contractor is hiring for
-- ============================================================================
CREATE TABLE public.job_listings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL, -- "Licensed HVAC Service Technician"
trade trade_type_enum NOT NULL,
job_location_zip TEXT NOT NULL, -- Center point ZIP
job_location_city TEXT,
job_location_state TEXT,
job_location_lat DECIMAL(9,6), -- For distance calc
job_location_lng DECIMAL(9,6),
location_radius_miles INT NOT NULL DEFAULT 30, -- Max commute distance
min_experience_years INT NOT NULL DEFAULT 0, -- Must-have minimum
preferred_experience_years INT DEFAULT 5, -- Nice-to-have target
required_certifications JSONB NOT NULL DEFAULT '[]', -- ["EPA 608 Universal", "NATE Core"]
preferred_certifications JSONB NOT NULL DEFAULT '[]',
required_licenses JSONB NOT NULL DEFAULT '[]', -- ["Texas HVAC Technician License"]
trade_specialties JSONB NOT NULL DEFAULT '[]', -- ["Residential", "Service/Repair"]
equipment_brands JSONB NOT NULL DEFAULT '[]', -- ["Carrier", "Trane"]
requires_drivers_license BOOLEAN NOT NULL DEFAULT true,
pay_range_min DECIMAL(8,2), -- Hourly rate
pay_range_max DECIMAL(8,2),
availability_preference availability_enum DEFAULT 'unknown',
job_description TEXT, -- Full description for AI context
custom_certifications JSONB NOT NULL DEFAULT '[]', -- Owner-added certs not in presets
scrape_sources JSONB NOT NULL DEFAULT '["indeed","ziprecruiter","linkedin"]',
status job_status_enum NOT NULL DEFAULT 'draft',
scrape_enabled BOOLEAN NOT NULL DEFAULT true,
alert_on_strong_match BOOLEAN NOT NULL DEFAULT true,
alert_phone TEXT, -- SMS alerts for 90+ matches
alert_email TEXT, -- Email alerts
digest_enabled BOOLEAN NOT NULL DEFAULT true,
digest_time TIME NOT NULL DEFAULT '06:00:00',
total_candidates_found INT NOT NULL DEFAULT 0,
total_shortlisted INT NOT NULL DEFAULT 0,
filled_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_job_listings_active ON public.job_listings (status)
WHERE status = 'active';
COMMENT ON TABLE public.job_listings IS 'Job listings with requirements config for AI candidate matching';
-- ============================================================================
-- TABLE: candidates
-- Parsed and scored candidate profiles
-- ============================================================================
CREATE TABLE public.candidates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_listing_id UUID NOT NULL REFERENCES public.job_listings(id) ON DELETE CASCADE,
source candidate_source_enum NOT NULL,
source_url TEXT, -- Original profile/resume URL
source_profile_id TEXT, -- Indeed/ZipRecruiter profile ID
candidate_name TEXT NOT NULL,
email TEXT,
phone TEXT,
location_city TEXT,
location_state TEXT,
location_zip TEXT,
location_lat DECIMAL(9,6),
location_lng DECIMAL(9,6),
distance_miles DECIMAL(6,1), -- Calculated from job location
experience_years INT,
certifications JSONB NOT NULL DEFAULT '[]', -- Parsed cert objects
licenses JSONB NOT NULL DEFAULT '[]', -- Parsed license objects
trade_specialties JSONB NOT NULL DEFAULT '[]',
equipment_brands JSONB NOT NULL DEFAULT '[]',
availability availability_enum DEFAULT 'unknown',
has_drivers_license BOOLEAN,
has_own_tools BOOLEAN,
education JSONB NOT NULL DEFAULT '[]',
work_history JSONB NOT NULL DEFAULT '[]',
languages JSONB NOT NULL DEFAULT '[]',
notable_skills JSONB NOT NULL DEFAULT '[]',
red_flags JSONB NOT NULL DEFAULT '[]',
ai_confidence_notes TEXT,
raw_profile_text TEXT, -- Original resume/profile text
parsed_data JSONB, -- Full AI extraction output
score_total INT NOT NULL DEFAULT 0, -- 0-100
score_certifications INT NOT NULL DEFAULT 0, -- 0-25
score_experience INT NOT NULL DEFAULT 0, -- 0-20
score_location INT NOT NULL DEFAULT 0, -- 0-20
score_license INT NOT NULL DEFAULT 0, -- 0-15
score_availability INT NOT NULL DEFAULT 0, -- 0-10
score_specialty INT NOT NULL DEFAULT 0, -- 0-10
score_tier score_tier_enum NOT NULL DEFAULT 'poor_match',
must_have_failed BOOLEAN NOT NULL DEFAULT false,
must_have_failure_reason TEXT,
is_strong_match BOOLEAN NOT NULL DEFAULT false,
is_duplicate BOOLEAN NOT NULL DEFAULT false,
duplicate_of_id UUID REFERENCES public.candidates(id),
pipeline_status pipeline_status_enum NOT NULL DEFAULT 'new',
pipeline_notes TEXT,
interview_date TIMESTAMPTZ,
included_in_digest BOOLEAN NOT NULL DEFAULT false,
last_digest_date DATE,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
scored_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_candidates_job_score ON public.candidates (job_listing_id, score_total DESC);
CREATE INDEX idx_candidates_shortlist ON public.candidates (job_listing_id, score_tier, score_total DESC)
WHERE must_have_failed = false AND score_total >= 70;
CREATE INDEX idx_candidates_pipeline ON public.candidates (job_listing_id, pipeline_status);
CREATE INDEX idx_candidates_dedup ON public.candidates (job_listing_id, candidate_name, location_city);
CREATE INDEX idx_candidates_digest ON public.candidates (job_listing_id, included_in_digest, score_total DESC)
WHERE included_in_digest = false AND score_total >= 70;
COMMENT ON TABLE public.candidates IS 'Parsed candidate profiles with fit scores and pipeline status';
-- ============================================================================
-- TABLE: outreach_messages
-- Drafted and sent outreach per candidate
-- ============================================================================
CREATE TABLE public.outreach_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
candidate_id UUID NOT NULL REFERENCES public.candidates(id) ON DELETE CASCADE,
job_listing_id UUID NOT NULL REFERENCES public.job_listings(id) ON DELETE CASCADE,
channel outreach_channel_enum NOT NULL DEFAULT 'email',
subject TEXT,
body TEXT NOT NULL,
score_tier score_tier_enum NOT NULL,
is_ai_generated BOOLEAN NOT NULL DEFAULT true,
was_edited BOOLEAN NOT NULL DEFAULT false, -- Owner modified before sending
drafted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
sent_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
opened_at TIMESTAMPTZ, -- Email open tracking
replied_at TIMESTAMPTZ,
resend_message_id TEXT, -- Resend API message ID
send_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_outreach_candidate ON public.outreach_messages (candidate_id, created_at DESC);
CREATE INDEX idx_outreach_unsent ON public.outreach_messages (job_listing_id, sent_at)
WHERE sent_at IS NULL;
COMMENT ON TABLE public.outreach_messages IS 'AI-drafted and owner-edited outreach messages to candidates';
-- ============================================================================
-- TABLE: scrape_log
-- History of daily scrape runs
-- ============================================================================
CREATE TABLE public.scrape_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_listing_id UUID NOT NULL REFERENCES public.job_listings(id) ON DELETE CASCADE,
source candidate_source_enum NOT NULL,
status scrape_status_enum NOT NULL DEFAULT 'running',
candidates_found INT NOT NULL DEFAULT 0, -- Total profiles scraped
candidates_new INT NOT NULL DEFAULT 0, -- New (not duplicates)
candidates_scored_70_plus INT NOT NULL DEFAULT 0, -- Shortlist-worthy
candidates_scored_90_plus INT NOT NULL DEFAULT 0, -- Strong matches
error_message TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
duration_seconds INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_scrape_log_job ON public.scrape_log (job_listing_id, started_at DESC);
COMMENT ON TABLE public.scrape_log IS 'Daily scrape execution log with candidate counts per source';
-- ============================================================================
-- TABLE: raw_candidate_profiles
-- Raw scraped data before AI parsing (for audit and re-processing)
-- ============================================================================
CREATE TABLE public.raw_candidate_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
scrape_log_id UUID REFERENCES public.scrape_log(id) ON DELETE SET NULL,
job_listing_id UUID NOT NULL REFERENCES public.job_listings(id) ON DELETE CASCADE,
source candidate_source_enum NOT NULL,
source_url TEXT,
source_profile_id TEXT,
raw_text TEXT NOT NULL, -- Full scraped text
raw_html TEXT, -- HTML if available
is_parsed BOOLEAN NOT NULL DEFAULT false,
candidate_id UUID REFERENCES public.candidates(id), -- Linked after parsing
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_raw_profiles_unparsed ON public.raw_candidate_profiles (job_listing_id, is_parsed)
WHERE is_parsed = false;
COMMENT ON TABLE public.raw_candidate_profiles IS 'Raw scraped resume/profile data before AI extraction';
-- ============================================================================
-- TABLE: digest_history
-- Log of sent daily candidate digests
-- ============================================================================
CREATE TABLE public.candidate_digest_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_listing_id UUID NOT NULL REFERENCES public.job_listings(id) ON DELETE CASCADE,
digest_date DATE NOT NULL,
strong_match_count INT NOT NULL DEFAULT 0,
good_fit_count INT NOT NULL DEFAULT 0,
total_new_candidates INT NOT NULL DEFAULT 0,
pipeline_sent INT NOT NULL DEFAULT 0,
pipeline_replied INT NOT NULL DEFAULT 0,
pipeline_interviewing INT NOT NULL DEFAULT 0,
digest_content TEXT, -- Full digest text for audit
channel TEXT NOT NULL DEFAULT 'email', -- email or sms
sent_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT candidate_digest_job_date_unique UNIQUE (job_listing_id, digest_date)
);
COMMENT ON TABLE public.candidate_digest_history IS 'Daily candidate finder digest delivery log';
-- ============================================================================
-- UPDATED_AT TRIGGER
-- ============================================================================
CREATE OR REPLACE FUNCTION public.handle_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.job_listings
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.candidates
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.outreach_messages
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
```
---
10. Testing Scenarios
Job Configuration Tests
| # | Scenario | Expected Result | Pass? |
|---|---|---|---|
| 1 | Owner selects HVAC trade | Certification presets populate: EPA 608 types, NATE specialties, R-410A, state license options | |
| 2 | Owner selects Plumbing trade | Presets populate: Journeyman/Master plumber, backflow, medical gas, OSHA | |
| 3 | Owner creates job without any must-have cert or license | Validation error: "At least one required certification or license is needed" | |
| 4 | Owner sets location radius to 0 | Slider minimum is 5 miles. Cannot go below. | |
| 5 | Owner adds custom certification "XYZ Manufacturer Training" | Custom cert saved to `custom_certifications`. Available in future job listings. | |
| 6 | Owner activates job listing | First scrape triggers immediately. Results appear within 5 minutes. |
Candidate Scoring Tests
| # | Scenario | Expected Result | Pass? |
|---|---|---|---|
| 7 | Candidate has all required certs, 10 years experience, 5 miles away, active license, available immediately, matching specialties | Score: 100. Tier: Strong Match. | |
| 8 | Candidate has all required certs but is 45 miles away (radius set to 30) | Score: 0. Must-have failure: location. Disqualified. | |
| 9 | Candidate missing one of three required certifications | Score: 0. Must-have failure: certification. Disqualified. | |
| 10 | Candidate has 2 years experience, minimum is 3 | Score: 0. Must-have failure: experience. Disqualified. | |
| 11 | Candidate meets all must-haves, 3 years experience (min 3, preferred 5), 20 miles away (radius 30), no preferred certs | Score breakdown: certs 15 + experience 12 + location ~13 + license 15 + availability varies + specialty varies. Total ~60-70. | |
| 12 | Candidate with unknown location, "include unknowns" toggled on | Location score: 0 (no penalty, but no points). Not disqualified. | |
| 13 | Candidate with unknown location, "include unknowns" toggled off | Candidate excluded from shortlist. Shown in "All Candidates" only. | |
| 14 | Owner changes requirements, previously 85-score candidate now fails must-have | Candidate moves from "Good Fit" to "Disqualified." Outreach draft is archived. |
AI Resume Parsing Tests
| # | Scenario | Expected Result | Pass? |
|---|---|---|---|
| 15 | Resume says "EPA 608 certified" without specifying type | AI extracts: "EPA 608" with type "unspecified." Does NOT assume Universal. | |
| 16 | Resume says "10+ years in residential HVAC" | experience_years: 10. trade_specialties: ["Residential"]. | |
| 17 | Resume lists 4 jobs in 18 months | red_flags: ["Frequent job changes: 4 employers in 18 months"] | |
| 18 | Resume has no phone or email | phone: null, email: null. Candidate still parsed and scored. | |
| 19 | Resume mentions "Journeyman Plumber License - expired 2024" | License extracted with status: "expired." If license is required, must-have failure. | |
| 20 | Profile text is mostly irrelevant (retail background, no trade experience) | Low score across all dimensions. experience_years: 0 for the relevant trade. | |
| 21 | Resume uploaded as PDF | PDF parsed to text. AI extraction runs normally. Score generated. |
Outreach Tests
| # | Scenario | Expected Result | Pass? |
|---|---|---|---|
| 22 | Candidate scores 95 | Strong Match template used. Message references 2 specific qualifications from their profile. Under 150 words. | |
| 23 | Candidate scores 78 | Good Fit template used. Message references 1 specific qualification. Professional but less personalized. | |
| 24 | Candidate scores 62 | No outreach drafted. Candidate visible in "All Candidates" but not in daily digest. | |
| 25 | Owner edits drafted outreach before sending | `was_edited` flag set to true. Original AI draft preserved in version history. | |
| 26 | Owner sends outreach via email | Email sent via Resend. `sent_at` timestamp recorded. Candidate status updates to "Outreach Sent." | |
| 27 | Owner copies outreach to clipboard | Toast: "Copied to clipboard." Status updates to "Sent (Manual)" after owner confirmation. | |
| 28 | Outreach email to candidate has no email on file | "Send via Email" button disabled. "Copy to Clipboard" available. Tooltip: "No email address found for this candidate." |
Daily Digest Tests
| # | Scenario | Expected Result | Pass? |
|---|---|---|---|
| 29 | 3 new candidates scored 90+ and 5 scored 70-89 overnight | Digest at 6am shows 3 strong matches first, then 5 good fits. Pipeline status included. | |
| 30 | Zero new candidates above 70 | Digest: "No new matches for Licensed HVAC Tech today. 23 candidates in pipeline." | |
| 31 | Strong match found at 2am | Immediate SMS alert: "Strong match for HVAC Tech: Mike Torres, score 94. Check your candidate finder." Plus included in 6am digest. | |
| 32 | Owner disables immediate alerts in settings | No SMS at 2am. Candidate still appears in 6am digest. |
Duplicate Detection Tests
| # | Scenario | Expected Result | Pass? |
|---|---|---|---|
| 33 | Same candidate appears on Indeed and ZipRecruiter | Duplicate detected on second scrape. Yellow warning with merge/keep separate options. | |
| 34 | Two candidates named "Mike Johnson" in different cities | No duplicate flag. Different locations mean different people. | |
| 35 | Owner clicks "Merge" on duplicate | Records combined. Higher-detail fields kept. Candidate re-scored. Single entry in shortlist. |
Pipeline Tracking Tests
| # | Scenario | Expected Result | Pass? |
|---|---|---|---|
| 36 | Owner marks candidate as "Interview Scheduled" | Date picker appears. Interview date saved. Pipeline funnel updates. | |
| 37 | Owner marks candidate as "Hired" | Prompt: "Mark this job listing as filled?" If yes, scraping stops. Job status changes to "filled." | |
| 38 | Owner marks job as filled with 5 other candidates in pipeline | Remaining candidates archived. Scraping stops. Digest stops. Job card shows "Filled" badge with hire date. |
Scraping Tests
| # | Scenario | Expected Result | Pass? |
|---|---|---|---|
| 39 | 5am cron fires, Indeed returns 20 profiles | 20 profiles saved to raw_candidate_profiles. Duplicates filtered. New candidates parsed and scored. Scrape log shows counts. | |
| 40 | Indeed scrape fails (rate limited or blocked) | Scrape log status: "failed." Error message logged. Other sources (ZipRecruiter, LinkedIn) still run. Owner not alerted unless all sources fail. | |
| 41 | All three sources fail on the same day | Alert to owner: "Candidate scraping failed for {job_title}. All sources returned errors. Check your settings." | |
| 42 | Job listing is paused | 5am cron skips this listing. No scrape runs. |
---
11. Example: Licensed HVAC Tech in Dallas
Wednesday morning. Ace Comfort HVAC is a 4-truck residential shop in Dallas. Owner is Ray Martinez. He has been trying to fill a licensed HVAC service tech position for 6 weeks. He has been checking Indeed every few days, reading through 10-15 resumes per session, and sending generic messages. Two candidates ghosted him. One was already hired. He has spent roughly 40 hours on this search with nothing to show for it.
Ray sets up the AI Candidate Finder.
Job Configuration
```
Job Title: Licensed HVAC Service Technician
Trade: HVAC
Location: 75201 (Dallas, TX)
Radius: 30 miles
Minimum Experience: 3 years
MUST-HAVES:
- EPA 608 Universal Certification
- Texas HVAC Technician License (active)
- 3+ years HVAC experience
- Valid driver's license
NICE-TO-HAVES:
- NATE Core or any NATE specialty
- R-410A Safety Certification
- 5+ years experience (preferred)
- Residential service/repair experience
- Carrier, Trane, or Lennox experience
Pay Range: $28-$36/hour
```
Ray clicks "Activate Job." The first scrape runs immediately.
Overnight Scrape Results
The 5am scrape on Thursday pulls profiles from Indeed, ZipRecruiter, and LinkedIn. The system finds 12 candidates with HVAC-related experience in the Dallas area.
All 12 Candidates Scored
| # | Name | Source | Exp (yrs) | Key Certs | Distance | License | Availability | Score | Tier |
|---|---|---|---|---|---|---|---|---|---|
| 1 | Marcus Williams | Indeed | 9 | EPA 608 Universal, NATE AC Service, R-410A | 8 mi | TX HVAC Active | Immediately | 96 | Strong Match |
| 2 | Tony Reeves | ZipRecruiter | 7 | EPA 608 Universal, R-410A | 12 mi | TX HVAC Active | Within 2 weeks | 91 | Strong Match |
| 3 | David Park | Indeed | 5 | EPA 608 Universal, NATE Core | 22 mi | TX HVAC Active | Within 2 weeks | 84 | Good Fit |
| 4 | Carlos Mendez | 6 | EPA 608 Universal | 18 mi | TX HVAC Active | Within 30 days | 78 | Good Fit | |
| 5 | James Okafor | Indeed | 4 | EPA 608 Universal | 27 mi | TX HVAC Active | Unknown | 73 | Good Fit |
| 6 | Brian Holloway | ZipRecruiter | 3 | EPA 608 Universal | 14 mi | TX HVAC Active | Within 2 weeks | 71 | Good Fit |
| 7 | Derek Nash | Indeed | 8 | EPA 608 Type II only | 6 mi | TX HVAC Active | Immediately | 0 | Disqualified |
| 8 | Kevin Tran | ZipRecruiter | 2 | EPA 608 Universal | 11 mi | TX HVAC Active | Immediately | 0 | Disqualified |
| 9 | Amy Rodriguez | Indeed | 5 | EPA 608 Universal | 35 mi | TX HVAC Active | Unknown | 0 | Disqualified |
| 10 | Steve Paulson | 10 | EPA 608 Universal, NATE Core | 9 mi | TX HVAC Expired | Immediately | 0 | Disqualified | |
| 11 | Raj Patel | Indeed | 1 | None listed | 15 mi | None | Unknown | 0 | Disqualified |
| 12 | Mike Cooper | ZipRecruiter | 6 | EPA 608 Universal | 20 mi | Oklahoma License (not TX) | Within 2 weeks | 0 | Disqualified |
Why each disqualified candidate failed:
- Derek Nash (#7): EPA 608 Type II only. Job requires Universal. Must-have failure.
- Kevin Tran (#8): 2 years experience. Job requires 3 minimum. Must-have failure.
- Amy Rodriguez (#9): 35 miles from Dallas. Radius set to 30. Must-have failure.
- Steve Paulson (#10): Texas HVAC license expired. License must be active. Must-have failure.
- Raj Patel (#11): No EPA 608 certification listed. 1 year experience. Multiple must-have failures.
- Mike Cooper (#12): Oklahoma license, not Texas. Must-have failure on license type.
Top 3 Candidates with Outreach Drafted
#### Candidate 1: Marcus Williams (Score: 96)
Score Breakdown:
| Dimension | Points | Max | Notes |
|---|---|---|---|
| Certification Match | 25 | 25 | EPA 608 Universal (required), NATE AC Service (preferred), R-410A (preferred) |
| Experience Years | 20 | 20 | 9 years, exceeds preferred 5 |
| Location Proximity | 20 | 20 | 8 miles, within 10-mile perfect score zone |
| License Status | 15 | 15 | Texas HVAC, active |
| Availability | 10 | 10 | Immediately |
| Trade Specialty | 6 | 10 | Residential service (match), no commercial experience listed |
| Total | 96 | 100 |
AI-Parsed Profile Highlights:
- 9 years at two companies. Current role: Senior HVAC Service Tech at DFW Climate Solutions (4 years).
- Previous: HVAC Installer/Service Tech at North Texas Air (5 years).
- Certifications: EPA 608 Universal, NATE AC Service, R-410A Safety.
- Brands: Carrier, Lennox, Goodman.
- Red flags: None.
- AI confidence notes: "Clean profile. All data explicitly stated. High confidence on extraction."
Drafted Outreach (Strong Match tier):
```
Subject: Marcus -- saw your profile, think you'd be a fit
Hey Marcus,
I'm Ray Martinez, owner of Ace Comfort HVAC in Dallas. We're a 4-truck
residential shop, mostly service and repair.
Your profile stood out. The EPA 608 Universal plus your NATE AC Service
cert and 9 years on residential systems is the exact combination we've
been looking for. The Carrier and Lennox experience is a bonus since
that's 70% of what we service.
We're hiring a service tech right now. $28-$36/hour depending on
experience, plus a take-home truck and no on-call more than one
weekend a month.
If you're open to a conversation, I'd like to set up a quick call
this week. No pressure, no recruiter games. Just two HVAC guys
talking shop.
Ray Martinez
Ace Comfort HVAC
(214) 555-0187
```
#### Candidate 2: Tony Reeves (Score: 91)
Score Breakdown:
| Dimension | Points | Max | Notes |
|---|---|---|---|
| Certification Match | 20 | 25 | EPA 608 Universal (required), R-410A (preferred). Missing NATE. |
| Experience Years | 20 | 20 | 7 years, exceeds preferred 5 |
| Location Proximity | 18 | 20 | 12 miles, slight taper from 10-mile zone |
| License Status | 15 | 15 | Texas HVAC, active |
| Availability | 8 | 10 | Within 2 weeks |
| Trade Specialty | 10 | 10 | Residential + commercial service |
| Total | 91 | 100 |
Drafted Outreach (Strong Match tier):
```
Subject: Tony -- saw your profile, think you'd be a fit
Hey Tony,
Ray Martinez here from Ace Comfort HVAC in Dallas. We run a 4-truck
residential shop.
Came across your ZipRecruiter profile. Your 7 years on both residential
and commercial systems caught my eye, and the R-410A cert is a must
for the work we do. We're looking for a service tech right now.
$28-$36/hour range. Take-home truck. Small crew, no corporate
nonsense.
If you're exploring options, shoot me a text or call. Happy to
talk through the details.
Ray Martinez
Ace Comfort HVAC
(214) 555-0187
```
#### Candidate 3: David Park (Score: 84)
Score Breakdown:
| Dimension | Points | Max | Notes |
|---|---|---|---|
| Certification Match | 22 | 25 | EPA 608 Universal (required), NATE Core (preferred). Missing R-410A. |
| Experience Years | 16 | 20 | 5 years, meets preferred exactly |
| Location Proximity | 11 | 20 | 22 miles, noticeable taper |
| License Status | 15 | 15 | Texas HVAC, active |
| Availability | 8 | 10 | Within 2 weeks |
| Trade Specialty | 10 | 10 | Residential service/repair + new construction |
| Total | 84 | 100 |
Drafted Outreach (Good Fit tier):
```
Subject: HVAC position in Dallas -- Ace Comfort HVAC
Hey David,
Ray Martinez here from Ace Comfort HVAC. We're hiring a service tech
and your background caught my eye, especially the NATE Core cert and
your residential service experience.
4-truck shop, mostly service and repair, based in Dallas. $28-$36/hour
range.
If you're exploring options, I'd like to chat. Drop me a text or call
at (214) 555-0187, or just reply here.
Ray Martinez
Ace Comfort HVAC
```
Ray's Thursday Morning
Ray's phone buzzes at 5:07am with an SMS: "Strong match for HVAC Tech: Marcus Williams, score 96. EPA 608 Universal + NATE AC Service + 9 yrs. Check your candidate finder."
At 6am, the full digest arrives by email:
```
CANDIDATE FINDER DAILY DIGEST
Thursday, March 26
Job: Licensed HVAC Service Technician | Dallas, TX | Active 1 day
=============================================
STRONG MATCHES (Score 90+): 2
---------------------------------------------
1. Marcus Williams -- Score: 96
9 yrs | EPA 608 Universal, NATE AC Service | Dallas, TX (8 mi)
Outreach: [DRAFTED - Review & Send]
2. Tony Reeves -- Score: 91
7 yrs | EPA 608 Universal, R-410A | Garland, TX (12 mi)
Outreach: [DRAFTED - Review & Send]
GOOD FITS (Score 70-89): 4
---------------------------------------------
3. David Park -- Score: 84
5 yrs | EPA 608 Universal, NATE Core | Plano, TX (22 mi)
Outreach: [DRAFTED - Review & Send]
4. Carlos Mendez -- Score: 78
6 yrs | EPA 608 Universal | Arlington, TX (18 mi)
Outreach: [DRAFTED - Review & Send]
5. James Okafor -- Score: 73
4 yrs | EPA 608 Universal | Fort Worth, TX (27 mi)
Outreach: [DRAFTED - Review & Send]
6. Brian Holloway -- Score: 71
3 yrs | EPA 608 Universal | Irving, TX (14 mi)
Outreach: [DRAFTED - Review & Send]
PIPELINE STATUS
---------------------------------------------
Outreach sent (awaiting reply): 0
Replies received: 0
Interviews scheduled: 0
Total candidates found (all time): 12
[View Full Shortlist in App]
=============================================
```
Ray reads the digest over coffee. Takes 2 minutes. He opens the app, reviews the outreach for Marcus Williams, changes one line about the take-home truck, and hits "Send." Does the same for Tony Reeves, no edits needed. Sends David Park's outreach as-is.
Three outreach messages sent by 6:15am. Total time: 8 minutes.
Marcus Williams replies at 10am: "Hey Ray, thanks for reaching out. Sounds interesting. I could do a call tomorrow afternoon if that works."
Ray has spent 8 minutes doing what used to take 8 hours. And for the first time in 6 weeks, a candidate replied the same day.
---
Appendix: Cost Estimate
| Component | Monthly Cost |
|---|---|
| AI API calls (parsing + outreach, ~30 candidates/day) | $15-$30 |
| PostgreSQL database | Free tier |
| Resend (outreach emails, ~50/month) | Free tier (100 emails/day) |
| Headless browser / scraping runtime | $5-$15 |
| SMS alerts for strong matches (Twilio) | $1-$3 |
| Total | $20-$50/month |
Compared to a recruiter at $5,000-$15,000 per hire, or 250-500 hours/year of owner time on manual searching. The math is not close.