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
✓ PassedNo 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.
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) |
Best for finding the decision maker's name and title before writing.
Search pattern:
- Search:
[Job Title] at [Company Name]orOwner [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:
- Highest priority: Email matching owner/manager name (
stefan.muller@...,jean.dupont@...) - High: Generic business email (
info@domainname.com,hola@domainname.com) - Medium: Department email (
contact@...,hello@...) - 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.
Write a review
Versions
- v0.1.0 Imported from the upstream source.