
30x Outreach
- 2 installs
- 2 repo stars
- Updated July 22, 2026
- norahe0304-art/30x-outreach
Cold-outbound engine that auto-profiles a business, defines ICPs, sources and verifies leads, writes scored email sequences, and sends them behind a human approval gate.
About
An end-to-end cold outbound engine that auto-profiles businesses, persists multi-ICP targeting, sources and verifies leads via Apollo, LeadMagic and Instantly, writes email sequences scored by an expert panel, and sends with a human approval gate. A developer uses it when building cold email campaigns, optimizing outbound, or managing ICPs.
- Auto-profiles the business via website scrape and founder interview
- Sources leads through Apollo, LeadMagic, and Instantly with verification
30x Outreach by the numbers
- 2 all-time installs (skills.sh)
- Ranked #730 of 853 Sales & Marketing skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/norahe0304-art/30x-outreach --skill 30x-outreachAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 2 |
| Last updated | July 22, 2026 |
| Repository | norahe0304-art/30x-outreach ↗ |
What it does
Cold-outbound engine that auto-profiles a business, defines ICPs, sources and verifies leads, writes scored email sequences, and sends them behind a human approval gate.
Files
30x Outreach Engine
---
Dynamic Context (auto-injected on skill load)
Business Profile
!cat ${CLAUDE_SKILL_DIR}/data/business/profile.json 2>/dev/null || echo '{"status": "NOT_CONFIGURED", "action": "Run Step 1 to set up business profile"}'
Active ICP
!cat ${CLAUDE_SKILL_DIR}/data/icps/$0/profile.json 2>/dev/null || echo '{"status": "NOT_FOUND", "icp": "$0", "action": "Run Step 2 to create this ICP"}'
Latest Competitive Intel
!cat ${CLAUDE_SKILL_DIR}/data/intel/competitive/latest.json 2>/dev/null || echo '{"status": "NO_DATA", "action": "Run Step 5 competitive-monitor.py"}'
Latest Cross-Signals
!cat ${CLAUDE_SKILL_DIR}/data/intel/signals/latest.json 2>/dev/null || echo '{"status": "NO_DATA", "action": "Run Step 5 cross-signal-detector.py"}'
---
Startup Router
Check what's already configured and skip to the right step:
1. No business profile? → Step 1 (first-time setup, one-time only) 2. Business profile exists, no ICP for `$0`? → Step 2 3. ICP exists? → Step 3 (or whichever step user needs)
If user passes no argument (/30x-outreach with no ICP name):
- List existing ICPs from
data/icps/directory - Ask: "Which ICP do you want to work with, or create a new one?"
---
Step 1: Business Intelligence (one-time setup)
Goal: Build a complete business profile from two parallel sources.
Ask: "What's your domain?"
Once you have the domain, run both paths simultaneously:
Path A: Auto-Scrape
Use web tools to gather:
- Homepage → positioning, product description, value prop
- Pricing page → pricing model, tiers, price points
- About page → team size, founding story, mission
- Case studies / testimonials → social proof, client names, results
- LinkedIn company page → industry, employee count, description
- Twitter/X → tone of voice, recent topics, engagement style
Path B: Founder Interview
Ask these questions (adapt based on what auto-scrape already found):
1. What do you sell? (one sentence, no jargon) 2. Who's your ideal buyer? (title + company type) 3. What's your primary offer? (free audit, demo, trial, consultation) 4. Why do customers choose you over alternatives? (real differentiator) 5. What's your best proof point? (specific result, named client if possible) 6. Who are your top 3 competitors? 7. What's the #1 objection you hear? 8. What's your price range? (helps calibrate ICP company size)
Merge + Confirm
- Combine both sources into a unified profile
- Flag conflicts (e.g., website says "enterprise", founder says "SMB")
- Present to user for review and correction
- Save to
data/business/profile.jsonanddata/business/brief.md
---
Step 2: ICP Definition
Goal: Create a targeting profile for a specific buyer persona.
Use references/icp-template.md as the collection framework, but tailor questions based on business profile context.
Collect:
- Target titles (primary + secondary + never-target)
- Industries / verticals (primary + secondary + excluded)
- Company size (employee range + revenue floor + funding stage)
- Geography (primary markets + excluded regions)
- Buying signals (job postings, funding, product launches, tech stack)
- Anti-ICP (explicit exclusions — company traits + contact traits)
- Offer-to-ICP fit (why this offer for this audience)
- Known objections (top 3 + how to neutralize in copy)
- Personalization data sources (Clay, Apollo, manual, none)
Save to:
data/icps/{icp-name}/profile.json— structured datadata/icps/{icp-name}/brief.md— human-readable summary
---
Step 3: Infrastructure Audit
Run: python3 scripts/instantly-audit.py
Reports:
- Sending accounts: count, warmup score, daily limit
- Domain health: SPF/DKIM/DMARC status
- Capacity math: ready accounts × 30/day = conservative volume
- Blockers: accounts with warmup < 80 or < 14 days → flagged red
Output: data/output/{date}-instantly-audit.json
Decision gate: If zero accounts are ready → STOP. Do not proceed to lead sourcing.
---
Step 4: Lead Pipeline
Run: python3 scripts/lead-pipeline.py --icp {icp-name}
The script reads data/icps/{icp-name}/profile.json for targeting parameters.
Pipeline: 1. Apollo search → source leads matching ICP criteria 2. LeadMagic verify → validate email addresses 3. Instantly dedup → remove already-contacted leads 4. Batch upload → 25 leads per batch to Instantly campaign
Output:
data/leads/{icp-name}/raw.json— Apollo resultsdata/leads/{icp-name}/verified.json— post-verificationdata/leads/{icp-name}/uploaded.json— final uploaded set
---
Step 5: Competitive Intelligence + Cross-Signals
Competitive Monitor
Run: python3 scripts/competitive-monitor.py
Reads competitor list from config.json. For each competitor:
- Fetch pricing page → snapshot + diff against previous
- Fetch blog → extract recent posts
- Auto-search LinkedIn + job boards (via Brave API / web search)
- Classify signals: 🔴 Threat / 🟡 Interesting / 🟢 Opportunity
Output: data/intel/competitive/latest.json
Cross-Signal Detector
Run: python3 scripts/cross-signal-detector.py
Scans all module outputs in data/output/ for overlapping:
- Company names appearing across multiple sources
- Industry verticals trending in multiple channels
- Keyword clusters showing cross-channel momentum
Output: data/intel/signals/latest.json
---
Step 6: Email Sequence Creation + Expert Panel
Input Context
The email writer has access to:
data/business/profile.json— who you are, what you sell, proof pointsdata/icps/{icp-name}/profile.json— who you're targeting, their pain pointsdata/intel/competitive/latest.json— competitive landscapereferences/copy-rules.md— writing rules (hard constraints)references/instantly-rules.md— platform rules (variables, limits)
Sequence Structure
Write a 5-6 step email sequence per references/instantly-rules.md:
- Step 1 (Day 0): Pattern interrupt + value + soft CTA. Max 3 sentences.
- Step 2 (Day 2): New angle or asset. 3-5 sentences.
- Step 3 (Day 4-7): Social proof or case study. 3-5 sentences.
- Step 4 (Day 7): Direct value or insight. 3-5 sentences.
- Step 5 (Day 7-14): Short bump. 1-2 sentences.
- Step 6 (Day 7-14): Breakup with genuine value. 2-3 sentences.
Expert Panel Scoring
Use references/expert-panel.md for the 10-expert roster.
Target: 90/100. Non-negotiable. Iterate until reached.
Each round: 1. All 10 panelists score (0-100) with one-line rationale 2. Calculate aggregate average 3. If < 90: identify top 3 weaknesses, revise, run next round 4. If ≥ 90: finalize 5. Show every round — the iteration trail is part of the deliverable
Copy Rules (hard constraints from references/copy-rules.md)
- Never start with "I" / "We" / "Our team"
- Soft CTAs only in Steps 1-3
- No links in Step 1
- Stats as observations, never as studies
- Never fabricate client names, numbers, or URLs
- Subject lines: 3-7 words, no caps, no emoji, no exclamation
---
Step 7: Human Review Gate
Generate the complete deliverable document:
1. Business + ICP Summary — who you are, who you're targeting 2. Infrastructure Status — accounts ready, capacity math 3. Lead Pipeline Summary — sourced → verified → uploaded counts 4. Competitive Intel Summary — key threats and opportunities 5. Email Sequence — all steps, Instantly-ready format with valid variables only 6. Expert Panel Record — every scoring round with scores + changes 7. Capacity Math — accounts × daily send = monthly volume → expected replies → pipeline 8. Weekly Metric Targets:
| Metric | Good | Great |
|---|---|---|
| Open rate | 40%+ | 60%+ |
| Reply rate | 3%+ | 7%+ |
| Positive reply rate | 1%+ | 3%+ |
| Meeting rate | 0.5%+ | 1.5%+ |
⛔ Do NOT send anything automatically. Wait for explicit "approved" from user.
---
Step 8: Send
Only after user says "approved":
Run: python3 scripts/cold-outbound-sender.py
- Reads approved prospects
- PII security scan (blocks API keys, passwords, local paths in email body)
- Daily send limit enforcement
- History tracking (no duplicate sends)
Output: data/output/{date}-send-log.json
---
Reference Files
| File | Purpose |
|---|---|
references/copy-rules.md | Email copy hard constraints |
references/expert-panel.md | 10-expert scoring roster + lenses |
references/instantly-rules.md | Instantly platform rules (variables, warmup, deliverability) |
references/icp-template.md | ICP data collection framework |
Scripts
| Script | Purpose |
|---|---|
scripts/instantly-audit.py | Pull Instantly campaigns, accounts, warmup scores |
scripts/lead-pipeline.py | Apollo → LeadMagic → Instantly pipeline |
scripts/competitive-monitor.py | Competitor pricing, blog, hiring signal tracking |
scripts/cross-signal-detector.py | Cross-source signal overlap detection |
scripts/cold-outbound-sender.py | Send approved emails with safety checks |
Configuration
All API keys and settings in config.json at skill root. See config.example.json for template.
# Apollo People Search API
APOLLO_API_KEY=your_apollo_api_key_here
# LeadMagic Email Verification API
LEADMAGIC_API_KEY=your_leadmagic_api_key_here
# Instantly Cold Email Platform
INSTANTLY_API_KEY=your_instantly_api_key_here
# Email Sending (for cold-outbound-sender.py)
SENDER_EMAIL=you@yourdomain.com
SENDER_NAME=Your Name
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=you@yourdomain.com
SMTP_PASSWORD=your_app_password_here
# Competitive Monitor (optional)
# Path to a JSON file defining your competitors
# See scripts/competitive-monitor.py for the expected format
COMPETITORS_CONFIG=./competitors.json
# Cross-Signal Detector (optional)
DATA_DIR=./data/agent-outputs
OUTPUT_FILE=./data/cross-signals-latest.json
# Cross-Signal Detector: comma-separated words to exclude from company extraction
# SIGNAL_STOP_WORDS=YourCompany,InternalTool
30x Outreach Engine
L2 | 父级: /ai-marketing-skills/CLAUDE.md
Claude Code Skill: 端到端冷邮件 outbound 引擎。 从业务画像到 ICP 定义,从 lead 获取到邮件创作,从专家评审到发送。 基于 Eric Siu 的 ai-marketing-skills/outbound-engine 改造,目标是世界顶级开源 outreach 系统。
成员清单
SKILL.md: 主操作手册,三层 skill 结构(frontmatter + body + 支撑文件)
CLAUDE.md: 本文件,L2 模块地图
config.example.json: 统一配置模板(API keys、发送限制、竞对列表)
requirements.txt: Python 依赖
references/: 知识库(按领域拆分,Layer 3 按需加载)
copy-rules.md: 邮件写作硬约束(开头、长度、CTA、链接、语气)
expert-panel.md: 10 人专家评审团名单 + 评分维度
instantly-rules.md: Instantly 平台规则(变量、warmup、deliverability)
icp-template.md: ICP 数据收集模板 + Apollo 参数映射
scripts/: Python 执行层(脚本自带 docstring,零 token 待机)
config_loader.py: 统一配置加载器 + 输出格式化,被所有脚本依赖
instantly-audit.py: Instantly v2 API 审计(账号、campaign、warmup)
lead-pipeline.py: Apollo→LeadMagic→Instantly lead 全流程
competitive-monitor.py:竞情追踪(定价快照 diff + 博客 + 招聘信号)
cross-signal-detector.py: 跨源信号交叉检测(公司名/行业/关键词重叠)
cold-outbound-sender.py: 审批后邮件发送(PII 安全扫描 + 日限控制)
data/: 持久化数据层(30x 新增,Eric 原版无此结构)
business/ 业务画像(全局,只需配置一次)
profile.example.json 业务画像 JSON schema 示例
icps/ 多 ICP 目录(每个 ICP 独立子目录)
profile.example.json ICP 画像 JSON schema 示例
leads/ Lead 数据(按 ICP 分目录:raw/verified/uploaded)
intel/ 情报数据
competitive/ 竞情快照和报告
signals/ 跨信号检测结果
output/ 统一格式输出(所有脚本写入此目录)数据流
config.json ─────────────────────────────────────────┐
│
domain → [自动扒 + 采访] → data/business/profile.json │
│
/30x-outreach {icp} ──┐ │
▼ ▼
data/icps/{icp}/profile.json ──→ lead-pipeline.py
│ │
│ data/leads/{icp}/*.json
│ │
competitive-monitor.py ──→ data/intel/competitive/
cross-signal-detector.py → data/intel/signals/
│
▼
[邮件创作 + 10 专家评审 → 递归到 90+]
│
▼
[人工审核门 ⛔]
│
user: "approved"
│
▼
cold-outbound-sender.py → data/output/send-log.json架构决策
- Creative Layer (AI) + Execution Layer (Python): AI 做判断/写作,Python 做 API 调用/数据处理
- config.json 统一配置: 所有 API key 和参数集中管理,不散落在 .env
- 统一 JSON 输出格式: 每个脚本输出
{module, timestamp, icp, status, data, summary} - 多 ICP 持久化:
data/icps/{name}/profile.json,支持$0变量切换 - 人工审核门: Step 7 必须等 "approved" 才能发送,绝不自动发邮件
[PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
{
"business_domain": "yourdomain.com",
"apis": {
"apollo": {
"key": "",
"note": "Apollo.io People Search API — for lead sourcing"
},
"leadmagic": {
"key": "",
"note": "LeadMagic — for email verification"
},
"instantly": {
"key": "",
"note": "Instantly v2 API — for campaign management and sending"
},
"brave": {
"key": "",
"note": "Brave Search API — for competitive monitoring and Twitter/X scanning"
}
},
"sending": {
"daily_limit_per_account": 30,
"warmup_min_score": 80,
"warmup_min_days": 14,
"batch_size": 25,
"smtp": {
"host": "smtp.gmail.com",
"port": 587,
"user": "",
"password": "",
"sender_email": "",
"sender_name": ""
}
},
"competitors": [
{
"key": "competitor-a",
"name": "Competitor A",
"domain": "competitor-a.com",
"pricing_url": "https://competitor-a.com/pricing",
"blog_url": "https://competitor-a.com/blog"
}
],
"lead_pipeline": {
"default_volume": 500,
"exclude_file": null
},
"cross_signals": {
"scan_hours": 48,
"stop_words": []
}
}
{
"company_name": "",
"domain": "",
"created_at": "",
"last_updated": "",
"positioning": {
"one_liner": "",
"value_prop": "",
"differentiator": "",
"category": ""
},
"product": {
"what_you_sell": "",
"primary_offer": "",
"offer_type": "",
"price_range": "",
"pricing_model": "",
"pricing_url": ""
},
"proof": {
"best_result": "",
"named_clients": [],
"testimonials": [],
"case_study_urls": [],
"stats": []
},
"market": {
"ideal_buyer_title": "",
"ideal_buyer_company_type": "",
"top_competitors": [],
"top_objection": "",
"industry_verticals": []
},
"brand_voice": {
"tone": "",
"style_notes": "",
"words_to_use": [],
"words_to_avoid": []
},
"sources": {
"auto_scraped": {
"homepage": {},
"pricing_page": {},
"about_page": {},
"case_studies": {},
"linkedin": {},
"twitter": {}
},
"founder_interview": {},
"conflicts": []
}
}
{
"icp_name": "",
"created_at": "",
"last_updated": "",
"business_domain": "",
"targeting": {
"titles": {
"primary": [],
"secondary": [],
"never_target": []
},
"industries": {
"primary": [],
"secondary": [],
"excluded": []
},
"company_size": {
"employee_min": null,
"employee_max": null,
"employee_sweet_spot": "",
"revenue_min": "",
"revenue_max": "",
"funding_stage": ""
},
"geography": {
"primary_markets": [],
"excluded_regions": []
}
},
"buying_signals": [
{
"signal": "",
"source": "",
"weight": "high"
}
],
"anti_icp": {
"company_traits": [],
"contact_traits": []
},
"offer_fit": {
"primary_offer": "",
"why_this_offer": ""
},
"objections": [
{
"objection": "",
"neutralizer": "",
"use_in_step": null
}
],
"personalization": {
"data_sources": [],
"available_fields": [],
"personalization_strategy": ""
},
"technographics": {
"required_tools": [],
"competitor_tools": [],
"complementary_tools": [],
"data_stack_maturity": ""
},
"intent_signals": {
"bombora_topics": [],
"g2_categories": [],
"data_sources": []
},
"apollo_search_params": {
"person_titles": [],
"q_organization_keyword_tags": [],
"organization_num_employees_ranges": [],
"person_locations": [],
"q_keywords": ""
},
"enrichment_fields": {
"funding_date": true,
"tech_stack_crm": true,
"days_in_role": true,
"note": "These 3 fields have highest conversion lift for trigger-event targeting"
}
}
AI Outbound Engine
From ICP definition to emails in inbox — fully automated cold outbound.
This skill category handles the complete cold outbound pipeline: defining your ideal customer profile, writing expert-scored email sequences, sourcing and verifying leads, deduplicating against existing campaigns, uploading to your email platform, and monitoring the competitive landscape.
What's Inside
🎯 Cold Outbound Optimizer (SKILL.md)
Full campaign design workflow:
- ICP Definition — structured template to define exactly who you're targeting
- Infrastructure Audit — pulls sending account inventory, warmup scores, and capacity math from Instantly
- Expert Panel Scoring — 10 simulated outbound experts score your copy (recursive until 90+/100)
- Sequence Copywriting — subject lines, body copy, follow-ups, breakup emails — all Instantly-ready
- Capacity Planning — accounts × daily limits = pipeline projections
- Implementation Docs — step-by-step launch plan
Supports both "start from scratch" and "optimize existing campaigns" modes.
📥 Lead Pipeline (scripts/lead-pipeline.py)
End-to-end lead sourcing: 1. Apollo People Search — pull leads matching your ICP criteria 2. LeadMagic Verification — validate every email before sending 3. Deduplication — check against existing Instantly leads + exclusion lists 4. Upload to Instantly — batch upload with rate limiting and retry logic
🔍 Competitive Monitor (scripts/competitive-monitor.py)
Track competitors automatically:
- Pricing page change detection (diff-based)
- Blog post monitoring for recent content
- Generates weekly competitive intelligence reports
- Configurable competitor list — add any company you want to track
🔗 Cross-Signal Detector (scripts/cross-signal-detector.py)
Find overlapping signals across multiple data sources:
- Company overlap across SEO, sales, and outbound data
- Vertical alignment detection
- Keyword cluster correlation
- Confidence-scored recommendations for coordinated action
📧 Cold Outbound Sender (scripts/cold-outbound-sender.py)
Sends approved outbound emails:
- Reads from an approved prospects JSON file
- Daily send limits (configurable)
- Full send history tracking
- Dry-run mode for testing
🔧 Instantly Audit (scripts/instantly-audit.py)
Pull campaign health data from the Instantly v2 API:
- Sending account inventory and warmup scores
- Campaign performance (open rate, reply rate, positive reply rate)
- Capacity math (conservative vs aggressive projections)
- Flags: low warmup scores, underperforming campaigns, blockers
Quick Start
1. Set Up Environment Variables
cp .env.example .env
# Fill in your API keys2. Install Dependencies
pip install -r requirements.txt3. Run the Lead Pipeline
python3 scripts/lead-pipeline.py \
--titles "VP Marketing,CMO,Head of Growth" \
--industries "SaaS,Marketing" \
--company-size "11,50" \
--locations "United States" \
--campaign-id "YOUR_CAMPAIGN_UUID" \
--volume 500 \
--dry-run4. Audit Your Instantly Account
python3 scripts/instantly-audit.py --output report.md5. Monitor Competitors
python3 scripts/competitive-monitor.py --output report.md6. Detect Cross-Signals
python3 scripts/cross-signal-detector.py \
--data-dir ./data/agent-outputs \
--output cross-signals.jsonArchitecture
ICP Definition
│
▼
Expert Panel Scoring (recursive → 90+)
│
▼
Apollo Search → LeadMagic Verify → Dedupe → Instantly Upload
│ │
▼ ▼
Competitive Monitor ◄──────────────► Cross-Signal Detector
│
▼
Weekly Intelligence ReportFile Structure
outbound-engine/
├── README.md # This file
├── SKILL.md # Claude Code skill definition
├── .env.example # Environment variable template
├── requirements.txt # Python dependencies
├── scripts/
│ ├── lead-pipeline.py # Apollo → LeadMagic → Dedupe → Instantly
│ ├── instantly-audit.py # Instantly account health check
│ ├── competitive-monitor.py # Competitor tracking
│ ├── cross-signal-detector.py # Multi-source signal detection
│ └── cold-outbound-sender.py # Send approved outbound emails
└── references/
├── expert-panel.md # Default 10-expert scoring roster
├── copy-rules.md # Cold email copywriting rules
├── icp-template.md # ICP data collection template
└── instantly-rules.md # Instantly variable syntax & deliverability rulesRequirements
- Python 3.9+
- API keys: Apollo, LeadMagic, Instantly (see
.env.example) - For the sender script: a configured email sending tool (e.g.,
gogCLI or SMTP) - Claude Code or similar AI coding agent (for running the SKILL.md workflow)
Customization
- ICP: Edit
references/icp-template.mdor provide parameters at runtime - Expert Panel: Swap panelists in
references/expert-panel.mdfor your industry - Competitors: Configure the
COMPETITORSdict incompetitive-monitor.py - Send limits: Adjust
MAX_PER_DAYincold-outbound-sender.py - Data sources: Point
cross-signal-detector.pyat your own data directories
License
MIT
---
<div align="center">
🧠 [Want these built and managed for you? →](https://singlebrain.com/?utm_source=github&utm_medium=skill_repo&utm_campaign=ai_marketing_skills)
This is how we build agents at [Single Brain](https://singlebrain.com/?utm_source=github&utm_medium=skill_repo&utm_campaign=ai_marketing_skills) for our clients.
Single Grain · our marketing agency
📬 [Level up your marketing with 14,000+ marketers and founders →](https://levelingup.beehiiv.com/subscribe) (free)
</div>
Cold Email Copy Rules
Rules for writing and evaluating cold email copy. Apply to every step in every sequence.
---
First Sentence Rules
NEVER start with:
- "I" — e.g., "I came across your company..."
- "We" — e.g., "We help companies like yours..."
- "Our team" — e.g., "Our team specializes in..."
- "I wanted to" — e.g., "I wanted to reach out because..."
- "Hope this finds you well" or any version of it
- "My name is..." (save for follow-ups if needed, never Step 1)
ALWAYS start with one of:
- Prospect's company name — "{{companyName}}'s recent..."
- A specific market observation — "Most [industry] companies we talk to are..."
- A specific finding — "Your [blog post / LinkedIn post / job listing] on..."
- A relevant trend — "Since [relevant thing] happened in [industry]..."
The first sentence earns the second. If it doesn't make the prospect think "hm, relevant," the email is dead.
---
Body Length Rules
| Step | Max sentences | Notes |
|---|---|---|
| Step 1 | 3 sentences | Open + value + CTA. That's it. |
| Steps 2-4 | 3-5 sentences | Add new angle or asset, not a repeat |
| Step 5 (bump) | 1-2 sentences | Short. "Still relevant?" style. |
| Step 6 (breakup) | 2-3 sentences | Leave value, don't close your file. |
If a step is longer than this, cut it. Ruthlessly.
---
Stats and Social Proof
Correct framing (observation):
"Most brands we audit are leaving 30-40% of their SEO traffic unconverted."
Incorrect framing (study/study-like):
"According to our data, 73% of brands have this problem."
Why: Observation sounds like earned experience. Study sounds like a marketing claim. Prospects believe the former.
Never fabricate:
- Specific client names unless verified and approved
- Revenue numbers or % improvements unless you have the actual data
- Podcast episodes or content references unless they exist and are linkable
- Case study specifics — if you can't verify it, generalize it
---
CTAs
Soft asks (preferred):
- "Worth a look?"
- "Want the data?"
- "Does this match what you're seeing?"
- "Relevant to what you're working on?"
- "Happy to share what we found — useful?"
Hard asks (avoid in Step 1):
- "Book a call with me" → too much commitment too early
- "Schedule 30 minutes" → presumes interest
- "Let's hop on a call" → pushy
- "Are you free Thursday?" → too forward for a stranger
Use hard asks only in Step 4+ if you've gotten engagement signals. Even then, soften them.
---
Links
- Step 1: No links (deliverability + trust)
- Steps 2-3: Max 1 link, only if it adds genuine value (a case study, a report, a tool)
- Breakup email: Include 1 real link to genuinely useful content (not a sales page)
- Never: Hallucinate URLs. All links must be verified real pages before use.
- Never: Link to a landing page with a form in Steps 1-2 — it signals spray-and-pray
---
Breakup Email (Final Step)
Correct:
Leave something genuinely useful. A real article, a real report, a real piece of content that relates to their problem.
"In case it's useful regardless — here's the framework we use: [real URL]. No pressure on the rest."
Incorrect:
"Just wanted to close the loop / closing your file / marking you as not interested"
This is negative framing and slightly manipulative. The prospect notices.
---
AI Engine References
When listing AI tools in copy or messaging, always include the full set: ChatGPT, Perplexity, Gemini, Claude
Do not omit any major AI platform. If listing "AI tools" or "AI search engines," include all four.
---
Personalization Rules
{{personalization}}field: must be set per lead. Don't leave it generic.- Personalization should reference something specific to the company: a recent hire, a published piece, a product launch, a job listing signal, a funding round.
- If you can't personalize at least 50% of the list, remove
{{personalization}}from the template and rewrite to not depend on it.
---
Subject Lines
- Length: 3-7 words is the sweet spot
- No exclamation points
- No all-caps
- No emoji in B2B cold email (unless targeting a persona that expects it)
- Best patterns:
- Question: "Quick question, {{firstName}}"
- Observation: "{{companyName}}'s content strategy"
- Specificity: "Saw your post on [topic]"
- Intrigue: "One thing we noticed"
- A/B test 2 variants per Step 1. Pick winner after 100+ sends each.
---
Tone
- Peer-to-peer, not vendor-to-prospect
- Curious, not desperate
- Specific, not generic
- Short, not comprehensive
- Human, not corporate
If it sounds like a marketing email, rewrite it. Cold email that converts sounds like a text from a knowledgeable peer.
---
2026 Benchmark Data (Instantly)
| Metric | Average | Elite Top 10% |
|---|---|---|
| Reply rate | 3.43% | 10.7%+ |
| Top quartile | — | 5.5%+ |
| Optimal length | — | Under 80 words |
| Optimal sequence | — | 4-7 touchpoints |
| Step 1 reply share | 58% | — |
| Follow-up reply share | 42% | — |
Step 1 captures 58% of all replies — invest most iteration budget there. Follow-ups still capture 42% — never skip them.
---
Campaign Diagnostic (30/30/50 Rule)
When reply rate is low, diagnose before rewriting:
- 30% targeting — is the ICP right? Are you reaching decision-makers?
- 30% offer — is the offer compelling? Would YOU respond to this?
- 50% deliverability — are emails landing in inbox? Warmup score? SPF/DKIM?
Don't rewrite copy if the problem is targeting or infrastructure.
Expert Panel — Default Roster
10 outbound sales experts. Each scores copy through their specific lens. User can swap or add panelists based on industry or offer type.
---
Default Panel
1. Alex Berman
Background: Cold email master, B2B agency lead gen. $100M+ pipeline generated via cold outreach. Scoring lens: Raw reply rate potential. Does this email get a "yes" or "tell me more"? Evaluates offer clarity, brevity, and specificity. Red flags he catches: Vague value props, over-explaining, walls of text.
2. Oren Klaff
Background: Author of Pitch Anything. Neuromarketing and frame control specialist. Scoring lens: Frame and status. Does this email position the sender as high-status? Is there genuine scarcity or social proof? Does it trigger "I need to respond to this"? Red flags he catches: Begging energy, "I just wanted to...", weak positioning.
3. Josh Braun
Background: Badass B2B Growth. Anti-spam cold email philosophy. Scoring lens: Does this email respect the prospect? Is it genuinely useful or just noise? Evaluates honest curiosity, relevant observations, and non-pushy CTAs. Red flags he catches: Fake personalization, presumptuous CTAs, spray-and-pray signals.
4. Becc Holland
Background: Creator of "Flip the Script." Pattern interrupt specialist. Scoring lens: Does Step 1 stop the scroll? Is the opening surprising enough to earn the next sentence? Evaluates subject line + first sentence combo. Red flags he catches: Generic openers, "I hope this finds you well", predictable subject lines.
5. Sam McKenna
Background: #samsales. "Show Me You Know Me" methodology. Scoring lens: Research depth. Does the email prove the sender actually knows this prospect? Evaluates specificity of personalization and relevance of observation. Red flags she catches: Generic compliments, surface-level research, "I noticed your website..."
6. Kyle Coleman
Background: Copy.ai VP Marketing. B2B sequencing strategy. Scoring lens: Sequence architecture. Does the follow-up ladder make sense? Does each step add new value rather than just bumping? Evaluates sequence logic and escalation. Red flags he catches: Repetitive follow-ups, "just checking in", no value escalation.
7. Will Allred
Background: Lavender co-founder. Reply rate optimization via AI-assisted email analysis. Scoring lens: Readability and reply rate signals. Reading grade level, sentence length, mobile rendering, emotional tone. Does this feel like a real email from a real person? Red flags he catches: Long sentences, passive voice, corporate jargon, "synergies".
8. Jeremy Donovan
Background: SalesLoft SVP of Revenue Strategy. Data-driven deliverability and analytics. Scoring lens: Deliverability and measurability. Are there spam triggers? Is the send structure safe? Are metrics targets realistic? Red flags he catches: Spam words, link overload, unrealistic reply rate expectations.
9. Jeb Blount
Background: Author of Fanatical Prospecting. Multi-channel outbound systems. Scoring lens: Pipeline math and multi-channel logic. Is the sequence volume sufficient? Should LinkedIn or phone be layered in? Is the outreach sustainable? Red flags he catches: Under-resourced sequences, single-channel dependency, no follow-through plan.
10. Patrick Dang
Background: B2B sales coach. Email + LinkedIn combo plays. Scoring lens: LinkedIn integration potential. Does the email sequence have natural LinkedIn touchpoints? Is the overall outreach strategy connected across channels? Red flags he catches: Siloed email sequences with no social proof layer, no profile warmup.
---
100-Point Scoring Rubric (structured dimensions)
Each panelist scores through their own lens, but the aggregate should cover all 10 dimensions:
| Dimension | Weight | What to evaluate |
|---|---|---|
| Subject line | 10 pts | Length 3-7 words, personalized, no spam triggers |
| Opening hook | 15 pts | Specific, not "I/We", earns the next sentence |
| Personalization | 15 pts | Company-specific observation, not token substitution |
| Value proposition | 15 pts | 1-2 sentences tied to prospect pain |
| Social proof | 10 pts | Brief, credible, not fabricated |
| CTA | 15 pts | Low-friction, single ask, no calendar link in Step 1 |
| Length | 5 pts | Under 80 words for Step 1, under 120 for follow-ups |
| Deliverability safety | 10 pts | No spam words, no links in Step 1, plain text |
| Tone | 5 pts | Peer-to-peer, not corporate, not desperate |
| Total | 100 pts |
Thresholds:
- 90+ = send ready
- 75-89 = revise 1-2 dimensions, run another round
- Under 75 = significant rewrite needed
---
Named Copy Frameworks (panelists may reference these)
- Josh Braun 4-T: Truth + Think + Third-party + Talk (binary CTA)
- Becc Holland 3-Line: Premise (longest) + Value prop (1 sentence) + CTA
- Alex Berman 3C: Compliment + Case Study + CTA
- Instantly Formula: Personalization + Value + Niche + Goals + Case Study + Cliffhanger + CTA
Campaign Diagnostic (Alex Berman 30/30/50 Rule):
- 30% = targeting accuracy (ICP quality)
- 30% = offer quality (what you're selling)
- 50% = deliverability (infrastructure health)
If reply rate is low, diagnose which of these 3 is failing before rewriting copy.
---
Scoring Table Format (per round)
| Panelist | Score | Rationale |
|---|---|---|
| Alex Berman | XX | [one-line reason] |
| Oren Klaff | XX | [one-line reason] |
| Josh Braun | XX | [one-line reason] |
| Becc Holland | XX | [one-line reason] |
| Sam McKenna | XX | [one-line reason] |
| Kyle Coleman | XX | [one-line reason] |
| Will Allred | XX | [one-line reason] |
| Jeremy Donovan | XX | [one-line reason] |
| Jeb Blount | XX | [one-line reason] |
| Patrick Dang | XX | [one-line reason] |
| AVERAGE | XX |
---
Swapping / Adding Panelists
User may request panelist changes. Examples:
- Selling to HR → add Lou Adler (hiring-focused B2B sales)
- Selling SaaS dev tools → add Jason Lemkin (SaaS-specific outbound)
- Selling to enterprise → add John Barrows (enterprise sales methodology)
- Selling to e-commerce → add Ezra Firestone (e-com marketing lens)
When adding panelists, define their scoring lens before running rounds. Minimum panel size: 5. Maximum: 15 (more than 15 creates noise, not signal).
ICP Data Collection Template
[PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
Use this template when defining the Ideal Customer Profile. Collect all fields before writing copy. After collection, persist to data/icps/{name}/profile.json.
---
ICP Definition
ICP Name: _______________ (slug format: saas-vp-marketing, ecom-founders) Client/Campaign: _______________ Date: _______________
---
Target Titles
Who specifically receives these emails?
Primary titles (high intent):
- e.g., VP of Marketing
- e.g., Director of Demand Generation
- e.g., Head of Growth
Secondary titles (acceptable, lower priority):
- e.g., CMO (at smaller companies)
- e.g., Marketing Manager (if company size <50)
Never target:
- e.g., Coordinators, Interns, Assistants (unless specifically requested)
---
Target Industries / Verticals
Primary verticals: 1. 2. 3.
Secondary verticals (test, not primary): 1. 2.
Excluded verticals (anti-ICP):
- e.g., Non-profits (budget constraints)
- e.g., Government (procurement timelines)
---
Company Size
Employee count range:
- Minimum: ___
- Maximum: ___
- Sweet spot: ___
Revenue range (if targeting by revenue):
- Minimum ARR/Revenue: $___
- Maximum: $___
Funding stage (if relevant):
- e.g., Series A+
- e.g., Bootstrapped >$5M revenue
- e.g., PE-backed
---
Geographic Targeting
Primary markets:
- e.g., US only
- e.g., US + Canada
- e.g., English-speaking markets
Excluded regions:
- e.g., APAC (different sales motion)
---
Buying Signals / Trigger Events
What makes a company more likely to buy right now?
- e.g., Recently hired a new VP Marketing (job posting signal)
- e.g., Raised funding in last 6 months
- e.g., Launched new product in last 90 days
- e.g., Running paid search (visible via SpyFu/SemRush)
- e.g., Job listings for [role] signal they need help
- e.g., Tech stack includes [specific tool] (via BuiltWith/Wappalyzer)
- e.g., Website traffic growing >20% MoM (via SimilarWeb)
---
Anti-ICP (Explicit Exclusions)
Who should never receive these emails?
Company characteristics:
- e.g., <10 employees (too small, no budget)
- e.g., Bootstrapped and not scaling
- e.g., Already a current client
- e.g., Competitors
Contact characteristics:
- e.g., No verified email (bounce risk)
- e.g., Missing firstName (won't personalize)
- e.g., Opt-out list
---
Offer-to-ICP Fit
What's the primary offer?
- [ ] Free audit
- [ ] Free trial
- [ ] Demo
- [ ] Strategy call
- [ ] Content/report download
- [ ] Other: _______________
Why this offer for this ICP? (One sentence — if you can't answer this, the offer needs rethinking)
---
Known Objections
What does this ICP typically say no to?
1. 2. 3.
How to neutralize in copy: (Pick the one that kills the most deals. Neutralize it in Step 3 or 4 — not Step 1)
---
Technographics (Tech Stack Signals)
What tools does the ideal target company use?
CRM / Marketing automation:
- e.g., HubSpot, Salesforce, Marketo
- e.g., No CRM = early stage, may need more education
Adjacent / Competitor tools:
- e.g., Uses [competitor product] = aware of category, easier sell
- e.g., Uses [complementary tool] = integration opportunity
Data stack maturity:
- [ ] Basic (spreadsheets, no CRM)
- [ ] Growing (CRM + basic automation)
- [ ] Mature (full stack: CRM + automation + analytics + enrichment)
Sources: BuiltWith, Wappalyzer, Apollo technographic filters, Clay enrichment
---
Behavioral Intent Signals
What online behaviors indicate readiness to buy?
- e.g., Researching your category keywords (Bombora intent data via Apollo)
- e.g., Visiting competitor G2/Capterra pages
- e.g., Downloading industry reports / attending webinars
- e.g., LinkedIn engagement with relevant content
- e.g., Website traffic growth >20% MoM (SimilarWeb)
Intent data sources:
- [ ] Bombora (via Apollo)
- [ ] G2 buyer intent
- [ ] LinkedIn engagement
- [ ] Website visitor identification (RB2B / Clearbit)
- [ ] None available
---
Personalization Data Available
What data fields are available per lead?
- [ ] firstName (required)
- [ ] companyName (required)
- [ ] personalization field — source: _______________
- [ ] Industry
- [ ] Employee count
- [ ] LinkedIn URL
- [ ] Recent funding round
- [ ] Recent job posting
- [ ] Tech stack signals
- [ ] Other: _______________
Personalization source:
- e.g., Clay enrichment
- e.g., Apollo export
- e.g., Manual research (for small lists)
- e.g., None (template must work without it)
Personalization strategy:
- High-touch (<100 leads): manual research per lead
- Medium-touch (100-500): Clay/Apollo enrichment + template
- Scale (500+): template-only, {{personalization}} field optional
---
Apollo Search Parameters
Map ICP to Apollo API fields (auto-populated by Claude when saving profile.json):
{
"person_titles": ["VP Marketing", "Head of Growth"],
"q_organization_keyword_tags": ["SaaS", "B2B"],
"organization_num_employees_ranges": ["11,50"],
"person_locations": ["United States"],
"q_keywords": ""
}---
Notes / Special Instructions
Any other context the copywriter needs:
_______________
Instantly-Specific Rules
Valid Variables (ONLY these — no others)
| Variable | Usage |
|---|---|
| `{{firstName\ | there}}` |
| `{{companyName\ | your company}}` |
{{personalization}} | Custom personalization field (set per lead) |
{{sendingAccountFirstName}} | Sender's first name (from sending account) |
Never use:
- Square-bracket placeholders like
[Competitor A],[Your Company],[Industry] - Custom variables not listed above — they won't render in Instantly
- If a concept can't be expressed with valid variables, rewrite the copy to not need it
firstName Rule (Critical)
- Always require firstName during lead upload. Filter out leads without first name.
- Do NOT rely on the
|therefallback as a design choice — it signals a bad list. - If the list has >5% missing firstName, flag it before launch.
Sequence Structure
- Steps: 5-6 max (not 8). Diminishing returns after 6.
- Step delays (days after previous step):
- Step 1: Day 0 (immediate)
- Step 2: Day 2
- Step 3: Day 4-7
- Step 4: Day 7
- Step 5: Day 7-14
- Step 6 (breakup): Day 7-14 after Step 5
A/B Testing
- Step 1 only: Test 2 subject line variants (A/B)
- Don't A/B test body copy in early campaigns — isolate subject line variable first
- Winning subject line = whichever hits higher open rate at 100+ sends per variant
Signature Format
{{sendingAccountFirstName}}- No company name, no title, no tagline — unless explicitly requested
- Keep it human. Feels like it came from a person, not a company.
Deliverability Rules
Send Limits
- Safe: 30 emails/day per account
- Aggressive: 50 emails/day per account (only with score 90+, warmed 30+ days)
- Never exceed 50/day per account without explicit discussion
Warmup Requirements
- Minimum: 14 days warmup before first campaign
- Minimum score: 80+ warmup score
- Accounts below 80 or under 14 days: DO NOT add to active campaigns
Domain Setup (must verify before launch)
- SPF: configured and passing
- DKIM: configured and passing
- DMARC: policy set (at minimum p=none with reporting)
- MX records: pointing correctly
- Custom tracking domain: set up in Instantly (subdomain, not root domain)
Spam Signals to Avoid
- Words: "free", "guarantee", "no risk", "limited time", "act now", "click here"
- Excessive links (max 1 per email, ideally 0 in Steps 1-2)
- Images in cold email (never)
- HTML formatting (plain text only)
- All-caps words
- Exclamation points in subject lines
Upload Requirements
Leads must have:
firstName(required — filter out if missing)email(required)companyName(required for{{companyName}}variable)personalization(required if using{{personalization}}in sequence)
Validate list before upload. Bad data = bad deliverability.
Cold Outreach Intelligence Brief — April 2026
Research run date: 2026-04-02 Sources scanned: 5 platforms, 3 community sources, 4 expert frameworks, GitHub topic index Confidence: High on frameworks/schema; Medium on star counts (live GitHub data varies)
---
1. ICP Template — Best Fields Across Leading Frameworks
Current Consensus: 4-Category Model
Every top framework (Apollo, Clay, Salesmotion, Sybill, Cognism, Becc Holland's Flip the Script) converges on the same 4 structural categories. The differentiation is in the fields within each category.
---
Category 1: Firmographics (40 pts in 100-pt scoring models)
| Field | Source Consensus | Notes |
|---|---|---|
| Industry / vertical | Universal | Primary + secondary + excluded |
| Employee headcount range | Universal | Min / max / sweet spot |
| Annual revenue or ARR | Universal | Range not exact |
| Funding stage | Apollo, Clay, Instantly | Bootstrapped / Series A+ / PE-backed |
| Business model | Salesmotion, Cognism | B2B SaaS / agency / marketplace / PLG |
| Geographic market | Universal | Country / region / language |
| Company growth rate | Clay, Apollo 2026 | Hiring velocity as proxy |
| HQ vs distributed | Niche signal | Useful for event-based targeting |
---
Category 2: Technographics (30 pts)
| Field | Source Consensus | Notes |
|---|---|---|
| CRM platform | Apollo, Clay, Clearbit | Salesforce / HubSpot / Pipedrive |
| Marketing automation | Clay, Clearbit | Marketo / HubSpot / Klaviyo |
| Data stack | Clay | Signal for data maturity |
| Competitive / adjacent tools | Clearbit, Apollo | Indicates switching potential |
| Tech stack complexity | Clay waterfall | Simple vs enterprise stack |
Clearbit adds 100+ data points; Clay pulls from 100+ providers via waterfall enrichment. Neither publishes a canonical field list publicly — they expose it through their UI.
---
Category 3: Behavioral & Intent Signals (30 pts)
| Field | Source | Signal type |
|---|---|---|
| Pricing page visits | HubSpot, 6sense, Demandbase | Hot intent |
| Content downloads / webinar | Apollo, HubSpot | Warm intent |
| Job listings for target role | Clay (Claygent scraper) | Proxy for budget/initiative |
| LinkedIn engagement | Clay, Apollo | Awareness signal |
| Demo request history | CRM | Highest intent |
| G2 / Capterra reviews of competitors | Clay | Switching signal |
| Bombora intent data | Apollo (65+ filters) | Keyword-level research behavior |
Apollo's 65+ filters explicitly include Bombora-powered intent signals as of 2025.
---
Category 4: Trigger Events (time-sensitive, highest conversion lift)
| Trigger | Source | Typical conversion lift |
|---|---|---|
| Funding round (last 6 months) | Clay, Apollo, Crunchbase | 2-3x |
| New VP/CXO hire | Clay (LinkedIn job change) | 1.5-2x |
| Product launch in last 90 days | Claygent web scraper | 1.5x |
| Headcount growth > 20% YoY | Clay, Apollo | 1.5x |
| Running paid search (SpyFu/SEMrush) | Custom enrichment | Intent signal |
| Company moved to new office | Minor signal | Useful for localized plays |
---
Anti-ICP Fields (Negative Scoring — Underused, High Value)
Apollo's 2026 ICP model explicitly builds in negative ICP signals. These should be documented:
- Historical churn indicators in your vertical
- Budget constraint proxies (sub-10 employees, no funding, NGO)
- Misaligned use cases (e.g., pure enterprise only, no mid-market)
- Contact-level: no verified email, missing firstName, on opt-out list
---
ICP Scoring Rubric (100-Point Model — Salesmotion 2026)
Recommended: 8-15 criteria across the 4 categories.
| Tier | Score Range | Recommended action |
|---|---|---|
| Tier A | 80-100 | Pursue immediately — personalized sequence |
| Tier B | 50-79 | Nurture — lighter touch sequence |
| Tier C | 0-49 | Deprioritize — remove or hold |
Enterprise multiplier weighting:
- Strategic fit: 3x
- Authority + budget: 2x
- Technical fit: 2x
- Timing signals: 2x
- Firmographic fit: 1x
Apollo benchmark: Companies with well-defined ICPs see 68% higher account engagement and 33% higher conversion rates.
---
Gap vs. Current icp-template.md
The existing template covers titles, industries, company size, geography, buying signals, anti-ICP, offer-to-ICP fit, objections, and personalization data availability. It is strong.
Missing fields to consider adding:
- Technographics section (CRM / MarTech stack fields)
- Behavioral intent signals (pricing page visits, G2 activity)
- Funding stage specifics (Crunchbase signal)
- ICP score threshold field (at what score do we include this lead?)
- Negative ICP scoring criteria (not just binary exclusions but weighted)
---
2. Cold Email Copy Frameworks — What Is Working in 2025-2026
Framework 1: Instantly's 25% Reply Rate Formula (Structural)
Full anatomy:
Personalization + Value Prop + Target Niche (subsegment/location)
+ Target's Goals + Target's Value Props
+ Relevant Case Study + Cliffhanger Value Prop + CTA17 named strategies, top 5 in active use: 1. One-sentence email — brevity wins with attention-constrained buyers 2. Research angle — request prospect's input before pitching 3. Permission-based — ask to send more before sending it 4. Guarantee-based — deliverability / asset / action guarantee reduces perceived risk 5. Prework / Free Audit — deliver upfront value, ask nothing first
Source: Instantly Help Center
---
Framework 2: Josh Braun's 4-Sentence Formula
Used by his "Badass B2B Growth" community. Anti-pitch philosophy.
Sentence 1 — Demonstrate research (evidence of homework on this specific prospect)
Sentence 2 — Highlight their challenge (fear of loss > promise of gain)
Sentence 3 — Solution + Social proof (similar companies you've helped)
Sentence 4 — Low-pressure CTA ("I'm not sure if this is a fit, but...")The 4-T Framework (Braun variant):
- T1 — Truth: A factual observation about a specific problem you solve
- T2 — Think: A question that challenges how they're currently solving it
- T3 — Third-party credibility: Social proof or external validation
- T4 — Talk: A binary yes/no or a "free taste" CTA
Signature language patterns: use "no" and "without" — e.g., "No back-and-forth emails" or "Determine payouts without hard-pasting Excel sheets." Emotionally charged language over facts.
Test 3 variations over 4-6 weeks before declaring a winner.
Source: Josh Braun
---
Framework 3: Becc Holland's Flip the Script — 3-Line Structure
Strict 3-line format (max 4 lines):
Line 1 (longest) — Premise / reason for outreach. Must be specific.
Line 2 — Value proposition (1 sentence)
Line 3 — Call to actionThe 4 Personalization Categories (Holland): 1. Demographic — person's role, title, education 2. Technographic — their technology stack signals right-fit 3. Firmographic — company size, industry, funding 4. Psychographic — values, passions, identity markers (highest response rate)
Holland's sequence structure: 21-day campaign, 4 touches Day 1-2, 3 touches every other day, breakup email at end.
The "Hook" framework (3 parts): premise + hook + relevance. Three ways to open a hook — specific observation, shared context, or contrarian insight.
Source: Flip the Script
---
Framework 4: Alex Berman's 3C Method + 30/30/50 Rule
3C Method (agency standard):
C1 — Compliment (specific, not generic)
C2 — Case Study (relevant proof)
C3 — Call to Action (direct ask)30/30/50 Rule:
- 30% of success = targeting accuracy
- 30% of success = offer quality
- 50% of success = deliverability
The Baking Method: Year-long follow-up sequence for B2B. Drip over 12 months with periodic new angles rather than a 4-step burn.
Source: Alex Berman
---
2026 Benchmark Data (Instantly Cold Email Report)
| Metric | Average | Elite (Top 10%) |
|---|---|---|
| Reply rate | 3.43% | 10.7%+ |
| Top quartile | — | 5.5%+ |
| Optimal email length | — | Under 80 words |
| Optimal sequence length | — | 4-7 touchpoints |
| First-touch reply share | 58% | — |
| Follow-up reply share | 42% | — |
Timing: Launch Monday, peak follow-up engagement Wednesday, Friday for triage.
Source: Instantly Benchmark Report 2026
---
What the Research Adds to Existing copy-rules.md
The current copy-rules.md is aligned with all frameworks. Specific additions worth incorporating:
- Add Becc Holland's 3-line strict format as a named variant
- Add Josh Braun's 4-T framework as a named variant for top-of-funnel
- Add the 30/30/50 rule as a campaign-level diagnostic, not just copy-level
- Explicitly document that 58% of replies come from Step 1 — this justifies spending the most iteration budget on Step 1
---
3. Company Intelligence Automation — Data Points and Approaches
Clay's Enrichment Architecture
Clay functions as a data orchestration hub, not a single data source. Its primary mechanism:
Waterfall Enrichment: Stack multiple data providers in sequence. Each provider is queried only if the prior one did not return a result. Cost-optimized: cheap sources first, expensive sources last.
Claygent (AI Scraper): Web scraper with a natural language interface — can answer questions about a company by reading its website, LinkedIn, job boards, or any public URL. Single-click activation.
100+ data providers connected, including: Clearbit, Hunter, Dropcontact, People Data Labs, LinkedIn, Crunchbase, Apollo, ZoomInfo, and more.
Data points Clay collects / enriches:
| Category | Specific fields |
|---|---|
| Company basics | Name, domain, description, HQ location, founded year |
| Firmographics | Industry, headcount, revenue range, growth rate |
| Funding | Round type (Seed/A/B/C), amount, date, investors |
| Leadership | CEO, CTO, CMO names + LinkedIn URLs |
| Tech stack | CRM, marketing automation, analytics, infrastructure tools |
| Hiring signals | Job postings count, roles being hired, department growth |
| Web presence | Traffic estimates, SEO metrics, ad spend proxies |
| Social | LinkedIn followers, Twitter/X followers, engagement rate |
| Contact-level | Full name, title, work email, personal email, LinkedIn URL, phone |
| Tenure | How long a contact has been in current role |
| Job change | Whether contact recently changed companies |
---
Apollo's Intelligence Collection
Apollo combines enrichment + outreach in one platform. Key data points:
65+ filters for lead search including:
- Firmographics: industry, headcount, revenue, geography, business model
- Technographics: CRM, MarTech stack
- Seniority: C-level / VP / Director / Manager
- Keyword: job description keywords, company description keywords
- Intent: Bombora-powered keyword-level research behavior
- Engagement: email open history, click history (within Apollo sequences)
Enrichment refresh: CRM records refreshed continuously. As of 2025, CSV enrichment includes all available phone number types.
Apollo's agentic direction (2025-2026): "AI Projects" learn and improve over time. AI Assistant auto-generates personalized messaging from enriched data. Direction toward fully agentic GTM.
---
Clearbit (Now HubSpot Breeze Intelligence)
Clearbit adds 100+ data points in seconds. Key differentiator: real-time CRM enrichment, refreshing Salesforce / HubSpot records every 30 days automatically.
Best for: companies that want passive enrichment of inbound leads rather than outbound prospecting lists.
---
4. Cold Email Quality Scoring — Expert Panel Framework
What Exists (Already in This Repo)
The expert-panel.md file in this repo contains a well-designed 10-expert panel with named scoring lenses per panelist. This is the strongest open framework found for cold email quality scoring. No comparable open-source rubric was found that outperforms it.
The panel covers:
- Reply rate potential (Berman)
- Frame and status signaling (Klaff)
- Prospect respect / non-pushiness (Braun)
- Pattern interrupt / opening hook (Holland)
- Research depth and specificity (McKenna)
- Sequence architecture and value escalation (Coleman)
- Readability and reply signals (Allred)
- Deliverability and spam risk (Donovan)
- Pipeline math and multi-channel logic (Blount)
- LinkedIn integration potential (Dang)
---
Scoring Rubric Dimensions — Consensus Across Sources
A scoring rubric for email quality should evaluate these 10 dimensions (synthesized from Instantly benchmarks, Salesmotion, Prospeo, and community sources):
| Dimension | What to evaluate | Weight |
|---|---|---|
| 1. Subject line | Length (2-4 words), personalization, no spam triggers | 10 pts |
| 2. Opening hook | Specific, not "I", earns next sentence | 15 pts |
| 3. Personalization | Real, company-specific, not token-substitution | 15 pts |
| 4. Value proposition | Clear, 1-2 sentences, tied to prospect's pain | 15 pts |
| 5. Social proof | Brief, credible, not fabricated | 10 pts |
| 6. CTA | Low-friction, single ask, no calendar link in Step 1 | 15 pts |
| 7. Length | Under 80 words for Step 1 | 5 pts |
| 8. Deliverability safety | No spam words, no links in Step 1, no attachments | 10 pts |
| 9. Tone | Peer-to-peer, not corporate or salesy | 5 pts |
| 10. Sequence logic | New angle per step, no "just checking in" | — (sequence-level) |
Total: 100 points
- 90+: Ready to send
- 75-89: Revise 1-2 dimensions
- Under 75: Rewrite
---
External Tools for Email Quality Scoring
| Tool | URL | What it scores |
|---|---|---|
| FirstSales Email Scorer | firstsales.io/tools/email-quality-scorer | Free, web-based quality check |
| Lavender (Will Allred) | lavender.ai | AI reply-rate scoring, readability grade, mobile preview |
| Mailreach | mailreach.co | Deliverability-specific scoring, spam word detection |
| Sender Score | senderscore.org | Domain/IP reputation scoring |
---
5. Open-Source Cold Outreach Repos on GitHub
Honest assessment: the open-source cold outreach ecosystem on GitHub is thin. Most serious practitioners use SaaS tools (Instantly, Smartlead, Lemlist). The best-architected open tooling lives in AI agent wrappers.
| Repo | Stars | Language | Architecture notes | URL |
|---|---|---|---|---|
| meteor-emails | 147 | JavaScript | SendGrid SMTP, free, basic campaign management | github.com/catin-black/meteor-emails |
| Email-automation (PaulleDemon) | 130 | Python/Django | Django + Celery + PostgreSQL + Redis, Jinja2 templates, follow-up rules, Railway/Render deployable — best architecture in the list | github.com/PaulleDemon/Email-automation |
| GPT_email_generator | 82 | Python | GPT-3 email generation from user input, no campaign mgmt | github.com/stefanrmmr/GPT_email_generator |
| map-email-scraper | 85 | JavaScript | Scrapes contact details from public business listings | github.com/MickeyUK/map-email-scraper |
| AI-agent-for-cold-emails (Ionio) | 15 | Jupyter/Python | LangChain + Apollo + Smartlead integrations, RAG on past emails for tone matching, agentic inbox management | github.com/Ionio-io/AI-agent-for-cold-emails |
| coldflow | 7 | TypeScript | "Open Source Cold Email That Actually Works", early stage | github.com/pypes-dev/coldflow |
| ai-marketing-skills (ericosiu) | — | Python | This repo — most complete open framework for agent-driven outbound found in the search | github.com/ericosiu/ai-marketing-skills |
Assessment: PaulleDemon/Email-automation has the best standalone architecture (130 stars). The Ionio AI-agent repo has the most interesting agentic design pattern (Apollo + Smartlead + RAG) at only 15 stars — worth watching. No open-source repo currently competes with the full pipeline architecture in scripts/lead-pipeline.py.
---
6. Standard Lead Data Schema — Essential vs Nice-to-Have
Apollo CSV Export Fields (confirmed via Apollo docs + community)
Contact-level essential:
| Field | Required for outreach | Notes |
|---|---|---|
| First Name | Yes — required for personalization | Missing = do not send |
| Last Name | Yes | |
| Work Email | Yes — verified status matters | Unverified = higher bounce risk |
| Job Title | Yes — for ICP qualification | |
| LinkedIn URL | Yes — for research + multichannel | |
| Company Name | Yes — required for personalization | |
| Company Website | Yes — for enrichment + deduplication | |
| Apollo Contact ID | Yes — for deduplication |
Contact-level nice-to-have:
| Field | Use case |
|---|---|
| Phone (mobile/direct) | Multichannel sequences (LinkedIn + call) |
| City / State / Country | Geo-personalization, local language plays |
| Seniority level | Routing logic (VP vs Director vs Manager) |
| Department | Segmentation |
| Years at company | Job change signal |
Company-level essential:
| Field | Required | Notes |
|---|---|---|
| Company Name | Yes | |
| Domain | Yes — for deduplication + enrichment | |
| Industry | Yes — ICP filtering | |
| Employee Count | Yes — ICP filtering | |
| HQ Country | Yes — geo targeting |
Company-level nice-to-have:
| Field | Use case |
|---|---|
| Annual Revenue / ARR | Budget signal |
| Funding Stage + Last Round Date | Trigger event targeting |
| Tech Stack (CRM, MAP) | Technographic ICP matching |
| LinkedIn Company URL | Enrichment source |
| Headcount Growth % | Buying signal |
| Hiring for [role] | Intent signal |
| G2 / review site presence | Competitor research signal |
---
Instantly Import Schema (confirmed from community + docs)
Instantly accepts CSV import with these mapped fields:
| Field name | Type | Required |
|---|---|---|
| string | Yes | |
| first_name | string | Strongly recommended |
| last_name | string | Recommended |
| company_name | string | Recommended |
| website | string | Optional |
| Any custom variable | string | Optional — maps to {{variable_name}} in templates |
Instantly's key constraint: custom variables are unlimited but must be pre-defined as column headers in the CSV. The {{personalization}} variable pattern used in icp-template.md maps directly to this schema.
---
Clay's Lead Schema (Internal Model)
Clay stores data in a spreadsheet-like table where columns = data fields. Standard columns built during a Clay enrichment workflow:
From Apollo enrichment: first_name, last_name, email, title, linkedin_url, company_name, company_domain, company_industry, company_headcount, company_location, company_revenue_range
From Clay-native enrichment (Claygent + waterfall): funding_round, funding_amount, funding_date, tech_stack, hiring_signals, company_description, recent_news, decision_maker_linkedin, tenure_in_role, recent_job_change
From custom Claygent research: Any natural language field — e.g., "What is their main product?", "Do they have a dedicated SDR team?", "What CRM are they using?"
---
Strategic Recommendations for This Repo
ICP Template (`icp-template.md`) 1. Add a Technographics section with CRM / MarTech fields 2. Add intent signal fields (pricing page visits, job posting signals, Bombora) 3. Add a numeric ICP score threshold field — at what score does a lead qualify? 4. Add negative ICP scoring criteria as weighted fields, not just binary exclusions
Expert Panel (`expert-panel.md`) The panel is comprehensive and aligns with current expert consensus. No changes needed. Consider adding a Tier B panel for specific industries: Lou Adler (HR tech), Jason Lemkin (SaaS), John Barrows (enterprise).
Copy Rules (`copy-rules.md`) 1. Add Becc Holland's 3-line format as a named variant 2. Add Josh Braun's 4-T framework as a named variant 3. Document the 58% / 42% split (Step 1 vs follow-ups) to justify iteration budget 4. Add the 30/30/50 rule (targeting / offer / deliverability) as a campaign diagnostic
Lead Schema (`scripts/lead-pipeline.py`) The Apollo → LeadMagic → Instantly pipeline covers the essential schema. Consider adding:
funding_dateas a trigger event field (requires Crunchbase or Clay enrichment)tech_stack_crmas a technographic field (requires Clay waterfall or BuiltWith)days_in_roleto flag recently-hired decision makers (requires LinkedIn enrichment)
---
Sources
- Instantly Cold Email Framework
- Instantly 2026 Benchmark Report
- Josh Braun — 35% Reply Rate Framework
- Josh Braun — Cold Email Learn
- Josh Braun LinkedIn — 4-T Framework
- Alex Berman — Official Site
- Becc Holland — Flip the Script
- Becc Holland — Personalization Webinar
- Apollo — ICP in Sales (2026)
- Salesmotion — ICP Scoring Rubric 2026
- Clay — ICP Glossary
- Clay University — Find ICP with AI
- OutreachArk — AI-Powered GTM Apollo + Clay 2025
- Pintel — Lead Enrichment Tools Compared
- Sybill — ICP Guide 2026
- GitHub — cold-emails topic
- PaulleDemon/Email-automation
- Ionio-io/AI-agent-for-cold-emails
- Apollo CSV Export Docs
- Apollo Import CSV Docs
- Prospeo — Lead Scoring for Cold Email 2026
- FirstSales Email Quality Scorer
requests>=2.28.0
#!/usr/bin/env python3
"""
Cold Outbound Sender — 审核通过后发送邮件
[INPUT]: 依赖 config_loader.py 的 load_config/get_sending_config/write_output
[OUTPUT]: 对外提供邮件发送 + 历史记录
[POS]: scripts/ 的发送脚本,被 SKILL.md Step 8 调用(需人工审批后才运行)
[PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
Usage:
python3 cold-outbound-sender.py [--dry-run] [--max N]
python3 cold-outbound-sender.py --approved-file path/to/approved.json
SMTP config: config.json > environment variables
"""
import argparse
import json
import os
import smtplib
import subprocess
import sys
from datetime import datetime
from email.mime.text import MIMEText
from pathlib import Path
try:
from config_loader import load_config, get_sending_config, write_output
except ImportError:
def load_config(): return {}
def get_sending_config(c): return {}
def write_output(m, d, **kw): pass
DEFAULT_MAX_PER_DAY = 10
DEFAULT_APPROVED_FILE = "./data/cold-outbound-approved.json"
DEFAULT_HISTORY_FILE = "./data/cold-outbound-history.json"
def validate_outbound(text):
"""Basic validation for outbound content. Returns (ok, text)."""
if not text or not isinstance(text, str):
return False, text
# Check for common leaked credential patterns
suspicious_patterns = [
r'sk-[a-zA-Z0-9]{20,}', # API keys
r'Bearer [a-zA-Z0-9\-_.]+', # Auth headers
r'/Users/[a-zA-Z]+/', # Local paths
r'password\s*[:=]\s*\S+', # Password patterns
]
import re
for pattern in suspicious_patterns:
if re.search(pattern, text, re.IGNORECASE):
return False, text
return True, text
def load_history(history_path):
if os.path.exists(history_path):
try:
with open(history_path) as f:
return json.load(f)
except Exception:
pass
return []
def save_history(history, history_path):
os.makedirs(os.path.dirname(history_path), exist_ok=True)
with open(history_path, 'w') as f:
json.dump(history, f, indent=2)
def count_sent_today(history):
today = datetime.now().strftime("%Y-%m-%d")
return sum(1 for h in history if h.get("sent_date", "").startswith(today))
def send_email_smtp(to, subject, body, sender_email, sender_name,
smtp_host, smtp_port, smtp_user, smtp_password, dry_run=False):
"""Send via SMTP."""
ok_subj, subject = validate_outbound(subject)
ok_body, body = validate_outbound(body)
if not ok_subj or not ok_body:
print(f" 🛡️ Email to {to} BLOCKED by validation (suspicious content detected)")
return False
if dry_run:
print(f" [DRY RUN] Would send to {to}: {subject}")
return True
try:
msg = MIMEText(body, 'plain')
msg['Subject'] = subject
msg['From'] = f"{sender_name} <{sender_email}>"
msg['To'] = to
with smtplib.SMTP(smtp_host, int(smtp_port)) as server:
server.starttls()
server.login(smtp_user, smtp_password)
server.sendmail(sender_email, [to], msg.as_string())
print(f" ✅ Sent to {to}: {subject}")
return True
except Exception as e:
print(f" ❌ Error sending to {to}: {e}", file=sys.stderr)
return False
def send_email_cli(to, subject, body, sender_email, sender_name, cli_command, dry_run=False):
"""Send via a CLI tool (e.g., gog, msmtp, mailx)."""
ok_subj, subject = validate_outbound(subject)
ok_body, body = validate_outbound(body)
if not ok_subj or not ok_body:
print(f" 🛡️ Email to {to} BLOCKED by validation (suspicious content detected)")
return False
if dry_run:
print(f" [DRY RUN] Would send to {to}: {subject}")
return True
try:
# Default CLI pattern: gog gmail send
cmd = cli_command.split() + [
"--to", to,
"--subject", subject,
"--body", body,
"--from", f"{sender_name} <{sender_email}>",
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode == 0:
print(f" ✅ Sent to {to}: {subject}")
return True
else:
print(f" ❌ Failed to send to {to}: {result.stderr}", file=sys.stderr)
return False
except Exception as e:
print(f" ❌ Error sending to {to}: {e}", file=sys.stderr)
return False
def main():
parser = argparse.ArgumentParser(description="Cold Outbound Sender")
parser.add_argument("--dry-run", action="store_true", help="Don't actually send emails")
parser.add_argument("--max", type=int, default=DEFAULT_MAX_PER_DAY,
help=f"Max emails per day (default: {DEFAULT_MAX_PER_DAY})")
parser.add_argument("--approved-file", default=DEFAULT_APPROVED_FILE,
help="Path to approved prospects JSON file")
parser.add_argument("--history-file", default=DEFAULT_HISTORY_FILE,
help="Path to send history JSON file")
parser.add_argument("--send-method", choices=["smtp", "cli"], default="smtp",
help="Send method: smtp or cli (default: smtp)")
parser.add_argument("--cli-command", default="gog gmail send",
help="CLI command for sending (used with --send-method cli)")
args = parser.parse_args()
# ── 加载配置 (config.json > env) ──
config = load_config()
smtp_cfg = get_sending_config(config).get("smtp", {})
sender_email = smtp_cfg.get("sender_email") or os.environ.get("SENDER_EMAIL", "")
sender_name = smtp_cfg.get("sender_name") or os.environ.get("SENDER_NAME", "")
smtp_host = smtp_cfg.get("host") or os.environ.get("SMTP_HOST", "smtp.gmail.com")
smtp_port = smtp_cfg.get("port") or os.environ.get("SMTP_PORT", "587")
smtp_user = smtp_cfg.get("user") or os.environ.get("SMTP_USER", sender_email)
smtp_password = smtp_cfg.get("password") or os.environ.get("SMTP_PASSWORD", "")
if not os.path.exists(args.approved_file):
print(f"No approved prospects file found at {args.approved_file}")
sys.exit(0)
with open(args.approved_file) as f:
approved = json.load(f)
history = load_history(args.history_file)
sent_today = count_sent_today(history)
remaining = args.max - sent_today
if remaining <= 0:
print(f"Already sent {sent_today} emails today (max {args.max}). Stopping.")
sys.exit(0)
sent_count = 0
for prospect in approved:
if sent_count >= remaining:
break
email = prospect.get("email")
if not email or email == "Unknown":
continue
# Check if already sent
if any(h.get("email") == email for h in history):
print(f" SKIP {email}: already in history")
continue
angle_key = prospect.get("approved_angle", "A")
drafts = prospect.get("angle_drafts", {})
draft = drafts.get(angle_key, {})
subject = draft.get("subject", f"Quick question for {prospect.get('company', 'you')}")
body = draft.get("body", "")
if not body:
print(f" SKIP {email}: no draft body for angle {angle_key}")
continue
if args.send_method == "smtp":
if not smtp_password and not args.dry_run:
print("ERROR: SMTP_PASSWORD env var required for smtp sending.")
sys.exit(1)
success = send_email_smtp(
email, subject, body, sender_email, sender_name,
smtp_host, smtp_port, smtp_user, smtp_password, args.dry_run
)
else:
success = send_email_cli(
email, subject, body, sender_email, sender_name,
args.cli_command, args.dry_run
)
if success:
history.append({
"company": prospect.get("company", ""),
"contact_name": prospect.get("contact_name", ""),
"email": email,
"angle": angle_key,
"subject": subject,
"sent_date": datetime.now().isoformat(),
"score": prospect.get("score", 0),
})
sent_count += 1
if not args.dry_run:
save_history(history, args.history_file)
# Remove sent prospects from approved file
if not args.dry_run and sent_count > 0:
sent_emails = {h["email"] for h in history}
remaining_approved = [p for p in approved if p.get("email") not in sent_emails]
with open(args.approved_file, 'w') as f:
json.dump(remaining_approved, f, indent=2)
print(f"\nSent {sent_count} emails ({'dry run' if args.dry_run else 'live'}). Total today: {sent_today + sent_count}")
# ── 统一格式输出 (30x) ──
write_output(
module="cold-outbound-sender",
summary=f"Sent {sent_count} emails ({'dry run' if args.dry_run else 'live'})",
data={
"sent": sent_count,
"total_today": sent_today + sent_count,
"dry_run": args.dry_run,
"max_per_day": args.max,
},
)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Competitive Monitor — 竞情追踪:定价变动、博客动态、招聘信号
[INPUT]: 依赖 config_loader.py 的 load_config/get_competitors/get_api_key/write_output
[OUTPUT]: 对外提供竞情报告,输出到 data/intel/competitive/
[POS]: scripts/ 的情报脚本,被 SKILL.md Step 5 调用
[PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
Usage:
python3 competitive-monitor.py # 从 config.json 读竞对列表
python3 competitive-monitor.py --company acme # 只扫描单个竞对
python3 competitive-monitor.py --output report.md
Competitor config: config.json > --config flag > COMPETITORS_CONFIG env var
"""
import argparse
import json
import os
import re
import sys
import urllib.request
import urllib.parse
from datetime import datetime, timedelta
from difflib import unified_diff
from typing import Dict, List, Optional
from html.parser import HTMLParser
from urllib.error import URLError, HTTPError
try:
from config_loader import load_config, get_competitors, get_api_key, write_output, SKILL_ROOT
except ImportError:
SKILL_ROOT = Path(__file__).resolve().parent.parent if 'Path' in dir() else None
def load_config(): return {}
def get_competitors(c): return []
def get_api_key(c, s): return ""
def write_output(m, d, **kw): pass
def validate_text(text, max_length=500000):
"""Basic input validation for scraped content."""
if not text or not isinstance(text, str):
return text
# Truncate extremely long content
if len(text) > max_length:
text = text[:max_length]
return text
class BlogExtractor(HTMLParser):
"""Extract blog post titles and dates from HTML."""
def __init__(self):
super().__init__()
self.posts = []
self.current_title = None
self.current_date = None
self.in_title = False
self.in_date = False
self.title_tags = ['h1', 'h2', 'h3', 'h4']
def handle_starttag(self, tag, attrs):
if tag.lower() in self.title_tags:
self.in_title = True
for name, value in attrs:
if name in ['class', 'id'] and any(
date_word in value.lower() for date_word in ['date', 'time', 'published']
):
self.in_date = True
def handle_endtag(self, tag):
if tag.lower() in self.title_tags:
self.in_title = False
self.in_date = False
def handle_data(self, data):
if self.in_title and data.strip():
self.current_title = data.strip()
if self.in_date and data.strip():
date_match = re.search(
r'\b\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\b|\b\w+ \d{1,2},? \d{4}\b', data
)
if date_match:
self.current_date = date_match.group()
if self.current_title and self.current_date:
self.posts.append({
'title': self.current_title,
'date': self.current_date,
})
self.current_title = None
self.current_date = None
class CompetitiveMonitor:
"""Main competitive monitoring class."""
# Example competitors for demo. Override with --config or COMPETITORS_CONFIG.
EXAMPLE_COMPETITORS = {
'competitor_a': {
'name': 'Competitor A',
'domain': 'competitor-a.com',
'pricing_url': 'https://www.competitor-a.com/pricing',
'blog_url': 'https://www.competitor-a.com/blog',
'linkedin_query': 'Competitor A site:linkedin.com',
'jobs_query': 'Competitor A careers OR jobs',
},
'competitor_b': {
'name': 'Competitor B',
'domain': 'competitor-b.com',
'pricing_url': 'https://www.competitor-b.com/pricing',
'blog_url': 'https://www.competitor-b.com/blog',
'linkedin_query': 'Competitor B site:linkedin.com',
'jobs_query': 'Competitor B careers OR jobs',
},
}
def __init__(self, data_dir: str = None, competitors: dict = None):
self.data_dir = data_dir or os.path.join(os.getcwd(), 'data', 'competitive')
self.pricing_dir = os.path.join(self.data_dir, 'pricing-snapshots')
self.history_dir = os.path.join(self.data_dir, 'scan-history')
self.competitors = competitors or self.EXAMPLE_COMPETITORS
os.makedirs(self.pricing_dir, exist_ok=True)
os.makedirs(self.history_dir, exist_ok=True)
def fetch_url(self, url: str, timeout: int = 10) -> Optional[str]:
"""Fetch URL content with error handling."""
try:
headers = {
'User-Agent': (
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/91.0.4472.124 Safari/537.36'
)
}
request = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(request, timeout=timeout) as response:
content = response.read().decode('utf-8', errors='ignore')
content = validate_text(content)
return content
except (URLError, HTTPError, UnicodeDecodeError) as e:
print(f"❌ Error fetching {url}: {e}")
return None
def extract_blog_posts(self, html: str) -> List[Dict]:
"""Extract blog posts from HTML."""
if not html:
return []
extractor = BlogExtractor()
try:
extractor.feed(html)
return extractor.posts
except Exception as e:
print(f"Error extracting blog posts: {e}")
return []
def is_recent_post(self, date_str: str, days_back: int = 7) -> bool:
"""Check if post is from last N days."""
if not date_str:
return False
formats = [
'%m/%d/%Y', '%m-%d-%Y', '%Y-%m-%d',
'%B %d, %Y', '%b %d, %Y', '%B %d %Y', '%b %d %Y',
]
for fmt in formats:
try:
post_date = datetime.strptime(date_str, fmt)
cutoff_date = datetime.now() - timedelta(days=days_back)
return post_date >= cutoff_date
except ValueError:
continue
return False
def get_pricing_diff(self, company_key: str, current_content: str) -> Optional[str]:
"""Compare current pricing with previous snapshot."""
today = datetime.now().strftime('%Y-%m-%d')
pricing_file = os.path.join(self.pricing_dir, f'{company_key}-{today}.txt')
with open(pricing_file, 'w', encoding='utf-8') as f:
f.write(current_content)
previous_files = [
f for f in os.listdir(self.pricing_dir)
if f.startswith(f'{company_key}-') and f != f'{company_key}-{today}.txt'
]
if not previous_files:
return "🆕 First pricing snapshot saved"
previous_files.sort(reverse=True)
previous_file = os.path.join(self.pricing_dir, previous_files[0])
try:
with open(previous_file, 'r', encoding='utf-8') as f:
previous_content = f.read()
if current_content.strip() == previous_content.strip():
return None
current_lines = current_content.splitlines()
previous_lines = previous_content.splitlines()
diff = list(unified_diff(
previous_lines, current_lines,
fromfile='previous', tofile='current', n=0
))
changes = len([
line for line in diff
if line.startswith(('+', '-')) and not line.startswith(('+++', '---'))
])
return f"🔍 {changes} lines changed since last snapshot"
except Exception as e:
return f"❌ Error comparing snapshots: {e}"
def scan_competitor(self, company_key: str) -> Dict:
"""Scan single competitor."""
company = self.competitors[company_key]
print(f"\n🔍 Scanning {company['name']}...")
results = {
'company': company['name'],
'domain': company['domain'],
'scan_time': datetime.now().isoformat(),
'pricing': {},
'blog': {},
'search_queries': {
'linkedin': company.get('linkedin_query', ''),
'jobs': company.get('jobs_query', ''),
},
}
# Fetch pricing page
pricing_url = company.get('pricing_url')
if pricing_url:
print(f" 📄 Fetching pricing: {pricing_url}")
pricing_content = self.fetch_url(pricing_url)
if pricing_content:
clean_content = re.sub(r'<[^>]+>', '', pricing_content)
clean_content = re.sub(r'\s+', ' ', clean_content).strip()
pricing_diff = self.get_pricing_diff(company_key, clean_content)
results['pricing'] = {
'url': pricing_url,
'fetched': True,
'content_length': len(clean_content),
'diff': pricing_diff,
}
else:
results['pricing'] = {
'url': pricing_url,
'fetched': False,
'error': 'Failed to fetch pricing page',
}
# Fetch blog page
blog_url = company.get('blog_url')
if blog_url:
print(f" 📝 Fetching blog: {blog_url}")
blog_content = self.fetch_url(blog_url)
recent_posts = []
if blog_content:
all_posts = self.extract_blog_posts(blog_content)
recent_posts = [post for post in all_posts if self.is_recent_post(post['date'])]
results['blog'] = {
'url': blog_url,
'fetched': bool(blog_content),
'total_posts_found': len(self.extract_blog_posts(blog_content)) if blog_content else 0,
'recent_posts': recent_posts,
}
return results
def generate_report(self, scan_results: List[Dict], threat_keywords: List[str] = None) -> str:
"""Generate markdown report."""
today = datetime.now().strftime('%Y-%m-%d')
# Configurable threat keywords (topics that signal competitive overlap)
if threat_keywords is None:
threat_keywords = ['funnel', 'conversion', 'landing page', 'ab test', 'optimize', 'cro']
report = f"""# 🔍 Competitive Intelligence Report - {today}
## Executive Summary
Monitored {len(scan_results)} competitors for pricing changes, recent blog activity, and market signals.
"""
threats = []
interesting = []
opportunities = []
search_queries = []
for result in scan_results:
company = result['company']
pricing = result.get('pricing', {})
if pricing.get('diff') and '🔍' in str(pricing['diff']):
interesting.append(
f"**{company}**: {pricing['diff']} → *Monitor for pricing strategy shifts*"
)
elif pricing.get('diff') and '🆕' in str(pricing['diff']):
interesting.append(
f"**{company}**: {pricing['diff']} → *Baseline established for future tracking*"
)
blog = result.get('blog', {})
recent_posts = blog.get('recent_posts', [])
if recent_posts:
post_titles = [
post['title'][:80] + '...' if len(post['title']) > 80 else post['title']
for post in recent_posts[:3]
]
content_lower = ' '.join(post_titles).lower()
if any(keyword in content_lower for keyword in threat_keywords):
threats.append(
f"**{company}**: {len(recent_posts)} recent posts, potential feature overlap → *Review competitive positioning*"
)
else:
interesting.append(
f"**{company}**: {len(recent_posts)} recent posts → *{', '.join(post_titles[:2])}*"
)
else:
opportunities.append(
f"**{company}**: No recent blog content → *Content marketing gap you can exploit*"
)
sq = result.get('search_queries', {})
if sq.get('linkedin'):
search_queries.append(f"LinkedIn search: {sq['linkedin']}")
if sq.get('jobs'):
search_queries.append(f"Jobs search: {sq['jobs']}")
if threats:
report += "## 🔴 THREATS\n\n"
for threat in threats:
report += f"- {threat}\n"
report += "\n"
if interesting:
report += "## 🟡 INTERESTING\n\n"
for item in interesting:
report += f"- {item}\n"
report += "\n"
if opportunities:
report += "## 🟢 OPPORTUNITIES\n\n"
for opp in opportunities:
report += f"- {opp}\n"
report += "\n"
if search_queries:
report += "## 🔎 LinkedIn/Jobs Search Queries\n\n"
report += "Run these queries for social/hiring signals:\n\n"
for query in search_queries:
report += f"- `{query}`\n"
report += "\n"
report += "## 📊 Technical Summary\n\n"
for result in scan_results:
company = result['company']
pricing = result.get('pricing', {})
blog = result.get('blog', {})
report += f"**{company}:**\n"
report += f"- Pricing: {'✅' if pricing.get('fetched') else '❌'} {pricing.get('diff', 'No changes')}\n"
report += f"- Blog: {'✅' if blog.get('fetched') else '❌'} {len(blog.get('recent_posts', []))} recent posts\n\n"
return report
def save_results(self, scan_results: List[Dict]) -> str:
"""Save scan results to files."""
today = datetime.now().strftime('%Y-%m-%d')
latest_file = os.path.join(self.data_dir, 'latest-scan.json')
with open(latest_file, 'w') as f:
json.dump(scan_results, f, indent=2)
history_file = os.path.join(self.history_dir, f'{today}.json')
with open(history_file, 'w') as f:
json.dump(scan_results, f, indent=2)
return latest_file
def run(self, company_filter: Optional[str] = None) -> str:
"""Run competitive monitoring scan."""
print("🚀 Starting competitive monitoring scan...")
companies_to_scan = (
[company_filter] if company_filter else list(self.competitors.keys())
)
if company_filter and company_filter not in self.competitors:
print(f"❌ Unknown company: {company_filter}")
print(f"Available companies: {', '.join(self.competitors.keys())}")
return ""
scan_results = []
for company_key in companies_to_scan:
try:
result = self.scan_competitor(company_key)
scan_results.append(result)
except Exception as e:
print(f"❌ Error scanning {company_key}: {e}")
self.save_results(scan_results)
report = self.generate_report(scan_results)
print(f"\n✅ Scan complete! Results for {len(scan_results)} companies.")
return report
def load_competitors_config(config_path: str) -> dict:
"""Load competitors from a JSON config file.
Expected format:
{
"competitor_key": {
"name": "Competitor Name",
"domain": "competitor.com",
"pricing_url": "https://competitor.com/pricing",
"blog_url": "https://competitor.com/blog",
"linkedin_query": "Competitor Name site:linkedin.com",
"jobs_query": "Competitor Name careers OR jobs"
}
}
"""
with open(config_path, 'r') as f:
return json.load(f)
def main():
parser = argparse.ArgumentParser(description='Competitive Monitoring Scraper')
parser.add_argument('--company', help='Scan specific company only (by key)')
parser.add_argument('--output', '-o', help='Save report to file')
parser.add_argument('--config', help='Path to competitors JSON config file')
parser.add_argument('--data-dir', help='Directory for storing scan data')
parser.add_argument('--threat-keywords', nargs='*',
help='Keywords that signal competitive overlap (space-separated)')
args = parser.parse_args()
# ── 加载竞对配置 (config.json > --config > env > 内置示例) ──
config = load_config()
competitors = None
# 优先从 config.json 读取
config_competitors = get_competitors(config)
if config_competitors:
competitors = {}
for comp in config_competitors:
key = comp.get("key", comp.get("domain", "").replace(".", "-"))
competitors[key] = {
"name": comp.get("name", key),
"domain": comp.get("domain", ""),
"pricing_url": comp.get("pricing_url", f"https://{comp.get('domain', '')}/pricing"),
"blog_url": comp.get("blog_url", f"https://{comp.get('domain', '')}/blog"),
"linkedin_query": f"{comp.get('name', key)} site:linkedin.com",
"jobs_query": f"{comp.get('name', key)} careers OR jobs",
}
print(f"📋 Loaded {len(competitors)} competitors from config.json")
# 其次从 --config 或 env 读取
if not competitors:
config_path = args.config or os.environ.get('COMPETITORS_CONFIG')
if config_path:
try:
competitors = load_competitors_config(config_path)
print(f"📋 Loaded {len(competitors)} competitors from {config_path}")
except Exception as e:
print(f"❌ Error loading config: {e}")
sys.exit(1)
# 设置数据目录到 30x 标准路径
data_dir = args.data_dir
if not data_dir:
try:
data_dir = str(SKILL_ROOT / "data" / "intel" / "competitive")
except:
data_dir = None
monitor = CompetitiveMonitor(
data_dir=data_dir,
competitors=competitors,
)
report = monitor.run(args.company)
if report:
print("\n" + "=" * 60)
print(report)
print("=" * 60)
if args.output:
with open(args.output, 'w') as f:
f.write(report)
print(f"\n📁 Report saved to: {args.output}")
# ── 统一格式输出 (30x) ──
write_output(
module="competitive-monitor",
summary=f"Scanned {len(monitor.competitors)} competitors",
data={"report": report[:2000], "competitors_scanned": list(monitor.competitors.keys())},
)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
config_loader.py — 统一配置加载器
[INPUT]: 依赖 config.json 或 config.example.json
[OUTPUT]: 对外提供 load_config(), get_api_key(), get_sending_config(), get_competitors(), write_output()
[POS]: scripts/ 的基础设施,被所有其他脚本依赖
[PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
"""
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
# ─────────────────────────────────────────────
# 配置文件路径解析
# ─────────────────────────────────────────────
def _find_skill_root():
"""向上查找包含 SKILL.md 的目录作为 skill root"""
current = Path(__file__).resolve().parent
for _ in range(5):
if (current / "SKILL.md").exists():
return current
current = current.parent
return Path(__file__).resolve().parent.parent
SKILL_ROOT = _find_skill_root()
def load_config():
"""加载 config.json,不存在则尝试 config.example.json"""
config_path = SKILL_ROOT / "config.json"
example_path = SKILL_ROOT / "config.example.json"
if config_path.exists():
with open(config_path) as f:
return json.load(f)
if example_path.exists():
print(f" ⚠️ No config.json found. Using config.example.json (API keys will be empty)")
with open(example_path) as f:
return json.load(f)
print(" ❌ No config.json or config.example.json found.")
sys.exit(1)
def get_api_key(config, service):
"""从 config 或环境变量获取 API key"""
env_map = {
"apollo": "APOLLO_API_KEY",
"leadmagic": "LEADMAGIC_API_KEY",
"instantly": "INSTANTLY_API_KEY",
"brave": "BRAVE_API_KEY",
}
env_var = env_map.get(service, "")
env_val = os.environ.get(env_var, "")
if env_val:
return env_val
return config.get("apis", {}).get(service, {}).get("key", "")
def get_sending_config(config):
"""获取发送配置"""
return config.get("sending", {})
def get_competitors(config):
"""获取竞对列表"""
return config.get("competitors", [])
# ─────────────────────────────────────────────
# 统一输出格式
# ─────────────────────────────────────────────
def write_output(module, data, summary="", icp=None):
"""写入统一格式的 JSON 输出到 data/output/
统一 schema:
{
"module": "instantly-audit",
"timestamp": "2026-04-02T10:30:00Z",
"icp": "saas-vp-marketing",
"status": "success",
"data": { ... },
"summary": "一句话总结"
}
"""
output_dir = SKILL_ROOT / "data" / "output"
output_dir.mkdir(parents=True, exist_ok=True)
today = datetime.now().strftime("%Y-%m-%d")
output_path = output_dir / f"{today}-{module}.json"
envelope = {
"module": module,
"timestamp": datetime.now(timezone.utc).isoformat(),
"icp": icp,
"status": "success",
"data": data,
"summary": summary,
}
with open(output_path, "w") as f:
json.dump(envelope, f, indent=2, default=str)
print(f" 💾 Output: {output_path}")
return output_path
def load_icp_profile(icp_name):
"""加载指定 ICP 的 profile.json"""
icp_path = SKILL_ROOT / "data" / "icps" / icp_name / "profile.json"
if not icp_path.exists():
print(f" ❌ ICP not found: {icp_path}")
print(f" Run /30x-outreach {icp_name} to create it first.")
return None
with open(icp_path) as f:
return json.load(f)
def load_business_profile():
"""加载业务 profile.json"""
biz_path = SKILL_ROOT / "data" / "business" / "profile.json"
if not biz_path.exists():
return None
with open(biz_path) as f:
return json.load(f)
#!/usr/bin/env python3
"""
Cross-Signal Detector — 跨源信号交叉检测
[INPUT]: 依赖 config_loader.py,扫描 data/output/ 下所有模块输出
[OUTPUT]: 对外提供跨信号检测报告,输出到 data/intel/signals/
[POS]: scripts/ 的情报脚本,被 SKILL.md Step 5 调用
[PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
Usage:
python3 cross-signal-detector.py # 扫描 data/output/
python3 cross-signal-detector.py --data-dir ./custom # 自定义目录
python3 cross-signal-detector.py --hours 48 # 回溯时间窗口
"""
import argparse
import json
import os
import re
import glob
from datetime import datetime, timedelta, timezone
from collections import defaultdict
from pathlib import Path
try:
from config_loader import load_config, write_output, SKILL_ROOT
except ImportError:
SKILL_ROOT = Path(__file__).resolve().parent.parent
def load_config(): return {}
def write_output(m, d, **kw): pass
# Words to exclude from company name extraction (common English words that look like names)
STOP_WORDS = {
'The', 'This', 'That', 'What', 'How', 'Why', 'When', 'Where',
'For', 'From', 'With', 'About', 'Into', 'Over', 'After',
'Before', 'Between', 'Under', 'During', 'Through',
'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday',
'Saturday', 'Sunday', 'January', 'February', 'March',
'April', 'May', 'June', 'July', 'August', 'September',
'October', 'November', 'December',
'None', 'True', 'False', 'Error', 'Warning',
}
# Configurable: add your own team names / internal terms to exclude
CUSTOM_STOP_WORDS = set(os.environ.get('SIGNAL_STOP_WORDS', '').split(',')) if os.environ.get('SIGNAL_STOP_WORDS') else set()
def get_recent_files(directory, hours=24):
"""Get files modified in the last N hours."""
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
recent = []
if not os.path.isdir(directory):
return recent
for f in glob.glob(os.path.join(directory, "*")):
if os.path.isfile(f):
mtime = datetime.fromtimestamp(os.path.getmtime(f), tz=timezone.utc)
if mtime > cutoff:
recent.append(f)
return recent
def extract_companies(text):
"""Extract company names (capitalized words, common patterns)."""
companies = set()
all_stop = STOP_WORDS | CUSTOM_STOP_WORDS
for match in re.findall(
r'\b([A-Z][a-zA-Z]+(?:\.[a-zA-Z]+)?(?:\s+(?:AI|Inc|Corp|Labs|Tech|io))?)\b',
text
):
if len(match) > 2 and match not in all_stop:
companies.add(match)
return companies
def extract_keywords(text):
"""Extract keyword themes from marketing/business text."""
keywords = set()
patterns = [
r'(?:ai|artificial intelligence)\s+(?:marketing|agent|tool|saas|automation)',
r'(?:seo|content|digital)\s+(?:marketing|strategy|optimization|growth)',
r'(?:b2b|saas|enterprise)\s+(?:marketing|growth|sales)',
r'(?:social media|linkedin|twitter|youtube)\s+(?:marketing|growth|strategy)',
r'(?:email|outbound|cold)\s+(?:marketing|outreach|campaign)',
r'(?:paid|ppc|google)\s+(?:ads|advertising|media)',
]
text_lower = text.lower()
for p in patterns:
match = re.search(p, text_lower)
if match:
keywords.add(match.group())
return keywords
def extract_verticals(text):
"""Extract industry verticals."""
verticals = set()
vertical_keywords = {
'fintech': ['fintech', 'financial', 'banking', 'payments'],
'healthtech': ['healthtech', 'health tech', 'healthcare', 'medical'],
'edtech': ['edtech', 'education', 'learning platform'],
'ai_saas': ['ai saas', 'ai tool', 'ai agent', 'ai platform', 'artificial intelligence'],
'ecommerce': ['ecommerce', 'e-commerce', 'shopify', 'dtc', 'd2c'],
'cybersecurity': ['cybersecurity', 'security', 'infosec'],
'martech': ['martech', 'marketing tech', 'marketing tool'],
'hr_tech': ['hr tech', 'hiring', 'recruiting', 'talent'],
}
text_lower = text.lower()
for vertical, kws in vertical_keywords.items():
if any(kw in text_lower for kw in kws):
verticals.add(vertical)
return verticals
def read_file_safe(filepath):
"""Read file content safely."""
try:
with open(filepath) as f:
return f.read()
except Exception:
return ""
def categorize_file(filepath, agent_patterns=None):
"""Categorize a file by agent/source based on filename patterns.
Override with agent_patterns dict: {"pattern": "agent_name"}
"""
basename = os.path.basename(filepath).lower()
# Default patterns — customize these for your setup
default_patterns = {
'seo': 'seo',
'oracle': 'seo',
'content': 'content',
'flash': 'content',
'trend': 'content',
'deal': 'deal',
'cold': 'cold_outbound',
'outbound': 'cold_outbound',
'recruit': 'recruiting',
'hiring': 'recruiting',
}
patterns = agent_patterns or default_patterns
for pattern, agent in patterns.items():
if pattern in basename:
return agent
return 'other'
def detect_signals(data_dir, additional_data_dirs=None, hours=48, agent_patterns=None):
"""Main detection logic.
Args:
data_dir: Primary directory to scan for agent output files
additional_data_dirs: Dict of {"agent_name": "glob_pattern"} for extra data
hours: How far back to look for files
agent_patterns: Dict of {"filename_pattern": "agent_name"} for categorization
"""
recent_files = get_recent_files(data_dir, hours=hours)
if not recent_files:
# Fallback to 7 days
recent_files = get_recent_files(data_dir, hours=168)
# Categorize by agent/source
agent_data = defaultdict(lambda: {
"files": [], "companies": set(), "keywords": set(), "verticals": set(), "text": ""
})
for f in recent_files:
agent = categorize_file(f, agent_patterns)
text = read_file_safe(f)
agent_data[agent]["files"].append(f)
agent_data[agent]["companies"].update(extract_companies(text))
agent_data[agent]["keywords"].update(extract_keywords(text))
agent_data[agent]["verticals"].update(extract_verticals(text))
agent_data[agent]["text"] += text + "\n"
# Scan additional data directories
if additional_data_dirs:
for agent, pattern in additional_data_dirs.items():
files = sorted(glob.glob(pattern))[-1:] # latest only
for f in files:
text = read_file_safe(f)
agent_data[agent]["companies"].update(extract_companies(text))
agent_data[agent]["keywords"].update(extract_keywords(text))
agent_data[agent]["verticals"].update(extract_verticals(text))
# Find overlaps
signals = []
agents_list = list(agent_data.keys())
# 1. Company overlap
for i, a1 in enumerate(agents_list):
for a2 in agents_list[i + 1:]:
common_companies = agent_data[a1]["companies"] & agent_data[a2]["companies"]
if common_companies:
confidence = min(95, 60 + len(common_companies) * 10)
signals.append({
"confidence": confidence,
"type": "company_overlap",
"agents": [a1, a2],
"signal": f"Company overlap: {', '.join(list(common_companies)[:5])} appearing in both {a1} and {a2}",
"recommended_play": f"Cross-reference {a1} and {a2} data for these companies — coordinate outreach/content",
"entities": list(common_companies)[:10],
})
# 2. Vertical overlap
for i, a1 in enumerate(agents_list):
for a2 in agents_list[i + 1:]:
common_verticals = agent_data[a1]["verticals"] & agent_data[a2]["verticals"]
if common_verticals:
confidence = min(90, 50 + len(common_verticals) * 15)
signals.append({
"confidence": confidence,
"type": "vertical_alignment",
"agents": [a1, a2],
"signal": f"Vertical alignment: {', '.join(common_verticals)} trending across {a1} + {a2}",
"recommended_play": f"Coordinated push into {', '.join(common_verticals)}: content + outbound + SEO",
"entities": list(common_verticals),
})
# 3. Keyword cluster overlap
for i, a1 in enumerate(agents_list):
for a2 in agents_list[i + 1:]:
common_kw = agent_data[a1]["keywords"] & agent_data[a2]["keywords"]
if common_kw:
confidence = min(88, 55 + len(common_kw) * 12)
signals.append({
"confidence": confidence,
"type": "keyword_cluster",
"agents": [a1, a2],
"signal": f"Keyword cluster overlap: {', '.join(list(common_kw)[:3])}",
"recommended_play": "Target these keywords in content and outbound simultaneously",
"entities": list(common_kw),
})
# Deduplicate and sort by confidence
signals.sort(key=lambda x: x["confidence"], reverse=True)
output = {
"date": datetime.now().strftime("%Y-%m-%d"),
"generated_at": datetime.now(timezone.utc).isoformat(),
"agents_analyzed": list(agent_data.keys()),
"files_scanned": sum(len(d["files"]) for d in agent_data.values()),
"signals": signals[:20], # top 20
}
return output
def main():
parser = argparse.ArgumentParser(
description='Cross-Signal Detector — find overlapping signals across data sources'
)
default_data_dir = str(SKILL_ROOT / "data" / "output") if SKILL_ROOT else "./data/output"
default_output = str(SKILL_ROOT / "data" / "intel" / "signals" / "latest.json") if SKILL_ROOT else "./data/cross-signals-latest.json"
parser.add_argument('--data-dir', default=os.environ.get('DATA_DIR', default_data_dir),
help='Directory containing module output files')
parser.add_argument('--output', default=os.environ.get('OUTPUT_FILE', default_output),
help='Output file path')
parser.add_argument('--hours', type=int, default=48,
help='How far back to look for files (default: 48)')
args = parser.parse_args()
output = detect_signals(data_dir=args.data_dir, hours=args.hours)
os.makedirs(os.path.dirname(args.output) or '.', exist_ok=True)
with open(args.output, "w") as f:
json.dump(output, f, indent=2)
signals = output.get("signals", [])
print(f"Cross-signal detection complete: {len(signals)} signals found")
print(f"Output: {args.output}")
if signals:
print(f"Top signal (confidence {signals[0]['confidence']}): {signals[0]['signal'][:100]}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
instantly-audit.py — Instantly v2 基础设施审计
[INPUT]: 依赖 config_loader.py 的 load_config/get_api_key/write_output
[OUTPUT]: 对外提供 Instantly 账号/Campaign/Warmup 审计报告
[POS]: scripts/ 的审计脚本,被 SKILL.md Step 3 调用
[PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
Usage:
python3 instantly-audit.py # 从 config.json 读 key
python3 instantly-audit.py --api-key YOUR_KEY # 直接传 key
python3 instantly-audit.py --json # 原始 JSON 输出
Instantly v2 API docs: https://developer.instantly.ai/
"""
import argparse
import json
import os
import sys
import time
from datetime import datetime
try:
import requests
except ImportError:
print("ERROR: 'requests' not installed. Run: pip install requests")
sys.exit(1)
try:
from config_loader import load_config, get_api_key, write_output
except ImportError:
def load_config(): return {}
def get_api_key(c, s): return ""
def write_output(m, d, **kw): pass
BASE_URL = "https://api.instantly.ai/api/v2"
def get_headers(api_key: str) -> dict:
return {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
def paginate(url: str, headers: dict, params: dict = None, limit: int = 100) -> list:
"""Handle Instantly v2 cursor-based pagination."""
results = []
params = params or {}
params["limit"] = limit
starting_after = None
while True:
if starting_after:
params["starting_after"] = starting_after
try:
resp = requests.get(url, headers=headers, params=params, timeout=30)
except requests.exceptions.RequestException as e:
print(f" ⚠️ Request failed: {e}")
break
if resp.status_code == 429:
retry_after = int(resp.headers.get("Retry-After", 5))
print(f" ⏳ Rate limited. Waiting {retry_after}s...")
time.sleep(retry_after)
continue
if resp.status_code == 401:
print(" 🔴 Authentication failed. Check your API key.")
sys.exit(1)
if not resp.ok:
print(f" ⚠️ API error {resp.status_code}: {resp.text[:200]}")
break
data = resp.json()
items = data.get("items", data if isinstance(data, list) else [])
results.extend(items)
next_cursor = data.get("next_starting_after") or data.get("next_cursor")
if not next_cursor or len(items) < limit:
break
starting_after = next_cursor
return results
def fetch_campaigns(headers: dict) -> list:
"""Fetch all campaigns with analytics."""
print("📋 Fetching campaigns...")
campaigns = paginate(f"{BASE_URL}/campaigns", headers)
print(f" Found {len(campaigns)} campaigns")
return campaigns
def fetch_campaign_analytics(headers: dict, campaign_ids: list) -> dict:
"""Fetch analytics summary for campaigns."""
if not campaign_ids:
return {}
print("📊 Fetching campaign analytics...")
analytics = {}
for i in range(0, len(campaign_ids), 10):
batch = campaign_ids[i:i+10]
try:
resp = requests.get(
f"{BASE_URL}/campaigns/analytics/overview",
headers=headers,
params={"campaign_id": batch},
timeout=30,
)
if resp.ok:
data = resp.json()
if isinstance(data, dict):
analytics.update(data)
elif isinstance(data, list):
for item in data:
cid = item.get("campaign_id") or item.get("id")
if cid:
analytics[cid] = item
except requests.exceptions.RequestException as e:
print(f" ⚠️ Analytics fetch failed for batch: {e}")
time.sleep(0.3)
return analytics
def fetch_accounts(headers: dict) -> list:
"""Fetch all sending accounts with warmup status."""
print("📧 Fetching sending accounts...")
accounts = paginate(f"{BASE_URL}/accounts", headers)
print(f" Found {len(accounts)} accounts")
return accounts
def fetch_warmup_scores(headers: dict, account_emails: list) -> dict:
"""Fetch warmup analytics for accounts."""
if not account_emails:
return {}
print("🔥 Fetching warmup scores...")
warmup_data = {}
for email in account_emails:
try:
resp = requests.get(
f"{BASE_URL}/accounts/{email}/warmup/analytics",
headers=headers,
timeout=30,
)
if resp.ok:
warmup_data[email] = resp.json()
elif resp.status_code == 404:
warmup_data[email] = {"score": None, "status": "no_warmup_data"}
time.sleep(0.1)
except requests.exceptions.RequestException:
warmup_data[email] = {"score": None, "status": "fetch_error"}
return warmup_data
def assess_warmup_readiness(account: dict, warmup: dict) -> tuple:
"""Return (ready: bool, issues: list) for an account."""
issues = []
score = (warmup.get("warmup_score") or warmup.get("score")
or account.get("stat_warmup_score") or account.get("warmup_score"))
if score is None:
issues.append("No warmup data available")
elif score < 80:
issues.append(f"Warmup score {score} < 80 (minimum required)")
warmup_start = account.get("warmup_start_date") or account.get("created_at")
if warmup_start:
try:
start_dt = datetime.fromisoformat(warmup_start.replace("Z", "+00:00"))
days_warmed = (datetime.now(start_dt.tzinfo) - start_dt).days
if days_warmed < 14:
issues.append(f"Only {days_warmed} days warmed (need 14+)")
except (ValueError, AttributeError):
pass
status = str(account.get("status", "")).lower()
if status in ("paused", "error", "suspended", "disabled"):
issues.append(f"Account status: {status}")
ready = len(issues) == 0
return ready, issues
def format_pct(value, total, decimals=1) -> str:
if not total:
return "N/A"
return f"{(value / total * 100):.{decimals}f}%"
def generate_report(campaigns: list, analytics: dict, accounts: list, warmup_scores: dict) -> str:
lines = []
now = datetime.now().strftime("%Y-%m-%d %H:%M")
lines.append(f"# Instantly Audit Report")
lines.append(f"Generated: {now}\n")
# ── Account Inventory ──
lines.append("## Sending Account Inventory\n")
ready_accounts = []
not_ready_accounts = []
for acct in accounts:
email = acct.get("email", "unknown")
warmup = warmup_scores.get(email, {})
ready, issues = assess_warmup_readiness(acct, warmup)
score = (warmup.get("warmup_score") or warmup.get("score")
or acct.get("stat_warmup_score") or acct.get("warmup_score") or "N/A")
daily_limit = acct.get("sending_limit") or acct.get("daily_limit", 30)
row = {
"email": email,
"status": acct.get("status", "unknown"),
"warmup_score": score,
"daily_limit": daily_limit,
"ready": ready,
"issues": issues,
}
if ready:
ready_accounts.append(row)
else:
not_ready_accounts.append(row)
total_accounts = len(accounts)
total_ready = len(ready_accounts)
lines.append(f"**Total accounts:** {total_accounts}")
lines.append(f"**Ready to send:** {total_ready} ✅")
lines.append(f"**Not ready:** {len(not_ready_accounts)} ⚠️\n")
if ready_accounts:
conservative_daily = total_ready * 30
aggressive_daily = total_ready * 50
conservative_monthly = conservative_daily * 22
aggressive_monthly = aggressive_daily * 22
lines.append("### Capacity Math (ready accounts only)")
lines.append(f"- Conservative (30/day/account): **{conservative_daily:,}/day → {conservative_monthly:,}/month**")
lines.append(f"- Aggressive (50/day/account): **{aggressive_daily:,}/day → {aggressive_monthly:,}/month**\n")
lines.append("### ✅ Ready Accounts")
if ready_accounts:
lines.append("| Account | Status | Warmup Score | Daily Limit |")
lines.append("|---------|--------|-------------|------------|")
for a in ready_accounts:
lines.append(f"| {a['email']} | {a['status']} | {a['warmup_score']} | {a['daily_limit']} |")
else:
lines.append("_None — no accounts meet warmup requirements_")
lines.append("\n### ⚠️ Not Ready Accounts")
if not_ready_accounts:
lines.append("| Account | Status | Warmup Score | Issues |")
lines.append("|---------|--------|-------------|--------|")
for a in not_ready_accounts:
issues_str = "; ".join(a["issues"]) if a["issues"] else "unknown"
lines.append(f"| {a['email']} | {a['status']} | {a['warmup_score']} | {issues_str} |")
else:
lines.append("_None — all accounts are ready_")
# ── Campaign Performance ──
lines.append("\n---\n## Campaign Performance\n")
lines.append(f"**Total campaigns:** {len(campaigns)}\n")
if not campaigns:
lines.append("_No campaigns found_")
else:
lines.append("| Campaign | Status | Sent | Open Rate | Reply Rate | Positive Reply Rate |")
lines.append("|----------|--------|------|-----------|-----------|-------------------|")
for c in campaigns:
cid = c.get("id", "")
name = c.get("name", "Unnamed")[:50]
status = c.get("status", "unknown")
a = analytics.get(cid, {})
sent = a.get("emails_sent", 0) or c.get("emails_sent", 0)
opened = a.get("emails_opened", 0)
replied = a.get("emails_replied", 0)
positive = a.get("positive_replies", 0)
open_rate = format_pct(opened, sent)
reply_rate = format_pct(replied, sent)
pos_rate = format_pct(positive, sent)
lines.append(f"| {name} | {status} | {sent:,} | {open_rate} | {reply_rate} | {pos_rate} |")
# ── Flags & Recommendations ──
lines.append("\n---\n## Flags & Recommendations\n")
flags = []
if total_ready == 0:
flags.append("🔴 **BLOCKER:** No accounts are ready to send. All fail warmup requirements. Do not launch campaigns.")
elif total_ready < 3:
flags.append(f"⚠️ Only {total_ready} account(s) ready. Low volume capacity. Consider warming more accounts.")
low_open = []
low_reply = []
for c in campaigns:
cid = c.get("id", "")
a = analytics.get(cid, {})
sent = a.get("emails_sent", 0)
if sent < 50:
continue
opened = a.get("emails_opened", 0)
replied = a.get("emails_replied", 0)
open_pct = (opened / sent * 100) if sent else 0
reply_pct = (replied / sent * 100) if sent else 0
if open_pct < 40:
low_open.append(c.get("name", cid))
if reply_pct < 3:
low_reply.append(c.get("name", cid))
if low_open:
flags.append(f"⚠️ Low open rate (<40%) campaigns (subject line issue): {', '.join(low_open[:5])}")
if low_reply:
flags.append(f"⚠️ Low reply rate (<3%) campaigns (copy/offer issue): {', '.join(low_reply[:5])}")
if not flags:
flags.append("✅ No critical flags detected.")
for f in flags:
lines.append(f"- {f}")
lines.append(f"\n---\n_Audit complete. {total_accounts} accounts, {len(campaigns)} campaigns analyzed._")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description="Instantly v2 API Audit Tool")
parser.add_argument("--api-key", help="Instantly API key (or set INSTANTLY_API_KEY env var)")
parser.add_argument("--output", help="Write report to this file (default: print to stdout)")
parser.add_argument("--json", action="store_true", help="Output raw JSON instead of markdown report")
args = parser.parse_args()
config = load_config()
api_key = args.api_key or get_api_key(config, "instantly") or os.environ.get("INSTANTLY_API_KEY")
if not api_key:
api_key = input("Instantly API key: ").strip()
if not api_key:
print("ERROR: API key required. Set in config.json, INSTANTLY_API_KEY env var, or --api-key.")
sys.exit(1)
headers = get_headers(api_key)
print(f"\n🔍 Starting Instantly audit...\n")
campaigns = fetch_campaigns(headers)
campaign_ids = [c.get("id") for c in campaigns if c.get("id")]
analytics = fetch_campaign_analytics(headers, campaign_ids)
accounts = fetch_accounts(headers)
account_emails = [a.get("email") for a in accounts if a.get("email")]
warmup_scores = fetch_warmup_scores(headers, account_emails)
if args.json:
output = json.dumps({
"campaigns": campaigns,
"analytics": analytics,
"accounts": accounts,
"warmup_scores": warmup_scores,
}, indent=2, default=str)
else:
output = generate_report(campaigns, analytics, accounts, warmup_scores)
if args.output:
with open(args.output, "w") as f:
f.write(output)
print(f"\n✅ Report written to: {args.output}")
else:
print("\n" + output)
# ── 统一格式输出 (30x) ──
total_ready = len([a for a in accounts if assess_warmup_readiness(a, warmup_scores.get(a.get("email",""), {}))[0]])
write_output(
module="instantly-audit",
summary=f"{len(accounts)} accounts, {total_ready} ready, {len(campaigns)} campaigns",
data={
"accounts_total": len(accounts),
"accounts_ready": total_ready,
"campaigns_total": len(campaigns),
"capacity_conservative_daily": total_ready * 30,
"capacity_aggressive_daily": total_ready * 50,
},
)
if __name__ == "__main__":
main()