---
name: vendor-finder
description: "Find new contractors/vendors via web research (Google, Yelp, Thumbtack, Google Reviews) and add them to the operator's Notion Vendor Database. Triggers on 'find [N] [trade] in [area]', 'find me a new [trade]', 'we need a vendor for [trade]', 'find contractors for [property/area]', 'add some [trade] options to the vendor database', 'research [trade] vendors in [city]', or any request to populate the Vendor Database with new candidates. NOT for editing existing vendor rows, sending outreach, or screening vendors - those are separate flows."
required_env_vars:
  - NOTION_TOKEN  # only needed by scripts that touch Notion (check_dedupe, create_vendor_page, sync_enums, setup); render_notes and validate_notes are pure-logic and need nothing
  - VENDOR_DB_ID  # the id of the operator's Notion Vendor Database (set once at install per README.md)
---

# Find new vendors and add them to the Notion Vendor Database

Research contractors on the web, dedupe them against the existing Vendor Database, and write new candidate pages with the canonical AI-suggestion format (`🤖` icon, `Source = AI Suggestion`, `Screening Status = Not Screened`, structured `Notes`).

## When to use this skill

Use this skill when the user wants new contractor candidates added to their Vendor Database. Examples:

- "Find me 3 HVAC vendors in [city]"
- "We need a new lawncare provider for the [property] property"
- "Find some pool builders near [city]"
- "Add 5 handymen in [area] to the vendor DB"

Do NOT use this skill for:
- Editing or updating an existing vendor row (use the Vendor Database directly)
- Sending outreach to a vendor (that is a separate per-trade outreach flow)
- Screening a vendor / changing their `Screening Status`
- Onboarding a confirmed vendor

## Prerequisites

Before invoking, two things must be true (the plugin README walks through both):

1. `NOTION_TOKEN` and `VENDOR_DB_ID` are set in the environment, and the Notion integration has been shared with the operator's Vendor Database.
2. `scripts/setup.js` has been run at least once. It writes `references/_enums.json` with the operator's actual `Trade` and `Service Area` multi-select values so the skill knows which values are valid without having to query Notion on every run.

If `_enums.json` is missing, stop and ask the user to run setup before proceeding. Do not guess valid Trade or Service Area names from prose.

## Workflow

Follow these steps in order. Each step is a checkpoint with the user - don't skip ahead.

### Step 1: Confirm the search brief

