AgentStack
SKILL verified MIT Self-run

B2b Local Outreach

skill-txampa-claude-skill-b2b-local-outreach-claude-skill-b2b-local-outreach · by txampa

Use this skill when the user needs to build a B2B lead generation pipeline targeting local businesses — including sourcing leads from Google Maps, business websites, or LinkedIn; scraping legal pages (Impressum, Aviso Legal, Mentions Légales) to discover contact emails; qualifying and scoring leads with intent signals; writing cold email sequences in multiple languages; personalizing outreach usi…

No reviews yet
0 installs
14 views
0.0% view→install

Install

$ agentstack add skill-txampa-claude-skill-b2b-local-outreach-claude-skill-b2b-local-outreach

✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures

What it can access

  • Network access Used
  • Filesystem access No
  • Shell / process execution No
  • Environment & secrets No
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

Are you the author of B2b Local Outreach? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

B2B Local Outreach Skill

You are an expert in B2B lead generation and outreach targeting local businesses — hotels, workshops, restaurants, salons, gyms, retailers, and any other brick-and-mortar or service business. You know how to build lead pipelines from scratch, discover real contact emails legally, store and deduplicate leads in a persistent database, qualify leads using intent signals and real scoring, audit businesses for observable problems to personalize outreach, write culturally calibrated outreach in multiple languages, automate sequences at scale, and track replies to improve results over time.


Pipeline Overview

Every B2B local outreach project follows this pipeline:

1. SOURCE        → Find businesses (Google Maps, directories, LinkedIn)
2. DISCOVER      → Find real contact email (legal pages, website, LinkedIn)
3. STORE         → Upsert into lead DB (SQLite / Supabase) — dedup by domain
4. ENRICH        → Capture intent signals (ads, job openings, activity, tech audit)
5. QUALIFY       → Score with weighted formula (intent + fit + maturity + revenue)
6. AUDIT         → Audit their website/social for observable problems to reference
7. PERSONALIZE   → Craft message using specific, verifiable detail from their business
8. SEQUENCE      → Send 3-email sequence + contact form fallback
9. COMPLY        → Follow local law (GDPR, CAN-SPAM, CASL)
10. TRACK        → Log replies, classify intent, adjust scoring

1. Lead Sources

Google Maps

The most reliable source for local businesses worldwide.

Manual approach:

  • Search: [business type] in [city]
  • Export to CSV using tools like: Outscraper, PhantomBuster Google Maps Extractor, or Apify Google Maps Scraper
  • Fields to capture: name, address, phone, website URL, rating, review count, category

Google Places API (programmatic):

// Search businesses by type and location
GET https://maps.googleapis.com/maps/api/place/textsearch/json
  ?query=auto+repair+shop+Austin+TX
  &key=YOUR_API_KEY

// Get details for a specific place
GET https://maps.googleapis.com/maps/api/place/details/json
  ?place_id=PLACE_ID
  &fields=name,formatted_address,website,formatted_phone_number,rating,user_ratings_total
  &key=YOUR_API_KEY

Key fields from Google Maps: | Field | Use | |-------|-----| | website | Entry point for email discovery | | rating | Lead qualification signal | | user_ratings_total | Business size/activity signal | | formatted_phone_number | Fallback contact + country detection | | business_status | Filter out closed businesses |

Business Directories by Region

| Region | Directory | |--------|-----------| | Germany | gelbe-seiten.de, branchenbuch.de | | Spain | paginasamarillas.es, yelp.es | | France | pagesjaunes.fr, societe.com | | Italy | paginegialle.it, kompass.com | | UK | yell.com, checkatrade.com | | US | yelp.com, yellowpages.com, thumbtack.com | | Global | foursquare.com, tripadvisor.com (hospitality/F&B) |

LinkedIn

Best for finding the decision maker's name and title before writing.

Search pattern:

  • Search: [Job Title] at [Company Name] or Owner [City] [Industry]
  • Common B2B local decision maker titles: Owner, Manager, Director, Founder, Geschäftsführer (DE), Gérant (FR), Propietario (ES)
  • Use LinkedIn to confirm the decision maker's name even if you find email elsewhere

2. Email Discovery

Strategy: Legal Pages First