Confirm with the user:
- **Trade(s)** - must map to one or more values in `references/_enums.json` under `trade_options`. If the user uses a synonym, map it to the closest option (e.g. "AC guy" -> `HVAC` if that's an option in their DB). If the user uses a term that has no clear mapping, flag it and ask which existing Trade to use.
- **Service Area** - must map to one or more values in `references/_enums.json` under `service_area_options`. If the user gives a neighborhood or property name, translate to the nearest Service Area. If you can't map it confidently, ask.
- **Count** - how many candidates to add. If unspecified, default to 3.
- **Quality bar (optional)** - e.g. "Thumbtack >= 4.5 stars and >= 10 reviews", "Yelp 4+ stars", "must be licensed and insured". If unspecified, target >= 4.5 stars and >= 10 reviews on at least one platform.

Show the brief back in one line and ask the user to confirm before searching.

### Step 2: Dedupe against the existing Vendor DB

Before researching, query the Vendor Database for existing rows with the same Trade and Service Area. Pull out their `Vendor Name` and `Phone` - you'll use these to avoid adding the same vendor twice.

Use the Notion MCP search/query tools, or - if `NOTION_TOKEN` and `VENDOR_DB_ID` are set in the environment - run the bundled dedupe script per candidate for a deterministic check:

```
node skills/vendor-finder/scripts/check_dedupe.js "<vendor name>" "<phone>"
```

The match logic (phone exact on the last 10 digits; vendor name fuzzy with levenshtein <= 2 after legal-suffix stripping) is implemented in `scripts/_dedupe.js`. Name-matches without a phone match must be flagged as `POSSIBLE DUPLICATE - verify with operator` in the summary table, not silently skipped.

### Step 3: Research candidates on the web

For each Trade x Service Area combination, search:
1. **Google** - `"<trade> <service_area>"` and `"best <trade> <service_area>"`
2. **Yelp** - `"<trade> <service_area> yelp"` or direct Yelp category pages
3. **Thumbtack** - `"<trade> <service_area> thumbtack"` (Thumbtack ratings + hires + years in business are the strongest single signal available)
4. **Google Reviews** - for any candidate name that surfaces, check their Google Business reviews

For each candidate, collect:
- Vendor / business name
- Owner or contact person name (if visible)
- Phone number - **must appear on at least one source you can link to**
- Website (or Yelp/Thumbtack URL if no website)
- Service area coverage (does it cover the requested area?)
- Trade focus (single-trade or multi-trade - set `Trade` to the array of trades they actually do, drawn from `_enums.json`)
- Signal data: Thumbtack stars / reviews / hires / years in business, OR Yelp stars / reviews
- Featured review snippet (one sentence)

If a candidate has no findable phone, skip them. Unreachable vendors don't get added.

### Step 4: Cross-verify each candidate's contact info

For every candidate that survives Step 3, do a **web cross-check**: the same phone number must appear on at least one platform you can link to (Yelp, Thumbtack, Google Business, or the vendor's own website). Record where you found it - that becomes the `VERIFIED CONTACT (web cross-check <today's date>): <phone> (via <source>)` clause in the `Notes` field.

If you can only find a number on a single sketchy source (e.g. a random directory aggregator), drop the candidate.

### Step 5: Show the summary table

Present the candidates in a markdown table in chat. Columns:

| # | Vendor Name | Trade(s) | Phone | Source URL | Signal | Dedupe |
|---|---|---|---|---|---|---|
| 1 | Example Co | Landscaper, General Contractor | (555) 555-0123 | [Thumbtack](...) | ★4.72 / 18 reviews / 9 hires / 7 yrs | New |
| 2 | ... | ... | ... | ... | ... | DUPLICATE - already exists as "..." |

Ask the user which rows to add (default: all rows marked "New"). Wait for confirmation before writing.

### Step 6: Build the Notes string and write the approved rows

**Build `Notes` via the renderer, not by hand.** For every approved candidate, assemble a JSON object with the structured Notes data, pipe it through `render_notes.js`, and use the returned string as the `Notes` property value. This is the only path that guarantees the 3-section format the rest of the operator's screening flow depends on.

```
echo '{"verified_contact":{...},"platform_signal":{...},"todos":[...]}' | \
  node skills/vendor-finder/scripts/render_notes.js
```

See the JSDoc at the top of `render_notes.js` for the full input schema and `references/notes-format.md` for what each section should contain.

**Validate every `Notes` string before writing.** As the final gate, run:

```
echo "<notes string>" | node skills/vendor-finder/scripts/validate_notes.js
```

If validation fails, do not write the row - fix the Notes string and re-validate. Exit code 0 = valid; non-zero = do not write.

**Create the Notion page.** Either use the Notion MCP `notion-create-pages` tool with the property payload below, OR (if `NOTION_TOKEN` is set) pipe the vendor JSON through `create_vendor_page.js`, which performs the validation and write atomically:

```
node skills/vendor-finder/scripts/create_vendor_page.js < vendor.json
```

The script refuses to write any row without a phone or with an invalid Notes string, so it is the safer path when a token is available.

Properties to set on the page (the script handles these automatically; the MCP path requires you to set them yourself):

**Required**
- `Vendor Name` (title) - the business name. Page icon: `🤖` emoji.
- `Trade` (multi-select) - the actual trades they do, drawn from `_enums.json`
- `Service Area` (multi-select) - from `_enums.json`
- `Contact Person` (text) - first name only if known, otherwise blank
- `Phone` (phone_number) - verified number from Step 4
- `Website` (url) - primary URL (their site, or Yelp/Thumbtack listing if no site)
- `Source` (select) - `AI Suggestion`
- `Screening Status` (select) - `Not Screened`
- `Status` (status) - `Not started`
- `Priority (1-4)` (multi-select) - `4` (lowest; the operator can bump after screening)
- `Notes` (text) - structured per `references/notes-format.md`

**Optional**
- `Most Recent Pricing` (text) - set only if publicly stated (e.g., "Free on-site estimate", "$X starting"); otherwise leave blank.

**Leave blank by default** (the operator fills these in during screening/onboarding, not this skill)
- `Email`, `License #`, `Insurance Carrier`, `Has Insurance?`, `Insurance Renewal Date`, `Rate`, `Payment Terms`, `Response Time`, `Weekends`, `Emergencies`, `Screened On`, `Summary`, plus any operator-specific properties not listed in `references/vendor-db-schema.md`.

### Step 7: Return the results

Report back in chat:
- A bullet list of newly-added vendors, each with a clickable Notion link
- Any candidates that were skipped, with the reason (duplicate, no phone, failed cross-check)
- A reminder that each new row is `Screening Status = Not Screened` and ready for the operator's screening flow

## Hard invariants (do not violate)

The screening flow and downstream automations all depend on these. If you can't satisfy them, refuse to write rather than write something almost-right:

- **No row without a verified phone.** No phone, no row.
- **`Notes` must pass `validate_notes.js`.** No exceptions.
- **`Source = AI Suggestion`**, **`Screening Status = Not Screened`**, **`Status = Not started`**, **`Priority (1-4) = 4`**. These are not optional; they let the operator distinguish AI-added rows from vetted ones.
- **Page icon is the `🤖` emoji.** Same reason.
- **Never edit existing rows.** Only create new ones. If a candidate already exists, surface that in the summary table and skip the write.
- **Never write Trade or Service Area values that aren't in `_enums.json`.** If the user wants a new option, ask them to add it in Notion and re-run `sync_enums.js`.