Business legal/compliance pages are the best source of real contact emails because they are:

  • Required by law in most countries — the business owner must put accurate contact info
  • Direct to the decision maker — not a generic info@ address
  • Public by design — no scraping restrictions apply to publicly required disclosures

URL Patterns by Country

Try these paths in order. Append to the base domain:

Germany — Impressum (legally required)

/impressum
/impressum.html
/impressum/
/imprint
/imprint.html
/about/impressum
/de/impressum

Spain — Aviso Legal

/aviso-legal
/aviso_legal
/aviso-legal/
/legal
/legal.html
/politica-privacidad
/terminos-condiciones

France — Mentions Légales

/mentions-legales
/mentions_legales
/mentions-legales.html
/mentions-legales/
/cgv
/cgu
/legal

Italy — Note Legali

/note-legali
/note_legali
/privacy
/chi-siamo
/contatti

UK & US — Privacy Policy / Contact

/privacy-policy
/privacy
/contact
/about
/contact-us
/about-us
/terms
/legal

Netherlands

/disclaimer
/privacyverklaring
/over-ons

Portugal / Brazil

/aviso-legal
/politica-de-privacidade
/termos-de-uso
/contato

Email Extraction

After fetching the page HTML, extract emails with this pattern:

import re
import requests
from bs4 import BeautifulSoup

def find_emails_on_page(url):
    try:
        response = requests.get(url, timeout=10, headers={
            'User-Agent': 'Mozilla/5.0 (compatible; business-contact-finder/1.0)'
        })
        text = response.text

        # Extract all email-like strings
        raw_emails = re.findall(
            r'[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}',
            text
        )

        # Filter out false positives
        excluded_domains = ['example.com', 'domain.com', 'email.com',
                           'wixpress.com', 'sentry.io', 'cloudflare.com',
                           'googleapis.com', 'schema.org']
        excluded_patterns = ['noreply', 'no-reply', 'donotreply',
                            'webmaster', 'admin@wordpress']

        clean_emails = []
        for email in set(raw_emails):
            domain = email.split('@')[1].lower()
            local = email.split('@')[0].lower()

            if any(excl in domain for excl in excluded_domains):
                continue
            if any(pat in local for pat in excluded_patterns):
                continue
            if len(local)  m[0].toLowerCase())
                .filter(e => !excludedDomains.some(d => e.includes(d)))
                .filter(e => !e.includes('noreply') && !e.includes('no-reply'));

            if (found.length > 0) return [...new Set(found)];
        } catch (e) { continue; }
    }

    return [];
}

Email Prioritization

When multiple emails are found on a page, rank them:

  1. Highest priority: Email matching owner/manager name (stefan.muller@..., jean.dupont@...)
  2. High: Generic business email (info@domainname.com, hola@domainname.com)
  3. Medium: Department email (contact@..., hello@...)
  4. Low/skip: Generic platforms (gmail.com, hotmail.com, yahoo.com) — often personal, lower deliverability for B2B

3. Lead Database

Store leads in a persistent database instead of CSV. This eliminates duplicates across campaigns, enables incremental scoring, and builds a proprietary dataset that improves over time.

SQLite (local) or Supabase (hosted)

Use SQLite for local-only pipelines. Use Supabase for multi-device or team access — same schema, just swap the connection string.

schema.sql (also available in db/schema.sql):

CREATE TABLE IF NOT EXISTS companies (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    domain        TEXT UNIQUE NOT NULL,
    name          TEXT NOT NULL,
    category      TEXT,
    address       TEXT,
    city          TEXT,
    country       TEXT,
    phone         TEXT,
    google_rating REAL,
    review_count  INTEGER,
    last_review_date TEXT,
    has_website   INTEGER DEFAULT 1,
    website_quality TEXT,       -- basic | medium | professional
    ssl           INTEGER,      -- 0 | 1
    mobile_friendly INTEGER,    -- 0 | 1
    meta_ads_active INTEGER,    -- 0 | 1 (checked via Meta Ads Library)
    job_openings  INTEGER,      -- 0 | 1 (signal of growth)
    last_google_activity TEXT,  -- date of last review / post
    linkedin_url  TEXT,
    created_at    TEXT DEFAULT (datetime('now')),
    updated_at    TEXT DEFAULT (datetime('now'))
);

CREATE TABLE IF NOT EXISTS leads (
    id                   INTEGER PRIMARY KEY AUTOINCREMENT,
    company_id           INTEGER REFERENCES companies(id),
    email                TEXT NOT NULL,
    email_source         TEXT,  -- impressum | contact_page | footer | manual
    decision_maker_name  TEXT,
    decision_maker_title TEXT,
    score                INTEGER DEFAULT 0,
    status               TEXT DEFAULT 'new',
    personalization_note TEXT,
    created_at           TEXT DEFAULT (datetime('now')),
    UNIQUE(company_id, email)
);

CREATE TABLE IF NOT EXISTS outreach_log (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    lead_id      INTEGER REFERENCES leads(id),
    sent_at      TEXT DEFAULT (datetime('now')),
    sequence_step INTEGER,       -- 1 | 2 | 3
    channel      TEXT DEFAULT 'email',  -- email | contact_form
    subject      TEXT,
    body_preview TEXT
);

CREATE TABLE IF NOT EXISTS replies (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    lead_id      INTEGER REFERENCES leads(id),
    received_at  TEXT DEFAULT (datetime('now')),
    intent       TEXT,   -- interested | not_now | no | unsubscribe
    raw_text     TEXT,
    notes        TEXT
);

-- Status values: new → contacted → replied → meeting → won → lost → unsubscribed
-- Indexes for common queries
CREATE INDEX IF NOT EXISTS idx_leads_status ON leads(status);
CREATE INDEX IF NOT EXISTS idx_leads_score ON leads(score DESC);
CREATE INDEX IF NOT EXISTS idx_companies_domain ON companies(domain);

Dedup by domain

Always upsert by domain — never create two records for the same website.

from urllib.parse import urlparse

def extract_domain(url):
    try:
        parsed = urlparse(url if url.startswith('http') else f'https://{url}')
        return parsed.netloc.replace('www.', '').lower()
    except:
        return None

def upsert_company(conn, data):
    domain = extract_domain(data['website'])
    if not domain:
        return None
    conn.execute("""
        INSERT INTO companies (domain, name, category, city, country,
            google_rating, review_count, last_review_date)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?)
        ON CONFLICT(domain) DO UPDATE SET
            name=excluded.name,
            google_rating=excluded.google_rating,
            review_count=excluded.review_count,
            last_review_date=excluded.last_review_date,
            updated_at=datetime('now')
    """, (domain, data['name'], data.get('category'), data.get('city'),
          data.get('country'), data.get('rating'), data.get('reviews'),
          data.get('last_review_date')))
    conn.commit()
    return conn.execute("SELECT id FROM companies WHERE domain=?", (domain,)).fetchone()[0]

def upsert_lead(conn, company_id, email, source, decision_maker=None, score=0):
    conn.execute("""
        INSERT INTO leads (company_id, email, email_source, decision_maker_name, score)
        VALUES (?, ?, ?, ?, ?)
        ON CONFLICT(company_id, email) DO UPDATE SET
            email_source=excluded.email_source,
            decision_maker_name=COALESCE(excluded.decision_maker_name, decision_maker_name),
            score=excluded.score
    """, (company_id, email.lower(), source, decision_maker, score))
    conn.commit()

Key queries

-- Leads ready to contact, not yet touched
SELECT l.*, c.name, c.city, c.domain
FROM leads l JOIN companies c ON l.company_id = c.id
WHERE l.status = 'new' AND l.score >= 70
ORDER BY l.score DESC;

-- Replies to classify today
SELECT l.email, c.name, r.raw_text, r.received_at
FROM replies r JOIN leads l ON r.lead_id = l.id
JOIN companies c ON l.company_id = c.id
WHERE r.intent IS NULL
ORDER BY r.received_at DESC;

-- Campaign performance snapshot
SELECT status, COUNT(*) as n, AVG(score) as avg_score
FROM leads GROUP BY status;

-- Re-engagement candidates (no reply, 90+ days ago)
SELECT l.*, c.name, c.domain
FROM leads l JOIN companies c ON l.company_id = c.id
JOIN outreach_log o ON o.lead_id = l.id
WHERE l.status = 'no_response'
AND o.sent_at = 200 else
         75 if lead['reviews'] >= 100 else
         50 if lead['reviews'] >= 50 else
         25 if lead['reviews'] >= 20 else 0) +
        (15 if lead.get('rating', 0) >= 4.5 else 0)
    ))
    maturity = (
        (30 if lead.get('domain_email') else 0) +
        (20 if lead.get('ssl') else 0) +
        (20 if lead.get('mobile_friendly') else 0) +
        (15 if lead.get('professional_cms') else 0) +
        (15 if lead.get('has_booking') else 0)
    )
    intent = (
        (40 if lead.get('meta_ads_active') else 0) +
        (25 if lead.get('job_openings') else 0) +
        (20 if lead.get('google_post_recent') else 0) +
        (15 if lead.get('recent_review') else 0)
    )
    icp = (
        (35 if lead.get('decision_maker_name') else 0) +
        (25 if lead.get('linkedin_url') else 0) +
        (25 if lead.get('vertical_match') else 0) +
        (15 if lead.get('city_match') else 0)
    )
    return round(revenue * 0.30 + maturity * 0.20 + intent * 0.30 + icp * 0.20)

Disqualification Signals

  • business_status: CLOSED_PERMANENTLY
  • Rating 12 months ago (inactive)
  • Review count ` tag: empty, generic ("Home"), or keyword-rich?
  • [ ] ``: missing = quick SEO win you can mention
  • [ ] Single H1 on the page? Multiple H1s = problem to reference
  • [ ] Contact form: fill it and submit. 404 or broken response?
  • [ ] Primary CTA button: does it have an action, or is it dead?
  • [ ] Mobile: count checkout/booking steps vs. industry standard

SEO — visible without tools (view-source):

  • [ ] Title tag missing or duplicated across pages
  • [ ] Meta description missing (shows generic text in Google results)
  • [ ] No structured data (no ``)
  • [ ] Images with alt="" or no alt attribute

Social activity — public profiles:

  • [ ] Last Instagram/Facebook post date (check their Google Business links)
  • [ ] Last post > 3 months = social abandonment signal
  • [ ] No social links at all on website

Google Business:

  • [ ] Profile photo quality (blurry/old photos = they haven't updated)
  • [ ] Business description present or empty
  • [ ] Last owner reply to reviews (never replied = bad signal)
  • [ ] Category accuracy (wrong category = missing search traffic)

Pricing/positioning:

  • [ ] Is pricing visible on the website?
  • [ ] Pricing hidden = transactional friction (can mention)
  • [ ] No testimonials/social proof section

Turning an audit into a personalized opener

The formula:

"[Business name] + [specific observable problem] + [cost of that problem to them]"

Examples (strong):

  • "I noticed [Workshop Name]'s contact form returns a 404 on mobile — anyone trying to reach you from their phone is hitting a dead end."
  • "[Hotel Name]'s Google Business description is empty — you're getting traffic from Maps but the listing isn't converting."
  • "Vuestro formulario de reservas en [Restaurant Name] tiene 4 pasos en móvil — la media del sector está en 2."
  • "[Salon Name]'s last Instagram post was 4 months ago — corporate wellness buyers check social before reaching out."
  • "Your checkout page has no SSL indicator on Safari mobile — that yellow warning is killing conversions."

Examples (weak — avoid):

  • "I came across your business and wanted to reach out."
  • "I love what you're doing at [Business Name]."
  • "I noticed you have great reviews on Google."

Good observable signals (when no problem found):

  • Nearby landmark / street / neighbourhood visible on Google Maps
  • Recent Google review mentioning something specific
  • A page on their website (events page, corporate page, seasonal menu)
  • Their rating vs. local average ("4.9★ with 340 reviews puts you in the top 3% of [category] in [city]")
  • A badge or certification visible on their website

Formula:

"[Business name] + [specific observable detail] + [why that makes your offer relevant]"

CSV-Based Personalization at Scale

import csv

def generate_email(template, lea

…

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [txampa](https://github.com/txampa)
- **Source:** [txampa/claude-skill-b2b-local-outreach](https://github.com/txampa/claude-skill-b2b-local-outreach)
- **License:** MIT

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet — be the first.

Versions

  • v0.1.0 Imported from the upstream source.