Compare commits

..

47 Commits

Author SHA1 Message Date
coreyhaines31 ea8df8290a chore: sync skills with marketplace.json and README 2026-02-19 20:56:56 +00:00
Corey Haines 4f803988e8 Merge pull request #58 from coreyhaines31/feature/churn-prevention-skill
Add churn-prevention skill
2026-02-19 15:56:46 -05:00
Corey Haines 4fef22901e fix: resolve merge conflicts with main after ai-seo merge
Both ai-seo and churn-prevention updated marketplace.json and
VERSIONS.md. Merged both entries, bumped skill count to 29.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:56:34 -08:00
coreyhaines31 16a2f788d4 chore: sync skills with marketplace.json and README 2026-02-19 20:56:00 +00:00
Corey Haines 8f8ae292ea Merge pull request #59 from coreyhaines31/feature/ai-seo-skill
Add ai-seo skill for AI search optimization
2026-02-19 15:55:50 -05:00
Corey Haines 6914c62688 refactor: rewrite platform-ranking-factors to be original
Rewrote platform-ranking-factors.md from scratch with our own
organizational approach. Replaced table-heavy format with narrative
prose, different section structure, and synthesized analysis.
Updated SKILL.md robots.txt section to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:29:06 -08:00
Corey Haines 5436b34b98 feat: add GEO research data and platform-specific ranking factors
Add Princeton GEO study (KDD 2024) 9-method table with exact visibility
percentages to SKILL.md. Add AI bot robots.txt configuration. Add keyword
stuffing warning (-10% visibility). Add platform-ranking-factors.md reference
with per-platform details: Google AI Overviews (5-stage pipeline), ChatGPT
(content-answer fit 55%, 30-day freshness 3.2x), Perplexity (3-layer RAG,
FAQ Schema priority), Copilot (Bing index + MS ecosystem), Claude (Brave
Search, 38K:1 crawl-to-refer ratio).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 17:14:33 -08:00
Corey Haines ce8dd3b41a feat: add ai-seo skill for AI search optimization (AEO, GEO, LLMO)
Split AEO/GEO content patterns out of seo-audit into a standalone ai-seo
skill. Covers AI visibility auditing, content extractability, authority
signals, third-party presence strategy, schema markup for AI, monitoring
tools (Otterly, Peec AI, ZipTie), and optimization by content type.

Moves aeo-geo-patterns.md from seo-audit/references to ai-seo/references
(renamed to content-patterns.md). Updates seo-audit to cross-reference
the new skill.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 17:06:09 -08:00
Corey Haines 10a708747d feat: add PostHog and ab-test-setup guidance for cancel flow experiments
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 14:04:04 -08:00
Corey Haines abb6e87c04 feat: add churn-prevention skill for cancel flows, save offers, and dunning
Covers voluntary churn (exit surveys, dynamic save offers, pause/downgrade,
cancel flow UI patterns) and involuntary churn (smart retries, dunning emails,
card updaters, grace periods). Includes Churnkey-style offer-to-reason mapping,
health scoring model, proactive retention triggers, and provider-specific
dunning setup for Stripe, Chargebee, Paddle, and Recurly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:56:32 -08:00
Corey Haines ce37279f12 Merge pull request #57 from coreyhaines31/feature/ad-creative-skill
Add ad-creative skill
2026-02-18 16:44:19 -05:00
Corey Haines d4e6ef796c feat: add Voicebox as open-source ElevenLabs alternative
Free, local-first voice synthesis studio (MIT license) powered by Qwen3-TTS.
Voice cloning from short samples, local REST API, no per-character costs.
4-5x faster on Apple Silicon via MLX.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:23:33 -08:00
Corey Haines f2a755f750 fix: correct character counts, platform specs, API model name, pricing
- Fix 3 wrong character counts in ad copy examples (all off by +1)
- Fix TikTok ad text: 80 recommended / 100 max (was 100 recommended)
- Fix LinkedIn description: add 300 max (was missing)
- Fix Gemini API model name: gemini-2.5-flash-image (was gemini-2.0-flash-exp)
- Fix Cartesia pricing: $0.03/min (was $0.0085/sec)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 23:37:22 -08:00
Corey Haines 256eec8709 feat: add voice and audio generation tools to generative reference
Covers ElevenLabs (voice cloning, best quality), OpenAI TTS (cheap at scale),
Cartesia Sonic (40ms latency), PlayHT, Resemble AI, WellSaid Labs, Fish Audio,
and cloud providers. Includes comparison table, decision tree, and voice+video
layering workflow with ffmpeg.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 23:31:50 -08:00
Corey Haines 6744cfcd91 feat: add Seedance 2.0 to generative tools reference
ByteDance's video model with native audio, up to 2K resolution, multimodal
inputs (12 references), and OpenAI-compatible API. Estimated 10-100x cheaper
than Sora 2 per clip, making it ideal for high-volume ad production.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 23:29:22 -08:00
Corey Haines bd00de7288 feat: add generative AI tools reference for ad creative
Covers image generation (Nano Banana Pro, Flux, Ideogram), video generation
(Veo, Kling, Runway, Sora, Higgsfield), and code-based video (Remotion).
Includes decision tree, cost comparison for 100+ variations, platform image
specs, and recommended hybrid workflow for scaled ad production.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 23:21:47 -08:00
Corey Haines 2497b090dc feat: add ad-creative skill for bulk ad creative generation and iteration
Inspired by Anthropic growth marketing team workflows. Covers generating
headlines, descriptions, and primary text at scale across Google Ads, Meta,
LinkedIn, TikTok, and Twitter/X with platform character limits, performance
based iteration loops, and batch generation workflows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 23:17:35 -08:00
Corey Haines a857eb683a Merge pull request #56 from coreyhaines31/development
feat: add 51 zero-dependency CLI tools for marketing platforms
2026-02-18 01:59:26 -05:00
Corey Haines 6aa03c3217 docs: add CLI tools release to VERSIONS.md changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:47:42 -08:00
Corey Haines 8eaff5e29f fix: add input validation and safe JSON parsing across 13 CLIs
Add missing parameter validation to prevent /path/undefined API calls:
- dub: require --url for links create
- google-search-console: require --sitemap-url for sitemaps submit
- kit: validate IDs and emails for subscribers, forms, sequences, tags, broadcasts
- mailchimp: validate IDs for lists get, campaigns get/create/send, members add, reports get
- resend: validate --from/--to/--subject for send, audience/contact IDs for contacts
- rewardful: validate IDs for affiliates get/update, commissions get, links create
- semrush: require --domain/--phrase for all domain and keyword commands
- sendgrid: validate --from/--to/--subject for send, campaign IDs, email for validate

Wrap bare JSON.parse() calls in try/catch for user-provided JSON:
- dub (--links), ga4 (--params), kit (--fields x4), mixpanel (--properties x2),
  onesignal (--filters), paddle (--scheduled-change, --items x2),
  resend (--emails, --variables x2), segment (--properties, --traits, --events),
  sendgrid (--template-data)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:44:06 -08:00
Corey Haines 51bdf2f6b3 fix: correct auth issues in amplitude, livestorm, mixpanel, onesignal
- amplitude: mask api_key in dry-run body output
- livestorm: add missing Bearer prefix to Authorization header
- mixpanel: mask token/$token in dry-run ingestion body output
- onesignal: change auth from 'Key' to 'Basic' per OneSignal REST API docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:40:03 -08:00
Corey Haines c1be574c8b fix: security hardening — move meta-ads to header auth, encode URLs
Critical:
- meta-ads: move access_token from URL query string to Authorization
  header to prevent credential leakage in server logs and referrers

Medium (URL encoding):
- g2: encode state and date filter values
- trustpilot: use URLSearchParams for reviews list params
- typeform: encode response IDs in delete endpoint
- demio: encode event type filter
- lemlist: encode email addresses in URL path segments

Docs:
- Fix 6 missing env vars in CLI README auth table
- Fix .gitignore typo (extra space in .DS_Store pattern)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:39:16 -08:00
Corey Haines 47b4571ca2 Merge remote-tracking branch 'origin/main' into development 2026-02-17 22:26:15 -08:00
Corey Haines 2d0e7bc34c Merge pull request #51 from Jiliac/feature/cold-email
High-quality cold-email skill with benchmarks, frameworks, and follow-up sequences. Complements email-sequence (lifecycle) and the new email outreach CLIs.
2026-02-18 01:25:44 -05:00
Corey Haines a4e7890eb1 Merge pull request #52 from upalkhouski/claude/fix-skill-version-property-0LwK3
Fixes spec compliance — version belongs under metadata, not top-level. Fixes Claude Desktop skill loading.
2026-02-18 01:25:26 -05:00
Corey Haines f39dfd0210 Merge pull request #46 from Ray0907/fix/reference-files-add-toc
Follows Anthropic's best practices — TOC on reference files over 100 lines.
2026-02-18 01:25:12 -05:00
Corey Haines a71f31c51e Merge pull request #53 from willscott-v2/fix/seo-audit-schema-detection-warning
Real bug fix — web_fetch strips JSON-LD schema, affecting audit accuracy.
2026-02-18 01:25:06 -05:00
Corey Haines c4b698f996 chore: harden repo for open source contributors
- Expand .gitignore with .env, .DS_Store, node_modules, editor files,
  and macOS iCloud duplicate patterns
- Add security section to CLI README warning against hardcoded keys
- Update AGENTS.md with CLI tools in repo structure and build commands
- Trash 40 macOS "2.md" duplicate files from working tree

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:12:00 -08:00
Corey Haines 2349865acb fix: correct remaining 5 API issues from codex review
- meta-ads: use empty array for special_ad_categories (not ['NONE'])
- ahrefs: add --target validation to all 5 site-explorer commands
- wistia: read SRT file contents from disk for captions create
- tiktok-ads: use URLSearchParams for cleaner URL construction
- mixpanel: require --from-date/--to-date for retention, add --event filter for export

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 14:38:36 -08:00
Corey Haines ebdf1dd2f1 fix: correct API issues found by second codex review across 14 CLIs
Fixes from thorough codex review (o3 high reasoning, 5 parallel batches):

Validation fixes:
- customer-io: add ID validation for customer/campaign commands, event name check
- dub: fix links get to use /links/info endpoint, add --id validation
- google-search-console: fix countries to use ['country'] only, add --url validation
- mention-me: add --customer-id validation on referral/share/reward commands
- tolt: add --id validation for affiliates get/update

Auth & API fixes:
- apollo: move API key from header to JSON body, fix search endpoint path
- rewardful: change from Bearer to Basic auth
- hotjar: split OAuth URL (unversioned) from resource URL (v2)
- amplitude: wrap retention e param in JSON array
- snov: change list prospects from GET to POST with JSON body
- optimizely: change archive from DELETE to PATCH status=archived
- google-ads: fix budget body field from camelCase to snake_case
- resend: change webhook field from endpoint to url, add validation
- linkedin-ads: add required campaignGroup URN, fix numeric amount types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 14:31:29 -08:00
Corey Haines aad399682c feat: add email outreach CLIs for backlink building
Add 4 new zero-dependency CLI tools for email outreach:
- hunter.js: Email finding/verification via Hunter.io (query param auth)
- snov.js: Email finding + drip campaigns via Snov.io (OAuth2 auth)
- lemlist.js: Cold email campaigns via Lemlist (Basic auth)
- instantly.js: Cold email at scale via Instantly.ai (query param auth)

Includes integration guides and registry/README updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 14:09:50 -08:00
Corey Haines f123804827 fix: correct API issues found by codex review across 10 CLIs
Fixes found by automated codex review of all 47 CLI tools:

- resend: webhook field name endpoint_url -> endpoint
- mailchimp: change from Bearer to Basic auth per API docs
- kit: fail fast when api_secret required but not set
- activecampaign: automation add-contact needs --contact-id not --email
- google-ads: budget updateMask must be snake_case (amount_micros)
- meta-ads: special_ad_categories default to ['NONE'] not empty array
- linkedin-ads: add required X-RestLi-Protocol-Version header
- onesignal: auth prefix should be Key, not Basic
- mixpanel: query dates must be YYYY-MM-DD, not relative strings
- wistia: change from Bearer to Basic auth per API docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:57:42 -08:00
Corey Haines 8ce007c983 fix: correct Adobe Analytics missing header and Clearbit auth method
- Adobe Analytics: add required x-proxy-global-company-id header
  (Adobe Analytics 2.0 API rejects requests without this header)
- Clearbit: change from Bearer to Basic auth (API key as username)
  per Clearbit API documentation

Found by codex review of all 47 CLI tools.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:55:05 -08:00
Corey Haines 2c26f8497b feat: add --dry-run flag to all 47 CLI tools
When --dry-run is passed, each CLI prints the HTTP request it would
make (method, URL, headers, body) without actually calling fetch().
Auth credentials are masked as "***" in the output.

Useful for verifying request shape and API endpoints without needing
real API keys or making actual API calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:47:12 -08:00
Corey Haines 3a85964305 feat: add 23 new CLI tools and integration guides
New tools across 13 categories:
- Email/Newsletter: beehiiv, klaviyo, postmark, brevo, activecampaign
- Data Enrichment: clearbit, apollo
- CRO/Testing: hotjar, optimizely
- Analytics: plausible
- Scheduling: calendly, savvycal
- Forms: typeform
- Messaging: intercom
- Social: buffer
- Video: wistia
- Payments: paddle
- Affiliate: partnerstack
- Reviews: trustpilot, g2
- Push: onesignal
- Webinar: demio, livestorm

Each tool includes a zero-dependency CLI and integration guide.
Registry and CLI README updated with all new entries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:28:41 -08:00
Corey Haines 8dba2d53de feat: add DataForSEO and Keywords Everywhere CLIs and integration guides
Add two new SEO tool integrations with zero-dependency CLI tools and
detailed API documentation for agent use.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:10:47 -08:00
Corey Haines 98bd9ede62 feat: add 22 zero-dependency CLI tools for marketing platforms
Single-file Node.js scripts for every tool in the registry that lacked
a CLI. All follow the same pattern: env var auth, JSON output, consistent
`{tool} <resource> <action>` command structure, zero npm dependencies.

CLIs added: resend, sendgrid, mailchimp, kit, customer-io, ahrefs,
semrush, google-search-console, ga4, mixpanel, amplitude, segment,
adobe-analytics, rewardful, tolt, mention-me, dub, google-ads,
meta-ads, linkedin-ads, tiktok-ads, zapier.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 06:28:46 -08:00
Valentin Manes b260631f47 docs: rewrite cold email skill guide with human-first approach 2026-02-17 07:22:05 +01:00
willscott-v2 170af13f24 seo-audit: Add warning about web_fetch unable to detect JS-rendered schema
web_fetch strips <script> tags during HTML→markdown conversion, which
silently discards JSON-LD schema blocks. Additionally, many CMS plugins
(AIOSEO, Yoast, RankMath) inject schema via client-side JavaScript,
making it invisible to both web_fetch and curl.

This has led to false audit findings in production (reporting 'zero
structured data' on sites with extensive schema implementation).

Adds a prominent warning in the Audit Framework section and a note
in the Tools section recommending browser rendering, Rich Results Test,
or Screaming Frog for accurate schema detection.
2026-02-15 07:12:46 -06:00
Claude 357e663767 feat: add official skills-ref validation script
Added validate-skills-official.sh that uses the official skills-ref
library from https://github.com/agentskills/agentskills for validation.

This replaces the custom bash validation with the canonical implementation
used by the Agent Skills project. All 25 skills pass validation.

Usage: ./validate-skills-official.sh

https://claude.ai/code/session_01DboBqyncsUPg5Z5qpLJx4x
2026-02-15 10:08:14 +00:00
Claude 7aa119cd85 feat: enhance skill validation with comprehensive checks
Enhanced validate-skills.sh to include:
- Better error reporting with per-skill details
- Validation of optional fields (license, metadata)
- Check for version placement (must be under metadata)
- Description quality checks (trigger phrases, related skills)
- Improved frontmatter parsing for quoted/unquoted descriptions
- File structure validation (optional directories)

Currently: 22 passed, 3 warnings (missing related skills references)

https://claude.ai/code/session_01DboBqyncsUPg5Z5qpLJx4x
2026-02-15 10:05:52 +00:00
Claude 3a56b53ecd feat: add skill specification validation script
Created validate-skills.sh to audit all skills against the Agent Skills
specification. Validates name format, frontmatter fields, description length,
and trigger phrases. All 25 skills currently pass validation.

https://claude.ai/code/session_01DboBqyncsUPg5Z5qpLJx4x
2026-02-15 10:01:39 +00:00
Claude 1865cd29be fix: move version from top-level to metadata in all SKILL.md frontmatter
Per the Agent Skills spec, `version` is not a valid top-level frontmatter
field. It belongs under `metadata` as a key-value pair. This fixes all 25
skills to use the correct structure.

https://claude.ai/code/session_01DboBqyncsUPg5Z5qpLJx4x
2026-02-15 09:43:44 +00:00
Valentin Manes d8df68677f fix: codex review 2026-02-14 15:15:42 +01:00
Valentin Manes 90b253d5ca feat: add cold-email skill for B2B outreach and follow-up sequences 2026-02-14 14:58:16 +01:00
Ray Tien aa58db4bf8 fix: add table of contents to all reference files
Per Anthropic's Agent Skills best practices, reference files over
  100 lines should include a TOC so Claude can see full scope when
  previewing with partial reads. Added ## Contents section to all
  32 reference files.
2026-02-08 17:42:34 +08:00
Corey Haines a04cb61a57 Add funding usernames to FUNDING.yml
Updated funding model with GitHub Sponsors and Buy Me a Coffee usernames.
2026-01-28 00:06:49 -08:00
161 changed files with 22016 additions and 59 deletions
+5 -1
View File
@@ -12,12 +12,16 @@
"plugins": [
{
"name": "marketing-skills",
"description": "25 marketing skills for technical marketers and founders: CRO, copywriting, SEO, paid ads, pricing strategy, referral programs, and more",
"description": "29 marketing skills for technical marketers and founders: CRO, copywriting, cold email, SEO, AI SEO, paid ads, ad creative, churn prevention, pricing strategy, referral programs, and more",
"source": "./",
"strict": false,
"skills": [
"./skills/ab-test-setup",
"./skills/ad-creative",
"./skills/ai-seo",
"./skills/analytics-tracking",
"./skills/churn-prevention",
"./skills/cold-email",
"./skills/competitor-alternatives",
"./skills/content-strategy",
"./skills/copy-editing",
+4
View File
@@ -0,0 +1,4 @@
# These are supported funding model platforms
github: coreyhaines31
buy_me_a_coffee: coreyhaines
+23
View File
@@ -1,2 +1,25 @@
# Dependencies
node_modules/
# Environment variables / secrets
.env
.env.*
!.env.example
# macOS
.DS_Store
**/.DS_Store
# macOS / iCloud duplicate files
* 2.*
* 2/
# Remotion video project
video/
# Editor
*.swp
*.swo
*~
.idea/
.vscode/
+12 -3
View File
@@ -20,6 +20,10 @@ marketingskills/
├── skills/ # Agent Skills
│ └── skill-name/
│ └── SKILL.md # Required skill file
├── tools/
│ ├── clis/ # Zero-dependency Node.js CLI tools (51 tools)
│ ├── integrations/ # API integration guides per tool
│ └── REGISTRY.md # Tool index with capabilities
├── CONTRIBUTING.md
├── LICENSE
└── README.md
@@ -27,14 +31,19 @@ marketingskills/
## Build / Lint / Test Commands
**Not applicable** - This is a content-only repository with no executable code.
Verify manually:
**Skills** are content-only (no build step). Verify manually:
- YAML frontmatter is valid
- `name` field matches directory name exactly
- `name` is 1-64 chars, lowercase alphanumeric and hyphens only
- `description` is 1-1024 characters
**CLI tools** (`tools/clis/*.js`) are zero-dependency Node.js scripts (Node 18+). Verify with:
```bash
node --check tools/clis/<name>.js # Syntax check
node tools/clis/<name>.js # Show usage (no args = help)
node tools/clis/<name>.js <cmd> --dry-run # Preview request without sending
```
## Agent Skills Specification
Skills follow the [Agent Skills spec](https://agentskills.io/specification.md).
+10
View File
@@ -18,7 +18,11 @@ Skills are markdown files that give AI agents specialized knowledge and workflow
| Skill | Description |
|-------|-------------|
| [ab-test-setup](skills/ab-test-setup/) | When the user wants to plan, design, or implement an A/B test or experiment. Also use when the user mentions "A/B... |
| [ad-creative](skills/ad-creative/) | When the user wants to generate, iterate, or scale ad creative — headlines, descriptions, primary text, or full ad... |
| [ai-seo](skills/ai-seo/) | When the user wants to optimize content for AI search engines, get cited by LLMs, or appear in AI-generated answers.... |
| [analytics-tracking](skills/analytics-tracking/) | When the user wants to set up, improve, or audit analytics tracking and measurement. Also use when the user mentions... |
| [churn-prevention](skills/churn-prevention/) | When the user wants to reduce churn, build cancellation flows, set up save offers, recover failed payments, or... |
| [cold-email](skills/cold-email/) | Write B2B cold emails and follow-up sequences that get replies. Use when the user wants to write cold outreach emails,... |
| [competitor-alternatives](skills/competitor-alternatives/) | When the user wants to create competitor comparison or alternative pages for SEO and sales enablement. Also use when... |
| [content-strategy](skills/content-strategy/) | When the user wants to plan a content strategy, decide what content to create, or figure out what topics to cover. Also... |
| [copy-editing](skills/copy-editing/) | When the user wants to edit, review, or improve existing marketing copy. Also use when the user mentions 'edit this... |
@@ -154,23 +158,29 @@ You can also invoke skills directly:
### Content & Copy
- `copywriting` - Marketing page copy
- `copy-editing` - Edit and polish existing copy
- `cold-email` - B2B cold outreach emails and sequences
- `email-sequence` - Automated email flows
- `social-content` - Social media content
### SEO & Discovery
- `seo-audit` - Technical and on-page SEO
- `ai-seo` - AI search optimization (AEO, GEO, LLMO)
- `programmatic-seo` - Scaled page generation
- `competitor-alternatives` - Comparison and alternative pages
- `schema-markup` - Structured data
### Paid & Distribution
- `paid-ads` - Google, Meta, LinkedIn ad campaigns
- `ad-creative` - Bulk ad creative generation and iteration
- `social-content` - Social media scheduling and strategy
### Measurement & Testing
- `analytics-tracking` - Event tracking setup
- `ab-test-setup` - Experiment design
### Retention
- `churn-prevention` - Cancel flows, save offers, dunning, payment recovery
### Growth Engineering
- `free-tool-strategy` - Marketing tools and calculators
- `referral-program` - Referral and affiliate programs
+17
View File
@@ -5,7 +5,11 @@ Current versions of all skills. Agents can compare against local versions to che
| Skill | Version | Last Updated |
|-------|---------|--------------|
| ab-test-setup | 1.0.0 | 2026-01-27 |
| ad-creative | 1.0.0 | 2026-02-17 |
| ai-seo | 1.0.0 | 2026-02-18 |
| analytics-tracking | 1.0.0 | 2026-01-27 |
| churn-prevention | 1.0.0 | 2026-02-18 |
| cold-email | 1.0.0 | 2026-02-14 |
| competitor-alternatives | 1.0.0 | 2026-01-27 |
| content-strategy | 1.0.0 | 2026-01-27 |
| copy-editing | 1.0.0 | 2026-01-27 |
@@ -32,6 +36,19 @@ Current versions of all skills. Agents can compare against local versions to che
## Recent Changes
### 2026-02-18
- Added `ai-seo` skill for AI search optimization (AEO, GEO, LLMO, AI Overviews)
- Moved AEO/GEO content patterns from `seo-audit` references to `ai-seo` skill
- Added `churn-prevention` skill for cancel flows, save offers, dunning, and payment recovery
### 2026-02-17
- Added `ad-creative` skill for bulk ad creative generation and performance-based iteration
- Added 51 zero-dependency CLI tools for marketing platforms (`tools/clis/`)
- Added 31 new integration guides (`tools/integrations/`)
- Added 4 email outreach CLIs: hunter, snov, lemlist, instantly
- Security hardening: header auth for meta-ads, URL encoding, input validation
- All CLIs reviewed via independent codex audit (auth, security, error handling, consistency)
### 2026-01-27
- Initial version tracking added
- Added tools registry with 29 integration guides
+2 -1
View File
@@ -1,7 +1,8 @@
---
name: ab-test-setup
version: 1.0.0
description: When the user wants to plan, design, or implement an A/B test or experiment. Also use when the user mentions "A/B test," "split test," "experiment," "test this change," "variant copy," "multivariate test," or "hypothesis." For tracking implementation, see analytics-tracking.
metadata:
version: 1.0.0
---
# A/B Test Setup
@@ -2,6 +2,17 @@
Reference for calculating sample sizes and test duration.
## Contents
- Sample Size Fundamentals (required inputs, what these mean)
- Sample Size Quick Reference Tables
- Duration Calculator (formula, examples, minimum duration rules, maximum duration guidelines)
- Online Calculators
- Adjusting for Multiple Variants
- Common Sample Size Mistakes
- When Sample Size Requirements Are Too High
- Sequential Testing
- Quick Decision Framework
## Sample Size Fundamentals
### Required Inputs
@@ -2,6 +2,15 @@
Templates for planning, documenting, and analyzing experiments.
## Contents
- Test Plan Template
- Results Documentation Template
- Test Repository Entry Template
- Quick Test Brief Template
- Stakeholder Update Template
- Experiment Prioritization Scorecard
- Hypothesis Bank Template
## Test Plan Template
```markdown
+362
View File
@@ -0,0 +1,362 @@
---
name: ad-creative
description: "When the user wants to generate, iterate, or scale ad creative — headlines, descriptions, primary text, or full ad variations — for any paid advertising platform. Also use when the user mentions 'ad copy variations,' 'ad creative,' 'generate headlines,' 'RSA headlines,' 'bulk ad copy,' 'ad iterations,' 'creative testing,' or 'ad performance optimization.' This skill covers generating ad creative at scale, iterating based on performance data, and enforcing platform character limits. For campaign strategy and targeting, see paid-ads. For landing page copy, see copywriting."
metadata:
version: 1.0.0
---
# Ad Creative
You are an expert performance creative strategist. Your goal is to generate high-performing ad creative at scale — headlines, descriptions, and primary text that drive clicks and conversions — and iterate based on real performance data.
## Before Starting
**Check for product marketing context first:**
If `.claude/product-marketing-context.md` exists, read it before asking questions. Use that context and only ask for information not already covered or specific to this task.
Gather this context (ask if not provided):
### 1. Platform & Format
- What platform? (Google Ads, Meta, LinkedIn, TikTok, Twitter/X)
- What ad format? (Search RSAs, display, social feed, stories, video)
- Are there existing ads to iterate on, or starting from scratch?
### 2. Product & Offer
- What are you promoting? (Product, feature, free trial, demo, lead magnet)
- What's the core value proposition?
- What makes this different from competitors?
### 3. Audience & Intent
- Who is the target audience?
- What stage of awareness? (Problem-aware, solution-aware, product-aware)
- What pain points or desires drive them?
### 4. Performance Data (if iterating)
- What creative is currently running?
- Which headlines/descriptions are performing best? (CTR, conversion rate, ROAS)
- Which are underperforming?
- What angles or themes have been tested?
### 5. Constraints
- Brand voice guidelines or words to avoid?
- Compliance requirements? (Industry regulations, platform policies)
- Any mandatory elements? (Brand name, trademark symbols, disclaimers)
---
## How This Skill Works
This skill supports two modes:
### Mode 1: Generate from Scratch
When starting fresh, you generate a full set of ad creative based on product context, audience insights, and platform best practices.
### Mode 2: Iterate from Performance Data
When the user provides performance data (CSV, paste, or API output), you analyze what's working, identify patterns in top performers, and generate new variations that build on winning themes while exploring new angles.
The core loop:
```
Pull performance data → Identify winning patterns → Generate new variations → Validate specs → Deliver
```
---
## Platform Specs
**Always enforce these limits.** Never deliver creative that exceeds platform character limits.
### Google Ads (Responsive Search Ads)
| Element | Limit | Quantity |
|---------|-------|----------|
| Headline | 30 characters | Up to 15 |
| Description | 90 characters | Up to 4 |
| Display URL path | 15 characters each | 2 paths |
**RSA rules:**
- Headlines must make sense independently and in any combination
- Pin headlines to positions only when necessary (reduces optimization)
- Include at least one keyword-focused headline
- Include at least one benefit-focused headline
- Include at least one CTA headline
### Meta Ads (Facebook/Instagram)
| Element | Limit | Notes |
|---------|-------|-------|
| Primary text | 125 chars visible (up to 2,200) | Front-load the hook |
| Headline | 40 characters recommended | Below the image |
| Description | 30 characters recommended | Below headline |
| URL display link | 40 characters | Optional |
### LinkedIn Ads
| Element | Limit | Notes |
|---------|-------|-------|
| Intro text | 150 chars recommended (600 max) | Above the image |
| Headline | 70 chars recommended (200 max) | Below the image |
| Description | 100 chars recommended (300 max) | Appears in some placements |
### TikTok Ads
| Element | Limit | Notes |
|---------|-------|-------|
| Ad text | 80 chars recommended (100 max) | Above the video |
| Display name | 40 characters | Brand name |
### Twitter/X Ads
| Element | Limit | Notes |
|---------|-------|-------|
| Tweet text | 280 characters | The ad copy |
| Headline | 70 characters | Card headline |
| Description | 200 characters | Card description |
For detailed specs and format variations, see [references/platform-specs.md](references/platform-specs.md).
---
## Generating Ad Visuals
For image and video ad creative, use generative AI tools and code-based video rendering. See [references/generative-tools.md](references/generative-tools.md) for the complete guide covering:
- **Image generation** — Nano Banana Pro (Gemini), Flux, Ideogram for static ad images
- **Video generation** — Veo, Kling, Runway, Sora, Seedance, Higgsfield for video ads
- **Voice & audio** — ElevenLabs, OpenAI TTS, Cartesia for voiceovers, cloning, multilingual
- **Code-based video** — Remotion for templated, data-driven video at scale
- **Platform image specs** — Correct dimensions for every ad placement
- **Cost comparison** — Pricing for 100+ ad variations across tools
**Recommended workflow for scaled production:**
1. Generate hero creative with AI tools (exploratory, high-quality)
2. Build Remotion templates based on winning patterns
3. Batch produce variations with Remotion using data feeds
4. Iterate — AI for new angles, Remotion for scale
---
## Generating Ad Copy
### Step 1: Define Your Angles
Before writing individual headlines, establish 3-5 distinct **angles** — different reasons someone would click. Each angle should tap into a different motivation.
**Common angle categories:**
| Category | Example Angle |
|----------|---------------|
| Pain point | "Stop wasting time on X" |
| Outcome | "Achieve Y in Z days" |
| Social proof | "Join 10,000+ teams who..." |
| Curiosity | "The X secret top companies use" |
| Comparison | "Unlike X, we do Y" |
| Urgency | "Limited time: get X free" |
| Identity | "Built for [specific role/type]" |
| Contrarian | "Why [common practice] doesn't work" |
### Step 2: Generate Variations per Angle
For each angle, generate multiple variations. Vary:
- **Word choice** — synonyms, active vs. passive
- **Specificity** — numbers vs. general claims
- **Tone** — direct vs. question vs. command
- **Structure** — short punch vs. full benefit statement
### Step 3: Validate Against Specs
Before delivering, check every piece of creative against the platform's character limits. Flag anything that's over and provide a trimmed alternative.
### Step 4: Organize for Upload
Present creative in a structured format that maps to the ad platform's upload requirements.
---
## Iterating from Performance Data
When the user provides performance data, follow this process:
### Step 1: Analyze Winners
Look at the top-performing creative (by CTR, conversion rate, or ROAS — ask which metric matters most) and identify:
- **Winning themes** — What topics or pain points appear in top performers?
- **Winning structures** — Questions? Statements? Commands? Numbers?
- **Winning word patterns** — Specific words or phrases that recur?
- **Character utilization** — Are top performers shorter or longer?
### Step 2: Analyze Losers
Look at the worst performers and identify:
- **Themes that fall flat** — What angles aren't resonating?
- **Common patterns in low performers** — Too generic? Too long? Wrong tone?
### Step 3: Generate New Variations
Create new creative that:
- **Doubles down** on winning themes with fresh phrasing
- **Extends** winning angles into new variations
- **Tests** 1-2 new angles not yet explored
- **Avoids** patterns found in underperformers
### Step 4: Document the Iteration
Track what was learned and what's being tested:
```
## Iteration Log
- Round: [number]
- Date: [date]
- Top performers: [list with metrics]
- Winning patterns: [summary]
- New variations: [count] headlines, [count] descriptions
- New angles being tested: [list]
- Angles retired: [list]
```
---
## Writing Quality Standards
### Headlines That Click
**Strong headlines:**
- Specific ("Cut reporting time 75%") over vague ("Save time")
- Benefits ("Ship code faster") over features ("CI/CD pipeline")
- Active voice ("Automate your reports") over passive ("Reports are automated")
- Include numbers when possible ("3x faster," "in 5 minutes," "10,000+ teams")
**Avoid:**
- Jargon the audience won't recognize
- Claims without specificity ("Best," "Leading," "Top")
- All caps or excessive punctuation
- Clickbait that the landing page can't deliver on
### Descriptions That Convert
Descriptions should complement headlines, not repeat them. Use descriptions to:
- Add proof points (numbers, testimonials, awards)
- Handle objections ("No credit card required," "Free forever for small teams")
- Reinforce CTAs ("Start your free trial today")
- Add urgency when genuine ("Limited to first 500 signups")
---
## Output Formats
### Standard Output
Organize by angle, with character counts:
```
## Angle: [Pain Point — Manual Reporting]
### Headlines (30 char max)
1. "Stop Building Reports by Hand" (29)
2. "Automate Your Weekly Reports" (28)
3. "Reports Done in 5 Min, Not 5 Hr" (31) <- OVER LIMIT, trimmed below
-> "Reports in 5 Min, Not 5 Hrs" (27)
### Descriptions (90 char max)
1. "Marketing teams save 10+ hours/week with automated reporting. Start free." (73)
2. "Connect your data sources once. Get automated reports forever. No code required." (80)
```
### Bulk CSV Output
When generating at scale (10+ variations), offer CSV format for direct upload:
```csv
headline_1,headline_2,headline_3,description_1,description_2,platform
"Stop Manual Reporting","Automate in 5 Minutes","Join 10K+ Teams","Save 10+ hrs/week on reports. Start free.","Connect data sources once. Reports forever.","google_ads"
```
### Iteration Report
When iterating, include a summary:
```
## Performance Summary
- Analyzed: [X] headlines, [Y] descriptions
- Top performer: "[headline]" — [metric]: [value]
- Worst performer: "[headline]" — [metric]: [value]
- Pattern: [observation]
## New Creative
[organized variations]
## Recommendations
- [What to pause, what to scale, what to test next]
```
---
## Batch Generation Workflow
For large-scale creative production (Anthropic's growth team generates 100+ variations per cycle):
### 1. Break into sub-tasks
- **Headline generation** — Focused on click-through
- **Description generation** — Focused on conversion
- **Primary text generation** — Focused on engagement (Meta/LinkedIn)
### 2. Generate in waves
- Wave 1: Core angles (3-5 angles, 5 variations each)
- Wave 2: Extended variations on top 2 angles
- Wave 3: Wild card angles (contrarian, emotional, specific)
### 3. Quality filter
- Remove anything over character limit
- Remove duplicates or near-duplicates
- Flag anything that might violate platform policies
- Ensure headline/description combinations make sense together
---
## Common Mistakes
- **Writing headlines that only work together** — RSA headlines get combined randomly
- **Ignoring character limits** — Platforms truncate without warning
- **All variations sound the same** — Vary angles, not just word choice
- **No CTA headlines** — Always include action-oriented headlines
- **Generic descriptions** — "Learn more about our solution" wastes the slot
- **Iterating without data** — Gut feelings are less reliable than metrics
- **Testing too many things at once** — Change one variable per test cycle
- **Retiring creative too early** — Allow 1,000+ impressions before judging
---
## Tool Integrations
For pulling performance data and managing campaigns, see the [tools registry](../../tools/REGISTRY.md).
| Platform | Pull Performance Data | Manage Campaigns | Guide |
|----------|:---------------------:|:----------------:|-------|
| **Google Ads** | `google-ads campaigns list`, `google-ads reports get` | `google-ads campaigns create` | [google-ads.md](../../tools/integrations/google-ads.md) |
| **Meta Ads** | `meta-ads insights get` | `meta-ads campaigns list` | [meta-ads.md](../../tools/integrations/meta-ads.md) |
| **LinkedIn Ads** | `linkedin-ads analytics get` | `linkedin-ads campaigns list` | [linkedin-ads.md](../../tools/integrations/linkedin-ads.md) |
| **TikTok Ads** | `tiktok-ads reports get` | `tiktok-ads campaigns list` | [tiktok-ads.md](../../tools/integrations/tiktok-ads.md) |
### Workflow: Pull Data, Analyze, Generate
```bash
# 1. Pull recent ad performance
node tools/clis/google-ads.js reports get --type ad_performance --date-range last_30_days
# 2. Analyze output (identify top/bottom performers)
# 3. Feed winning patterns into this skill
# 4. Generate new variations
# 5. Upload to platform
```
---
## Related Skills
- **paid-ads**: For campaign strategy, targeting, budgets, and optimization
- **copywriting**: For landing page copy (where ad traffic lands)
- **ab-test-setup**: For structuring creative tests with statistical rigor
- **marketing-psychology**: For psychological principles behind high-performing creative
- **copy-editing**: For polishing ad copy before launch
@@ -0,0 +1,637 @@
# Generative AI Tools for Ad Creative
Reference for using AI image generators, video generators, and code-based video tools to produce ad visuals at scale.
---
## When to Use Generative Tools
| Need | Tool Category | Best Fit |
|------|---------------|----------|
| Static ad images (banners, social) | Image generation | Nano Banana Pro, Flux, Ideogram |
| Ad images with text overlays | Image generation (text-capable) | Ideogram, Nano Banana Pro |
| Short video ads (6-30 sec) | Video generation | Veo, Kling, Runway, Sora, Seedance |
| Video ads with voiceover | Video gen + voice | Veo/Sora (native), or Runway + ElevenLabs |
| Voiceover tracks for ads | Voice generation | ElevenLabs, OpenAI TTS, Cartesia |
| Multi-language ad versions | Voice generation | ElevenLabs, PlayHT |
| Brand voice cloning | Voice generation | ElevenLabs, Resemble AI |
| Product mockups and variations | Image generation + references | Flux (multi-image reference) |
| Templated video ads at scale | Code-based video | Remotion |
| Personalized video (name, data) | Code-based video | Remotion |
| Brand-consistent variations | Image gen + style refs | Flux, Ideogram, Nano Banana Pro |
---
## Image Generation
### Nano Banana Pro (Gemini)
Google DeepMind's image generation model, available through the Gemini API.
**Best for:** High-quality ad images, product visuals, text rendering
**API:** Gemini API (Google AI Studio, Vertex AI)
**Pricing:** ~$0.04/image (Gemini 2.5 Flash Image), ~$0.24/4K image (Nano Banana Pro)
**Strengths:**
- Strong text rendering in images (logos, headlines)
- Native image editing (modify existing images with prompts)
- Available through the same Gemini API used for text generation
- Supports both generation and editing in one model
**Ad creative use cases:**
- Generate social media ad images from text descriptions
- Create product mockup variations
- Edit existing ad images (swap backgrounds, change colors)
- Generate images with headline text baked in
**API example:**
```bash
# Using the Gemini API for image generation
curl -X POST "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent" \
-H "Content-Type: application/json" \
-H "x-goog-api-key: $GEMINI_API_KEY" \
-d '{
"contents": [{"parts": [{"text": "Create a clean, modern social media ad image for a project management tool. Show a laptop with a kanban board interface. Bright, professional, 16:9 ratio."}]}],
"generationConfig": {"responseModalities": ["TEXT", "IMAGE"]}
}'
```
**Docs:** [Gemini Image Generation](https://ai.google.dev/gemini-api/docs/image-generation)
---
### Flux (Black Forest Labs)
Open-weight image generation models with API access through Replicate and BFL's native API.
**Best for:** Photorealistic images, brand-consistent variations, multi-reference generation
**API:** Replicate, BFL API, fal.ai
**Pricing:** ~$0.01-0.06/image depending on model and resolution
**Model variants:**
| Model | Speed | Quality | Cost | Best For |
|-------|-------|---------|------|----------|
| Flux 2 Pro | ~6 sec | Highest | $0.015/MP | Final production assets |
| Flux 2 Flex | ~22 sec | High + editing | $0.06/MP | Iterative editing |
| Flux 2 Dev | ~2.5 sec | Good | $0.012/MP | Rapid prototyping |
| Flux 2 Klein | Fastest | Good | Lowest | High-volume batch generation |
**Strengths:**
- Multi-image reference (up to 8 images) for consistent identity across ads
- Product consistency — same product in different contexts
- Style transfer from reference images
- Open-weight Dev model for self-hosting
**Ad creative use cases:**
- Generate 50+ ad variations with consistent product/person identity
- Create product-in-context images (your SaaS on different devices)
- Style-match to existing brand assets using reference images
- Rapid A/B test image variations
**Docs:** [Replicate Flux](https://replicate.com/black-forest-labs/flux-2-pro), [BFL API](https://docs.bfl.ml/)
---
### Ideogram
Specialized in typography and text rendering within images.
**Best for:** Ad banners with text, branded graphics, social ad images with headlines
**API:** Ideogram API, Runware
**Pricing:** ~$0.06/image (API), ~$0.009/image (subscription)
**Strengths:**
- Best-in-class text rendering (~90% accuracy vs ~30% for most tools)
- Style reference system (upload up to 3 reference images)
- 4.3 billion style presets for consistent brand aesthetics
- Strong at logos and branded typography
**Ad creative use cases:**
- Generate ad banners with headline text directly in the image
- Create social media graphics with branded text overlays
- Produce multiple design variations with consistent typography
- Generate promotional materials without needing a designer for each iteration
**Docs:** [Ideogram API](https://developer.ideogram.ai/), [Ideogram](https://ideogram.ai/)
---
### Other Image Tools
| Tool | Best For | API Status | Notes |
|------|----------|------------|-------|
| **DALL-E 3** (OpenAI) | General image generation | Official API | Integrated with ChatGPT, good text rendering |
| **Midjourney** | Artistic, high-aesthetic images | No official public API | Discord-based; unofficial APIs exist but risk bans |
| **Stable Diffusion** | Self-hosted, customizable | Open source | Best for teams with GPU infrastructure |
---
## Video Generation
### Google Veo
Google DeepMind's video generation model, available through the Gemini API and Vertex AI.
**Best for:** High-quality video ads with native audio, vertical video for social
**API:** Gemini API, Vertex AI
**Pricing:** ~$0.15/sec (Veo 3.1 Fast), ~$0.40/sec (Veo 3.1 Standard)
**Capabilities:**
- Up to 60 seconds at 1080p
- Native audio generation (dialogue, sound effects, ambient)
- Vertical 9:16 output for Stories/Reels/Shorts
- Upscale to 4K
- Text-to-video and image-to-video
**Ad creative use cases:**
- Generate short video ads (15-30 sec) from text descriptions
- Create vertical video ads for TikTok, Reels, Shorts
- Produce product demos with voiceover
- Generate multiple video variations from the same prompt with different styles
**Docs:** [Veo on Vertex AI](https://cloud.google.com/vertex-ai/generative-ai/docs/video/overview)
---
### Kling (Kuaishou)
Video generation with simultaneous audio-visual generation and camera controls.
**Best for:** Cinematic video ads, longer-form content, audio-synced video
**API:** Kling API, PiAPI, fal.ai
**Pricing:** ~$0.09/sec (via fal.ai third-party)
**Capabilities:**
- Up to 3 minutes at 1080p/30-48fps
- Simultaneous audio-visual generation (Kling 2.6)
- Text-to-video and image-to-video
- Motion and camera controls
**Ad creative use cases:**
- Longer product explainer videos
- Cinematic brand videos with synchronized audio
- Animate product images into video ads
**Docs:** [Kling AI Developer](https://klingai.com/global/dev/model/video)
---
### Runway
Video generation and editing platform with strong controllability.
**Best for:** Controlled video generation, style-consistent content, editing existing footage
**API:** Runway Developer Portal
**Capabilities:**
- Gen-4: Character/scene consistency across shots
- Motion brush and camera controls
- Image-to-video with reference images
- Video-to-video style transfer
**Ad creative use cases:**
- Generate video ads with consistent characters/products across scenes
- Style-transfer existing footage to match brand aesthetics
- Extend or remix existing video content
**Docs:** [Runway API](https://docs.dev.runwayml.com/)
---
### Sora 2 (OpenAI)
OpenAI's video generation model with synchronized audio.
**Best for:** High-fidelity video with dialogue and sound
**API:** OpenAI API
**Pricing:** Free tier available; Pro from $0.10-0.50/sec depending on resolution
**Capabilities:**
- Up to 60 seconds with synchronized audio
- Dialogue, sound effects, and ambient audio
- sora-2 (fast) and sora-2-pro (quality) variants
- Text-to-video and image-to-video
**Ad creative use cases:**
- Video testimonials and talking-head style ads
- Product demo videos with narration
- Narrative brand videos
**Docs:** [OpenAI Video Generation](https://platform.openai.com/docs/guides/video-generation)
---
### Seedance 2.0 (ByteDance)
ByteDance's video generation model with simultaneous audio-visual generation and multimodal inputs.
**Best for:** Fast, affordable video ads with native audio, multimodal reference inputs
**API:** BytePlus (official), Replicate, WaveSpeedAI, fal.ai (third-party); OpenAI-compatible API format
**Pricing:** ~$0.10-0.80/min depending on resolution (estimated 10-100x cheaper than Sora 2 per clip)
**Capabilities:**
- Up to 20 seconds at up to 2K resolution
- Simultaneous audio-visual generation (Dual-Branch Diffusion Transformer)
- Text-to-video and image-to-video
- Up to 12 reference files for multimodal input
- OpenAI-compatible API structure
**Ad creative use cases:**
- High-volume short video ad production at low cost
- Video ads with synchronized voiceover and sound effects in one pass
- Multi-reference generation (feed product images, brand assets, style references)
- Rapid iteration on video ad concepts
**Docs:** [Seedance](https://seed.bytedance.com/en/seedance2_0)
---
### Higgsfield
Full-stack video creation platform with cinematic camera controls.
**Best for:** Social video ads, cinematic style, mobile-first content
**Platform:** [higgsfield.ai](https://higgsfield.ai/)
**Capabilities:**
- 50+ professional camera movements (zooms, pans, FPV drone shots)
- Image-to-video animation
- Built-in editing, transitions, and keyframing
- All-in-one workflow: image gen, animation, editing
**Ad creative use cases:**
- Social media video ads with cinematic feel
- Animate product images into dynamic video
- Create multiple video variations with different camera styles
- Quick-turn video content for social campaigns
---
### Video Tool Comparison
| Tool | Max Length | Audio | Resolution | API | Best For |
|------|-----------|-------|------------|-----|----------|
| **Veo 3.1** | 60 sec | Native | 1080p/4K | Gemini | Vertical social video |
| **Kling 2.6** | 3 min | Native | 1080p | Third-party | Longer cinematic |
| **Runway Gen-4** | 10 sec | No | 1080p | Official | Controlled, consistent |
| **Sora 2** | 60 sec | Native | 1080p | Official | Dialogue-heavy |
| **Seedance 2.0** | 20 sec | Native | 2K | Official + third-party | Affordable high-volume |
| **Higgsfield** | Varies | Yes | 1080p | Web-based | Social, mobile-first |
---
## Voice & Audio Generation
For layering realistic voiceovers onto video ads, adding narration to product demos, or generating audio for Remotion-rendered videos. These tools turn ad scripts into natural-sounding voice tracks.
### When to Use Voice Tools
Many video generators (Veo, Kling, Sora, Seedance) now include native audio. Use standalone voice tools when you need:
- **Voiceover on silent video** — Runway Gen-4 and Remotion produce silent output
- **Brand voice consistency** — Clone a specific voice for all ads
- **Multi-language versions** — Same ad script in 20+ languages
- **Script iteration** — Re-record voiceover without reshooting video
- **Precise control** — Exact timing, emotion, and pacing
---
### ElevenLabs
The market leader in realistic voice generation and voice cloning.
**Best for:** Most natural-sounding voiceovers, brand voice cloning, multilingual
**API:** REST API with streaming support
**Pricing:** ~$0.12-0.30 per 1,000 characters depending on plan; starts at $5/month
**Capabilities:**
- 29+ languages with natural accent and intonation
- Voice cloning from short audio clips (instant) or longer recordings (professional)
- Emotion and style control
- Streaming for real-time generation
- Voice library with hundreds of pre-built voices
**Ad creative use cases:**
- Generate voiceover tracks for video ads
- Clone your brand spokesperson's voice for all ad variations
- Produce the same ad in 10+ languages from one script
- A/B test different voice styles (authoritative vs. friendly vs. urgent)
**API example:**
```bash
curl -X POST "https://api.elevenlabs.io/v1/text-to-speech/{voice_id}" \
-H "xi-api-key: $ELEVENLABS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "Stop wasting hours on manual reporting. Try DataFlow free for 14 days.",
"model_id": "eleven_multilingual_v2",
"voice_settings": {"stability": 0.5, "similarity_boost": 0.75}
}' --output voiceover.mp3
```
**Docs:** [ElevenLabs API](https://elevenlabs.io/docs/api-reference/text-to-speech)
---
### OpenAI TTS
Simple, affordable text-to-speech built into the OpenAI API.
**Best for:** Quick voiceovers, cost-effective at scale, simple integration
**API:** OpenAI API (same SDK as GPT/DALL-E)
**Pricing:** $15/million chars (standard), $30/million chars (HD); ~$0.015/min with gpt-4o-mini-tts
**Capabilities:**
- 13 built-in voices (no custom cloning)
- Multiple languages
- Real-time streaming
- HD quality option
- Simple API — same SDK you already use for GPT
**Ad creative use cases:**
- Fast, cheap voiceover for draft/test ad versions
- High-volume narration at low cost
- Prototype ad audio before investing in premium voice
**Docs:** [OpenAI TTS](https://platform.openai.com/docs/guides/text-to-speech)
---
### Cartesia Sonic
Ultra-low latency voice generation built for real-time applications.
**Best for:** Real-time voice, lowest latency, emotional expressiveness
**API:** REST + WebSocket streaming
**Pricing:** Starts at $5/month; pay-as-you-go from $0.03/min
**Capabilities:**
- 40ms time-to-first-audio (fastest in class)
- 15+ languages
- Nonverbal expressiveness: laughter, breathing, emotional inflections
- Sonic Turbo for even lower latency
- Streaming API for real-time generation
**Ad creative use cases:**
- Real-time ad preview during creative iteration
- Interactive demo videos with dynamic narration
- Ads requiring natural laughter, sighs, or emotional reactions
**Docs:** [Cartesia Sonic](https://docs.cartesia.ai/build-with-cartesia/tts-models/latest)
---
### Voicebox (Open Source)
Free, local-first voice synthesis studio powered by Qwen3-TTS. The open-source alternative to ElevenLabs.
**Best for:** Free voice cloning, local/private generation, zero-cost batch production
**API:** Local REST API at `http://localhost:8000`
**Pricing:** Free (MIT license). Runs entirely on your machine.
**Stack:** Tauri (Rust) + React + FastAPI (Python)
**Capabilities:**
- Voice cloning from short audio samples via Qwen3-TTS
- Multi-language support (English, Chinese, more planned)
- Multi-track timeline editor for composing conversations
- 4-5x faster inference on Apple Silicon via MLX Metal acceleration
- Local REST API for programmatic generation
- No cloud dependency — all processing on-device
**Ad creative use cases:**
- Free voice cloning for brand spokesperson across all ad variations
- Batch generate voiceovers without per-character costs
- Private/local generation when ad content is sensitive or pre-launch
- Prototype voice variations before committing to a paid service
**API example:**
```bash
curl -X POST http://localhost:8000/generate \
-H "Content-Type: application/json" \
-d '{"text": "Stop wasting hours on manual reporting.", "profile_id": "abc123", "language": "en"}'
```
**Install:** Desktop apps for macOS and Windows at [voicebox.sh](https://voicebox.sh), or build from source:
```bash
git clone https://github.com/jamiepine/voicebox.git
cd voicebox && make setup && make dev
```
**Docs:** [GitHub](https://github.com/jamiepine/voicebox)
---
### Other Voice Tools
| Tool | Best For | Differentiator | API |
|------|----------|---------------|-----|
| **PlayHT** | Large voice library, low latency | 900+ voices, <300ms latency, ultra-realistic | [play.ht](https://play.ht/) |
| **Resemble AI** | Enterprise voice cloning | On-premise deployment, real-time speech-to-speech | [resemble.ai](https://www.resemble.ai/) |
| **WellSaid Labs** | Ethical, commercial-safe voices | Voices from compensated actors, safe for commercial use | [wellsaid.io](https://www.wellsaid.io/) |
| **Fish Audio** | Budget-friendly, emotion control | ~50-70% cheaper than ElevenLabs, emotion tags | [fish.audio](https://fish.audio/) |
| **Murf AI** | Non-technical teams | Browser-based studio, 200+ voices | [murf.ai](https://murf.ai/) |
| **Google Cloud TTS** | Google ecosystem, scale | 220+ voices, 40+ languages, enterprise SLAs | [Google TTS](https://cloud.google.com/text-to-speech) |
| **Amazon Polly** | AWS ecosystem, cost | Neural voices, SSML control, cheap at volume | [Amazon Polly](https://aws.amazon.com/polly/) |
---
### Voice Tool Comparison
| Tool | Quality | Cloning | Languages | Latency | Price/1K chars |
|------|---------|---------|-----------|---------|----------------|
| **ElevenLabs** | Best | Yes (instant + pro) | 29+ | ~200ms | $0.12-0.30 |
| **OpenAI TTS** | Good | No | 13+ | ~300ms | $0.015-0.030 |
| **Cartesia Sonic** | Very good | No | 15+ | ~40ms | ~$0.03/min |
| **PlayHT** | Very good | Yes | 140+ | <300ms | ~$0.10-0.20 |
| **Fish Audio** | Good | Yes | 13+ | ~200ms | ~$0.05-0.10 |
| **WellSaid** | Very good | No (actor voices) | English | ~300ms | Custom pricing |
| **Voicebox** | Good | Yes (local) | 2+ | Local | Free (open source) |
### Choosing a Voice Tool
```
Need voiceover for ads?
├── Need to clone a specific brand voice?
│ ├── Best quality → ElevenLabs
│ ├── Enterprise/on-premise → Resemble AI
│ └── Budget-friendly → Fish Audio, PlayHT
├── Need multilingual (same ad, many languages)?
│ ├── Most languages → PlayHT (140+)
│ └── Best quality → ElevenLabs (29+)
├── Need free / open source / local?
│ └── Voicebox (MIT, runs on your machine)
├── Need cheap, fast, good-enough?
│ └── OpenAI TTS ($0.015/min)
├── Need commercially-safe licensing?
│ └── WellSaid Labs (actor-compensated voices)
└── Need real-time/interactive?
└── Cartesia Sonic (40ms TTFA)
```
### Workflow: Voice + Video
```
1. Write ad script (use ad-creative skill for copy)
2. Generate voiceover with ElevenLabs/OpenAI TTS
3. Generate or render video:
a. Silent video from Runway/Remotion → layer voice track
b. Or use Veo/Sora/Seedance with native audio (skip separate VO)
4. Combine with ffmpeg if layering separately:
ffmpeg -i video.mp4 -i voiceover.mp3 -c:v copy -c:a aac output.mp4
5. Generate variations (different scripts, voices, or languages)
```
---
## Code-Based Video: Remotion
For templated, data-driven video ads at scale, Remotion is the best option. Unlike AI video generators that produce unique video from prompts, Remotion uses React code to render deterministic, brand-perfect video from templates and data.
**Best for:** Templated ad variations, personalized video, brand-consistent production
**Stack:** React + TypeScript
**Pricing:** Free for individuals/small teams; commercial license required for 4+ employees
**Docs:** [remotion.dev](https://www.remotion.dev/)
### Why Remotion for Ads
| AI Video Generators | Remotion |
|---------------------|----------|
| Unique output each time | Deterministic, pixel-perfect |
| Prompt-based, less control | Full code control over every frame |
| Hard to match brand exactly | Exact brand colors, fonts, spacing |
| One-at-a-time generation | Batch render hundreds from data |
| No dynamic data insertion | Personalize with names, prices, stats |
### Ad Creative Use Cases
**1. Dynamic product ads**
Feed a JSON array of products and render a unique video ad for each:
```tsx
// Simplified Remotion component for product ads
export const ProductAd: React.FC<{
productName: string;
price: string;
imageUrl: string;
tagline: string;
}> = ({productName, price, imageUrl, tagline}) => {
return (
<AbsoluteFill style={{backgroundColor: '#fff'}}>
<Img src={imageUrl} style={{width: 400, height: 400}} />
<h1>{productName}</h1>
<p>{tagline}</p>
<div className="price">{price}</div>
<div className="cta">Shop Now</div>
</AbsoluteFill>
);
};
```
**2. A/B test video variations**
Render the same template with different headlines, CTAs, or color schemes:
```tsx
const variations = [
{headline: "Save 50% Today", cta: "Get the Deal", theme: "urgent"},
{headline: "Join 10K+ Teams", cta: "Start Free", theme: "social-proof"},
{headline: "Built for Speed", cta: "Try It Now", theme: "benefit"},
];
// Render all variations programmatically
```
**3. Personalized outreach videos**
Generate videos addressing prospects by name for cold outreach or sales.
**4. Social ad batch production**
Render the same content across different aspect ratios:
- 1:1 for feed
- 9:16 for Stories/Reels
- 16:9 for YouTube
### Remotion Workflow for Ad Creative
```
1. Design template in React (or use AI to generate the component)
2. Define data schema (products, headlines, CTAs, images)
3. Feed data array into template
4. Batch render all variations
5. Upload to ad platform
```
### Getting Started
```bash
# Create a new Remotion project
npx create-video@latest
# Render a single video
npx remotion render src/index.ts MyComposition out/video.mp4
# Batch render from data
npx remotion render src/index.ts MyComposition --props='{"data": [...]}'
```
---
## Choosing the Right Tool
### Decision Tree
```
Need video ads?
├── Templated, data-driven (same structure, different data)
│ └── Use Remotion
├── Unique creative from prompts (exploratory)
│ ├── Need dialogue/voiceover? → Sora 2, Veo 3.1, Kling 2.6, Seedance 2.0
│ ├── Need consistency across scenes? → Runway Gen-4
│ ├── Need vertical social video? → Veo 3.1 (native 9:16)
│ ├── Need high volume at low cost? → Seedance 2.0
│ └── Need cinematic camera work? → Higgsfield, Kling
└── Both → Use AI gen for hero creative, Remotion for variations
Need image ads?
├── Need text/headlines in image? → Ideogram
├── Need product consistency across variations? → Flux (multi-ref)
├── Need quick iterations on existing images? → Nano Banana Pro
├── Need highest visual quality? → Flux Pro, Midjourney
└── Need high volume at low cost? → Flux Klein, Nano Banana
```
### Cost Comparison for 100 Ad Variations
| Approach | Tool | Approximate Cost |
|----------|------|-----------------|
| 100 static images | Nano Banana Pro | ~$4-24 |
| 100 static images | Flux Dev | ~$1-2 |
| 100 static images | Ideogram API | ~$6 |
| 100 × 15-sec videos | Veo 3.1 Fast | ~$225 |
| 100 × 15-sec videos | Remotion (templated) | ~$0 (self-hosted render) |
| 10 hero videos + 90 templated | Veo + Remotion | ~$22 + render time |
### Recommended Workflow for Scaled Ad Production
1. **Generate hero creative** with AI (Nano Banana, Flux, Veo) — high-quality, exploratory
2. **Build templates** in Remotion based on winning creative patterns
3. **Batch produce variations** with Remotion using data (products, headlines, CTAs)
4. **Iterate** — use AI tools for new angles, Remotion for scale
This hybrid approach gives you the creative exploration of AI generators and the consistency and scale of code-based rendering.
---
## Platform-Specific Image Specs
When generating images for ads, request the correct dimensions:
| Platform | Placement | Aspect Ratio | Recommended Size |
|----------|-----------|-------------|-----------------|
| Meta Feed | Single image | 1:1 | 1080x1080 |
| Meta Stories/Reels | Vertical | 9:16 | 1080x1920 |
| Meta Carousel | Square | 1:1 | 1080x1080 |
| Google Display | Landscape | 1.91:1 | 1200x628 |
| Google Display | Square | 1:1 | 1200x1200 |
| LinkedIn Feed | Landscape | 1.91:1 | 1200x627 |
| LinkedIn Feed | Square | 1:1 | 1200x1200 |
| TikTok Feed | Vertical | 9:16 | 1080x1920 |
| Twitter/X Feed | Landscape | 16:9 | 1200x675 |
| Twitter/X Card | Landscape | 1.91:1 | 800x418 |
Include these dimensions in your generation prompts to avoid needing to crop or resize.
@@ -0,0 +1,213 @@
# Platform Specs Reference
Complete character limits, format requirements, and best practices for each ad platform.
---
## Google Ads
### Responsive Search Ads (RSAs)
| Element | Character Limit | Required | Notes |
|---------|----------------|----------|-------|
| Headline | 30 chars | 3 minimum, 15 max | Any 3 may be shown together |
| Description | 90 chars | 2 minimum, 4 max | Any 2 may be shown together |
| Display path 1 | 15 chars | Optional | Appears after domain in URL |
| Display path 2 | 15 chars | Optional | Appears after path 1 |
| Final URL | No limit | Required | Landing page URL |
**Combination rules:**
- Google selects up to 3 headlines and 2 descriptions to show
- Headlines appear separated by " | " or stacked
- Any headline can appear in any position unless pinned
- Pinning reduces Google's ability to optimize — use sparingly
**Pinning strategy:**
- Pin your brand name to position 1 if brand guidelines require it
- Pin your strongest CTA to position 2 or 3
- Leave most headlines unpinned for machine learning
**Headline mix recommendation (15 headlines):**
- 3-4 keyword-focused (match search intent)
- 3-4 benefit-focused (what they get)
- 2-3 social proof (numbers, awards, customers)
- 2-3 CTA-focused (action to take)
- 1-2 differentiators (why you over competitors)
- 1 brand name headline
**Description mix recommendation (4 descriptions):**
- 1 benefit + proof point
- 1 feature + outcome
- 1 social proof + CTA
- 1 urgency/offer + CTA (if applicable)
### Performance Max
| Element | Character Limit | Notes |
|---------|----------------|-------|
| Headline | 30 chars (5 required) | Short headlines for various placements |
| Long headline | 90 chars (5 required) | Used in display, video, discover |
| Description | 90 chars (1 required, 5 max) | Accompany various ad formats |
| Business name | 25 chars | Required |
### Display Ads
| Element | Character Limit |
|---------|----------------|
| Headline | 30 chars |
| Long headline | 90 chars |
| Description | 90 chars |
| Business name | 25 chars |
---
## Meta Ads (Facebook & Instagram)
### Single Image / Video / Carousel
| Element | Recommended | Maximum | Notes |
|---------|-------------|---------|-------|
| Primary text | 125 chars | 2,200 chars | Text above image; truncated after ~125 |
| Headline | 40 chars | 255 chars | Below image; truncated after ~40 |
| Description | 30 chars | 255 chars | Below headline; may not show |
| URL display link | 40 chars | N/A | Optional custom display URL |
**Placement-specific notes:**
- **Feed**: All elements show; primary text most visible
- **Stories/Reels**: Primary text overlaid; keep under 72 chars
- **Right column**: Only headline visible; skip description
- **Audience Network**: Varies by publisher
**Best practices:**
- Front-load the hook in primary text (first 125 chars)
- Use line breaks for readability in longer primary text
- Emojis: test, but don't overuse — 1-2 per ad max
- Questions in primary text increase engagement
- Headline should be a clear CTA or value statement
### Lead Ads (Instant Form)
| Element | Limit |
|---------|-------|
| Greeting headline | 60 chars |
| Greeting description | 360 chars |
| Privacy policy text | 200 chars |
---
## LinkedIn Ads
### Single Image Ad
| Element | Recommended | Maximum | Notes |
|---------|-------------|---------|-------|
| Intro text | 150 chars | 600 chars | Above the image; truncated after ~150 |
| Headline | 70 chars | 200 chars | Below the image |
| Description | 100 chars | 300 chars | Only shows on Audience Network |
### Carousel Ad
| Element | Limit |
|---------|-------|
| Intro text | 255 chars |
| Card headline | 45 chars |
| Card count | 2-10 cards |
### Message Ad (InMail)
| Element | Limit |
|---------|-------|
| Subject line | 60 chars |
| Message body | 1,500 chars |
| CTA button | 20 chars |
### Text Ad
| Element | Limit |
|---------|-------|
| Headline | 25 chars |
| Description | 75 chars |
**LinkedIn-specific guidelines:**
- Professional tone, but not boring
- Use job-specific language the audience recognizes
- Statistics and data points perform well
- Avoid consumer-style hype ("Amazing!" "Incredible!")
- First-person testimonials from peers resonate
---
## TikTok Ads
### In-Feed Ads
| Element | Recommended | Maximum | Notes |
|---------|-------------|---------|-------|
| Ad text | 80 chars | 100 chars | Above the video |
| Display name | N/A | 40 chars | Brand name |
| CTA button | Platform options | Predefined | Select from TikTok's options |
### Spark Ads (Boosted Organic)
| Element | Notes |
|---------|-------|
| Caption | Uses original post caption |
| CTA button | Added by advertiser |
| Display name | Original creator's handle |
**TikTok-specific guidelines:**
- Native content outperforms polished ads
- First 2 seconds determine if they watch
- Use trending sounds and formats
- Text overlay is essential (most watch with sound off)
- Vertical video only (9:16)
---
## Twitter/X Ads
### Promoted Tweets
| Element | Limit | Notes |
|---------|-------|-------|
| Tweet text | 280 chars | Full tweet with image/video |
| Card headline | 70 chars | Website card |
| Card description | 200 chars | Website card |
### Website Cards
| Element | Limit |
|---------|-------|
| Headline | 70 chars |
| Description | 200 chars |
**Twitter/X-specific guidelines:**
- Conversational, casual tone
- Short sentences work best
- One clear message per tweet
- Hashtags: 1-2 max (0 is often better for ads)
- Threads can work for consideration-stage content
---
## Character Counting Tips
- **Spaces count** as characters on all platforms
- **Emojis** count as 1-2 characters depending on platform
- **Special characters** (|, &, etc.) count as 1 character
- **URLs** in body text count against limits
- **Dynamic keyword insertion** (`{KeyWord:default}`) can exceed limits — set safe defaults
- Always verify in the platform's ad preview before launching
---
## Multi-Platform Creative Adaptation
When creating for multiple platforms simultaneously, start with the most restrictive format:
1. **Google Search headlines** (30 chars) — forces the tightest messaging
2. **Expand to Meta headlines** (40 chars) — add a word or two
3. **Expand to LinkedIn intro text** (150 chars) — add context and proof
4. **Expand to Meta primary text** (125+ chars) — full hook and value prop
This cascading approach ensures your core message works everywhere, then gets enriched for platforms that allow more space.
+398
View File
@@ -0,0 +1,398 @@
---
name: ai-seo
description: "When the user wants to optimize content for AI search engines, get cited by LLMs, or appear in AI-generated answers. Also use when the user mentions 'AI SEO,' 'AEO,' 'GEO,' 'LLMO,' 'answer engine optimization,' 'generative engine optimization,' 'LLM optimization,' 'AI Overviews,' 'optimize for ChatGPT,' 'optimize for Perplexity,' 'AI citations,' 'AI visibility,' or 'zero-click search.' This skill covers content optimization for AI answer engines, monitoring AI visibility, and getting cited as a source. For traditional technical and on-page SEO audits, see seo-audit. For structured data implementation, see schema-markup."
metadata:
version: 1.0.0
---
# AI SEO
You are an expert in AI search optimization — the practice of making content discoverable, extractable, and citable by AI systems including Google AI Overviews, ChatGPT, Perplexity, Claude, Gemini, and Copilot. Your goal is to help users get their content cited as a source in AI-generated answers.
## Before Starting
**Check for product marketing context first:**
If `.claude/product-marketing-context.md` exists, read it before asking questions. Use that context and only ask for information not already covered or specific to this task.
Gather this context (ask if not provided):
### 1. Current AI Visibility
- Do you know if your brand appears in AI-generated answers today?
- Have you checked ChatGPT, Perplexity, or Google AI Overviews for your key queries?
- What queries matter most to your business?
### 2. Content & Domain
- What type of content do you produce? (Blog, docs, comparisons, product pages)
- What's your domain authority / traditional SEO strength?
- Do you have existing structured data (schema markup)?
### 3. Goals
- Get cited as a source in AI answers?
- Appear in Google AI Overviews for specific queries?
- Compete with specific brands already getting cited?
- Optimize existing content or create new AI-optimized content?
### 4. Competitive Landscape
- Who are your top competitors in AI search results?
- Are they being cited where you're not?
---
## How AI Search Works
### The AI Search Landscape
| Platform | How It Works | Source Selection |
|----------|-------------|----------------|
| **Google AI Overviews** | Summarizes top-ranking pages | Strong correlation with traditional rankings |
| **ChatGPT (with search)** | Searches web, cites sources | Draws from wider range, not just top-ranked |
| **Perplexity** | Always cites sources with links | Favors authoritative, recent, well-structured content |
| **Gemini** | Google's AI assistant | Pulls from Google index + Knowledge Graph |
| **Copilot** | Bing-powered AI search | Bing index + authoritative sources |
| **Claude** | Brave Search (when enabled) | Training data + Brave search results |
For a deep dive on how each platform selects sources and what to optimize per platform, see [references/platform-ranking-factors.md](references/platform-ranking-factors.md).
### Key Difference from Traditional SEO
Traditional SEO gets you ranked. AI SEO gets you **cited**.
In traditional search, you need to rank on page 1. In AI search, a well-structured page can get cited even if it ranks on page 2 or 3 — AI systems select sources based on content quality, structure, and relevance, not just rank position.
**Critical stats:**
- AI Overviews appear in ~45% of Google searches
- AI Overviews reduce clicks to websites by up to 58%
- Brands are 6.5x more likely to be cited via third-party sources than their own domains
- Optimized content gets cited 3x more often than non-optimized
- Statistics and citations boost visibility by 40%+ across queries
---
## AI Visibility Audit
Before optimizing, assess your current AI search presence.
### Step 1: Check AI Answers for Your Key Queries
Test 10-20 of your most important queries across platforms:
| Query | Google AI Overview | ChatGPT | Perplexity | You Cited? | Competitors Cited? |
|-------|:-----------------:|:-------:|:----------:|:----------:|:-----------------:|
| [query 1] | Yes/No | Yes/No | Yes/No | Yes/No | [who] |
| [query 2] | Yes/No | Yes/No | Yes/No | Yes/No | [who] |
**Query types to test:**
- "What is [your product category]?"
- "Best [product category] for [use case]"
- "[Your brand] vs [competitor]"
- "How to [problem your product solves]"
- "[Your product category] pricing"
### Step 2: Analyze Citation Patterns
When your competitors get cited and you don't, examine:
- **Content structure** — Is their content more extractable?
- **Authority signals** — Do they have more citations, stats, expert quotes?
- **Freshness** — Is their content more recently updated?
- **Schema markup** — Do they have structured data you're missing?
- **Third-party presence** — Are they cited via Wikipedia, Reddit, review sites?
### Step 3: Content Extractability Check
For each priority page, verify:
| Check | Pass/Fail |
|-------|-----------|
| Clear definition in first paragraph? | |
| Self-contained answer blocks (work without surrounding context)? | |
| Statistics with sources cited? | |
| Comparison tables for "[X] vs [Y]" queries? | |
| FAQ section with natural-language questions? | |
| Schema markup (FAQ, HowTo, Article, Product)? | |
| Expert attribution (author name, credentials)? | |
| Recently updated (within 6 months)? | |
| Heading structure matches query patterns? | |
| AI bots allowed in robots.txt? | |
### Step 4: AI Bot Access Check
Verify your robots.txt allows AI crawlers. Each AI platform has its own bot, and blocking it means that platform can't cite you:
- **GPTBot** and **ChatGPT-User** — OpenAI (ChatGPT)
- **PerplexityBot** — Perplexity
- **ClaudeBot** and **anthropic-ai** — Anthropic (Claude)
- **Google-Extended** — Google Gemini and AI Overviews
- **Bingbot** — Microsoft Copilot (via Bing)
Check your robots.txt for `Disallow` rules targeting any of these. If you find them blocked, you have a business decision to make: blocking prevents AI training on your content but also prevents citation. One middle ground is blocking training-only crawlers (like **CCBot** from Common Crawl) while allowing the search bots listed above.
See [references/platform-ranking-factors.md](references/platform-ranking-factors.md) for the full robots.txt configuration.
---
## Optimization Strategy
### The Three Pillars
```
1. Structure (make it extractable)
2. Authority (make it citable)
3. Presence (be where AI looks)
```
### Pillar 1: Structure — Make Content Extractable
AI systems extract passages, not pages. Every key claim should work as a standalone statement.
**Content block patterns:**
- **Definition blocks** for "What is X?" queries
- **Step-by-step blocks** for "How to X" queries
- **Comparison tables** for "X vs Y" queries
- **Pros/cons blocks** for evaluation queries
- **FAQ blocks** for common questions
- **Statistic blocks** with cited sources
For detailed templates for each block type, see [references/content-patterns.md](references/content-patterns.md).
**Structural rules:**
- Lead every section with a direct answer (don't bury it)
- Keep key answer passages to 40-60 words (optimal for snippet extraction)
- Use H2/H3 headings that match how people phrase queries
- Tables beat prose for comparison content
- Numbered lists beat paragraphs for process content
- Each paragraph should convey one clear idea
### Pillar 2: Authority — Make Content Citable
AI systems prefer sources they can trust. Build citation-worthiness.
**The Princeton GEO research** (KDD 2024, studied across Perplexity.ai) ranked 9 optimization methods:
| Method | Visibility Boost | How to Apply |
|--------|:---------------:|--------------|
| **Cite sources** | +40% | Add authoritative references with links |
| **Add statistics** | +37% | Include specific numbers with sources |
| **Add quotations** | +30% | Expert quotes with name and title |
| **Authoritative tone** | +25% | Write with demonstrated expertise |
| **Improve clarity** | +20% | Simplify complex concepts |
| **Technical terms** | +18% | Use domain-specific terminology |
| **Unique vocabulary** | +15% | Increase word diversity |
| **Fluency optimization** | +15-30% | Improve readability and flow |
| ~~Keyword stuffing~~ | **-10%** | **Actively hurts AI visibility** |
**Best combination:** Fluency + Statistics = maximum boost. Low-ranking sites benefit even more — up to 115% visibility increase with citations.
**Statistics and data** (+37-40% citation boost)
- Include specific numbers with sources
- Cite original research, not summaries of research
- Add dates to all statistics
- Original data beats aggregated data
**Expert attribution** (+25-30% citation boost)
- Named authors with credentials
- Expert quotes with titles and organizations
- "According to [Source]" framing for claims
- Author bios with relevant expertise
**Freshness signals**
- "Last updated: [date]" prominently displayed
- Regular content refreshes (quarterly minimum for competitive topics)
- Current year references and recent statistics
- Remove or update outdated information
**E-E-A-T alignment**
- First-hand experience demonstrated
- Specific, detailed information (not generic)
- Transparent sourcing and methodology
- Clear author expertise for the topic
### Pillar 3: Presence — Be Where AI Looks
AI systems don't just cite your website — they cite where you appear.
**Third-party sources matter more than your own site:**
- Wikipedia mentions (7.8% of all ChatGPT citations)
- Reddit discussions (1.8% of ChatGPT citations)
- Industry publications and guest posts
- Review sites (G2, Capterra, TrustRadius for B2B SaaS)
- YouTube (frequently cited by Google AI Overviews)
- Quora answers
**Actions:**
- Ensure your Wikipedia page is accurate and current
- Participate authentically in Reddit communities
- Get featured in industry roundups and comparison articles
- Maintain updated profiles on relevant review platforms
- Create YouTube content for key how-to queries
- Answer relevant Quora questions with depth
### Schema Markup for AI
Structured data helps AI systems understand your content. Key schemas:
| Content Type | Schema | Why It Helps |
|-------------|--------|-------------|
| Articles/Blog posts | `Article`, `BlogPosting` | Author, date, topic identification |
| How-to content | `HowTo` | Step extraction for process queries |
| FAQs | `FAQPage` | Direct Q&A extraction |
| Products | `Product` | Pricing, features, reviews |
| Comparisons | `ItemList` | Structured comparison data |
| Reviews | `Review`, `AggregateRating` | Trust signals |
| Organization | `Organization` | Entity recognition |
Content with proper schema shows 30-40% higher AI visibility. For implementation, use the **schema-markup** skill.
---
## Content Types That Get Cited Most
Not all content is equally citable. Prioritize these formats:
| Content Type | Citation Share | Why AI Cites It |
|-------------|:------------:|----------------|
| **Comparison articles** | ~33% | Structured, balanced, high-intent |
| **Definitive guides** | ~15% | Comprehensive, authoritative |
| **Original research/data** | ~12% | Unique, citable statistics |
| **Best-of/listicles** | ~10% | Clear structure, entity-rich |
| **Product pages** | ~10% | Specific details AI can extract |
| **How-to guides** | ~8% | Step-by-step structure |
| **Opinion/analysis** | ~10% | Expert perspective, quotable |
**Underperformers for AI citation:**
- Generic blog posts without structure
- Thin product pages with marketing fluff
- Gated content (AI can't access it)
- Content without dates or author attribution
- PDF-only content (harder for AI to parse)
---
## Monitoring AI Visibility
### What to Track
| Metric | What It Measures | How to Check |
|--------|-----------------|-------------|
| AI Overview presence | Do AI Overviews appear for your queries? | Manual check or Semrush/Ahrefs |
| Brand citation rate | How often you're cited in AI answers | AI visibility tools (see below) |
| Share of AI voice | Your citations vs. competitors | Peec AI, Otterly, ZipTie |
| Citation sentiment | How AI describes your brand | Manual review + monitoring tools |
| Source attribution | Which of your pages get cited | Track referral traffic from AI sources |
### AI Visibility Monitoring Tools
| Tool | Coverage | Best For |
|------|----------|----------|
| **Otterly AI** | ChatGPT, Perplexity, Google AI Overviews | Share of AI voice tracking |
| **Peec AI** | ChatGPT, Gemini, Perplexity, Claude, Copilot+ | Multi-platform monitoring at scale |
| **ZipTie** | Google AI Overviews, ChatGPT, Perplexity | Brand mention + sentiment tracking |
| **LLMrefs** | ChatGPT, Perplexity, AI Overviews, Gemini | SEO keyword → AI visibility mapping |
### DIY Monitoring (No Tools)
Monthly manual check:
1. Pick your top 20 queries
2. Run each through ChatGPT, Perplexity, and Google
3. Record: Are you cited? Who is? What page?
4. Log in a spreadsheet, track month-over-month
---
## AI SEO for Different Content Types
### SaaS Product Pages
**Goal:** Get cited in "What is [category]?" and "Best [category]" queries.
**Optimize:**
- Clear product description in first paragraph (what it does, who it's for)
- Feature comparison tables (you vs. category, not just competitors)
- Specific metrics ("processes 10,000 transactions/sec" not "blazing fast")
- Customer count or social proof with numbers
- Pricing transparency (AI cites pages with visible pricing)
- FAQ section addressing common buyer questions
### Blog Content
**Goal:** Get cited as an authoritative source on topics in your space.
**Optimize:**
- One clear target query per post (match heading to query)
- Definition in first paragraph for "What is" queries
- Original data, research, or expert quotes
- "Last updated" date visible
- Author bio with relevant credentials
- Internal links to related product/feature pages
### Comparison/Alternative Pages
**Goal:** Get cited in "[X] vs [Y]" and "Best [X] alternatives" queries.
**Optimize:**
- Structured comparison tables (not just prose)
- Fair and balanced (AI penalizes obviously biased comparisons)
- Specific criteria with ratings or scores
- Updated pricing and feature data
- Cite the competitor-alternatives skill for building these pages
### Documentation / Help Content
**Goal:** Get cited in "How to [X] with [your product]" queries.
**Optimize:**
- Step-by-step format with numbered lists
- Code examples where relevant
- HowTo schema markup
- Screenshots with descriptive alt text
- Clear prerequisites and expected outcomes
---
## Common Mistakes
- **Ignoring AI search entirely** — ~45% of Google searches now show AI Overviews, and ChatGPT/Perplexity are growing fast
- **Treating AI SEO as separate from SEO** — Good traditional SEO is the foundation; AI SEO adds structure and authority on top
- **Writing for AI, not humans** — If content reads like it was written to game an algorithm, it won't get cited or convert
- **No freshness signals** — Undated content loses to dated content. Always show when content was last updated
- **Gating all content** — AI can't access gated content. Keep your most authoritative content open
- **Ignoring third-party presence** — You may get more AI citations from a Wikipedia mention than from your own blog
- **No structured data** — Schema markup gives AI systems structured context about your content
- **Keyword stuffing** — Unlike traditional SEO where it's just ineffective, keyword stuffing actively reduces AI visibility by 10% (Princeton GEO study)
- **Blocking AI bots** — If GPTBot, PerplexityBot, or ClaudeBot are blocked in robots.txt, those platforms can't cite you
- **Generic content without data** — "We're the best" won't get cited. "Our customers see 3x improvement in [metric]" will
- **Forgetting to monitor** — You can't improve what you don't measure. Check AI visibility monthly at minimum
---
## Tool Integrations
For implementation, see the [tools registry](../../tools/REGISTRY.md).
| Tool | Use For |
|------|---------|
| `semrush` | AI Overview tracking, keyword research, content gap analysis |
| `ahrefs` | Backlink analysis, content explorer, AI Overview data |
| `gsc` | Search Console performance data, query tracking |
| `ga4` | Referral traffic from AI sources |
---
## Task-Specific Questions
1. What are your top 10-20 most important queries?
2. Have you checked if AI answers exist for those queries today?
3. Do you have structured data (schema markup) on your site?
4. What content types do you publish? (Blog, docs, comparisons, etc.)
5. Are competitors being cited by AI where you're not?
6. Do you have a Wikipedia page or presence on review sites?
---
## Related Skills
- **seo-audit**: For traditional technical and on-page SEO audits
- **schema-markup**: For implementing structured data that helps AI understand your content
- **content-strategy**: For planning what content to create
- **competitor-alternatives**: For building comparison pages that get cited
- **programmatic-seo**: For building SEO pages at scale
- **copywriting**: For writing content that's both human-readable and AI-extractable
@@ -4,6 +4,12 @@ Reusable content block patterns optimized for answer engines and AI citation.
---
## Contents
- Answer Engine Optimization (AEO) Patterns (Definition Block, Step-by-Step Block, Comparison Table Block, Pros and Cons Block, FAQ Block, Listicle Block)
- Generative Engine Optimization (GEO) Patterns (Statistic Citation Block, Expert Quote Block, Authoritative Claim Block, Self-Contained Answer Block, Evidence Sandwich Block)
- Domain-Specific GEO Tactics (Technology Content, Health/Medical Content, Financial Content, Legal Content, Business/Marketing Content)
- Voice Search Optimization (Question Formats for Voice, Voice-Optimized Answer Structure)
## Answer Engine Optimization (AEO) Patterns
These patterns help content appear in featured snippets, AI Overviews, voice search results, and answer boxes.
@@ -0,0 +1,152 @@
# How Each AI Platform Picks Sources
Each AI search platform has its own search index, ranking logic, and content preferences. This guide covers what matters for getting cited on each one.
Sources cited throughout: Princeton GEO study (KDD 2024), SE Ranking domain authority study, ZipTie content-answer fit analysis.
---
## The Fundamentals
Every AI platform shares three baseline requirements:
1. **Your content must be in their index** — Each platform uses a different search backend (Google, Bing, Brave, or their own). If you're not indexed, you can't be cited.
2. **Your content must be crawlable** — AI bots need access via robots.txt. Block the bot, lose the citation.
3. **Your content must be extractable** — AI systems pull passages, not pages. Clear structure and self-contained paragraphs win.
Beyond these basics, each platform weights different signals. Here's what matters and where.
---
## Google AI Overviews
Google AI Overviews pull from Google's own index and lean heavily on E-E-A-T signals (Experience, Expertise, Authoritativeness, Trustworthiness). They appear in roughly 45% of Google searches.
**What makes Google AI Overviews different:** They already have your traditional SEO signals — backlinks, page authority, topical relevance. The additional AI layer adds a preference for content with cited sources and structured data. Research shows that including authoritative citations in your content correlates with a 132% visibility boost, and writing with an authoritative (not salesy) tone adds another 89%.
**Importantly, AI Overviews don't just recycle the traditional Top 10.** Only about 15% of AI Overview sources overlap with conventional organic results. Pages that wouldn't crack page 1 in traditional search can still get cited if they have strong structured data and clear, extractable answers.
**What to focus on:**
- Schema markup is the single biggest lever — Article, FAQPage, HowTo, and Product schemas give AI Overviews structured context to work with (30-40% visibility boost)
- Build topical authority through content clusters with strong internal linking
- Include named, sourced citations in your content (not just claims)
- Author bios with real credentials matter — E-E-A-T is weighted heavily
- Get into Google's Knowledge Graph where possible (an accurate Wikipedia entry helps)
- Target "how to" and "what is" query patterns — these trigger AI Overviews most often
---
## ChatGPT
ChatGPT's web search draws from a Bing-based index. It combines this with its training knowledge to generate answers, then cites the web sources it relied on.
**What makes ChatGPT different:** Domain authority matters more here than on other AI platforms. An SE Ranking analysis of 129,000 domains found that authority and credibility signals account for roughly 40% of what determines citation, with content quality at about 35% and platform trust at 25%. Sites with very high referring domain counts (350K+) average 8.4 citations per response, while sites with slightly lower trust scores (91-96 vs 97-100) drop from 8.4 to 6 citations.
**Freshness is a major differentiator.** Content updated within the last 30 days gets cited about 3.2x more often than older content. ChatGPT clearly favors recent information.
**The most important signal is content-answer fit** — a ZipTie analysis of 400,000 pages found that how well your content's style and structure matches ChatGPT's own response format accounts for about 55% of citation likelihood. This is far more important than domain authority (12%) or on-page structure (14%) alone. Write the way ChatGPT would answer the question, and you're more likely to be the source it cites.
**Where ChatGPT looks beyond your site:** Wikipedia accounts for 7.8% of all ChatGPT citations, Reddit for 1.8%, and Forbes for 1.1%. Brand official sites are cited frequently but third-party mentions carry significant weight.
**What to focus on:**
- Invest in backlinks and domain authority — it's the strongest baseline signal
- Update competitive content at least monthly
- Structure your content the way ChatGPT structures its answers (conversational, direct, well-organized)
- Include verifiable statistics with named sources
- Clean heading hierarchy (H1 > H2 > H3) with descriptive headings
---
## Perplexity
Perplexity always cites its sources with clickable links, making it the most transparent AI search platform. It combines its own index with Google's and runs results through multiple reranking passes — initial relevance retrieval, then traditional ranking factor scoring, then ML-based quality evaluation that can discard entire result sets if they don't meet quality thresholds.
**What makes Perplexity different:** It's the most "research-oriented" AI search engine, and its citation behavior reflects that. Perplexity maintains curated lists of authoritative domains (Amazon, GitHub, major academic sites) that get inherent ranking boosts. It uses a time-decay algorithm that evaluates new content quickly, giving fresh publishers a real shot at citation.
**Perplexity has unique content preferences:**
- **FAQ Schema (JSON-LD)** — Pages with FAQ structured data get cited noticeably more often
- **PDF documents** — Publicly accessible PDFs (whitepapers, research reports) are prioritized. If you have authoritative PDF content gated behind a form, consider making a version public.
- **Publishing velocity** — How frequently you publish matters more than keyword targeting
- **Self-contained paragraphs** — Perplexity prefers atomic, semantically complete paragraphs it can extract cleanly
**What to focus on:**
- Allow PerplexityBot in robots.txt
- Implement FAQPage schema on any page with Q&A content
- Host PDF resources publicly (whitepapers, guides, reports)
- Add Article schema with publication and modification timestamps
- Write in clear, self-contained paragraphs that work as standalone answers
- Build deep topical authority in your specific niche
---
## Microsoft Copilot
Copilot is embedded across Microsoft's ecosystem — Edge, Windows, Microsoft 365, and Bing Search. It relies entirely on Bing's index, so if Bing hasn't indexed your content, Copilot can't cite it.
**What makes Copilot different:** The Microsoft ecosystem connection creates unique optimization opportunities. Mentions and content on LinkedIn and GitHub provide ranking boosts that other platforms don't offer. Copilot also puts more weight on page speed — sub-2-second load times are a clear threshold.
**What to focus on:**
- Submit your site to Bing Webmaster Tools (many sites only submit to Google Search Console)
- Use IndexNow protocol for faster indexing of new and updated content
- Optimize page speed to under 2 seconds
- Write clear entity definitions — when your content defines a term or concept, make the definition explicit and extractable
- Build presence on LinkedIn (publish articles, maintain company page) and GitHub if relevant
- Ensure Bingbot has full crawl access
---
## Claude
Claude uses Brave Search as its search backend when web search is enabled — not Google, not Bing. This is a completely different index, which means your Brave Search visibility directly determines whether Claude can find and cite you.
**What makes Claude different:** Claude is extremely selective about what it cites. While it processes enormous amounts of content, its citation rate is very low — it's looking for the most factually accurate, well-sourced content on a given topic. Data-rich content with specific numbers and clear attribution performs significantly better than general-purpose content.
**What to focus on:**
- Verify your content appears in Brave Search results (search for your brand and key terms at search.brave.com)
- Allow ClaudeBot and anthropic-ai user agents in robots.txt
- Maximize factual density — specific numbers, named sources, dated statistics
- Use clear, extractable structure with descriptive headings
- Cite authoritative sources within your content
- Aim to be the most factually accurate source on your topic — Claude rewards precision
---
## Allowing AI Bots in robots.txt
If your robots.txt blocks an AI bot, that platform can't cite your content. Here are the user agents to allow:
```
User-agent: GPTBot # OpenAI — powers ChatGPT search
User-agent: ChatGPT-User # ChatGPT browsing mode
User-agent: PerplexityBot # Perplexity AI search
User-agent: ClaudeBot # Anthropic Claude
User-agent: anthropic-ai # Anthropic Claude (alternate)
User-agent: Google-Extended # Google Gemini and AI Overviews
User-agent: Bingbot # Microsoft Copilot (via Bing)
Allow: /
```
**Training vs. search:** Some AI bots are used for both model training and search citation. If you want to be cited but don't want your content used for training, your options are limited — GPTBot handles both for OpenAI. However, you can safely block **CCBot** (Common Crawl) without affecting any AI search citations, since it's only used for training dataset collection.
---
## Where to Start
If you're optimizing for AI search for the first time, focus your effort where your audience actually is:
**Start with Google AI Overviews** — They reach the most users (45%+ of Google searches) and you likely already have Google SEO foundations in place. Add schema markup, include cited sources in your content, and strengthen E-E-A-T signals.
**Then address ChatGPT** — It's the most-used standalone AI search tool for tech and business audiences. Focus on freshness (update content monthly), domain authority, and matching your content structure to how ChatGPT formats its responses.
**Then expand to Perplexity** — Especially valuable if your audience includes researchers, early adopters, or tech professionals. Add FAQ schema, publish PDF resources, and write in clear, self-contained paragraphs.
**Copilot and Claude are lower priority** unless your audience skews enterprise/Microsoft (Copilot) or developer/analyst (Claude). But the fundamentals — structured content, cited sources, schema markup — help across all platforms.
**Actions that help everywhere:**
1. Allow all AI bots in robots.txt
2. Implement schema markup (FAQPage, Article, Organization at minimum)
3. Include statistics with named sources in your content
4. Update content regularly — monthly for competitive topics
5. Use clear heading structure (H1 > H2 > H3)
6. Keep page load time under 2 seconds
7. Add author bios with credentials
+2 -1
View File
@@ -1,7 +1,8 @@
---
name: analytics-tracking
version: 1.0.0
description: When the user wants to set up, improve, or audit analytics tracking and measurement. Also use when the user mentions "set up tracking," "GA4," "Google Analytics," "conversion tracking," "event tracking," "UTM parameters," "tag manager," "GTM," "analytics implementation," or "tracking plan." For A/B test measurement, see ab-test-setup.
metadata:
version: 1.0.0
---
# Analytics Tracking
@@ -2,6 +2,15 @@
Comprehensive list of events to track by business type and context.
## Contents
- Marketing Site Events (navigation & engagement, CTA & form interactions, conversion events)
- Product/App Events (onboarding, core usage, errors & support)
- Monetization Events (pricing & checkout, subscription management)
- E-commerce Events (browsing, cart, checkout, post-purchase)
- B2B / SaaS Specific Events (team & collaboration, integration events, account events)
- Event Properties (Parameters)
- Funnel Event Sequences
## Marketing Site Events
### Navigation & Engagement
@@ -2,6 +2,16 @@
Detailed implementation guide for Google Analytics 4.
## Contents
- Configuration (data streams, enhanced measurement events, recommended events)
- Custom Events (gtag.js implementation, Google Tag Manager)
- Conversions Setup (creating conversions, conversion values)
- Custom Dimensions and Metrics (when to use, setup steps, examples)
- Audiences (creating audiences, audience examples)
- Debugging (DebugView, real-time reports, common issues)
- Data Quality (filters, cross-domain tracking, session settings)
- Integration with Google Ads (linking, audience export)
## Configuration
### Data Streams
@@ -2,6 +2,16 @@
Detailed guide for implementing tracking via Google Tag Manager.
## Contents
- Container Structure (tags, triggers, variables)
- Naming Conventions
- Data Layer Patterns
- Common Tag Configurations (GA4 configuration tag, GA4 event tag, Facebook pixel)
- Preview and Debug
- Workspaces and Versioning
- Consent Management
- Advanced Patterns (tag sequencing, exception handling, custom JavaScript variables)
## Container Structure
### Tags
+424
View File
@@ -0,0 +1,424 @@
---
name: churn-prevention
description: "When the user wants to reduce churn, build cancellation flows, set up save offers, recover failed payments, or implement retention strategies. Also use when the user mentions 'churn,' 'cancel flow,' 'offboarding,' 'save offer,' 'dunning,' 'failed payment recovery,' 'win-back,' 'retention,' 'exit survey,' 'pause subscription,' or 'involuntary churn.' This skill covers voluntary churn (cancel flows, save offers, exit surveys) and involuntary churn (dunning, payment recovery). For post-cancel win-back email sequences, see email-sequence. For in-app upgrade paywalls, see paywall-upgrade-cro."
metadata:
version: 1.0.0
---
# Churn Prevention
You are an expert in SaaS retention and churn prevention. Your goal is to help reduce both voluntary churn (customers choosing to cancel) and involuntary churn (failed payments) through well-designed cancel flows, dynamic save offers, proactive retention, and dunning strategies.
## Before Starting
**Check for product marketing context first:**
If `.claude/product-marketing-context.md` exists, read it before asking questions. Use that context and only ask for information not already covered or specific to this task.
Gather this context (ask if not provided):
### 1. Current Churn Situation
- What's your monthly churn rate? (Voluntary vs. involuntary if known)
- How many active subscribers?
- What's the average MRR per customer?
- Do you have a cancel flow today, or does cancel happen instantly?
### 2. Billing & Platform
- What billing provider? (Stripe, Chargebee, Paddle, Recurly, Braintree)
- Monthly, annual, or both billing intervals?
- Do you support plan pausing or downgrades?
- Any existing retention tooling? (Churnkey, ProsperStack, Raaft)
### 3. Product & Usage Data
- Do you track feature usage per user?
- Can you identify engagement drop-offs?
- Do you have cancellation reason data from past churns?
- What's your activation metric? (What do retained users do that churned users don't?)
### 4. Constraints
- B2B or B2C? (Affects flow design)
- Self-serve cancellation required? (Some regulations mandate easy cancel)
- Brand tone for offboarding? (Empathetic, direct, playful)
---
## How This Skill Works
Churn has two types requiring different strategies:
| Type | Cause | Solution |
|------|-------|----------|
| **Voluntary** | Customer chooses to cancel | Cancel flows, save offers, exit surveys |
| **Involuntary** | Payment fails | Dunning emails, smart retries, card updaters |
Voluntary churn is typically 50-70% of total churn. Involuntary churn is 30-50% but is often easier to fix.
This skill supports three modes:
1. **Build a cancel flow** — Design from scratch with survey, save offers, and confirmation
2. **Optimize an existing flow** — Analyze cancel data and improve save rates
3. **Set up dunning** — Failed payment recovery with retries and email sequences
---
## Cancel Flow Design
### The Cancel Flow Structure
Every cancel flow follows this sequence:
```
Trigger → Survey → Dynamic Offer → Confirmation → Post-Cancel
```
**Step 1: Trigger**
Customer clicks "Cancel subscription" in account settings.
**Step 2: Exit Survey**
Ask why they're cancelling. This determines which save offer to show.
**Step 3: Dynamic Save Offer**
Present a targeted offer based on their reason (discount, pause, downgrade, etc.)
**Step 4: Confirmation**
If they still want to cancel, confirm clearly with end-of-billing-period messaging.
**Step 5: Post-Cancel**
Set expectations, offer easy reactivation path, trigger win-back sequence.
### Exit Survey Design
The exit survey is the foundation. Good reason categories:
| Reason | What It Tells You |
|--------|-------------------|
| Too expensive | Price sensitivity, may respond to discount or downgrade |
| Not using it enough | Low engagement, may respond to pause or onboarding help |
| Missing a feature | Product gap, show roadmap or workaround |
| Switching to competitor | Competitive pressure, understand what they offer |
| Technical issues / bugs | Product quality, escalate to support |
| Temporary / seasonal need | Usage pattern, offer pause |
| Business closed / changed | Unavoidable, learn and let go gracefully |
| Other | Catch-all, include free text field |
**Survey best practices:**
- 1 question, single-select with optional free text
- 5-8 reason options max (avoid decision fatigue)
- Put most common reasons first (review data quarterly)
- Don't make it feel like a guilt trip
- "Help us improve" framing works better than "Why are you leaving?"
### Dynamic Save Offers
The key insight: **match the offer to the reason.** A discount won't save someone who isn't using the product. A feature roadmap won't save someone who can't afford it.
**Offer-to-reason mapping:**
| Cancel Reason | Primary Offer | Fallback Offer |
|---------------|---------------|----------------|
| Too expensive | Discount (20-30% for 2-3 months) | Downgrade to lower plan |
| Not using it enough | Pause (1-3 months) | Free onboarding session |
| Missing feature | Roadmap preview + timeline | Workaround guide |
| Switching to competitor | Competitive comparison + discount | Feedback session |
| Technical issues | Escalate to support immediately | Credit + priority fix |
| Temporary / seasonal | Pause subscription | Downgrade temporarily |
| Business closed | Skip offer (respect the situation) | — |
### Save Offer Types
**Discount**
- 20-30% off for 2-3 months is the sweet spot
- Avoid 50%+ discounts (trains customers to cancel for deals)
- Time-limit the offer ("This offer expires when you leave this page")
- Show the dollar amount saved, not just the percentage
**Pause subscription**
- 1-3 month pause maximum (longer pauses rarely reactivate)
- 60-80% of pausers eventually return to active
- Auto-reactivation with advance notice email
- Keep their data and settings intact
**Plan downgrade**
- Offer a lower tier instead of full cancellation
- Show what they keep vs. what they lose
- Position as "right-size your plan" not "downgrade"
- Easy path back up when ready
**Feature unlock / extension**
- Unlock a premium feature they haven't tried
- Extend trial of a higher tier
- Works best for "not getting enough value" reasons
**Personal outreach**
- For high-value accounts (top 10-20% by MRR)
- Route to customer success for a call
- Personal email from founder for smaller companies
### Cancel Flow UI Patterns
```
┌─────────────────────────────────────┐
│ We're sorry to see you go │
│ │
│ What's the main reason you're │
│ cancelling? │
│ │
│ ○ Too expensive │
│ ○ Not using it enough │
│ ○ Missing a feature I need │
│ ○ Switching to another tool │
│ ○ Technical issues │
│ ○ Temporary / don't need right now │
│ ○ Other: [____________] │
│ │
│ [Continue] │
│ [Never mind, keep my subscription] │
└─────────────────────────────────────┘
↓ (selects "Too expensive")
┌─────────────────────────────────────┐
│ What if we could help? │
│ │
│ We'd love to keep you. Here's a │
│ special offer: │
│ │
│ ┌───────────────────────────────┐ │
│ │ 25% off for the next 3 months│ │
│ │ Save $XX/month │ │
│ │ │ │
│ │ [Accept Offer] │ │
│ └───────────────────────────────┘ │
│ │
│ Or switch to [Basic Plan] at │
│ $X/month → │
│ │
│ [No thanks, continue cancelling] │
└─────────────────────────────────────┘
```
**UI principles:**
- Keep the "continue cancelling" option visible (no dark patterns)
- One primary offer + one fallback, not a wall of options
- Show specific dollar savings, not abstract percentages
- Use the customer's name and account data when possible
- Mobile-friendly (many cancellations happen on mobile)
For detailed cancel flow patterns by industry and billing provider, see [references/cancel-flow-patterns.md](references/cancel-flow-patterns.md).
---
## Churn Prediction & Proactive Retention
The best save happens before the customer ever clicks "Cancel."
### Risk Signals
Track these leading indicators of churn:
| Signal | Risk Level | Timeframe |
|--------|-----------|-----------|
| Login frequency drops 50%+ | High | 2-4 weeks before cancel |
| Key feature usage stops | High | 1-3 weeks before cancel |
| Support tickets spike then stop | High | 1-2 weeks before cancel |
| Email open rates decline | Medium | 2-6 weeks before cancel |
| Billing page visits increase | High | Days before cancel |
| Team seats removed | High | 1-2 weeks before cancel |
| Data export initiated | Critical | Days before cancel |
| NPS score drops below 6 | Medium | 1-3 months before cancel |
### Health Score Model
Build a simple health score (0-100) from weighted signals:
```
Health Score = (
Login frequency score × 0.30 +
Feature usage score × 0.25 +
Support sentiment × 0.15 +
Billing health × 0.15 +
Engagement score × 0.15
)
```
| Score | Status | Action |
|-------|--------|--------|
| 80-100 | Healthy | Upsell opportunities |
| 60-79 | Needs attention | Proactive check-in |
| 40-59 | At risk | Intervention campaign |
| 0-39 | Critical | Personal outreach |
### Proactive Interventions
**Before they think about cancelling:**
| Trigger | Intervention |
|---------|-------------|
| Usage drop >50% for 2 weeks | "We noticed you haven't used [feature]. Need help?" email |
| Approaching plan limit | Upgrade nudge (not a wall — paywall-upgrade-cro handles this) |
| No login for 14 days | Re-engagement email with recent product updates |
| NPS detractor (0-6) | Personal follow-up within 24 hours |
| Support ticket unresolved >48h | Escalation + proactive status update |
| Annual renewal in 30 days | Value recap email + renewal confirmation |
---
## Involuntary Churn: Payment Recovery
Failed payments cause 30-50% of all churn but are the most recoverable.
### The Dunning Stack
```
Pre-dunning → Smart retry → Dunning emails → Grace period → Hard cancel
```
### Pre-Dunning (Prevent Failures)
- **Card expiry alerts**: Email 30, 15, and 7 days before card expires
- **Backup payment method**: Prompt for a second payment method at signup
- **Card updater services**: Visa/Mastercard auto-update programs (reduces hard declines 30-50%)
- **Pre-billing notification**: Email 3-5 days before charge for annual plans
### Smart Retry Logic
Not all failures are the same. Retry strategy by decline type:
| Decline Type | Examples | Retry Strategy |
|-------------|----------|----------------|
| Soft decline (temporary) | Insufficient funds, processor timeout | Retry 3-5 times over 7-10 days |
| Hard decline (permanent) | Card stolen, account closed | Don't retry — ask for new card |
| Authentication required | 3D Secure, SCA | Send customer to update payment |
**Retry timing best practices:**
- Retry 1: 24 hours after failure
- Retry 2: 3 days after failure
- Retry 3: 5 days after failure
- Retry 4: 7 days after failure (with dunning email escalation)
- After 4 retries: Hard cancel with reactivation path
**Smart retry tip:** Retry on the day of the month the payment originally succeeded (if Day 1 worked before, retry on Day 1). Stripe Smart Retries handles this automatically.
### Dunning Email Sequence
| Email | Timing | Tone | Content |
|-------|--------|------|---------|
| 1 | Day 0 (failure) | Friendly alert | "Your payment didn't go through. Update your card." |
| 2 | Day 3 | Helpful reminder | "Quick reminder — update your payment to keep access." |
| 3 | Day 7 | Urgency | "Your account will be paused in 3 days. Update now." |
| 4 | Day 10 | Final warning | "Last chance to keep your account active." |
**Dunning email best practices:**
- Direct link to payment update page (no login required if possible)
- Show what they'll lose (their data, their team's access)
- Don't blame ("your payment failed" not "you failed to pay")
- Include support contact for help
- Plain text performs better than designed emails for dunning
### Recovery Benchmarks
| Metric | Poor | Average | Good |
|--------|------|---------|------|
| Soft decline recovery | <40% | 50-60% | 70%+ |
| Hard decline recovery | <10% | 20-30% | 40%+ |
| Overall payment recovery | <30% | 40-50% | 60%+ |
| Pre-dunning prevention | None | 10-15% | 20-30% |
For the complete dunning playbook with provider-specific setup, see [references/dunning-playbook.md](references/dunning-playbook.md).
---
## Metrics & Measurement
### Key Churn Metrics
| Metric | Formula | Target |
|--------|---------|--------|
| Monthly churn rate | Churned customers / Start-of-month customers | <5% B2C, <2% B2B |
| Revenue churn (net) | (Lost MRR - Expansion MRR) / Start MRR | Negative (net expansion) |
| Cancel flow save rate | Saved / Total cancel sessions | 25-35% |
| Offer acceptance rate | Accepted offers / Shown offers | 15-25% |
| Pause reactivation rate | Reactivated / Total paused | 60-80% |
| Dunning recovery rate | Recovered / Total failed payments | 50-60% |
| Time to cancel | Days from first churn signal to cancel | Track trend |
### Cohort Analysis
Segment churn by:
- **Acquisition channel** — Which channels bring stickier customers?
- **Plan type** — Which plans churn most?
- **Tenure** — When do most cancellations happen? (30, 60, 90 days?)
- **Cancel reason** — Which reasons are growing?
- **Save offer type** — Which offers work best for which segments?
### Cancel Flow A/B Tests
Test one variable at a time:
| Test | Hypothesis | Metric |
|------|-----------|--------|
| Discount % (20% vs 30%) | Higher discount saves more | Save rate, LTV impact |
| Pause duration (1 vs 3 months) | Longer pause increases return rate | Reactivation rate |
| Survey placement (before vs after offer) | Survey-first personalizes offers | Save rate |
| Offer presentation (modal vs full page) | Full page gets more attention | Save rate |
| Copy tone (empathetic vs direct) | Empathetic reduces friction | Save rate |
**How to run cancel flow experiments:** Use the **ab-test-setup** skill to design statistically rigorous tests. PostHog is a good fit for cancel flow experiments — its feature flags can split users into different flows server-side, and its funnel analytics track each step of the cancel flow (survey → offer → accept/decline → confirm). See the [PostHog integration guide](../../tools/integrations/posthog.md) for setup.
---
## Common Mistakes
- **No cancel flow at all** — Instant cancel leaves money on the table. Even a simple survey + one offer saves 10-15%
- **Making cancellation hard to find** — Hidden cancel buttons breed resentment and bad reviews. Many jurisdictions require easy cancellation (FTC Click-to-Cancel rule)
- **Same offer for every reason** — A blanket discount doesn't address "missing feature" or "not using it"
- **Discounts too deep** — 50%+ discounts train customers to cancel-and-return for deals
- **Ignoring involuntary churn** — Often 30-50% of total churn and the easiest to fix
- **No dunning emails** — Letting payment failures silently cancel accounts
- **Guilt-trip copy** — "Are you sure you want to abandon us?" damages brand trust
- **Not tracking save offer LTV** — A "saved" customer who churns 30 days later wasn't really saved
- **Pausing too long** — Pauses beyond 3 months rarely reactivate. Set limits.
- **No post-cancel path** — Always make reactivation easy and trigger win-back emails
---
## Tool Integrations
For implementation, see the [tools registry](../../tools/REGISTRY.md).
### Retention Platforms
| Tool | Best For | Key Feature |
|------|----------|-------------|
| **Churnkey** | Full cancel flow + dunning | AI-powered adaptive offers, 34% avg save rate |
| **ProsperStack** | Cancel flows with analytics | Advanced rules engine, Stripe/Chargebee integration |
| **Raaft** | Simple cancel flow builder | Easy setup, good for early-stage |
| **Chargebee Retention** | Chargebee customers | Native integration, was Brightback |
### Billing Providers (Dunning)
| Provider | Smart Retries | Dunning Emails | Card Updater |
|----------|:------------:|:--------------:|:------------:|
| **Stripe** | Built-in (Smart Retries) | Built-in | Automatic |
| **Chargebee** | Built-in | Built-in | Via gateway |
| **Paddle** | Built-in | Built-in | Managed |
| **Recurly** | Built-in | Built-in | Built-in |
| **Braintree** | Manual config | Manual | Via gateway |
### Related CLI Tools
| Tool | Use For |
|------|---------|
| `stripe` | Subscription management, dunning config, payment retries |
| `customer-io` | Dunning email sequences, retention campaigns |
| `posthog` | Cancel flow A/B tests via feature flags, funnel analytics |
| `mixpanel` / `ga4` | Usage tracking, churn signal analysis |
| `segment` | Event routing for health scoring |
---
## Related Skills
- **email-sequence**: For win-back email sequences after cancellation
- **paywall-upgrade-cro**: For in-app upgrade moments and trial expiration
- **pricing-strategy**: For plan structure and annual discount strategy
- **onboarding-cro**: For activation to prevent early churn
- **analytics-tracking**: For setting up churn signal events
- **ab-test-setup**: For testing cancel flow variations with statistical rigor
@@ -0,0 +1,316 @@
# Cancel Flow Patterns
Detailed cancel flow patterns by business type, billing provider, and industry.
---
## Cancel Flow by Business Type
### B2C / Self-Serve SaaS
High volume, low touch. The flow must work without human intervention.
**Flow structure:**
```
Cancel button → Exit survey (1 question) → Dynamic offer → Confirm → Post-cancel
```
**Characteristics:**
- Fully automated, no human in the loop
- Quick — 2-3 screens maximum
- One offer + one fallback, not a menu of options
- Mobile-optimized (significant cancellations on mobile)
- Clear "continue cancelling" at every step
**Typical save rate:** 20-30%
**Example flow for a $29/mo productivity app:**
1. "What's the main reason?" → 6 options
2. Selected "Too expensive" → "Get 25% off for 3 months (save $21.75)"
3. Declined → "Or switch to our Starter plan at $12/mo"
4. Declined → "We're sorry to see you go. Your access continues until [date]."
---
### B2B / Team Plans
Lower volume, higher stakes. Personal outreach is worth the cost.
**Flow structure:**
```
Cancel button → Exit survey → Offer (or route to CS) → Confirm → Post-cancel
```
**Characteristics:**
- Route accounts above MRR threshold to customer success
- Show team impact ("Your 8 team members will lose access")
- Offer admin-to-admin call for enterprise accounts
- Longer consideration — allow "schedule a call" as a save option
- Require admin/owner role to cancel (not any team member)
**Typical save rate:** 30-45% (higher because of personal touch)
**MRR-based routing:**
| Account MRR | Cancel Flow |
|-------------|-------------|
| <$100/mo | Automated flow with offers |
| $100-$500/mo | Automated + flag for CS follow-up |
| $500-$2,000/mo | Route to CS before cancel completes |
| $2,000+/mo | Block self-serve cancel, require CS call |
---
### Freemium / Free-to-Paid
Users cancelling paid to return to free tier. Different psychology — they're not leaving, they're downgrading.
**Flow structure:**
```
Cancel button → "Switch to Free?" prompt → Exit survey (if still cancelling) → Offer → Confirm
```
**Characteristics:**
- Lead with the free tier as the first option (not a save offer)
- Show what they keep on free vs. what they lose
- The "save" is keeping them on free, not losing them entirely
- Track free-tier users for future re-upgrade campaigns
---
## Cancel Flow by Billing Interval
### Monthly Subscribers
- More price-sensitive, shorter commitment
- Discount offers work well (20-30% for 2-3 months)
- Pause is effective (1-2 months)
- Suggest annual plan at a discount as an alternative
**Offer priority:**
1. Discount (if reason = price)
2. Pause (if reason = not using / temporary)
3. Annual plan switch (if engaged but price-sensitive)
### Annual Subscribers
- Higher commitment, often cancelling for stronger reasons
- Prorate refund expectations matter
- Longer save window (they've already paid)
- Personal outreach more justified (higher LTV at stake)
**Offer priority:**
1. Pause remainder of term (if temporary)
2. Plan adjustment + credit for next renewal
3. Personal outreach from CS
4. Partial refund + downgrade (better than full refund + cancel)
**Refund handling:**
- Offer prorated refund if significant time remaining
- "Pause until renewal" if less than 3 months left
- Be generous — bad refund experiences create vocal detractors
---
## Save Offer Patterns
### The Discount Ladder
Don't lead with your biggest discount. Escalate:
```
Cancel click → 15% off → Still cancelling → 25% off → Still cancelling → Let them go
```
**Rules:**
- Maximum 2 discount offers per cancel session
- Never exceed 30% (higher trains cancel-for-discount behavior)
- Time-limit discounts (2-3 months, then full price resumes)
- Track discount accepters — if they cancel again at full price, don't re-offer
### The Pause Playbook
Pause is often better than a discount because it doesn't devalue your product.
**Implementation:**
| Setting | Recommendation |
|---------|---------------|
| Pause duration options | 1 month, 2 months, 3 months |
| Default selection | 1 month (shortest) |
| Maximum pause | 3 months (longer pauses rarely return) |
| During pause | Keep data, remove access |
| Reactivation | Auto-reactivate with 7-day advance email |
| Repeat pauses | Allow 1 pause per 12-month period |
**Pause reactivation sequence:**
- Day -7: "Your pause ends in 7 days. We've been busy — here's what's new."
- Day -1: "Welcome back tomorrow! Here's what's waiting for you."
- Day 0: "You're back! Here's a quick tour of what's new."
### The Downgrade Path
For multi-plan products, downgrade is the strongest save:
```
┌─────────────────────────────────────────┐
│ Before you go, what about right-sizing │
│ your plan? │
│ │
│ Current: Pro ($49/mo) │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Switch to Starter ($19/mo) │ │
│ │ │ │
│ │ ✓ Keep: Projects, integrations │ │
│ │ ✗ Lose: Advanced analytics, │ │
│ │ team features │ │
│ │ │ │
│ │ [Switch to Starter] │ │
│ └─────────────────────────────────┘ │
│ │
│ [No thanks, continue cancelling] │
└─────────────────────────────────────────┘
```
**Downgrade best practices:**
- Show exactly what they keep and what they lose
- Use checkmarks and X marks for scanability
- Preserve their data even on the lower plan
- If they downgrade, don't show upgrade prompts for at least 30 days
### The Competitor Switch Handler
When the cancel reason is "switching to competitor":
1. **Ask which competitor** (optional, don't force it)
2. **Show a comparison** if you have one (see competitor-alternatives skill)
3. **Offer a migration credit** ("We'll match their price for 3 months")
4. **Request a feedback call** ("15 minutes to understand what we're missing")
This data is gold for product and marketing teams.
---
## Post-Cancel Experience
What happens after cancel matters for:
- Win-back potential
- Word of mouth
- Review sentiment
### Confirmation Page
```
Your subscription has been cancelled.
What happens next:
• Your access continues until [billing period end date]
• Your data will be preserved for 90 days
• You can reactivate anytime from your account settings
[Reactivate My Account]
We'd love to have you back. We'll keep improving based on feedback
from customers like you.
```
### Post-Cancel Sequence
| Timing | Action |
|--------|--------|
| Immediately | Confirmation email with access end date |
| Day 1 | (Nothing — don't be desperate) |
| Day 7 | NPS/satisfaction survey about overall experience |
| Day 30 | "What's new" email with recent improvements |
| Day 60 | Address their specific cancel reason if resolved |
| Day 90 | Final win-back with special offer |
**For detailed win-back email sequences**: See the email-sequence skill.
---
## Segmentation Rules
The most effective cancel flows use segmentation to show different offers to different customers.
### Segmentation Dimensions
| Dimension | Why It Matters |
|-----------|---------------|
| Plan / MRR | Higher-value customers get personal outreach |
| Tenure | Long-term customers get more generous offers |
| Usage level | High-usage customers get different messaging than dormant ones |
| Billing interval | Monthly vs. annual need different approaches |
| Previous saves | Don't re-offer the same discount to a repeat canceller |
| Cancel reason | Drives which offer to show (core mapping) |
### Segment-Specific Flows
**New customer (< 30 days):**
- They haven't activated. The save is onboarding, not discounts.
- Offer: Free onboarding call, setup help, extended trial
- Ask: "What were you hoping to accomplish?" (learn what's missing)
**Engaged customer cancelling on price:**
- They love the product but can't justify the cost.
- Offer: Discount, annual plan switch, downgrade
- High save potential
**Dormant customer (no login 30+ days):**
- They forgot about you. A discount won't bring them back.
- Offer: Pause subscription, "what changed?" conversation
- Low save potential — focus on learning why
**Power user switching to competitor:**
- They're actively choosing something else.
- Offer: Competitive match, feedback call, roadmap preview
- Medium save potential — depends on reason
---
## Implementation Checklist
### Phase 1: Foundation (Week 1)
- [ ] Add cancel flow (survey + 1 offer + confirmation)
- [ ] Set up exit survey with 5-7 reason categories
- [ ] Map one offer per reason (simple 1:1 mapping)
- [ ] Track cancel reasons and save rate in analytics
- [ ] Enable pre-dunning card expiry emails
### Phase 2: Optimization (Weeks 2-4)
- [ ] Add fallback offers (primary + secondary per reason)
- [ ] Implement pause subscription option
- [ ] Set up dunning email sequence (4 emails over 10 days)
- [ ] Enable smart retries (Stripe Smart Retries or equivalent)
- [ ] Add MRR-based routing for high-value accounts
### Phase 3: Advanced (Month 2+)
- [ ] Build health score from usage signals
- [ ] Set up proactive intervention triggers
- [ ] A/B test discount amounts and offer types
- [ ] Segment flows by plan, tenure, and usage
- [ ] Post-cancel win-back sequence (coordinate with email-sequence skill)
- [ ] Cohort analysis: churn by channel, plan, tenure
---
## Compliance Notes
### FTC Click-to-Cancel Rule (US)
- Cancellation must be as easy as signup
- Cannot require a phone call to cancel if signup was online
- Cannot add excessive steps to discourage cancellation
- Save offers are allowed but "continue cancelling" must be clear
### GDPR / Data Retention (EU)
- Inform users about data retention period post-cancel
- Offer data export before account deletion
- Honor deletion requests within 30 days
- Don't use post-cancel data for marketing without consent
### General Best Practices
- Always show a clear path to complete cancellation
- Never hide the cancel button (dark pattern)
- Process cancellation even if save flow has errors
- Confirm cancellation with email receipt
@@ -0,0 +1,408 @@
# Dunning Playbook
Complete guide to recovering failed payments and reducing involuntary churn.
---
## Why Dunning Matters
- Failed payments cause 30-50% of all subscription churn
- Most failed payments are recoverable with the right strategy
- Subscription businesses lose an estimated $129 billion annually to involuntary churn
- Effective dunning recovers 50-60% of failed payments
---
## The Dunning Timeline
```
Day -30 to -7: Pre-dunning (prevent failures)
Day 0: Payment fails → Smart retry #1 + Email #1
Day 1-3: Smart retry #2 + Email #2
Day 3-5: Smart retry #3
Day 5-7: Smart retry #4 + Email #3
Day 7-10: Final retry + Email #4 (final warning)
Day 10-14: Grace period ends → Account paused/cancelled
Day 14+: Win-back sequence begins
```
---
## Pre-Dunning: Prevent Failures Before They Happen
### Card Expiry Management
| Timing | Action |
|--------|--------|
| 30 days before expiry | Email: "Your card ending in 4242 expires next month" |
| 15 days before expiry | Email: "Update your payment method to avoid interruption" |
| 7 days before expiry | Email: "Your card expires in 7 days — update now" |
| 3 days before expiry | In-app banner: "Payment method expiring soon" |
**Email template — Card expiring:**
```
Subject: Your card ending in 4242 expires soon
Hi [Name],
The card on file for your [Product] subscription expires on [date].
Update your payment method now to avoid any interruption:
[Update Payment Method →]
This takes less than 30 seconds.
— [Product] Team
```
### Card Updater Services
Major card networks offer automatic card update programs:
| Service | Network | What It Does |
|---------|---------|--------------|
| Visa Account Updater (VAU) | Visa | Auto-updates stored card numbers and expiry dates |
| Mastercard Automatic Billing Updater (ABU) | Mastercard | Same for Mastercard |
| Amex Cardrefresher | American Express | Same for Amex |
**Impact:** Reduces hard declines from expired/replaced cards by 30-50%.
**How to enable:**
- **Stripe**: Automatic — enabled by default
- **Chargebee**: Enabled through gateway settings
- **Recurly**: Built-in, enabled by default
- **Braintree**: Contact processor to enable
### Backup Payment Methods
Prompt for a second payment method:
- During signup: "Add a backup payment method" (low conversion)
- After first successful payment: "Protect your account with a backup card" (better timing)
- After a failed payment is recovered: "Add a backup to prevent future interruptions" (best timing — they felt the pain)
### Pre-Billing Notifications
For annual plans or high-value subscriptions:
- Email 7 days before renewal with amount and date
- Include link to update payment method
- Show what's included in the renewal
- Required by some regulations for auto-renewals
---
## Smart Retry Strategy
### Decline Type Classification
| Code | Type | Meaning | Retry? |
|------|------|---------|--------|
| `insufficient_funds` | Soft | Temporarily low balance | Yes — retry in 2-3 days |
| `card_declined` (generic) | Soft | Various temporary reasons | Yes — retry 3-4 times |
| `processing_error` | Soft | Gateway/network issue | Yes — retry within 24h |
| `expired_card` | Hard | Card is expired | No — request new card |
| `stolen_card` | Hard | Card reported stolen | No — request new card |
| `do_not_honor` | Soft/Hard | Bank refused (ambiguous) | Try once more, then ask for new card |
| `authentication_required` | Auth | SCA/3DS needed | Send customer to authenticate |
### Retry Schedule by Provider
**Stripe (Smart Retries — recommended):**
- Enable "Smart Retries" in Stripe Dashboard → Billing → Settings
- Stripe's ML model picks optimal retry timing based on billions of transactions
- Typically 4-8 retry attempts over 3-4 weeks
- Recovers ~15% more than fixed-schedule retries
**Manual retry schedule (if no smart retries):**
| Retry | Timing | Best Day/Time |
|-------|--------|--------------|
| 1 | Day 1 (24h after failure) | Morning, same day of week as original |
| 2 | Day 3 | Try a different time of day |
| 3 | Day 5 | After typical payday (1st, 15th) |
| 4 | Day 7 | Morning of the next business day |
| 5 (final) | Day 10 | Last attempt before grace period ends |
**Retry timing insights:**
- Retry on the same day of month the original payment succeeded
- Retry after common paydays (1st and 15th of the month)
- Avoid retrying on weekends (lower approval rates)
- Morning retries (8-10am local time) perform slightly better
---
## Dunning Email Sequence
### Email 1: Payment Failed (Day 0)
**Tone:** Friendly, matter-of-fact. No alarm.
```
Subject: Action needed — your payment didn't go through
Hi [Name],
We tried to charge your [card type] ending in [last 4] for your
[Product] subscription ($[amount]), but it didn't go through.
This happens sometimes — usually a quick card update fixes it.
[Update Payment Method →]
Your access isn't affected yet. We'll retry automatically, but
updating your card is the fastest fix.
Need help? Just reply to this email.
— [Product] Team
```
### Email 2: Reminder (Day 3)
**Tone:** Helpful, slightly more urgent.
```
Subject: Quick reminder — update your payment for [Product]
Hi [Name],
Just a heads-up — we still haven't been able to process your
$[amount] payment for [Product].
[Update Payment Method →]
Takes less than 30 seconds. Your [data/projects/team access]
is safe, but we'll need a valid payment method to keep your
account active.
Questions? Reply here and we'll help.
— [Product] Team
```
### Email 3: Urgency (Day 7)
**Tone:** Direct, clear consequences.
```
Subject: Your [Product] account will be paused in 3 days
Hi [Name],
We've tried to process your payment several times, but your
[card type] ending in [last 4] keeps getting declined.
If we don't receive payment by [date], your account will be
paused and you'll lose access to:
• [Key feature/data they use]
• [Their projects/workspace]
• [Team access for X members]
[Update Payment Method Now →]
Your data won't be deleted — you can reactivate anytime by
updating your payment method.
— [Product] Team
```
### Email 4: Final Warning (Day 10)
**Tone:** Final, clear, no guilt.
```
Subject: Last chance to keep your [Product] account active
Hi [Name],
This is our last reminder. Your payment of $[amount] is past
due, and your account will be paused tomorrow ([date]).
[Update Payment Method →]
After pausing:
• Your data is saved for [90 days]
• You can reactivate anytime
• Just update your card to restore access
If you intended to cancel, no action needed — your account
will be paused automatically.
— [Product] Team
```
---
## Grace Period Management
### What Happens During Grace Period
| Setting | Recommendation |
|---------|---------------|
| Duration | 7-14 days after final retry |
| Access | Degraded (read-only) or full access |
| Visibility | In-app banner: "Payment past due — update to continue" |
| Retry | Continue background retries during grace |
| Communication | Dunning emails continue |
### Access Degradation Options
**Option A: Full access during grace (recommended for B2B)**
- Lower friction, customer feels respected
- Higher recovery rate (they still see value)
- Risk: some customers exploit the grace period
**Option B: Read-only access (recommended for B2C)**
- Can view but not create/edit
- Creates urgency without data loss fear
- Clear message: "Update payment to resume full access"
**Option C: Immediate lockout (not recommended)**
- Aggressive, damages relationship
- Lower recovery rate
- Only appropriate for very low-cost plans
### Post-Grace Period
| Timing | Action |
|--------|--------|
| Grace period ends | Pause account (not delete) |
| Day 1 post-pause | "Your account has been paused" email |
| Day 7 post-pause | "Your data is still here" reminder |
| Day 30 post-pause | Win-back attempt with new offer |
| Day 60 post-pause | Final win-back |
| Day 90 post-pause | Data deletion warning (if applicable) |
---
## Provider-Specific Setup
### Stripe
**Enable Smart Retries:**
1. Dashboard → Settings → Billing → Subscriptions and emails
2. Enable "Smart Retries" under retry rules
3. Set failed payment emails in Dashboard → Settings → Emails
**Custom retry rules (if not using Smart Retries):**
```
Retry 1: 3 days after failure
Retry 2: 5 days after failure
Retry 3: 7 days after failure
Final: Mark subscription as unpaid after last retry
```
**Webhook events to handle:**
- `invoice.payment_failed` — trigger dunning
- `invoice.paid` — cancel dunning, restore access
- `customer.subscription.updated` — status changes
- `customer.subscription.deleted` — final cancellation
### Chargebee
**Built-in dunning:**
1. Settings → Configure Chargebee → Retry Settings
2. Configure retry attempts and intervals
3. Settings → Configure Chargebee → Email Notifications → Dunning
**Dunning options:**
- Automatic retries with configurable schedule
- Built-in dunning emails (customizable templates)
- Grace period configuration per plan
### Paddle
**Managed dunning:**
- Paddle handles retries and dunning automatically
- Limited customization (Paddle manages the relationship)
- Webhook: `subscription.payment_failed`, `subscription.cancelled`
- Best for hands-off approach
### Recurly
**Revenue Recovery:**
1. Configuration → Dunning Management
2. Set retry schedule per plan
3. Configure grace period and final action (pause vs cancel)
**Advanced features:**
- Machine-learning retry optimization
- Per-plan dunning schedules
- Built-in Account Updater
---
## In-App Dunning
Don't rely on email alone. Show payment failures in the app:
### Banner Pattern
```
┌──────────────────────────────────────────────────────┐
│ ⚠ Your payment of $29 failed. Update your card to │
│ avoid losing access. [Update Payment →] [Dismiss] │
└──────────────────────────────────────────────────────┘
```
**Rules:**
- Show on every page load during dunning period
- Allow dismiss (but show again next session)
- Direct link to payment update (fewest clicks possible)
- Don't block the product — let them continue using it
### Modal Pattern (for final warning)
```
┌─────────────────────────────────────┐
│ │
│ Your account will be paused │
│ on [date] │
│ │
│ Update your payment method to │
│ keep access to your [X] projects │
│ and [Y] team members. │
│ │
│ [Update Payment Method] │
│ [Remind Me Later] │
│ │
└─────────────────────────────────────┘
```
---
## Measuring Dunning Performance
### Key Metrics
| Metric | How to Calculate | Target |
|--------|-----------------|--------|
| Recovery rate | Recovered payments / Total failed | 50-60% |
| Recovery rate by decline type | Recovered / Failed per type | Soft: 70%+, Hard: 40%+ |
| Time to recovery | Days from failure to successful payment | <5 days |
| Pre-dunning prevention rate | Prevented failures / Expected failures | 20-30% |
| Dunning email open rate | Opens / Sent per email | 60%+ |
| Dunning email click rate | Clicks / Opens per email | 30%+ |
| Revenue recovered (monthly) | Sum of recovered payment amounts | Track trend |
| Revenue lost to involuntary churn | Sum of failed + unrecovered amounts | Track trend |
### Benchmarking
**By company stage:**
| Stage | Typical Involuntary Churn | Target After Optimization |
|-------|--------------------------|--------------------------|
| Early (< $1M ARR) | 3-5% of MRR/month | 1-2% |
| Growth ($1-10M ARR) | 2-4% of MRR/month | 0.5-1.5% |
| Scale ($10M+ ARR) | 1-3% of MRR/month | 0.3-0.8% |
### ROI Calculation
```
Monthly failed payment MRR: $10,000
Current recovery rate: 30% ($3,000 recovered)
Target recovery rate: 60% ($6,000 recovered)
Monthly improvement: $3,000/month
Annual improvement: $36,000/year
Cost of dunning optimization: ~$200-500/month (tooling)
ROI: 6-15x
```
+155
View File
@@ -0,0 +1,155 @@
---
name: cold-email
description: Write B2B cold emails and follow-up sequences that get replies. Use when the user wants to write cold outreach emails, prospecting emails, cold email campaigns, sales development emails, or SDR emails. Covers subject lines, opening lines, body copy, CTAs, personalization, and multi-touch follow-up sequences.
---
# Cold Email Writing
You are an expert cold email writer. Your goal is to write emails that sound like they came from a sharp, thoughtful human — not a sales machine following a template.
## Before Writing
**Check for product marketing context first:**
If `.claude/product-marketing-context.md` exists, read it before asking questions. Use that context and only ask for information not already covered or specific to this task.
Understand the situation (ask if not provided):
1. **Who are you writing to?** — Role, company, why them specifically
2. **What do you want?** — The outcome (meeting, reply, intro, demo)
3. **What's the value?** — The specific problem you solve for people like them
4. **What's your proof?** — A result, case study, or credibility signal
5. **Any research signals?** — Funding, hiring, LinkedIn posts, company news, tech stack changes
Work with whatever the user gives you. If they have a strong signal and a clear value prop, that's enough to write. Don't block on missing inputs — use what you have and note what would make it stronger.
---
## Writing Principles
### Write like a peer, not a vendor
The email should read like it came from someone who understands their world — not someone trying to sell them something. Use contractions. Read it aloud. If it sounds like marketing copy, rewrite it.
### Every sentence must earn its place
Cold email is ruthlessly short. If a sentence doesn't move the reader toward replying, cut it. The best cold emails feel like they could have been shorter, not longer.
### Personalization must connect to the problem
If you remove the personalized opening and the email still makes sense, the personalization isn't working. The observation should naturally lead into why you're reaching out.
See [personalization.md](references/personalization.md) for the 4-level system and research signals.
### Lead with their world, not yours
The reader should see their own situation reflected back. "You/your" should dominate over "I/we." Don't open with who you are or what your company does.
### One ask, low friction
Interest-based CTAs ("Worth exploring?" / "Would this be useful?") beat meeting requests. One CTA per email. Make it easy to say yes with a one-line reply.
---
## Voice & Tone
**The target voice:** A smart colleague who noticed something relevant and is sharing it. Conversational but not sloppy. Confident but not pushy.
**Calibrate to the audience:**
- C-suite: ultra-brief, peer-level, understated
- Mid-level: more specific value, slightly more detail
- Technical: precise, no fluff, respect their intelligence
**What it should NOT sound like:**
- A template with fields swapped in
- A pitch deck compressed into paragraph form
- A LinkedIn DM from someone you've never met
- An AI-generated email (avoid the telltale patterns: "I hope this email finds you well," "I came across your profile," "leverage," "synergy," "best-in-class")
---
## Structure
There's no single right structure. Choose a framework that fits the situation, or write freeform if the email flows naturally without one.
**Common shapes that work:**
- **Observation → Problem → Proof → Ask** — You noticed X, which usually means Y challenge. We helped Z with that. Interested?
- **Question → Value → Ask** — Struggling with X? We do Y. Company Z saw [result]. Worth a look?
- **Trigger → Insight → Ask** — Congrats on X. That usually creates Y challenge. We've helped similar companies with that. Curious?
- **Story → Bridge → Ask** — [Similar company] had [problem]. They [solved it this way]. Relevant to you?
For the full catalog of frameworks with examples, see [frameworks.md](references/frameworks.md).
---
## Subject Lines
Short, boring, internal-looking. The subject line's only job is to get the email opened — not to sell.
- 2-4 words, lowercase, no punctuation tricks
- Should look like it came from a colleague ("reply rates," "hiring ops," "Q2 forecast")
- No product pitches, no urgency, no emojis, no prospect's first name
See [subject-lines.md](references/subject-lines.md) for the full data.
---
## Follow-Up Sequences
Each follow-up must add something new — a different angle, fresh proof, a useful resource. Never "just checking in."
- 3-5 total emails, increasing gaps between them
- Each email should stand alone (they may not have read the previous ones)
- The breakup email is your last touch — honor it
See [follow-up-sequences.md](references/follow-up-sequences.md) for cadence, angle rotation, and breakup email templates.
---
## Quality Check
Before presenting, gut-check:
- Does it sound like a human wrote it? (Read it aloud)
- Would YOU reply to this if you received it?
- Does every sentence serve the reader, not the sender?
- Is the personalization connected to the problem?
- Is there one clear, low-friction ask?
---
## What to Avoid
- Opening with "I hope this email finds you well" or "My name is X and I work at Y"
- Jargon: "synergy," "leverage," "circle back," "best-in-class," "leading provider"
- Feature dumps — one proof point beats ten features
- HTML, images, or multiple links
- Fake "Re:" or "Fwd:" subject lines
- Identical templates with only {{FirstName}} swapped
- Asking for 30-minute calls in first touch
- "Just checking in" follow-ups
---
## Data & Benchmarks
The references contain performance data if you need to make informed choices:
- [benchmarks.md](references/benchmarks.md) — Reply rates, conversion funnels, expert methods, common mistakes
- [personalization.md](references/personalization.md) — 4-level personalization system, research signals
- [subject-lines.md](references/subject-lines.md) — Subject line data and optimization
- [follow-up-sequences.md](references/follow-up-sequences.md) — Cadence, angles, breakup emails
- [frameworks.md](references/frameworks.md) — All copywriting frameworks with examples
Use this data to inform your writing — not as a checklist to satisfy.
---
## Related Skills
- **copywriting**: For landing pages and web copy
- **email-sequence**: For lifecycle/nurture email sequences (not cold outreach)
- **social-content**: For LinkedIn and social posts
- **product-marketing-context**: For establishing foundational positioning
@@ -0,0 +1,83 @@
# Benchmarks, Data & Expert Methods
## Core Performance Metrics (20242025)
| Metric | Average | Good | Excellent | Source |
| -------------------------- | ------- | ------ | --------- | ------------------------ |
| Open rate | 27.7% | 4045% | 50%+ | Belkins, Snov.io |
| Reply rate | 45.8% | 510% | 1015% | Belkins, Reachoutly |
| Reply rate (best-in-class) | — | — | 1525%+ | Digital Bloom, Instantly |
| Positive reply % | ~48% | 5560% | 6265% | Digital Bloom |
| Meeting booking rate | 0.51% | 12% | 2.3%+ | Reachoutly |
| Bounce rate | 7.5% | <4% | <2% | Belkins |
## Realistic Funnel Model
500 emails → 100 opens (20%) → 25 replies (5%) → 8 positive replies (30%) → 4 meetings (50%) → 1 client (25% close). ~**0.2% end-to-end conversion** for average performers.
## Performance Levers (ranked by impact)
1. **Hook type** — Timeline hooks outperform problem hooks by 3.4x in meetings
2. **Personalization depth** — Up to 250% more replies
3. **Brevity** — 2575 words optimal, 83% more replies under 75 words
4. **Targeting precision** — ≤50 contacts per campaign = 2.76x higher reply rates
5. **Follow-up strategy** — First follow-up adds 49% more replies
6. **Reading level** — 3rd5th grade = 67% more replies
7. **Send timing** — Thursday peaks at 6.87% reply rate
## Declining Effectiveness Trend
Reply rates dropped from 78% (20202022) to 45.8% (20242025), ~15% YoY decline. Drivers: inbox saturation (10+ cold emails/week, 20% say none relevant), stricter anti-spam (Google's threshold: 0.1% complaints), AI email flood (more volume, less quality signal). Writing craft matters more, not less — gap between average and excellent is widening.
## Response Rates by Seniority
- **Entry-level:** Highest engagement at 8% reply, 50% open
- **C-level:** 23% more likely to respond than non-C-suite when they engage (6.4% vs 5.2%)
- **CTOs/VP Tech:** 7.68% reply
- **CEOs/Founders:** 7.63% reply
- **Heads of Sales:** 6.60% (most targeted role, highest saturation)
## Industry Variation
**Highest responding:** Nonprofits (16.5%+), legal (10%), EdTech (7.8%), chemical (7.3%), manufacturing (6.1%).
**Lowest responding:** SaaS (3.5%), financial services (3.4%), IT services (3.5%).
## Top 15 Mistakes (ranked by impact)
1. **Too long** — 70% of emails above 10th-grade level. Under 75 words = 83% more replies
2. **Too self-focused** — "We are a leading..." signals sales pitch. Count I/We sentences
3. **No clear value prop** — 71% of decision-makers ignore irrelevant emails
4. **Generic templates** — {{FirstName}} isn't personalization. Recipients detect instantly
5. **Feature dumping** — "Great reps lead with problems" (Lavender). One proof point beats ten features
6. **False personalization** — "Loved your post!" without specifics is transparent
7. **Asking too much too soon** — 30-min call in first email = "proposing on first date"
8. **Pushy language** — "Act Now" stacking increases spam flagging by 67%
9. **No CTA** — Without a clear next step, momentum dies
10. **"Just checking in" follow-ups** — "I never heard back" = 12% drop in bookings
11. **Wrong tone for audience** — Founder ≠ RevOps lead ≠ sales leader
12. **Jargon/buzzwords** — "Leverage synergistic platform" → "We help you book more meetings"
13. **Unsubstantiated claims** — "300% more leads" without proof triggers skepticism
14. **Too many contacts per company** — 12 people = 7.8% reply; 10+ = 3.8%
15. **Fake urgency** — Fake "Re:" / "Fwd:" / countdown timers destroy trust
## Cultural Calibration
| Factor | US | UK | Germany/DACH | Scandinavia |
| ------------ | --------------- | ------------------------ | -------------------- | ----------------------- |
| Tone | Direct, casual | Polite, professional | Precise, data-driven | Fact-based, egalitarian |
| Length | Shorter, blunt | Longer, insight-led | Detail-oriented | Concise but substantive |
| Social proof | Outcome numbers | Research-led credibility | Technical precision | Shared values |
North America: 4.1% response. Europe: 3.1%. Asia-Pacific: 2.8%. Shorter, more direct sequences work better in US. UK needs more insight/personality. GDPR affects European tone.
## Expert Quick Reference
| Expert | Core Method | Best For |
| -------------- | --------------------------------------------------------------- | ----------------------------------------------- |
| Alex Berman | 3C's: Compliment → Case Study → CTA | High-ticket B2B services, agencies |
| Josh Braun | "Poke the Bear" — neutral questions exposing invisible problems | Empathy-driven consultative selling |
| Kyle Coleman | Systematic research + AI personalization at scale | Bridging mass outreach and deep personalization |
| Becc Holland | Psychographic personalization, Premise Buckets | Combining personalization with relevance |
| Will Allred | Data-driven coaching, Mouse Trap, Vanilla Ice Cream | Any context; universal frameworks |
| Justin Michael | 13 sentence hyper-brevity, quote their own words | High-velocity SDR teams at scale |
| Sam Nelson | Agoge Sequence — Triple on Day 1 (email + LinkedIn + call) | Multi-channel, tiered personalization |
@@ -0,0 +1,81 @@
# Follow-Up Sequences
55% of replies come from follow-ups, not the initial email. Yet 48% of salespeople never follow up even once.
## How Many: 35 Total Emails
- Highest single-email reply rate: **8.4%** (Belkins).
- 47 email campaigns achieve **27% reply rates** vs 9% for 13 emails (Woodpecker, 20M emails).
- By 4th follow-up, response rates drop **55%** and spam complaints **triple**.
- Resolution: longer sequences catch different timing windows. Cap at 4 follow-ups (5 total emails). Each must add genuinely new value.
## Optimal Cadence
Increase the gap between each touch:
| Touch | Day | Notes |
| ------------- | ----- | ---------------------------------------------- |
| Initial email | 0 | Maximum personalization investment |
| Follow-up 1 | 3 | Waiting 3 days increases response by up to 31% |
| Follow-up 2 | 78 | Different angle |
| Follow-up 3 | 14 | New value piece |
| Follow-up 4 | 2128 | Breakup email |
**Best days:** TuesdayThursday (Thursday peaks at 6.87% reply rate).
**Best times:** 911 AM or 13 PM in prospect's local time.
**Avoid:** Monday mornings (inbox overload), Friday afternoons (checked out).
## Angle Rotation
Each follow-up must stand alone while building toward the goal. Never just "bump this up."
| Email | Angle | Purpose |
| ----------- | ---------------------------------------------------------- | -------------------------- |
| Initial | Personalized hook + core value prop + soft CTA | Introduce problem/solution |
| Follow-up 1 | Different angle, new value piece (stat, insight, resource) | Show additional benefit |
| Follow-up 2 | Social proof / case study from similar company | Build credibility |
| Follow-up 3 | New insight, industry trend, or relevant resource | Demonstrate expertise |
| Follow-up 4 | Breakup — acknowledge silence, leave door open | Trigger loss aversion |
Add only **one new value proposition per email** (SalesBread). This naturally forces different angles.
## The Breakup Email
Leverages loss aversion — removing pressure while creating scarcity through withdrawal. Close.com reports **1015% response rates** from breakup emails with cold prospects.
**Structure:**
1. Acknowledge you've reached out multiple times
2. Validate their potential lack of interest
3. State this is your final email for now
4. Leave the door open
**Example:**
> I haven't heard back, so I'll assume now isn't the right time. Before I close the loop: [1-sentence insight or resource]. If that changes things, feel free to reply. Otherwise, no hard feelings — good luck with [their goal].
**1-2-3 Format** (reduces friction to near zero):
> Since I haven't heard back, I'll keep it simple. Reply with a number:
>
> 1 — Interested, let's talk
> 2 — Not now, check back in 3 months
> 3 — Not interested, please stop
**Critical rule:** If you send a breakup email, honor it. Do not contact the prospect again.
## Phrases That Kill Response Rates
- "I never heard back" → **12% drop** in meeting booking rate (Gong)
- "Just checking in" → Zero value, signals laziness
- "Bumping this to the top of your inbox" → Presumptuous
- "Did you see my last email?" → Guilt-tripping
- "Following up on my previous message" → Generic, adds nothing
## CTA Adjustment by Seniority
**Executives/founders:** Ultra-low-effort, curiosity-driven. "Curious?" or "Worth 2 min?"
**Mid-level managers:** More specific value. "Want me to walk through how [Company] saved 15 hours/week?"
Higher in the org chart = less friction you can ask for.
@@ -0,0 +1,90 @@
# Cold Email Copywriting Frameworks
Frameworks beat templates — they teach thinking patterns, not copy-paste shortcuts.
## PAS — Problem, Agitate, Solution (default)
**Structure:** Identify pain → Amplify consequences → Present solution + soft CTA.
**Best for:** Problem-aware but not solution-aware prospects. The workhorse framework.
> Most VP Sales at companies your size spend 5+ hours/week on manual CRM reporting. That's 250+ hours/year not spent coaching reps — and often means inaccurate forecasts reaching leadership. We built a tool that auto-generates CRM reports in real time. Teams like Datadog reduced reporting time by 80%. Would it make sense to see how?
## BAB — Before, After, Bridge
**Structure:** Current painful situation → Ideal future → Your product as the bridge.
**Best for:** Transformation-driven offers with clear before/after. Emotional decision-makers.
> Right now, your team is likely spending hours manually sourcing leads — feast or famine each quarter. Imagine qualified leads arriving daily on autopilot, reps spending 100% of their time selling. That's what our platform does. Companies like HubSpot saw a 40% pipeline increase within 90 days. Can I show you how?
## QVC — Question, Value, CTA
**Structure:** Targeted pain question → Brief value → Direct next step.
**Best for:** C-suite prospects who prefer brevity. Qualify interest immediately.
> Are your SDRs spending more time researching than selling? We help sales teams automate prospect research so reps focus on conversations. Clients see 3x more meetings per rep per week. Worth a 10-minute demo?
## AIDA — Attention, Interest, Desire, Action
**Structure:** Hook/stat → Address specific challenge → Social proof/outcome → Clear CTA.
**Best for:** Data-driven prospects, high-ticket pitches with strong stats.
> Companies in pharma lose 30% of leads due to manual outreach. Given {{Company}}'s growth this quarter, pipeline velocity is likely top of mind. Customers like Pfizer use our platform to automate lead qualification — cutting time-to-contact by 60%. Worth a 15-minute call?
## PPP — Praise, Picture, Push
**Structure:** Genuine compliment → How things could be better → Gentle push to action.
**Best for:** Senior prospects who respond to relationship-building. Requires genuine trigger.
> Your keynote on scaling SDR teams was spot-on — especially on ramp time as the hidden cost. What if you could cut that in half? Our in-inbox coach helps new reps write effective emails from day one with real-time scoring. Open to a quick chat about how this could support your growth?
## Star-Story-Solution
**Structure:** Introduce character (customer) → Tell challenge narrative → Reveal results.
**Best for:** Strong customer success stories. Humanizes the pitch.
> Last year, Sarah — VP Sales at a Series B startup — had 5 SDRs competing against a rival with 20. Her team was getting crushed on volume. They adopted our AI prospecting tool and sent hyper-personalized emails at 3x pace without losing quality. Within 90 days, they booked more meetings than their competitor's entire team. Happy to share how this could work for {{Company}}.
## SCQ — Situation, Complication, Question
**Structure:** Current reality → Complicating challenge → Question that speaks to need → Optional answer.
**Best for:** Consultative selling. Mirrors how professionals present to leadership.
> Your team doubled this year. That usually means onboarding is eating into selling time. How are you handling ramp for new hires?
## ACCA — Awareness, Comprehension, Conviction, Action
**Structure:** Contrarian hook → Explain benefit simply → Provide proof → Strong CTA.
**Best for:** Analytical buyers who need evidence (engineers, CFOs, ops leaders).
> Most sales teams measure rep activity. The top 5% measure rep efficiency instead. When Acme switched, they booked 40% more meetings with fewer emails. Worth seeing how?
## 3C's (Alex Berman)
**Structure:** Compliment → Case Study → CTA.
**Best for:** Agency/services cold outreach. Case study does the heavy lifting.
> Big fan of [Company]. We just built an app for [Competitor] that does XYZ. I have a few more ideas. Interested?
## Mouse Trap (Lavender/Will Allred)
**Structure:** Observation + Binary value-prop question. 12 sentences total.
**Best for:** Maximum brevity. Impulsive reply based on curiosity.
> Looks like you're hiring reps. Would it be helpful to get a more granular look at how they're ramping on email?
## Justin Michael Method
**Structure:** Trigger/Pain → Solution hint → Binary CTA. 13 sentences, no intro.
**Best for:** High-velocity SDR teams. Mobile-optimized. Deliberately polarizing.
Spend max 1 minute on personalization. Use industry/persona-level signals. For top-tier prospects, quote their own words from interviews — they almost always respond.
## Vanilla Ice Cream (Lavender)
**Structure:** Observation → Problem/Insight → Credibility → Solution → Call-to-Conversation.
**Best for:** Universal "base" framework that works everywhere. Five parts.
## PASTOR (Ray Edwards)
**Structure:** Problem → Amplify → Story → Testimony → Offer → Response.
**Best for:** Longer-form or multi-email sequences. Consulting, education, complex B2B services. Each element can be developed across separate touches.
@@ -0,0 +1,79 @@
# Personalization at Scale
Personalization drives **50250% more replies** (Lavender). The key insight: **if your personalization has nothing to do with the problem you solve, it's just an attention hack** (Clay).
## Four Levels of Personalization
### Level 1 — Basic (merge tags)
First name, company name, job title. Table stakes, no longer differentiating. ~5% lift.
### Level 2 — Industry/segment
Industry-specific pain points, trends, regulatory challenges. Scalable via micro-segmentation.
> Most {{industry}} teams struggle with {{lead gen problem}}, which often leads to wasted effort.
### Level 3 — Role-level
Challenges specific to their role and seniority.
> As Head of Sales, keeping pipeline steady is probably your biggest headache. Your RevOps team is small, so you're likely wearing multiple hats during scaling.
### Level 4 — Individual (gold standard)
Specific, timely observations about that person connected to the problem you solve.
> Noticed you're hiring 3 SDRs — sounds like you're scaling outbound fast. Most teams hit follow-up fatigue during onboarding.
## Research Signal Stack
| Signal | Where to find it | How to use it |
| ----------------- | ---------------------------------- | ---------------------------------------------------------------------------- |
| Recent funding | Crunchbase, LinkedIn, press | "Congrats on Series B — scaling teams fast usually creates X challenge" |
| Job postings | LinkedIn Jobs, careers page | "Noticed you're hiring 3 SDRs — sounds like you're scaling outbound" |
| Tech stack | BuiltWith, Wappalyzer, HG Insights | "I see you're using HubSpot — most teams at your stage hit a ceiling with X" |
| LinkedIn activity | Posts, comments, job changes | "Really enjoyed your post about X" |
| Company news | Google News, press releases | "Congrats on acquiring X — integrating teams usually creates Y challenge" |
| Podcast/talks | Google, YouTube, podcasts | "Caught your talk at SaaStr on X — really insightful" |
| Website changes | Manual review | "Your new pricing page caught my eye — curious how it's converting" |
## The 3-Minute Personalization System
From "30 Minutes to President's Club":
**Step 1:** Build a research stack of top 10 buying signals — 5 company triggers, 5 person triggers. Stack-rank by relevance.
**Step 2:** Build a 3x3 template: (1) personalization attached to a problem, (2) problem you solve, (3) one-sentence solution + low-friction CTA.
**Step 3:** Create 5 "trigger templates" — pre-written personalization paragraphs for each trigger, with a smooth segue into the problem.
The personalization must logically connect to the problem. This creates 5 reusable triggers with the rest of the email constant. A top SDR writes a personalized email in **under 3 minutes**.
## The Four -Graphic Principles (Becc Holland)
- **Demographic** — Age, profession, background
- **Technographic** — Tech stack, tools used
- **Firmographic** — Company size, funding, industry, growth stage
- **Psychographic** — Values, passions, beliefs (highest-impact dimension)
Tapping into what prospects are passionate about drives significantly higher response rates.
## Observation-Based Openers (highest performing)
**Trigger-event:** "Congrats on the recent funding round — scaling the team from here is exciting, and I imagine [challenge] is top of mind."
**Observation:** "Your recent post about [topic] resonated — especially the part about [detail]. Got me thinking about how that applies to [challenge]."
**Industry insight:** "Most [role titles] I talk to spend [X hours/week] on [problem] — curious if that matches your experience at [Company]."
## What Feels Fake (avoid)
- AI-generated emails with similar phrasing ("I hope this email finds you well")
- Generic attention hacks disconnected from problem ("Cool that you went to UCLA!" → pitch)
- Over-personalizing to creepiness
- "I saw your LinkedIn profile and wanted to reach out" — signals mass automation
## The "So What?" Test
After writing any opening line, read from prospect's perspective: "So what? Why would I care?" If the answer is nothing, rewrite.
@@ -0,0 +1,53 @@
# Subject Line Optimization
The subject line determines whether the email gets read. The data is counterintuitive: **short, boring, internal-looking subject lines win decisively.**
## Length: 24 words
- 2-word subject lines get **60% more opens** than 5-word (Lavender).
- Going from 2 to 4 words reduces replies by **17.5%**.
- 24 words yield **46% open rates** vs 34% for 10 words (Belkins, 5.5M emails).
- Mobile truncates at 3035 characters — brevity is practical necessity.
## Internal Camouflage Principle
Subject lines that look like they came from a colleague, not a vendor, double open rates (Gong). Buyers mentally categorize before opening — if it looks like sales, it's filtered.
**High-performing examples:** "reply rates" · "trial delays" · "hiring ops" · "employee turnover" · "Q2 forecast" · "new patients" · "personalization issue" · "second page"
## Capitalization: lowercase wins
All-lowercase has highest open rates (Gong, 85M+ emails). Lowercase looks more personal/internal. For cold outreach specifically, lowercase beats title case.
## Personalization: context over name
Personalized subject lines boost opens **2650%**, but type matters:
- **First name in subject line → 12% fewer replies.** Signals automation.
- **Contextual personalization works:** pain points, competitors, trigger events, industry challenges.
- Use {{painPoint}}, {{competitor}}, {{commonGround}} — not {{firstName}}.
## Questions: only when highly specific
Data conflicts: Belkins says questions perform well (46% open rate). Lavender says questions lower opens by **56%**. Resolution: **specific pain questions work** ("Need help with {{challenge}}?"), **generic questions fail** ("Quick question?" / "Have 15 minutes?"). Default to statements.
## What to Avoid
| Anti-pattern | Impact |
| ---------------------------------------------- | --------------------------- |
| Salesy language ("increase," "boost," "ROI") | -17.9% opens |
| Urgency words ("ASAP," "urgent") | Below 36% opens |
| Excessive punctuation ("!!!" or "??") | -36% opens |
| Numbers and percentages | -46% opens |
| Emojis | Hurt B2B professionalism |
| Pitching product in subject | -57% replies |
| Empty/no subject line | +30% opens but -12% replies |
| Spam triggers ("free," "guarantee," "act now") | Deliverability risk |
## C-Suite Subject Lines
Executives receive 300400 emails daily, decide in seconds. They respond **23% more often** than non-C-suite when emails pass their filter (6.4% reply rate).
What works: ultra-concise, human, understated. "{{companyInitiative}}" · "thank you" · "an update" · "a question" · reference to a specific project or trigger event.
Anything "salesy" is immediately rejected.
+2 -1
View File
@@ -1,7 +1,8 @@
---
name: competitor-alternatives
version: 1.0.0
description: "When the user wants to create competitor comparison or alternative pages for SEO and sales enablement. Also use when the user mentions 'alternative page,' 'vs page,' 'competitor comparison,' 'comparison page,' '[Product] vs [Product],' '[Product] alternative,' or 'competitive landing pages.' Covers four formats: singular alternative, plural alternatives, you vs competitor, and competitor vs competitor. Emphasizes deep research, modular content architecture, and varied section types beyond feature tables."
metadata:
version: 1.0.0
---
# Competitor & Alternative Pages
@@ -2,6 +2,14 @@
How to structure and maintain competitor data for scalable comparison pages.
## Contents
- Centralized Competitor Data
- Competitor Data Template
- Your Product Data
- Page Generation
- Index Page Structure (alternatives index, vs comparisons index, index page best practices)
- Footer Navigation
## Centralized Competitor Data
Create a single source of truth for each competitor:
@@ -2,6 +2,17 @@
Ready-to-use templates for each section of competitor comparison pages.
## Contents
- TL;DR Summary
- Paragraph Comparison (Not Just Tables)
- Feature Comparison Section
- Pricing Comparison Section
- Service & Support Comparison
- Who It's For Section
- Migration Section
- Social Proof Section
- Comparison Table Best Practices (beyond checkmarks, organize by category, include ratings where useful)
## TL;DR Summary
Start every page with a quick summary for scanners:
+3 -1
View File
@@ -1,7 +1,8 @@
---
name: content-strategy
version: 1.0.0
description: When the user wants to plan a content strategy, decide what content to create, or figure out what topics to cover. Also use when the user mentions "content strategy," "what should I write about," "content ideas," "blog strategy," "topic clusters," or "content planning." For writing individual pieces, see copywriting. For SEO-specific audits, see seo-audit.
metadata:
version: 1.0.0
---
# Content Strategy
@@ -351,6 +352,7 @@ Visual or structured representation of how content interconnects.
- **copywriting**: For writing individual content pieces
- **seo-audit**: For technical SEO and on-page optimization
- **ai-seo**: For optimizing content for AI search engines and getting cited by LLMs
- **programmatic-seo**: For scaled content generation
- **email-sequence**: For email-based content
- **social-content**: For social media content
+2 -1
View File
@@ -1,7 +1,8 @@
---
name: copy-editing
version: 1.0.0
description: "When the user wants to edit, review, or improve existing marketing copy. Also use when the user mentions 'edit this copy,' 'review my copy,' 'copy feedback,' 'proofread,' 'polish this,' 'make this better,' or 'copy sweep.' This skill provides a systematic approach to editing marketing copy through multiple focused passes."
metadata:
version: 1.0.0
---
# Copy Editing
@@ -6,6 +6,24 @@ Source: Plain English Campaign A-Z of Alternative Words (2001), Australian Gover
---
## Contents
- A
- B
- C
- D
- E
- F
- G-H
- I
- L-M
- N-O
- P
- R
- S
- T-U
- V-Z
- Phrases to Remove Entirely
## A
| Complex | Plain Alternative |
+2 -1
View File
@@ -1,7 +1,8 @@
---
name: copywriting
version: 1.0.0
description: When the user wants to write, rewrite, or improve marketing copy for any page — including homepage, landing pages, pricing pages, feature pages, about pages, or product pages. Also use when the user says "write copy for," "improve this copy," "rewrite this page," "marketing copy," "headline help," or "CTA copy." For email copy, see email-sequence. For popup copy, see popup-cro.
metadata:
version: 1.0.0
---
# Copywriting
@@ -2,6 +2,12 @@
Headline formulas, page section types, and structural templates.
## Contents
- Headline Formulas (outcome-focused, problem-focused, audience-focused, differentiation-focused, proof-focused, additional formulas)
- Landing Page Section Types (core sections, supporting sections)
- Page Structure Templates (feature-heavy page, varied engaging page, compact landing page, enterprise/B2B landing page, product launch page)
- Section Writing Tips (problem section, benefits section, how it works section, testimonial selection)
## Headline Formulas
### Outcome-Focused
@@ -6,6 +6,26 @@ Adapted from: University of Manchester Academic Phrasebank (2023), Plain English
---
## Contents
- Previewing Content Structure
- Introducing a New Topic
- Referring Back
- Moving Between Sections
- Indicating Addition
- Indicating Contrast
- Indicating Similarity
- Indicating Cause and Effect
- Giving Examples
- Emphasising Key Points
- Providing Evidence (neutral attribution, expert quotes, supporting claims)
- Summarising Sections
- Concluding Content
- Question-Based Transitions
- List Introductions
- Hedging Language
- Best Practice Guidelines
- Transitions to Avoid (AI Tells)
## Previewing Content Structure
Use to orient readers and set expectations:
+3 -1
View File
@@ -1,7 +1,8 @@
---
name: email-sequence
version: 1.0.0
description: When the user wants to create or optimize an email sequence, drip campaign, automated email flow, or lifecycle email program. Also use when the user mentions "email sequence," "drip campaign," "nurture sequence," "onboarding emails," "welcome sequence," "re-engagement emails," "email automation," or "lifecycle emails." For in-app onboarding, see onboarding-cro.
metadata:
version: 1.0.0
---
# Email Sequence Design
@@ -300,6 +301,7 @@ For implementation, see the [tools registry](../../tools/REGISTRY.md). Key email
## Related Skills
- **churn-prevention**: For cancel flows, save offers, and dunning strategy (email supports this)
- **onboarding-cro**: For in-app onboarding (email supports this)
- **copywriting**: For landing pages emails link to
- **ab-test-setup**: For testing email elements
@@ -1,5 +1,15 @@
# Email Copy Guidelines
## Contents
- Structure
- Formatting
- Tone
- Length
- CTA Buttons vs. Links
- Personalization (merge fields, dynamic content, triggered emails)
- Segmentation Strategies (by behavior, by stage, by profile)
- Testing and Optimization (what to test, how to test, metrics to track)
## Structure
1. **Hook**: First line grabs attention
@@ -2,6 +2,15 @@
A comprehensive guide to lifecycle and campaign emails. Use this as an audit checklist and implementation reference.
## Contents
- Onboarding Emails (new users series, new customers series, key onboarding step reminder, new user invite)
- Retention Emails (upgrade to paid, upgrade to higher plan, ask for review, offer support proactively, product usage report, NPS survey, referral program)
- Billing Emails (switch to annual, failed payment recovery, cancellation survey, upcoming renewal reminder)
- Usage Emails (daily/weekly/monthly summary, key event or milestone notifications)
- Win-Back Emails (expired trials, cancelled customers)
- Campaign Emails (monthly roundup/newsletter, seasonal promotions, product updates, industry news roundup, pricing update)
- Email Audit Checklist (onboarding, retention, billing, usage, win-back, campaigns)
## Onboarding Emails
### New Users Series
@@ -2,6 +2,12 @@
Detailed templates for common email sequences.
## Contents
- Welcome Sequence (Post-Signup)
- Lead Nurture Sequence (Pre-Sale)
- Re-Engagement Sequence
- Onboarding Sequence (Product Users)
## Welcome Sequence (Post-Signup)
**Email 1: Welcome (Immediate)**
+2 -1
View File
@@ -1,7 +1,8 @@
---
name: form-cro
version: 1.0.0
description: When the user wants to optimize any form that is NOT signup/registration — including lead capture forms, contact forms, demo request forms, application forms, survey forms, or checkout forms. Also use when the user mentions "form optimization," "lead form conversions," "form friction," "form fields," "form completion rate," or "contact form." For signup/registration forms, see signup-flow-cro. For popups containing forms, see popup-cro.
metadata:
version: 1.0.0
---
# Form CRO
+2 -1
View File
@@ -1,7 +1,8 @@
---
name: free-tool-strategy
version: 1.0.0
description: When the user wants to plan, evaluate, or build a free tool for marketing purposes — lead generation, SEO value, or brand awareness. Also use when the user mentions "engineering as marketing," "free tool," "marketing tool," "calculator," "generator," "interactive tool," "lead gen tool," "build a tool for leads," or "free resource." This skill bridges engineering and marketing — useful for founders and technical marketers.
metadata:
version: 1.0.0
---
# Free Tool Strategy (Engineering as Marketing)
@@ -2,6 +2,15 @@
Detailed guide to each type of marketing tool you can build.
## Contents
- Calculators
- Generators
- Analyzers/Auditors
- Testers/Validators
- Libraries/Resources
- Interactive Educational
- Tool Concept Examples by Industry (SaaS product, agency/services, e-commerce, developer tools, finance)
## Calculators
**Best for**: Decisions involving numbers, comparisons, estimates
+2 -1
View File
@@ -1,7 +1,8 @@
---
name: launch-strategy
version: 1.0.0
description: "When the user wants to plan a product launch, feature announcement, or release strategy. Also use when the user mentions 'launch,' 'Product Hunt,' 'feature release,' 'announcement,' 'go-to-market,' 'beta launch,' 'early access,' 'waitlist,' or 'product update.' This skill covers phased launches, channel strategy, and ongoing launch momentum."
metadata:
version: 1.0.0
---
# Launch Strategy
+2 -1
View File
@@ -1,7 +1,8 @@
---
name: marketing-ideas
version: 1.0.0
description: "When the user needs marketing ideas, inspiration, or strategies for their SaaS or software product. Also use when the user asks for 'marketing ideas,' 'growth ideas,' 'how to market,' 'marketing strategies,' 'marketing tactics,' 'ways to promote,' or 'ideas to grow.' This skill provides 139 proven marketing approaches organized by category."
metadata:
version: 1.0.0
---
# Marketing Ideas for SaaS
@@ -2,6 +2,25 @@
Complete list of proven marketing approaches organized by category.
## Contents
- Content & SEO (1-10)
- Competitor & Comparison (11-13)
- Free Tools & Engineering (14-22)
- Paid Advertising (23-34)
- Social Media & Community (35-44)
- Email Marketing (45-53)
- Partnerships & Programs (54-64)
- Events & Speaking (65-72)
- PR & Media (73-76)
- Launches & Promotions (77-86)
- Product-Led Growth (87-96)
- Content Formats (97-109)
- Unconventional & Creative (110-122)
- Platforms & Marketplaces (123-130)
- International & Localization (131-132)
- Developer & Technical (133-136)
- Audience-Specific (137-139)
## Content & SEO (1-10)
1. **Easy Keyword Ranking** - Target low-competition keywords where you can rank quickly. Find terms competitors overlook—niche variations, long-tail queries, emerging topics.
+2 -1
View File
@@ -1,7 +1,8 @@
---
name: marketing-psychology
version: 1.0.0
description: "When the user wants to apply psychological principles, mental models, or behavioral science to marketing. Also use when the user mentions 'psychology,' 'mental models,' 'cognitive bias,' 'persuasion,' 'behavioral science,' 'why people buy,' 'decision-making,' or 'consumer behavior.' This skill provides 70+ mental models organized for marketing application."
metadata:
version: 1.0.0
---
# Marketing Psychology & Mental Models
+2 -1
View File
@@ -1,7 +1,8 @@
---
name: onboarding-cro
version: 1.0.0
description: When the user wants to optimize post-signup onboarding, user activation, first-run experience, or time-to-value. Also use when the user mentions "onboarding flow," "activation rate," "user activation," "first-run experience," "empty states," "onboarding checklist," "aha moment," or "new user experience." For signup/registration optimization, see signup-flow-cro. For ongoing email sequences, see email-sequence.
metadata:
version: 1.0.0
---
# Onboarding CRO
@@ -2,6 +2,16 @@
Comprehensive list of A/B tests and experiments for user onboarding and activation.
## Contents
- Flow Simplification Experiments (reduce friction, step sequencing, progress & motivation)
- Guided Experience Experiments (product tours, CTA optimization, UI guidance)
- Personalization Experiments (user segmentation, dynamic content)
- Quick Wins & Engagement Experiments (time-to-value, motivation mechanics, support & help)
- Email & Multi-Channel Experiments (onboarding emails, email content, feedback loops)
- Re-engagement Experiments (stalled user recovery, return experience)
- Technical & UX Experiments (performance, mobile onboarding, accessibility)
- Metrics to Track
## Flow Simplification Experiments
### Reduce Friction
+2 -1
View File
@@ -1,7 +1,8 @@
---
name: page-cro
version: 1.0.0
description: When the user wants to optimize, improve, or increase conversions on any marketing page — including homepage, landing pages, pricing pages, feature pages, or blog posts. Also use when the user says "CRO," "conversion rate optimization," "this page isn't converting," "improve conversions," or "why isn't this page working." For signup/registration flows, see signup-flow-cro. For post-signup activation, see onboarding-cro. For forms outside of signup, see form-cro. For popups/modals, see popup-cro.
metadata:
version: 1.0.0
---
# Page Conversion Rate Optimization (CRO)
@@ -2,6 +2,15 @@
Comprehensive list of A/B tests and experiments organized by page type.
## Contents
- Homepage Experiments (Hero Section, Trust & Social Proof, Features & Content, Navigation & UX)
- Pricing Page Experiments (Price Presentation, Pricing UX, Objection Handling, Trust Signals)
- Demo Request Page Experiments (Form Optimization, Page Content, CTA & Routing)
- Resource/Blog Page Experiments (Content CTAs, Resource Section)
- Landing Page Experiments (Message Match, Conversion Focus, Page Length)
- Feature Page Experiments (Feature Presentation, Conversion Path)
- Cross-Page Experiments (Site-Wide Tests, Navigation Tests)
## Homepage Experiments
### Hero Section
+4 -2
View File
@@ -1,7 +1,8 @@
---
name: paid-ads
version: 1.0.0
description: "When the user wants help with paid advertising campaigns on Google Ads, Meta (Facebook/Instagram), LinkedIn, Twitter/X, or other ad platforms. Also use when the user mentions 'PPC,' 'paid media,' 'ad copy,' 'ad creative,' 'ROAS,' 'CPA,' 'ad campaign,' 'retargeting,' or 'audience targeting.' This skill covers campaign strategy, ad creation, audience targeting, and optimization."
description: "When the user wants help with paid advertising campaigns on Google Ads, Meta (Facebook/Instagram), LinkedIn, Twitter/X, or other ad platforms. Also use when the user mentions 'PPC,' 'paid media,' 'ROAS,' 'CPA,' 'ad campaign,' 'retargeting,' or 'audience targeting.' This skill covers campaign strategy, audience targeting, and optimization. For bulk ad creative generation and iteration, see ad-creative."
metadata:
version: 1.0.0
---
# Paid Ads
@@ -307,6 +308,7 @@ For tracking, see also: [ga4.md](../../tools/integrations/ga4.md), [segment.md](
## Related Skills
- **ad-creative**: For generating and iterating ad headlines, descriptions, and creative at scale
- **copywriting**: For landing page copy that converts ad traffic
- **analytics-tracking**: For proper conversion tracking setup
- **ab-test-setup**: For landing page testing to improve ROAS
@@ -2,6 +2,13 @@
Detailed formulas and templates for writing high-converting ad copy.
## Contents
- Primary Text Formulas (Problem-Agitate-Solve, Before-After-Bridge, Social Proof Lead, Feature-Benefit Bridge, Direct Response)
- Headline Formulas (For Search Ads, For Social Ads)
- CTA Variations (Soft CTAs, Hard CTAs, Urgency CTAs, Action-Oriented CTAs)
- Platform-Specific Copy Guidelines (Google Search Ads, Meta Ads, LinkedIn Ads)
- Copy Testing Priority
## Primary Text Formulas
### Problem-Agitate-Solve (PAS)
@@ -2,6 +2,15 @@
Detailed targeting strategies for each major ad platform.
## Contents
- Google Ads Audiences (Search Campaign Targeting, Display/YouTube Targeting)
- Meta Audiences (Core Audiences, Custom Audiences, Lookalike Audiences)
- LinkedIn Audiences (Job-Based Targeting, Company-Based Targeting, High-Performing Combinations)
- Twitter/X Audiences
- TikTok Audiences
- Audience Size Guidelines
- Exclusion Strategy
## Google Ads Audiences
### Search Campaign Targeting
@@ -2,6 +2,14 @@
Complete setup checklists for major ad platforms.
## Contents
- Google Ads Setup (Account Foundation, Conversion Tracking, Analytics Integration, Audience Setup, Campaign Readiness, Ad Extensions, Brand Protection)
- Meta Ads Setup (Business Manager Foundation, Pixel & Tracking, Domain & Aggregated Events, Audience Setup, Catalog, Creative Assets, Compliance)
- LinkedIn Ads Setup (Campaign Manager Foundation, Insight Tag & Tracking, Audience Setup, Lead Gen Forms, Document Ads, Creative Assets, Budget Considerations)
- Twitter/X Ads Setup (Account Foundation, Tracking, Audience Setup, Creative)
- TikTok Ads Setup (Account Foundation, Pixel & Tracking, Audience Setup, Creative)
- Universal Pre-Launch Checklist
## Google Ads Setup
### Account Foundation
+3 -1
View File
@@ -1,7 +1,8 @@
---
name: paywall-upgrade-cro
version: 1.0.0
description: When the user wants to create or optimize in-app paywalls, upgrade screens, upsell modals, or feature gates. Also use when the user mentions "paywall," "upgrade screen," "upgrade modal," "upsell," "feature gate," "convert free to paid," "freemium conversion," "trial expiration screen," "limit reached screen," "plan upgrade prompt," or "in-app pricing." Distinct from public pricing pages (see page-cro) — this skill focuses on in-product upgrade moments where the user has already experienced value.
metadata:
version: 1.0.0
---
# Paywall and Upgrade Screen CRO
@@ -220,6 +221,7 @@ What you've accomplished:
## Related Skills
- **churn-prevention**: For cancel flows, save offers, and reducing churn post-upgrade
- **page-cro**: For public pricing page optimization
- **onboarding-cro**: For driving to aha moment before upgrade
- **ab-test-setup**: For testing paywall variations
@@ -2,6 +2,15 @@
Comprehensive list of A/B tests and experiments for paywall optimization.
## Contents
- Trigger & Timing Experiments (When to Show, Trigger Type)
- Paywall Design Experiments (Layout & Format, Value Presentation, Visual Elements)
- Pricing Presentation Experiments (Price Display, Plan Options, Discounts & Offers)
- Copy & Messaging Experiments (Headlines, CTAs, Objection Handling)
- Trial & Conversion Experiments (Trial Structure, Trial Expiration, Upgrade Path)
- Personalization Experiments (Usage-Based, Segment-Specific)
- Frequency & UX Experiments (Frequency Capping, Dismiss Behavior)
## Trigger & Timing Experiments
### When to Show
+2 -1
View File
@@ -1,7 +1,8 @@
---
name: popup-cro
version: 1.0.0
description: When the user wants to create or optimize popups, modals, overlays, slide-ins, or banners for conversion purposes. Also use when the user mentions "exit intent," "popup conversions," "modal optimization," "lead capture popup," "email popup," "announcement banner," or "overlay." For forms outside of popups, see form-cro. For general page conversion optimization, see page-cro.
metadata:
version: 1.0.0
---
# Popup CRO
+3 -1
View File
@@ -1,7 +1,8 @@
---
name: pricing-strategy
version: 1.0.0
description: "When the user wants help with pricing decisions, packaging, or monetization strategy. Also use when the user mentions 'pricing,' 'pricing tiers,' 'freemium,' 'free trial,' 'packaging,' 'price increase,' 'value metric,' 'Van Westendorp,' 'willingness to pay,' or 'monetization.' This skill covers pricing research, tier structure, and packaging strategy."
metadata:
version: 1.0.0
---
# Pricing Strategy
@@ -221,6 +222,7 @@ Identifies which features customers value most:
## Related Skills
- **churn-prevention**: For cancel flows, save offers, and reducing revenue churn
- **page-cro**: For optimizing pricing page conversion
- **copywriting**: For pricing page copy
- **marketing-psychology**: For pricing psychology principles
@@ -1,5 +1,11 @@
# Pricing Research Methods
## Contents
- Van Westendorp Price Sensitivity Meter (The Four Questions, How to Analyze, Survey Tips, Sample Output)
- MaxDiff Analysis (How It Works, Example Survey Question, Analyzing Results, Using MaxDiff for Packaging)
- Willingness to Pay Surveys
- Usage-Value Correlation Analysis
## Van Westendorp Price Sensitivity Meter
The Van Westendorp survey identifies the acceptable price range for your product.
@@ -1,5 +1,14 @@
# Tier Structure and Packaging
## Contents
- How Many Tiers?
- Good-Better-Best Framework
- Tier Differentiation Strategies
- Example Tier Structure
- Packaging for Personas (Identifying Pricing Personas, Persona-Based Packaging)
- Freemium vs. Free Trial (When to Use Freemium, When to Use Free Trial, Hybrid Approaches)
- Enterprise Pricing (When to Add Custom Pricing, Enterprise Tier Elements, Enterprise Pricing Strategies)
## How Many Tiers?
**2 tiers:** Simple, clear choice
+2 -1
View File
@@ -1,7 +1,8 @@
---
name: product-marketing-context
version: 1.0.0
description: "When the user wants to create or update their product marketing context document. Also use when the user mentions 'product context,' 'marketing context,' 'set up context,' 'positioning,' or wants to avoid repeating foundational information across marketing tasks. Creates `.claude/product-marketing-context.md` that other marketing skills reference."
metadata:
version: 1.0.0
---
# Product Marketing Context
+2 -1
View File
@@ -1,7 +1,8 @@
---
name: programmatic-seo
version: 1.0.0
description: When the user wants to create SEO-driven pages at scale using templates and data. Also use when the user mentions "programmatic SEO," "template pages," "pages at scale," "directory pages," "location pages," "[keyword] + [city] pages," "comparison pages," "integration pages," or "building many pages for SEO." For auditing existing SEO issues, see seo-audit.
metadata:
version: 1.0.0
---
# Programmatic SEO
@@ -2,6 +2,21 @@
Beyond mixing and matching data point permutations, these are the proven playbooks for programmatic SEO.
## Contents
- 1. Templates
- 2. Curation
- 3. Conversions
- 4. Comparisons
- 5. Examples
- 6. Locations
- 7. Personas
- 8. Integrations
- 9. Glossary
- 10. Translations
- 11. Directory
- 12. Profiles
- Choosing Your Playbook (Match to Your Assets, Combine Playbooks)
## 1. Templates
**Pattern**: "[Type] template" or "free [type] template"
+2 -1
View File
@@ -1,7 +1,8 @@
---
name: referral-program
version: 1.0.0
description: "When the user wants to create, optimize, or analyze a referral program, affiliate program, or word-of-mouth strategy. Also use when the user mentions 'referral,' 'affiliate,' 'ambassador,' 'word of mouth,' 'viral loop,' 'refer a friend,' or 'partner program.' This skill covers program design, incentive structure, and growth optimization."
metadata:
version: 1.0.0
---
# Referral & Affiliate Programs
@@ -2,6 +2,14 @@
Detailed guidance for building and managing affiliate programs.
## Contents
- Commission Structures
- Cookie Duration
- Affiliate Recruitment
- Affiliate Enablement
- Tools & Platforms (Referral Program Tools, Affiliate Program Tools, Choosing a Tool)
- Fraud Prevention (Common Referral Fraud, Prevention Measures)
## Commission Structures
**Percentage of sale:**
@@ -2,6 +2,15 @@
Real-world examples of successful referral programs.
## Contents
- Dropbox (Classic)
- Uber/Lyft
- Morning Brew
- Notion
- Incentive Types Comparison
- Incentive Sizing Framework
- Viral Coefficient & Metrics (Key Metrics, Calculating Referral Program ROI)
## Dropbox (Classic)
**Program:** Give 500MB storage, get 500MB storage
+3 -1
View File
@@ -1,7 +1,8 @@
---
name: schema-markup
version: 1.0.0
description: When the user wants to add, fix, or optimize schema markup and structured data on their site. Also use when the user mentions "schema markup," "structured data," "JSON-LD," "rich snippets," "schema.org," "FAQ schema," "product schema," "review schema," or "breadcrumb schema." For broader SEO issues, see seo-audit.
metadata:
version: 1.0.0
---
# Schema Markup
@@ -173,4 +174,5 @@ You can combine multiple schema types on one page using `@graph`:
## Related Skills
- **seo-audit**: For overall SEO including schema review
- **ai-seo**: For AI search optimization (schema helps AI understand content)
- **programmatic-seo**: For templated schema at scale
@@ -2,6 +2,20 @@
Complete JSON-LD examples for common schema types.
## Contents
- Organization
- WebSite (with SearchAction)
- Article / BlogPosting
- Product
- SoftwareApplication
- FAQPage
- HowTo
- BreadcrumbList
- LocalBusiness
- Event
- Multiple Schema Types
- Implementation Example (Next.js)
## Organization
For company/brand homepage or about page.
+20 -3
View File
@@ -1,7 +1,8 @@
---
name: seo-audit
version: 1.0.0
description: When the user wants to audit, review, or diagnose SEO issues on their site. Also use when the user mentions "SEO audit," "technical SEO," "why am I not ranking," "SEO issues," "on-page SEO," "meta tags review," or "SEO health check." For building pages at scale to target keywords, see programmatic-seo. For adding structured data, see schema-markup.
metadata:
version: 1.0.0
---
# SEO Audit
@@ -34,6 +35,19 @@ Before auditing, understand:
## Audit Framework
### ⚠️ Important: Schema Markup Detection Limitation
**`web_fetch` and `curl` cannot reliably detect structured data / schema markup.**
Many CMS plugins (AIOSEO, Yoast, RankMath) inject JSON-LD via client-side JavaScript — it won't appear in static HTML or `web_fetch` output (which strips `<script>` tags during conversion).
**To accurately check for schema markup, use one of these methods:**
1. **Browser tool** — render the page and run: `document.querySelectorAll('script[type="application/ld+json"]')`
2. **Google Rich Results Test** — https://search.google.com/test/rich-results
3. **Screaming Frog export** — if the client provides one, use it (SF renders JavaScript)
**Never report "no schema found" based solely on `web_fetch` or `curl`.** This has led to false audit findings in production.
### Priority Order
1. **Crawlability & Indexation** (can Google find and index it?)
2. **Technical Foundations** (is the site fast and functional?)
@@ -354,7 +368,7 @@ Same format as above
## References
- [AI Writing Detection](references/ai-writing-detection.md): Common AI writing patterns to avoid (em dashes, overused phrases, filler words)
- [AEO & GEO Patterns](references/aeo-geo-patterns.md): Content patterns optimized for answer engines and AI citation
- For AI search optimization (AEO, GEO, LLMO, AI Overviews), see the **ai-seo** skill
---
@@ -364,10 +378,12 @@ Same format as above
- Google Search Console (essential)
- Google PageSpeed Insights
- Bing Webmaster Tools
- Rich Results Test
- Rich Results Test (**use this for schema validation — it renders JavaScript**)
- Mobile-Friendly Test
- Schema Validator
> **Note on schema detection:** `web_fetch` strips `<script>` tags (including JSON-LD) and cannot detect JS-injected schema. Always use the browser tool, Rich Results Test, or Screaming Frog for schema checks. See the warning at the top of the Audit Framework section.
**Paid Tools** (if available)
- Screaming Frog
- Ahrefs / Semrush
@@ -388,6 +404,7 @@ Same format as above
## Related Skills
- **ai-seo**: For optimizing content for AI search engines (AEO, GEO, LLMO)
- **programmatic-seo**: For building SEO pages at scale
- **schema-markup**: For implementing structured data
- **page-cro**: For optimizing pages for conversion (not just ranking)
@@ -6,6 +6,16 @@ Sources: Grammarly (2025), Microsoft 365 Life Hacks (2025), GPTHuman (2025), Wal
---
## Contents
- Em Dashes: The Primary AI Tell
- Overused Verbs
- Overused Adjectives
- Overused Transitions and Connectors
- Phrases That Signal AI Writing (Opening Phrases, Transitional Phrases, Concluding Phrases, Structural Patterns)
- Filler Words and Empty Intensifiers
- Academic-Specific AI Tells
- How to Self-Check
## Em Dashes: The Primary AI Tell
**The em dash (—) has become one of the most reliable markers of AI-generated content.**
+2 -1
View File
@@ -1,7 +1,8 @@
---
name: signup-flow-cro
version: 1.0.0
description: When the user wants to optimize signup, registration, account creation, or trial activation flows. Also use when the user mentions "signup conversions," "registration friction," "signup form optimization," "free trial signup," "reduce signup dropoff," or "account creation flow." For post-signup onboarding, see onboarding-cro. For lead capture forms (not account creation), see form-cro.
metadata:
version: 1.0.0
---
# Signup Flow CRO
+2 -1
View File
@@ -1,7 +1,8 @@
---
name: social-content
version: 1.0.0
description: "When the user wants help creating, scheduling, or optimizing social media content for LinkedIn, Twitter/X, Instagram, TikTok, Facebook, or other platforms. Also use when the user mentions 'LinkedIn post,' 'Twitter thread,' 'social media,' 'content calendar,' 'social scheduling,' 'engagement,' or 'viral content.' This skill covers content creation, repurposing, and platform-specific strategies."
metadata:
version: 1.0.0
---
# Social Content
@@ -2,6 +2,13 @@
Detailed strategies for each major social platform.
## Contents
- LinkedIn
- Twitter/X
- Instagram
- TikTok
- Facebook
## LinkedIn
**Best for:** B2B, thought leadership, professional networking, recruiting
@@ -2,6 +2,12 @@
Ready-to-use templates for different platforms and content types.
## Contents
- LinkedIn Post Templates (The Story Post, The Contrarian Take, The List Post, The How-To)
- Twitter/X Thread Templates (The Tutorial Thread, The Story Thread, The Breakdown Thread)
- Instagram Templates (The Carousel Hook, The Reel Script)
- Hook Formulas (Curiosity Hooks, Story Hooks, Value Hooks, Contrarian Hooks, Social Proof Hooks)
## LinkedIn Post Templates
### The Story Post
@@ -2,6 +2,11 @@
Instead of guessing what works, systematically analyze top-performing content in your niche and extract proven patterns.
## Contents
- The 6-Step Framework (Niche ID, Scrape, Analyze, Playbook, Layer Voice, Convert)
- The Formula
- Reverse Engineering Checklist
## The 6-Step Framework
### 1. NICHE ID — Find Top Creators
+201 -27
View File
@@ -14,32 +14,61 @@ Quick reference for AI agents to discover tool capabilities and integration meth
| Tool | Category | API | MCP | CLI | SDK | Guide |
|------|----------|:---:|:---:|:---:|:---:|-------|
| ga4 | Analytics | ✓ | ✓ | - | ✓ | [ga4.md](integrations/ga4.md) |
| mixpanel | Analytics | ✓ | - | - | ✓ | [mixpanel.md](integrations/mixpanel.md) |
| amplitude | Analytics | ✓ | - | - | ✓ | [amplitude.md](integrations/amplitude.md) |
| ga4 | Analytics | ✓ | ✓ | [](clis/ga4.js) | ✓ | [ga4.md](integrations/ga4.md) |
| mixpanel | Analytics | ✓ | - | [](clis/mixpanel.js) | ✓ | [mixpanel.md](integrations/mixpanel.md) |
| amplitude | Analytics | ✓ | - | [](clis/amplitude.js) | ✓ | [amplitude.md](integrations/amplitude.md) |
| posthog | Analytics | ✓ | - | ✓ | ✓ | [posthog.md](integrations/posthog.md) |
| segment | Analytics | ✓ | - | - | ✓ | [segment.md](integrations/segment.md) |
| adobe-analytics | Analytics | ✓ | - | - | ✓ | [adobe-analytics.md](integrations/adobe-analytics.md) |
| google-search-console | SEO | ✓ | - | - | | [google-search-console.md](integrations/google-search-console.md) |
| semrush | SEO | ✓ | - | - | - | [semrush.md](integrations/semrush.md) |
| ahrefs | SEO | ✓ | - | - | - | [ahrefs.md](integrations/ahrefs.md) |
| segment | Analytics | ✓ | - | [](clis/segment.js) | ✓ | [segment.md](integrations/segment.md) |
| adobe-analytics | Analytics | ✓ | - | [](clis/adobe-analytics.js) | ✓ | [adobe-analytics.md](integrations/adobe-analytics.md) |
| plausible | Analytics | ✓ | - | [](clis/plausible.js) | - | [plausible.md](integrations/plausible.md) |
| google-search-console | SEO | ✓ | - | [](clis/google-search-console.js) | | [google-search-console.md](integrations/google-search-console.md) |
| semrush | SEO | ✓ | - | [](clis/semrush.js) | - | [semrush.md](integrations/semrush.md) |
| ahrefs | SEO | ✓ | - | [](clis/ahrefs.js) | - | [ahrefs.md](integrations/ahrefs.md) |
| dataforseo | SEO | ✓ | - | [](clis/dataforseo.js) | ✓ | [dataforseo.md](integrations/dataforseo.md) |
| keywords-everywhere | SEO | ✓ | - | [](clis/keywords-everywhere.js) | - | [keywords-everywhere.md](integrations/keywords-everywhere.md) |
| clearbit | Data Enrichment | ✓ | - | [](clis/clearbit.js) | ✓ | [clearbit.md](integrations/clearbit.md) |
| apollo | Data Enrichment | ✓ | - | [](clis/apollo.js) | - | [apollo.md](integrations/apollo.md) |
| hubspot | CRM | ✓ | - | ✓ | ✓ | [hubspot.md](integrations/hubspot.md) |
| salesforce | CRM | ✓ | - | ✓ | ✓ | [salesforce.md](integrations/salesforce.md) |
| stripe | Payments | ✓ | ✓ | ✓ | ✓ | [stripe.md](integrations/stripe.md) |
| rewardful | Referral | ✓ | - | - | - | [rewardful.md](integrations/rewardful.md) |
| tolt | Referral | ✓ | - | - | - | [tolt.md](integrations/tolt.md) |
| dub-co | Links | ✓ | - | - | | [dub-co.md](integrations/dub-co.md) |
| mention-me | Referral | ✓ | - | - | - | [mention-me.md](integrations/mention-me.md) |
| mailchimp | Email | ✓ | | - | | [mailchimp.md](integrations/mailchimp.md) |
| customer-io | Email | ✓ | - | - | | [customer-io.md](integrations/customer-io.md) |
| sendgrid | Email | ✓ | - | - | ✓ | [sendgrid.md](integrations/sendgrid.md) |
| resend | Email | ✓ | | - | ✓ | [resend.md](integrations/resend.md) |
| kit | Email | ✓ | - | - | ✓ | [kit.md](integrations/kit.md) |
| google-ads | Ads | ✓ | ✓ | - | ✓ | [google-ads.md](integrations/google-ads.md) |
| meta-ads | Ads | ✓ | - | - | ✓ | [meta-ads.md](integrations/meta-ads.md) |
| linkedin-ads | Ads | ✓ | - | - | - | [linkedin-ads.md](integrations/linkedin-ads.md) |
| tiktok-ads | Ads | ✓ | - | - | ✓ | [tiktok-ads.md](integrations/tiktok-ads.md) |
| zapier | Automation | ✓ | | - | - | [zapier.md](integrations/zapier.md) |
| paddle | Payments | ✓ | - | [](clis/paddle.js) | | [paddle.md](integrations/paddle.md) |
| rewardful | Referral | ✓ | - | [](clis/rewardful.js) | - | [rewardful.md](integrations/rewardful.md) |
| tolt | Referral | ✓ | - | [](clis/tolt.js) | - | [tolt.md](integrations/tolt.md) |
| dub-co | Links | ✓ | - | [](clis/dub.js) | | [dub-co.md](integrations/dub-co.md) |
| mention-me | Referral | ✓ | - | [](clis/mention-me.js) | - | [mention-me.md](integrations/mention-me.md) |
| partnerstack | Affiliate | ✓ | - | [](clis/partnerstack.js) | - | [partnerstack.md](integrations/partnerstack.md) |
| mailchimp | Email | ✓ | | [](clis/mailchimp.js) | ✓ | [mailchimp.md](integrations/mailchimp.md) |
| customer-io | Email | ✓ | - | [](clis/customer-io.js) | ✓ | [customer-io.md](integrations/customer-io.md) |
| sendgrid | Email | ✓ | - | [](clis/sendgrid.js) | ✓ | [sendgrid.md](integrations/sendgrid.md) |
| resend | Email | ✓ | ✓ | [](clis/resend.js) | ✓ | [resend.md](integrations/resend.md) |
| kit | Email | ✓ | - | [](clis/kit.js) | ✓ | [kit.md](integrations/kit.md) |
| beehiiv | Newsletter | ✓ | - | [](clis/beehiiv.js) | - | [beehiiv.md](integrations/beehiiv.md) |
| klaviyo | Email/SMS | ✓ | - | [](clis/klaviyo.js) | ✓ | [klaviyo.md](integrations/klaviyo.md) |
| postmark | Email | ✓ | - | [](clis/postmark.js) | | [postmark.md](integrations/postmark.md) |
| brevo | Email/SMS | ✓ | - | [](clis/brevo.js) | ✓ | [brevo.md](integrations/brevo.md) |
| activecampaign | Email/CRM | ✓ | - | [](clis/activecampaign.js) | ✓ | [activecampaign.md](integrations/activecampaign.md) |
| hunter | Email Outreach | ✓ | - | [](clis/hunter.js) | - | [hunter.md](integrations/hunter.md) |
| snov | Email Outreach | ✓ | - | [](clis/snov.js) | - | [snov.md](integrations/snov.md) |
| lemlist | Email Outreach | ✓ | - | [](clis/lemlist.js) | - | [lemlist.md](integrations/lemlist.md) |
| instantly | Email Outreach | ✓ | - | [](clis/instantly.js) | - | [instantly.md](integrations/instantly.md) |
| google-ads | Ads | ✓ | ✓ | [](clis/google-ads.js) | ✓ | [google-ads.md](integrations/google-ads.md) |
| meta-ads | Ads | ✓ | - | [](clis/meta-ads.js) | ✓ | [meta-ads.md](integrations/meta-ads.md) |
| linkedin-ads | Ads | ✓ | - | [](clis/linkedin-ads.js) | - | [linkedin-ads.md](integrations/linkedin-ads.md) |
| tiktok-ads | Ads | ✓ | - | [](clis/tiktok-ads.js) | ✓ | [tiktok-ads.md](integrations/tiktok-ads.md) |
| zapier | Automation | ✓ | ✓ | [](clis/zapier.js) | - | [zapier.md](integrations/zapier.md) |
| hotjar | CRO | ✓ | - | [](clis/hotjar.js) | - | [hotjar.md](integrations/hotjar.md) |
| optimizely | A/B Testing | ✓ | - | [](clis/optimizely.js) | ✓ | [optimizely.md](integrations/optimizely.md) |
| calendly | Scheduling | ✓ | - | [](clis/calendly.js) | - | [calendly.md](integrations/calendly.md) |
| savvycal | Scheduling | ✓ | - | [](clis/savvycal.js) | - | [savvycal.md](integrations/savvycal.md) |
| typeform | Forms | ✓ | - | [](clis/typeform.js) | ✓ | [typeform.md](integrations/typeform.md) |
| intercom | Messaging | ✓ | - | [](clis/intercom.js) | ✓ | [intercom.md](integrations/intercom.md) |
| buffer | Social | ✓ | - | [](clis/buffer.js) | - | [buffer.md](integrations/buffer.md) |
| wistia | Video | ✓ | - | [](clis/wistia.js) | - | [wistia.md](integrations/wistia.md) |
| trustpilot | Reviews | ✓ | - | [](clis/trustpilot.js) | - | [trustpilot.md](integrations/trustpilot.md) |
| g2 | Reviews | ✓ | - | [](clis/g2.js) | - | [g2.md](integrations/g2.md) |
| onesignal | Push | ✓ | - | [](clis/onesignal.js) | ✓ | [onesignal.md](integrations/onesignal.md) |
| demio | Webinar | ✓ | - | [](clis/demio.js) | - | [demio.md](integrations/demio.md) |
| livestorm | Webinar | ✓ | - | [](clis/livestorm.js) | - | [livestorm.md](integrations/livestorm.md) |
| shopify | Commerce | ✓ | - | ✓ | ✓ | [shopify.md](integrations/shopify.md) |
| wordpress | CMS | ✓ | - | ✓ | ✓ | [wordpress.md](integrations/wordpress.md) |
| webflow | CMS | ✓ | - | ✓ | ✓ | [webflow.md](integrations/webflow.md) |
@@ -60,8 +89,9 @@ Track user behavior, measure conversions, and analyze marketing performance.
| **posthog** | Open-source analytics, session replay | - |
| **segment** | Customer data platform, routing | - |
| **adobe-analytics** | Enterprise analytics | - |
| **plausible** | Privacy-focused analytics | - |
**Agent recommendation**: Start with GA4 if using Google ecosystem. Use Mixpanel or Amplitude for deeper product analytics.
**Agent recommendation**: Start with GA4 if using Google ecosystem. Use Mixpanel or Amplitude for deeper product analytics. Plausible for privacy-focused sites.
### SEO
@@ -72,8 +102,10 @@ Search engine optimization tools for keyword research, rank tracking, and site a
| **google-search-console** | Free, authoritative search data | Direct from Google |
| **semrush** | Competitive analysis, keyword research | Comprehensive |
| **ahrefs** | Backlink analysis, content research | Best for links |
| **dataforseo** | SERP tracking, backlinks, on-page audits | Comprehensive API |
| **keywords-everywhere** | Quick keyword research, traffic estimates | Credit-based |
**Agent recommendation**: Google Search Console is essential (free). Add Semrush or Ahrefs for competitive research.
**Agent recommendation**: Google Search Console is essential (free). Add Semrush or Ahrefs for competitive research. DataForSEO for programmatic SERP data. Keywords Everywhere for quick keyword lookups.
### CRM
@@ -94,7 +126,9 @@ Payment processing and subscription management.
|------|----------|:-------------:|
| **stripe** | SaaS subscriptions, developer-friendly | ✓ |
**Agent recommendation**: Stripe is the default for SaaS and developer-focused products.
| **paddle** | SaaS billing with tax handling | - |
**Agent recommendation**: Stripe is the default for SaaS. Paddle for built-in tax compliance.
### Referral & Affiliate
@@ -106,8 +140,9 @@ Tools for referral programs, affiliate tracking, and partner management.
| **tolt** | SaaS affiliate programs | ✓ |
| **mention-me** | Enterprise referral programs | ✓ |
| **dub-co** | Link tracking, attribution | - |
| **partnerstack** | Enterprise partner programs | ✓ |
**Agent recommendation**: Rewardful or Tolt for Stripe-based SaaS. Dub.co for link attribution.
**Agent recommendation**: Rewardful or Tolt for Stripe-based SaaS. PartnerStack for enterprise partner programs. Dub.co for link attribution.
### Email
@@ -120,8 +155,13 @@ Email marketing, transactional email, and automation platforms.
| **sendgrid** | Transactional email at scale | - |
| **resend** | Developer-friendly transactional | ✓ |
| **kit** | Creator/newsletter focused | - |
| **beehiiv** | Newsletter platform | - |
| **klaviyo** | E-commerce email + SMS | - |
| **postmark** | Deliverability-focused transactional | - |
| **brevo** | Email + SMS, popular in EU | - |
| **activecampaign** | Email automation + CRM | - |
**Agent recommendation**: Resend for transactional (dev-friendly). Customer.io for advanced automation. Kit for creators.
**Agent recommendation**: Resend for transactional (dev-friendly). Postmark for deliverability. Customer.io for advanced automation. Kit for creators. Beehiiv for newsletters. Klaviyo for e-commerce email/SMS. ActiveCampaign for email + CRM combo.
### Advertising
@@ -146,6 +186,124 @@ Workflow automation and integration platforms.
**Agent recommendation**: Zapier for connecting tools without code.
### CRO & A/B Testing
Conversion rate optimization, heatmaps, and experimentation.
| Tool | Best For | Notes |
|------|----------|-------|
| **hotjar** | Heatmaps, recordings, surveys | Visual behavior data |
| **optimizely** | A/B testing, feature flags | Enterprise experimentation |
**Agent recommendation**: Hotjar for understanding user behavior. Optimizely for running experiments.
### Scheduling
Booking and appointment scheduling tools.
| Tool | Best For | Notes |
|------|----------|-------|
| **calendly** | Meeting scheduling, lead gen | Most popular |
| **savvycal** | Personalized scheduling | Developer-friendly |
**Agent recommendation**: Calendly for general use. SavvyCal for personalized booking experiences.
### Forms & Surveys
Form builders and survey platforms.
| Tool | Best For | Notes |
|------|----------|-------|
| **typeform** | Interactive forms, surveys | Conversational UX |
**Agent recommendation**: Typeform for engaging forms and surveys.
### Messaging
In-app messaging, chat, and customer communication.
| Tool | Best For | Notes |
|------|----------|-------|
| **intercom** | In-app messaging, support, product tours | Full customer platform |
**Agent recommendation**: Intercom for in-app messaging and customer support.
### Social Media
Social media scheduling, management, and analytics.
| Tool | Best For | Notes |
|------|----------|-------|
| **buffer** | Social scheduling, analytics | Multi-platform |
**Agent recommendation**: Buffer for scheduling and analytics across social platforms.
### Video
Video hosting, analytics, and engagement.
| Tool | Best For | Notes |
|------|----------|-------|
| **wistia** | Video hosting, marketing analytics | Best for marketing video |
**Agent recommendation**: Wistia for marketing video hosting with analytics.
### Data Enrichment
Company and person data enrichment for sales and marketing.
| Tool | Best For | Notes |
|------|----------|-------|
| **clearbit** | Company/person enrichment | Now HubSpot Breeze |
| **apollo** | B2B prospecting, email finding | Large database |
**Agent recommendation**: Clearbit for enrichment. Apollo for prospecting and outbound.
### Reviews
Review management and social proof platforms.
| Tool | Best For | Notes |
|------|----------|-------|
| **trustpilot** | Consumer business reviews | Most recognized |
| **g2** | Software/B2B reviews | Best for SaaS |
**Agent recommendation**: Trustpilot for consumer products. G2 for B2B software.
### Push Notifications
Push notification delivery platforms.
| Tool | Best For | Notes |
|------|----------|-------|
| **onesignal** | Multi-channel push notifications | Web + mobile |
**Agent recommendation**: OneSignal for web and mobile push notifications.
### Webinar
Webinar and virtual event platforms.
| Tool | Best For | Notes |
|------|----------|-------|
| **demio** | Marketing webinars | Simple, focused |
| **livestorm** | Video engagement, webinars | Full event platform |
**Agent recommendation**: Demio for marketing-focused webinars. Livestorm for full event engagement.
### Email Outreach
Cold email outreach and email finding tools for link building and sales prospecting.
| Tool | Best For | Notes |
|------|----------|-------|
| **hunter** | Email finding, domain search | Largest email database |
| **snov** | Email finding, drip campaigns | Built-in sequences |
| **lemlist** | Cold email campaigns | Personalization features |
| **instantly** | Cold email at scale | Email warmup built-in |
**Agent recommendation**: Hunter for finding emails. Lemlist or Instantly for sending cold email campaigns. Snov for combined finding + outreach.
### Commerce & CMS
E-commerce platforms and content management systems.
@@ -160,6 +318,18 @@ E-commerce platforms and content management systems.
---
## CLI Tools
Zero-dependency, single-file Node.js CLIs for tools that don't ship their own. See [`clis/README.md`](clis/README.md) for install instructions and usage.
All CLIs follow a consistent pattern:
- **No dependencies** — Node 18+ only, uses native `fetch`
- **JSON output** — pipe to `jq`, save to file, or use in scripts
- **Env var auth** — set `{TOOL}_API_KEY` and go
- **Consistent commands**`{tool} <resource> <action> [options]`
---
## MCP-Enabled Tools
These tools have Model Context Protocol servers available, enabling direct agent interaction:
@@ -189,6 +359,10 @@ To use MCP tools, ensure the appropriate MCP server is configured in your enviro
1. Read [customer-io.md](integrations/customer-io.md) for behavior-based automation
2. Read [resend.md](integrations/resend.md) for transactional email
### Running email outreach for backlinks
1. Read [hunter.md](integrations/hunter.md) for finding emails
2. Read [lemlist.md](integrations/lemlist.md) or [instantly.md](integrations/instantly.md) for sending campaigns
### Running paid ads
1. Read [google-ads.md](integrations/google-ads.md) for search campaigns
2. Read [meta-ads.md](integrations/meta-ads.md) for social campaigns
+187
View File
@@ -0,0 +1,187 @@
# Marketing CLIs
Zero-dependency, single-file CLI tools for marketing platforms that don't ship their own.
Every CLI is a standalone Node.js script (Node 18+) with no `npm install` required — just `chmod +x` and go.
## Install
### Option 1: Run directly
```bash
node tools/clis/ahrefs.js backlinks list --target example.com
```
### Option 2: Symlink for global access
```bash
# Symlink any CLI you want available globally
ln -sf "$(pwd)/tools/clis/ahrefs.js" ~/.local/bin/ahrefs
ln -sf "$(pwd)/tools/clis/resend.js" ~/.local/bin/resend
# Then use directly
ahrefs backlinks list --target example.com
resend send --from you@example.com --to them@example.com --subject "Hello" --html "<p>Hi</p>"
```
### Option 3: Add the whole directory to PATH
```bash
export PATH="$PATH:/path/to/marketingskills/tools/clis"
```
## Authentication
Every CLI reads credentials from environment variables:
| CLI | Environment Variable |
|-----|---------------------|
| `activecampaign` | `ACTIVECAMPAIGN_API_KEY`, `ACTIVECAMPAIGN_API_URL` |
| `adobe-analytics` | `ADOBE_ACCESS_TOKEN`, `ADOBE_CLIENT_ID`, `ADOBE_COMPANY_ID` |
| `ahrefs` | `AHREFS_API_KEY` |
| `amplitude` | `AMPLITUDE_API_KEY`, `AMPLITUDE_SECRET_KEY` |
| `apollo` | `APOLLO_API_KEY` |
| `beehiiv` | `BEEHIIV_API_KEY` |
| `brevo` | `BREVO_API_KEY` |
| `buffer` | `BUFFER_API_KEY` |
| `calendly` | `CALENDLY_API_KEY` |
| `clearbit` | `CLEARBIT_API_KEY` |
| `customer-io` | `CUSTOMERIO_APP_KEY` (App API), `CUSTOMERIO_SITE_ID` + `CUSTOMERIO_API_KEY` (Track API) |
| `dataforseo` | `DATAFORSEO_LOGIN`, `DATAFORSEO_PASSWORD` |
| `demio` | `DEMIO_API_KEY`, `DEMIO_API_SECRET` |
| `dub` | `DUB_API_KEY` |
| `g2` | `G2_API_TOKEN` |
| `ga4` | `GA4_ACCESS_TOKEN` |
| `google-ads` | `GOOGLE_ADS_TOKEN`, `GOOGLE_ADS_DEVELOPER_TOKEN`, `GOOGLE_ADS_CUSTOMER_ID` |
| `google-search-console` | `GSC_ACCESS_TOKEN` |
| `hotjar` | `HOTJAR_CLIENT_ID`, `HOTJAR_CLIENT_SECRET` |
| `intercom` | `INTERCOM_API_KEY` |
| `keywords-everywhere` | `KEYWORDS_EVERYWHERE_API_KEY` |
| `kit` | `KIT_API_KEY`, `KIT_API_SECRET` |
| `klaviyo` | `KLAVIYO_API_KEY` |
| `linkedin-ads` | `LINKEDIN_ACCESS_TOKEN` |
| `livestorm` | `LIVESTORM_API_TOKEN` |
| `mailchimp` | `MAILCHIMP_API_KEY` |
| `mention-me` | `MENTIONME_API_KEY` |
| `meta-ads` | `META_ACCESS_TOKEN`, `META_AD_ACCOUNT_ID` |
| `mixpanel` | `MIXPANEL_TOKEN` (ingestion), `MIXPANEL_API_KEY` + `MIXPANEL_SECRET` (query) |
| `onesignal` | `ONESIGNAL_REST_API_KEY`, `ONESIGNAL_APP_ID` |
| `optimizely` | `OPTIMIZELY_API_KEY` |
| `paddle` | `PADDLE_API_KEY`, `PADDLE_SANDBOX` (optional) |
| `partnerstack` | `PARTNERSTACK_PUBLIC_KEY`, `PARTNERSTACK_SECRET_KEY` |
| `plausible` | `PLAUSIBLE_API_KEY`, `PLAUSIBLE_BASE_URL` (optional, for self-hosted) |
| `postmark` | `POSTMARK_API_KEY` |
| `resend` | `RESEND_API_KEY` |
| `rewardful` | `REWARDFUL_API_KEY` |
| `savvycal` | `SAVVYCAL_API_KEY` |
| `segment` | `SEGMENT_WRITE_KEY` (tracking), `SEGMENT_ACCESS_TOKEN` (profile) |
| `semrush` | `SEMRUSH_API_KEY` |
| `sendgrid` | `SENDGRID_API_KEY` |
| `tiktok-ads` | `TIKTOK_ACCESS_TOKEN`, `TIKTOK_ADVERTISER_ID` |
| `tolt` | `TOLT_API_KEY` |
| `trustpilot` | `TRUSTPILOT_API_KEY`, `TRUSTPILOT_API_SECRET`, `TRUSTPILOT_BUSINESS_UNIT_ID` |
| `typeform` | `TYPEFORM_API_KEY` |
| `hunter` | `HUNTER_API_KEY` |
| `instantly` | `INSTANTLY_API_KEY` |
| `lemlist` | `LEMLIST_API_KEY` |
| `snov` | `SNOV_CLIENT_ID`, `SNOV_CLIENT_SECRET` |
| `wistia` | `WISTIA_API_KEY` |
| `zapier` | `ZAPIER_API_KEY` |
## Security
**Never hardcode API keys or tokens in scripts.** All CLIs read credentials exclusively from environment variables.
- Store keys in your shell profile (`~/.zshrc`, `~/.bashrc`) or a `.env` file
- The `.env` file is gitignored — but double-check before committing
- Use `--dry-run` on any command to preview the request without sending it (credentials are masked as `***`)
- If you fork this repo, audit your commits to ensure no secrets are included
## Command Pattern
All CLIs follow the same structure:
```
{tool} <resource> <action> [options]
```
Examples:
```bash
ahrefs backlinks list --target example.com --limit 50
semrush keywords overview --phrase "marketing automation" --database us
mailchimp campaigns list --limit 20
resend send --from you@example.com --to them@example.com --subject "Hello" --html "<p>Hi</p>"
dub links create --url https://example.com/landing --key summer-sale
```
## Output
All CLIs output JSON to stdout for easy piping:
```bash
# Pipe to jq
ahrefs backlinks list --target example.com | jq '.backlinks[].url_from'
# Save to file
semrush keywords overview --phrase "saas marketing" --database us > keywords.json
# Use in scripts
DOMAINS=$(rewardful affiliates list | jq -r '.data[].email')
```
## Available CLIs
| CLI | Category | Tool |
|-----|----------|------|
| `activecampaign.js` | Email/CRM | [ActiveCampaign](https://activecampaign.com) |
| `adobe-analytics.js` | Analytics | [Adobe Analytics](https://business.adobe.com/products/analytics) |
| `ahrefs.js` | SEO | [Ahrefs](https://ahrefs.com) |
| `amplitude.js` | Analytics | [Amplitude](https://amplitude.com) |
| `apollo.js` | Data Enrichment | [Apollo.io](https://apollo.io) |
| `beehiiv.js` | Newsletter | [Beehiiv](https://beehiiv.com) |
| `brevo.js` | Email/SMS | [Brevo](https://brevo.com) |
| `buffer.js` | Social | [Buffer](https://buffer.com) |
| `calendly.js` | Scheduling | [Calendly](https://calendly.com) |
| `clearbit.js` | Data Enrichment | [Clearbit](https://clearbit.com) |
| `customer-io.js` | Email | [Customer.io](https://customer.io) |
| `dataforseo.js` | SEO | [DataForSEO](https://dataforseo.com) |
| `demio.js` | Webinar | [Demio](https://demio.com) |
| `dub.js` | Links | [Dub.co](https://dub.co) |
| `g2.js` | Reviews | [G2](https://g2.com) |
| `ga4.js` | Analytics | [Google Analytics 4](https://analytics.google.com) |
| `google-ads.js` | Ads | [Google Ads](https://ads.google.com) |
| `google-search-console.js` | SEO | [Google Search Console](https://search.google.com/search-console) |
| `hotjar.js` | CRO | [Hotjar](https://hotjar.com) |
| `hunter.js` | Email Outreach | [Hunter.io](https://hunter.io) |
| `instantly.js` | Email Outreach | [Instantly.ai](https://instantly.ai) |
| `intercom.js` | Messaging | [Intercom](https://intercom.com) |
| `keywords-everywhere.js` | SEO | [Keywords Everywhere](https://keywordseverywhere.com) |
| `kit.js` | Email | [Kit](https://kit.com) |
| `klaviyo.js` | Email/SMS | [Klaviyo](https://klaviyo.com) |
| `lemlist.js` | Email Outreach | [Lemlist](https://lemlist.com) |
| `linkedin-ads.js` | Ads | [LinkedIn Ads](https://business.linkedin.com/marketing-solutions/ads) |
| `livestorm.js` | Webinar | [Livestorm](https://livestorm.co) |
| `mailchimp.js` | Email | [Mailchimp](https://mailchimp.com) |
| `mention-me.js` | Referral | [Mention Me](https://www.mention-me.com) |
| `meta-ads.js` | Ads | [Meta Ads](https://www.facebook.com/business/ads) |
| `mixpanel.js` | Analytics | [Mixpanel](https://mixpanel.com) |
| `onesignal.js` | Push | [OneSignal](https://onesignal.com) |
| `optimizely.js` | A/B Testing | [Optimizely](https://optimizely.com) |
| `paddle.js` | Payments | [Paddle](https://paddle.com) |
| `partnerstack.js` | Affiliate | [PartnerStack](https://partnerstack.com) |
| `plausible.js` | Analytics | [Plausible](https://plausible.io) |
| `postmark.js` | Email | [Postmark](https://postmarkapp.com) |
| `resend.js` | Email | [Resend](https://resend.com) |
| `rewardful.js` | Referral | [Rewardful](https://www.getrewardful.com) |
| `savvycal.js` | Scheduling | [SavvyCal](https://savvycal.com) |
| `segment.js` | Analytics | [Segment](https://segment.com) |
| `semrush.js` | SEO | [SEMrush](https://semrush.com) |
| `sendgrid.js` | Email | [SendGrid](https://sendgrid.com) |
| `snov.js` | Email Outreach | [Snov.io](https://snov.io) |
| `tiktok-ads.js` | Ads | [TikTok Ads](https://ads.tiktok.com) |
| `tolt.js` | Referral | [Tolt](https://tolt.io) |
| `trustpilot.js` | Reviews | [Trustpilot](https://trustpilot.com) |
| `typeform.js` | Forms | [Typeform](https://typeform.com) |
| `wistia.js` | Video | [Wistia](https://wistia.com) |
| `zapier.js` | Automation | [Zapier](https://zapier.com) |
+435
View File
@@ -0,0 +1,435 @@
#!/usr/bin/env node
const API_KEY = process.env.ACTIVECAMPAIGN_API_KEY
const API_URL = process.env.ACTIVECAMPAIGN_API_URL
if (!API_KEY) {
console.error(JSON.stringify({ error: 'ACTIVECAMPAIGN_API_KEY environment variable required' }))
process.exit(1)
}
if (!API_URL) {
console.error(JSON.stringify({ error: 'ACTIVECAMPAIGN_API_URL environment variable required (e.g. https://yourname.api-us1.com)' }))
process.exit(1)
}
const BASE_URL = `${API_URL.replace(/\/$/, '')}/api/3`
async function api(method, path, body) {
if (args['dry-run']) {
return { _dry_run: true, method, url: `${BASE_URL}${path}`, headers: { 'Api-Token': '***', 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: body || undefined }
}
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
'Api-Token': API_KEY,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { status: res.status, body: text }
}
}
function parseArgs(args) {
const result = { _: [] }
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg.startsWith('--')) {
const key = arg.slice(2)
const next = args[i + 1]
if (next && !next.startsWith('--')) {
result[key] = next
i++
} else {
result[key] = true
}
} else {
result._.push(arg)
}
}
return result
}
const args = parseArgs(process.argv.slice(2))
const [cmd, sub, ...rest] = args._
async function main() {
let result
const limit = args.limit ? Number(args.limit) : 20
const offset = args.offset ? Number(args.offset) : 0
switch (cmd) {
case 'contacts':
switch (sub) {
case 'list': {
const params = new URLSearchParams()
params.set('limit', String(limit))
params.set('offset', String(offset))
if (args.email) params.set('email', args.email)
if (args.search) params.set('search', args.search)
if (args['list-id']) params.set('listid', args['list-id'])
if (args.status) params.set('status', args.status)
result = await api('GET', `/contacts?${params.toString()}`)
break
}
case 'get': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('GET', `/contacts/${id}`)
break
}
case 'create': {
const email = args.email
if (!email) { result = { error: '--email required' }; break }
const contact = { email }
if (args['first-name']) contact.firstName = args['first-name']
if (args['last-name']) contact.lastName = args['last-name']
if (args.phone) contact.phone = args.phone
result = await api('POST', '/contacts', { contact })
break
}
case 'update': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
const contact = {}
if (args.email) contact.email = args.email
if (args['first-name']) contact.firstName = args['first-name']
if (args['last-name']) contact.lastName = args['last-name']
if (args.phone) contact.phone = args.phone
result = await api('PUT', `/contacts/${id}`, { contact })
break
}
case 'delete': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('DELETE', `/contacts/${id}`)
break
}
case 'sync': {
const email = args.email
if (!email) { result = { error: '--email required' }; break }
const contact = { email }
if (args['first-name']) contact.firstName = args['first-name']
if (args['last-name']) contact.lastName = args['last-name']
if (args.phone) contact.phone = args.phone
result = await api('POST', '/contact/sync', { contact })
break
}
default:
result = { error: 'Unknown contacts subcommand. Use: list, get, create, update, delete, sync' }
}
break
case 'lists':
switch (sub) {
case 'list': {
const params = new URLSearchParams()
params.set('limit', String(limit))
params.set('offset', String(offset))
result = await api('GET', `/lists?${params.toString()}`)
break
}
case 'get': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('GET', `/lists/${id}`)
break
}
case 'create': {
const name = args.name
if (!name) { result = { error: '--name required' }; break }
const list = { name }
if (args['string-id']) list.stringid = args['string-id']
if (args['sender-url']) list.sender_url = args['sender-url']
if (args['sender-reminder']) list.sender_reminder = args['sender-reminder']
result = await api('POST', '/lists', { list })
break
}
case 'delete': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('DELETE', `/lists/${id}`)
break
}
case 'subscribe': {
const listId = args['list-id'] || args.id
const contactId = args['contact-id']
if (!listId) { result = { error: '--list-id required' }; break }
if (!contactId) { result = { error: '--contact-id required' }; break }
result = await api('POST', '/contactLists', {
contactList: { list: listId, contact: contactId, status: 1 }
})
break
}
case 'unsubscribe': {
const listId = args['list-id'] || args.id
const contactId = args['contact-id']
if (!listId) { result = { error: '--list-id required' }; break }
if (!contactId) { result = { error: '--contact-id required' }; break }
result = await api('POST', '/contactLists', {
contactList: { list: listId, contact: contactId, status: 2 }
})
break
}
default:
result = { error: 'Unknown lists subcommand. Use: list, get, create, delete, subscribe, unsubscribe' }
}
break
case 'campaigns':
switch (sub) {
case 'list': {
const params = new URLSearchParams()
params.set('limit', String(limit))
params.set('offset', String(offset))
result = await api('GET', `/campaigns?${params.toString()}`)
break
}
case 'get': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('GET', `/campaigns/${id}`)
break
}
default:
result = { error: 'Unknown campaigns subcommand. Use: list, get' }
}
break
case 'deals':
switch (sub) {
case 'list': {
const params = new URLSearchParams()
params.set('limit', String(limit))
params.set('offset', String(offset))
if (args.search) params.set('search', args.search)
if (args.stage) params.set('filters[stage]', args.stage)
if (args.owner) params.set('filters[owner]', args.owner)
result = await api('GET', `/deals?${params.toString()}`)
break
}
case 'get': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('GET', `/deals/${id}`)
break
}
case 'create': {
const title = args.title
if (!title) { result = { error: '--title required' }; break }
const deal = { title }
if (args.value) deal.value = Number(args.value)
if (args.currency) deal.currency = args.currency
if (args.pipeline) deal.group = args.pipeline
if (args.stage) deal.stage = args.stage
if (args.owner) deal.owner = args.owner
if (args['contact-id']) deal.contact = args['contact-id']
result = await api('POST', '/deals', { deal })
break
}
case 'update': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
const deal = {}
if (args.title) deal.title = args.title
if (args.value) deal.value = Number(args.value)
if (args.stage) deal.stage = args.stage
if (args.owner) deal.owner = args.owner
if (args.status) deal.status = Number(args.status)
result = await api('PUT', `/deals/${id}`, { deal })
break
}
case 'delete': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('DELETE', `/deals/${id}`)
break
}
default:
result = { error: 'Unknown deals subcommand. Use: list, get, create, update, delete' }
}
break
case 'automations':
switch (sub) {
case 'list': {
const params = new URLSearchParams()
params.set('limit', String(limit))
params.set('offset', String(offset))
result = await api('GET', `/automations?${params.toString()}`)
break
}
case 'get': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('GET', `/automations/${id}`)
break
}
case 'add-contact': {
const automationId = args.id
const contactId = args['contact-id']
if (!automationId) { result = { error: '--id required (automation ID)' }; break }
if (!contactId) { result = { error: '--contact-id required (contact ID, not email)' }; break }
result = await api('POST', '/contactAutomations', {
contactAutomation: { contact: contactId, automation: automationId }
})
break
}
default:
result = { error: 'Unknown automations subcommand. Use: list, get, add-contact' }
}
break
case 'tags':
switch (sub) {
case 'list': {
const params = new URLSearchParams()
params.set('limit', String(limit))
params.set('offset', String(offset))
if (args.search) params.set('search', args.search)
result = await api('GET', `/tags?${params.toString()}`)
break
}
case 'get': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('GET', `/tags/${id}`)
break
}
case 'create': {
const name = args.name
if (!name) { result = { error: '--name required' }; break }
result = await api('POST', '/tags', {
tag: { tag: name, tagType: args.type || 'contact' }
})
break
}
case 'delete': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('DELETE', `/tags/${id}`)
break
}
case 'add-to-contact': {
const tagId = args['tag-id']
const contactId = args['contact-id']
if (!tagId) { result = { error: '--tag-id required' }; break }
if (!contactId) { result = { error: '--contact-id required' }; break }
result = await api('POST', '/contactTags', {
contactTag: { contact: contactId, tag: tagId }
})
break
}
case 'remove-from-contact': {
const contactTagId = args.id
if (!contactTagId) { result = { error: '--id required (contactTag ID)' }; break }
result = await api('DELETE', `/contactTags/${contactTagId}`)
break
}
default:
result = { error: 'Unknown tags subcommand. Use: list, get, create, delete, add-to-contact, remove-from-contact' }
}
break
case 'pipelines':
switch (sub) {
case 'list': {
const params = new URLSearchParams()
params.set('limit', String(limit))
params.set('offset', String(offset))
result = await api('GET', `/dealGroups?${params.toString()}`)
break
}
case 'get': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('GET', `/dealGroups/${id}`)
break
}
default:
result = { error: 'Unknown pipelines subcommand. Use: list, get' }
}
break
case 'webhooks':
switch (sub) {
case 'list': {
const params = new URLSearchParams()
params.set('limit', String(limit))
params.set('offset', String(offset))
result = await api('GET', `/webhooks?${params.toString()}`)
break
}
case 'get': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('GET', `/webhooks/${id}`)
break
}
case 'create': {
const name = args.name
const url = args.url
if (!name) { result = { error: '--name required' }; break }
if (!url) { result = { error: '--url required' }; break }
const events = args.events?.split(',') || ['subscribe']
const sources = args.sources?.split(',') || ['public', 'admin', 'api', 'system']
result = await api('POST', '/webhooks', {
webhook: { name, url, events, sources }
})
break
}
case 'delete': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('DELETE', `/webhooks/${id}`)
break
}
default:
result = { error: 'Unknown webhooks subcommand. Use: list, get, create, delete' }
}
break
case 'users':
switch (sub) {
case 'me':
result = await api('GET', '/users/me')
break
case 'list':
result = await api('GET', '/users')
break
default:
result = { error: 'Unknown users subcommand. Use: me, list' }
}
break
default:
result = {
error: 'Unknown command',
usage: {
contacts: 'contacts [list | get --id <id> | create --email <email> | update --id <id> | delete --id <id> | sync --email <email>]',
lists: 'lists [list | get --id <id> | create --name <name> | delete --id <id> | subscribe --list-id <lid> --contact-id <cid> | unsubscribe --list-id <lid> --contact-id <cid>]',
campaigns: 'campaigns [list | get --id <id>]',
deals: 'deals [list | get --id <id> | create --title <title> | update --id <id> | delete --id <id>]',
automations: 'automations [list | get --id <id> | add-contact --id <aid> --email <email>]',
tags: 'tags [list | get --id <id> | create --name <name> | delete --id <id> | add-to-contact --tag-id <tid> --contact-id <cid>]',
pipelines: 'pipelines [list | get --id <id>]',
webhooks: 'webhooks [list | get --id <id> | create --name <name> --url <url> | delete --id <id>]',
users: 'users [me | list]',
options: '--limit <n> --offset <n> --search <query> --email <email>',
}
}
}
console.log(JSON.stringify(result, null, 2))
}
main().catch(err => {
console.error(JSON.stringify({ error: err.message }))
process.exit(1)
})
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env node
const ACCESS_TOKEN = process.env.ADOBE_ACCESS_TOKEN
const CLIENT_ID = process.env.ADOBE_CLIENT_ID
const COMPANY_ID = process.env.ADOBE_COMPANY_ID
if (!ACCESS_TOKEN || !CLIENT_ID || !COMPANY_ID) {
console.error(JSON.stringify({ error: 'ADOBE_ACCESS_TOKEN, ADOBE_CLIENT_ID, and ADOBE_COMPANY_ID environment variables required' }))
process.exit(1)
}
const BASE_URL = `https://analytics.adobe.io/api/${COMPANY_ID}`
async function api(method, path, body) {
if (args['dry-run']) {
return { _dry_run: true, method, url: `${BASE_URL}${path}`, headers: { 'Authorization': '***', 'x-api-key': '***', 'x-proxy-global-company-id': COMPANY_ID, 'Content-Type': 'application/json' }, body: body || undefined }
}
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
'Authorization': `Bearer ${ACCESS_TOKEN}`,
'x-api-key': CLIENT_ID,
'x-proxy-global-company-id': COMPANY_ID,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { status: res.status, body: text }
}
}
function parseArgs(args) {
const result = { _: [] }
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg.startsWith('--')) {
const key = arg.slice(2)
const next = args[i + 1]
if (next && !next.startsWith('--')) {
result[key] = next
i++
} else {
result[key] = true
}
} else {
result._.push(arg)
}
}
return result
}
const args = parseArgs(process.argv.slice(2))
const [cmd, sub, ...rest] = args._
async function main() {
let result
switch (cmd) {
case 'reportsuites':
switch (sub) {
case 'list':
result = await api('GET', '/reportsuites')
break
default:
result = { error: 'Unknown reportsuites subcommand. Use: list' }
}
break
case 'dimensions':
switch (sub) {
case 'list': {
if (!args.rsid) { result = { error: '--rsid required' }; break }
const params = new URLSearchParams()
params.set('rsid', args.rsid)
result = await api('GET', `/dimensions?${params}`)
break
}
default:
result = { error: 'Unknown dimensions subcommand. Use: list' }
}
break
case 'metrics':
switch (sub) {
case 'list': {
if (!args.rsid) { result = { error: '--rsid required' }; break }
const params = new URLSearchParams()
params.set('rsid', args.rsid)
result = await api('GET', `/metrics?${params}`)
break
}
default:
result = { error: 'Unknown metrics subcommand. Use: list' }
}
break
case 'reports':
switch (sub) {
case 'run': {
if (!args.rsid) { result = { error: '--rsid required' }; break }
if (!args['start-date']) { result = { error: '--start-date required' }; break }
if (!args['end-date']) { result = { error: '--end-date required' }; break }
if (!args.metrics) { result = { error: '--metrics required (comma-separated)' }; break }
const body = {
rsid: args.rsid,
globalFilters: [{
type: 'dateRange',
dateRange: `${args['start-date']}T00:00:00/${args['end-date']}T23:59:59`,
}],
metricContainer: {
metrics: args.metrics.split(',').map(m => ({ id: m.trim() })),
},
}
if (args.dimension) {
body.dimension = args.dimension
}
result = await api('POST', '/reports', body)
break
}
default:
result = { error: 'Unknown reports subcommand. Use: run' }
}
break
case 'segments':
switch (sub) {
case 'list': {
const params = new URLSearchParams()
if (args.rsid) params.set('rsid', args.rsid)
result = await api('GET', `/segments?${params}`)
break
}
default:
result = { error: 'Unknown segments subcommand. Use: list' }
}
break
default:
result = {
error: 'Unknown command',
usage: {
reportsuites: 'reportsuites list',
dimensions: 'dimensions list --rsid <report_suite_id>',
metrics: 'metrics list --rsid <report_suite_id>',
reports: 'reports run --rsid <report_suite_id> --start-date <YYYY-MM-DD> --end-date <YYYY-MM-DD> --metrics <metrics> [--dimension <dimension>]',
segments: 'segments list [--rsid <report_suite_id>]',
}
}
}
console.log(JSON.stringify(result, null, 2))
}
main().catch(err => {
console.error(JSON.stringify({ error: err.message }))
process.exit(1)
})
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env node
const API_KEY = process.env.AHREFS_API_KEY
const BASE_URL = 'https://api.ahrefs.com/v3'
if (!API_KEY) {
console.error(JSON.stringify({ error: 'AHREFS_API_KEY environment variable required' }))
process.exit(1)
}
async function api(method, path) {
if (args['dry-run']) {
return { _dry_run: true, method, url: `${BASE_URL}${path}`, headers: { 'Authorization': '***', 'Content-Type': 'application/json' } }
}
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
})
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { status: res.status, body: text }
}
}
function parseArgs(args) {
const result = { _: [] }
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg.startsWith('--')) {
const key = arg.slice(2)
const next = args[i + 1]
if (next && !next.startsWith('--')) {
result[key] = next
i++
} else {
result[key] = true
}
} else {
result._.push(arg)
}
}
return result
}
const args = parseArgs(process.argv.slice(2))
const [cmd, sub, ...rest] = args._
async function main() {
let result
const mode = args.mode || 'domain'
switch (cmd) {
case 'domain-rating':
switch (sub) {
case 'get': {
if (!args.target) { result = { error: '--target required (domain)' }; break }
const params = new URLSearchParams({ target: args.target })
result = await api('GET', `/site-explorer/domain-rating?${params}`)
break
}
default:
result = { error: 'Unknown domain-rating subcommand. Use: get' }
}
break
case 'backlinks':
switch (sub) {
case 'list': {
if (!args.target) { result = { error: '--target required (domain or URL)' }; break }
const params = new URLSearchParams({ target: args.target, mode })
if (args.limit) params.set('limit', args.limit)
result = await api('GET', `/site-explorer/backlinks?${params}`)
break
}
default:
result = { error: 'Unknown backlinks subcommand. Use: list' }
}
break
case 'refdomains':
switch (sub) {
case 'list': {
if (!args.target) { result = { error: '--target required (domain or URL)' }; break }
const params = new URLSearchParams({ target: args.target, mode })
if (args.limit) params.set('limit', args.limit)
result = await api('GET', `/site-explorer/refdomains?${params}`)
break
}
default:
result = { error: 'Unknown refdomains subcommand. Use: list' }
}
break
case 'keywords':
switch (sub) {
case 'organic': {
if (!args.target) { result = { error: '--target required (domain or URL)' }; break }
const params = new URLSearchParams({ target: args.target, mode })
if (args.country) params.set('country', args.country)
if (args.limit) params.set('limit', args.limit)
result = await api('GET', `/site-explorer/organic-keywords?${params}`)
break
}
default:
result = { error: 'Unknown keywords subcommand. Use: organic' }
}
break
case 'top-pages':
switch (sub) {
case 'list': {
if (!args.target) { result = { error: '--target required (domain or URL)' }; break }
const params = new URLSearchParams({ target: args.target, mode })
if (args.country) params.set('country', args.country)
if (args.limit) params.set('limit', args.limit)
result = await api('GET', `/site-explorer/top-pages?${params}`)
break
}
default:
result = { error: 'Unknown top-pages subcommand. Use: list' }
}
break
case 'keyword-overview':
switch (sub) {
case 'get': {
const params = new URLSearchParams({ keywords: args.keywords })
if (args.country) params.set('country', args.country)
result = await api('GET', `/keywords-explorer/overview?${params}`)
break
}
default:
result = { error: 'Unknown keyword-overview subcommand. Use: get' }
}
break
case 'keyword-suggestions':
switch (sub) {
case 'get': {
const params = new URLSearchParams({ keyword: args.keyword })
if (args.country) params.set('country', args.country)
if (args.limit) params.set('limit', args.limit)
result = await api('GET', `/keywords-explorer/matching-terms?${params}`)
break
}
default:
result = { error: 'Unknown keyword-suggestions subcommand. Use: get' }
}
break
case 'serp':
switch (sub) {
case 'get': {
const params = new URLSearchParams({ keyword: args.keyword })
if (args.country) params.set('country', args.country)
result = await api('GET', `/keywords-explorer/serp-overview?${params}`)
break
}
default:
result = { error: 'Unknown serp subcommand. Use: get' }
}
break
default:
result = {
error: 'Unknown command',
usage: {
'domain-rating': 'domain-rating get --target <domain>',
'backlinks': 'backlinks list --target <domain> [--mode <mode>] [--limit <n>]',
'refdomains': 'refdomains list --target <domain> [--mode <mode>] [--limit <n>]',
'keywords': 'keywords organic --target <domain> [--country <cc>] [--limit <n>]',
'top-pages': 'top-pages list --target <domain> [--country <cc>] [--limit <n>]',
'keyword-overview': 'keyword-overview get --keywords <kw1,kw2> [--country <cc>]',
'keyword-suggestions': 'keyword-suggestions get --keyword <keyword> [--country <cc>] [--limit <n>]',
'serp': 'serp get --keyword <keyword> [--country <cc>]',
'modes': 'domain (default), subdomains, prefix, exact',
}
}
}
console.log(JSON.stringify(result, null, 2))
}
main().catch(err => {
console.error(JSON.stringify({ error: err.message }))
process.exit(1)
})
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env node
const API_KEY = process.env.AMPLITUDE_API_KEY
const SECRET_KEY = process.env.AMPLITUDE_SECRET_KEY
const INGESTION_URL = 'https://api2.amplitude.com'
const QUERY_URL = 'https://amplitude.com/api/2'
if (!API_KEY) {
console.error(JSON.stringify({ error: 'AMPLITUDE_API_KEY environment variable required' }))
process.exit(1)
}
async function ingestApi(method, path, body) {
if (args['dry-run']) {
const maskedBody = body ? JSON.parse(JSON.stringify(body)) : undefined
if (maskedBody && maskedBody.api_key) maskedBody.api_key = '***'
return { _dry_run: true, method, url: `${INGESTION_URL}${path}`, headers: { 'Content-Type': 'application/json' }, body: maskedBody }
}
const res = await fetch(`${INGESTION_URL}${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { status: res.status, body: text }
}
}
async function queryApi(method, path, params) {
if (!SECRET_KEY) {
return { error: 'AMPLITUDE_SECRET_KEY required for query/export operations' }
}
const url = params ? `${QUERY_URL}${path}?${params}` : `${QUERY_URL}${path}`
if (args['dry-run']) {
return { _dry_run: true, method, url, headers: { 'Authorization': '***', 'Content-Type': 'application/json' } }
}
const auth = Buffer.from(`${API_KEY}:${SECRET_KEY}`).toString('base64')
const res = await fetch(url, {
method,
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/json',
},
})
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { status: res.status, body: text }
}
}
function parseArgs(args) {
const result = { _: [] }
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg.startsWith('--')) {
const key = arg.slice(2)
const next = args[i + 1]
if (next && !next.startsWith('--')) {
result[key] = next
i++
} else {
result[key] = true
}
} else {
result._.push(arg)
}
}
return result
}
const args = parseArgs(process.argv.slice(2))
const [cmd, sub, ...rest] = args._
async function main() {
let result
switch (cmd) {
case 'track':
switch (sub) {
case 'event': {
if (!args['user-id']) { result = { error: '--user-id required' }; break }
if (!args['event-type']) { result = { error: '--event-type required' }; break }
const event = {
user_id: args['user-id'],
event_type: args['event-type'],
}
if (args.properties) {
event.event_properties = JSON.parse(args.properties)
}
result = await ingestApi('POST', '/2/httpapi', {
api_key: API_KEY,
events: [event],
})
break
}
case 'batch': {
if (!args.events) { result = { error: '--events required (JSON array)' }; break }
const events = JSON.parse(args.events)
result = await ingestApi('POST', '/batch', {
api_key: API_KEY,
events,
})
break
}
default:
result = { error: 'Unknown track subcommand. Use: event, batch' }
}
break
case 'users':
switch (sub) {
case 'activity': {
if (!args['user-id']) { result = { error: '--user-id required' }; break }
const params = new URLSearchParams()
params.set('user', args['user-id'])
result = await queryApi('GET', '/useractivity', params)
break
}
default:
result = { error: 'Unknown users subcommand. Use: activity' }
}
break
case 'export':
switch (sub) {
case 'events': {
if (!args.start) { result = { error: '--start required (e.g. 20240101T00)' }; break }
if (!args.end) { result = { error: '--end required (e.g. 20240131T23)' }; break }
const params = new URLSearchParams()
params.set('start', args.start)
params.set('end', args.end)
result = await queryApi('GET', '/export', params)
break
}
default:
result = { error: 'Unknown export subcommand. Use: events' }
}
break
case 'retention':
switch (sub) {
case 'get': {
if (!args.start) { result = { error: '--start required (e.g. 20240101)' }; break }
if (!args.end) { result = { error: '--end required (e.g. 20240131)' }; break }
const params = new URLSearchParams()
params.set('start', args.start)
params.set('end', args.end)
if (args.event) {
params.set('e', JSON.stringify([{ event_type: args.event }]))
}
result = await queryApi('GET', '/retention', params)
break
}
default:
result = { error: 'Unknown retention subcommand. Use: get' }
}
break
default:
result = {
error: 'Unknown command',
usage: {
track: 'track [event --user-id <id> --event-type <type> [--properties <json>] | batch --events <json>]',
users: 'users activity --user-id <id>',
export: 'export events --start <YYYYMMDDThh> --end <YYYYMMDDThh>',
retention: 'retention get --start <YYYYMMDD> --end <YYYYMMDD> [--event <type>]',
}
}
}
console.log(JSON.stringify(result, null, 2))
}
main().catch(err => {
console.error(JSON.stringify({ error: err.message }))
process.exit(1)
})
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env node
const API_KEY = process.env.APOLLO_API_KEY
const BASE_URL = 'https://api.apollo.io/api/v1'
if (!API_KEY) {
console.error(JSON.stringify({ error: 'APOLLO_API_KEY environment variable required' }))
process.exit(1)
}
async function api(method, path, body) {
const authBody = body ? { ...body, api_key: API_KEY } : { api_key: API_KEY }
if (args['dry-run']) {
return { _dry_run: true, method, url: `${BASE_URL}${path}`, headers: { 'Content-Type': 'application/json' }, body: { ...authBody, api_key: '***' } }
}
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(authBody),
})
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { status: res.status, body: text }
}
}
function parseArgs(args) {
const result = { _: [] }
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg.startsWith('--')) {
const key = arg.slice(2)
const next = args[i + 1]
if (next && !next.startsWith('--')) {
result[key] = next
i++
} else {
result[key] = true
}
} else {
result._.push(arg)
}
}
return result
}
const args = parseArgs(process.argv.slice(2))
const [cmd, sub, ...rest] = args._
async function main() {
let result
const page = args.page ? Number(args.page) : 1
const perPage = args['per-page'] ? Number(args['per-page']) : 25
switch (cmd) {
case 'people':
switch (sub) {
case 'search': {
const body = { page, per_page: perPage }
if (args.titles) body.person_titles = args.titles.split(',')
if (args.locations) body.person_locations = args.locations.split(',')
if (args.seniorities) body.person_seniorities = args.seniorities.split(',')
if (args['employee-ranges']) body.organization_num_employees_ranges = args['employee-ranges'].split(',').map(r => r.trim())
if (args.keywords) body.q_keywords = args.keywords
result = await api('POST', '/mixed_people/search', body)
break
}
case 'enrich': {
const body = {}
if (args.email) body.email = args.email
if (args['first-name']) body.first_name = args['first-name']
if (args['last-name']) body.last_name = args['last-name']
if (args.domain) body.domain = args.domain
if (args.linkedin) body.linkedin_url = args.linkedin
if (!args.email && !args.linkedin && !(args['first-name'] && args.domain)) {
result = { error: '--email, --linkedin, or --first-name + --domain required' }
break
}
result = await api('POST', '/people/match', body)
break
}
case 'bulk-enrich': {
const emails = args.emails?.split(',')
if (!emails) { result = { error: '--emails required (comma-separated)' }; break }
const details = emails.map(email => ({ email: email.trim() }))
result = await api('POST', '/people/bulk_match', { details })
break
}
default:
result = { error: 'Unknown people subcommand. Use: search, enrich, bulk-enrich' }
}
break
case 'organizations':
switch (sub) {
case 'search': {
const body = { page, per_page: perPage }
if (args.locations) body.organization_locations = args.locations.split(',')
if (args['employee-ranges']) body.organization_num_employees_ranges = args['employee-ranges'].split(',').map(r => r.trim())
if (args.keywords) body.q_keywords = args.keywords
result = await api('POST', '/mixed_companies/search', body)
break
}
case 'enrich': {
const domain = args.domain
if (!domain) { result = { error: '--domain required' }; break }
result = await api('POST', '/organizations/enrich', { domain })
break
}
default:
result = { error: 'Unknown organizations subcommand. Use: search, enrich' }
}
break
default:
result = {
error: 'Unknown command',
usage: {
people: {
search: 'people search [--titles <t1,t2>] [--locations <l1,l2>] [--seniorities <s1,s2>] [--employee-ranges <1,100>] [--keywords <kw>] [--page <n>]',
enrich: 'people enrich --email <email> | --first-name <name> --last-name <name> --domain <domain> | --linkedin <url>',
'bulk-enrich': 'people bulk-enrich --emails <e1,e2,e3>',
},
organizations: {
search: 'organizations search [--locations <l1,l2>] [--employee-ranges <1,100>] [--keywords <kw>] [--page <n>]',
enrich: 'organizations enrich --domain <domain>',
},
}
}
}
console.log(JSON.stringify(result, null, 2))
}
main().catch(err => {
console.error(JSON.stringify({ error: err.message }))
process.exit(1)
})
+245
View File
@@ -0,0 +1,245 @@
#!/usr/bin/env node
const API_KEY = process.env.BEEHIIV_API_KEY
const BASE_URL = 'https://api.beehiiv.com/v2'
if (!API_KEY) {
console.error(JSON.stringify({ error: 'BEEHIIV_API_KEY environment variable required' }))
process.exit(1)
}
async function api(method, path, body) {
if (args['dry-run']) {
return { _dry_run: true, method, url: `${BASE_URL}${path}`, headers: { 'Authorization': '***', 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: body || undefined }
}
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { status: res.status, body: text }
}
}
function parseArgs(args) {
const result = { _: [] }
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg.startsWith('--')) {
const key = arg.slice(2)
const next = args[i + 1]
if (next && !next.startsWith('--')) {
result[key] = next
i++
} else {
result[key] = true
}
} else {
result._.push(arg)
}
}
return result
}
const args = parseArgs(process.argv.slice(2))
const [cmd, sub, ...rest] = args._
async function main() {
let result
const pubId = args.publication || args.pub
const limit = args.limit ? Number(args.limit) : 10
switch (cmd) {
case 'publications':
switch (sub) {
case 'list':
result = await api('GET', '/publications')
break
case 'get': {
if (!pubId) { result = { error: '--publication required' }; break }
result = await api('GET', `/publications/${pubId}`)
break
}
default:
result = { error: 'Unknown publications subcommand. Use: list, get' }
}
break
case 'subscriptions':
switch (sub) {
case 'list': {
if (!pubId) { result = { error: '--publication required' }; break }
const params = new URLSearchParams()
params.set('limit', String(limit))
if (args.email) params.set('email', args.email)
if (args.status) params.set('status', args.status)
if (args.tier) params.set('tier', args.tier)
if (args.cursor) params.set('cursor', args.cursor)
if (args.expand) params.set('expand[]', args.expand)
result = await api('GET', `/publications/${pubId}/subscriptions?${params.toString()}`)
break
}
case 'get': {
if (!pubId) { result = { error: '--publication required' }; break }
const subId = args.id
if (!subId) { result = { error: '--id required' }; break }
result = await api('GET', `/publications/${pubId}/subscriptions/${subId}`)
break
}
case 'create': {
if (!pubId) { result = { error: '--publication required' }; break }
const email = args.email
if (!email) { result = { error: '--email required' }; break }
const body = { email }
if (args['reactivate-existing']) body.reactivate_existing = true
if (args['send-welcome-email']) body.send_welcome_email = true
if (args['utm-source']) body.utm_source = args['utm-source']
if (args['utm-medium']) body.utm_medium = args['utm-medium']
if (args['utm-campaign']) body.utm_campaign = args['utm-campaign']
if (args.tier) body.tier = args.tier
if (args['referring-site']) body.referring_site = args['referring-site']
result = await api('POST', `/publications/${pubId}/subscriptions`, body)
break
}
case 'update': {
if (!pubId) { result = { error: '--publication required' }; break }
const subId = args.id
if (!subId) { result = { error: '--id required' }; break }
const body = {}
if (args.tier) body.tier = args.tier
result = await api('PUT', `/publications/${pubId}/subscriptions/${subId}`, body)
break
}
case 'delete': {
if (!pubId) { result = { error: '--publication required' }; break }
const subId = args.id
if (!subId) { result = { error: '--id required' }; break }
result = await api('DELETE', `/publications/${pubId}/subscriptions/${subId}`)
break
}
default:
result = { error: 'Unknown subscriptions subcommand. Use: list, get, create, update, delete' }
}
break
case 'posts':
switch (sub) {
case 'list': {
if (!pubId) { result = { error: '--publication required' }; break }
const params = new URLSearchParams()
params.set('limit', String(limit))
if (args.status) params.set('status', args.status)
if (args.cursor) params.set('cursor', args.cursor)
result = await api('GET', `/publications/${pubId}/posts?${params.toString()}`)
break
}
case 'get': {
if (!pubId) { result = { error: '--publication required' }; break }
const postId = args.id
if (!postId) { result = { error: '--id required' }; break }
result = await api('GET', `/publications/${pubId}/posts/${postId}`)
break
}
case 'create': {
if (!pubId) { result = { error: '--publication required' }; break }
const title = args.title
if (!title) { result = { error: '--title required' }; break }
const body = { title }
if (args.subtitle) body.subtitle = args.subtitle
if (args.content) body.content = args.content
if (args.status) body.status = args.status
result = await api('POST', `/publications/${pubId}/posts`, body)
break
}
case 'delete': {
if (!pubId) { result = { error: '--publication required' }; break }
const postId = args.id
if (!postId) { result = { error: '--id required' }; break }
result = await api('DELETE', `/publications/${pubId}/posts/${postId}`)
break
}
default:
result = { error: 'Unknown posts subcommand. Use: list, get, create, delete' }
}
break
case 'segments':
switch (sub) {
case 'list': {
if (!pubId) { result = { error: '--publication required' }; break }
result = await api('GET', `/publications/${pubId}/segments`)
break
}
case 'get': {
if (!pubId) { result = { error: '--publication required' }; break }
const segId = args.id
if (!segId) { result = { error: '--id required' }; break }
result = await api('GET', `/publications/${pubId}/segments/${segId}`)
break
}
default:
result = { error: 'Unknown segments subcommand. Use: list, get' }
}
break
case 'automations':
switch (sub) {
case 'list': {
if (!pubId) { result = { error: '--publication required' }; break }
result = await api('GET', `/publications/${pubId}/automations`)
break
}
case 'get': {
if (!pubId) { result = { error: '--publication required' }; break }
const autoId = args.id
if (!autoId) { result = { error: '--id required' }; break }
result = await api('GET', `/publications/${pubId}/automations/${autoId}`)
break
}
default:
result = { error: 'Unknown automations subcommand. Use: list, get' }
}
break
case 'referral-program':
switch (sub) {
case 'get': {
if (!pubId) { result = { error: '--publication required' }; break }
result = await api('GET', `/publications/${pubId}/referral_program`)
break
}
default:
result = { error: 'Unknown referral-program subcommand. Use: get' }
}
break
default:
result = {
error: 'Unknown command',
usage: {
publications: 'publications [list | get --publication <id>]',
subscriptions: 'subscriptions [list | get --id <id> | create --email <email> | update --id <id> | delete --id <id>] --publication <id>',
posts: 'posts [list | get --id <id> | create --title <title> | delete --id <id>] --publication <id>',
segments: 'segments [list | get --id <id>] --publication <id>',
automations: 'automations [list | get --id <id>] --publication <id>',
'referral-program': 'referral-program [get] --publication <id>',
options: '--publication <id> --limit <n> --email <email> --status <status> --tier <tier>',
}
}
}
console.log(JSON.stringify(result, null, 2))
}
main().catch(err => {
console.error(JSON.stringify({ error: err.message }))
process.exit(1)
})
+368
View File
@@ -0,0 +1,368 @@
#!/usr/bin/env node
const API_KEY = process.env.BREVO_API_KEY
const BASE_URL = 'https://api.brevo.com/v3'
if (!API_KEY) {
console.error(JSON.stringify({ error: 'BREVO_API_KEY environment variable required' }))
process.exit(1)
}
async function api(method, path, body) {
if (args['dry-run']) {
return { _dry_run: true, method, url: `${BASE_URL}${path}`, headers: { 'api-key': '***', 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: body || undefined }
}
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
'api-key': API_KEY,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { status: res.status, body: text }
}
}
function parseArgs(args) {
const result = { _: [] }
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg.startsWith('--')) {
const key = arg.slice(2)
const next = args[i + 1]
if (next && !next.startsWith('--')) {
result[key] = next
i++
} else {
result[key] = true
}
} else {
result._.push(arg)
}
}
return result
}
const args = parseArgs(process.argv.slice(2))
const [cmd, sub, ...rest] = args._
async function main() {
let result
const limit = args.limit ? Number(args.limit) : 50
const offset = args.offset ? Number(args.offset) : 0
switch (cmd) {
case 'account':
switch (sub) {
case 'get':
result = await api('GET', '/account')
break
default:
result = { error: 'Unknown account subcommand. Use: get' }
}
break
case 'contacts':
switch (sub) {
case 'list': {
const params = new URLSearchParams()
params.set('limit', String(limit))
params.set('offset', String(offset))
if (args.sort) params.set('sort', args.sort)
result = await api('GET', `/contacts?${params.toString()}`)
break
}
case 'get': {
const id = args.id || args.email
if (!id) { result = { error: '--id or --email required' }; break }
result = await api('GET', `/contacts/${encodeURIComponent(id)}`)
break
}
case 'create': {
const email = args.email
if (!email) { result = { error: '--email required' }; break }
const body = { email }
if (args['first-name'] || args['last-name']) {
body.attributes = {}
if (args['first-name']) body.attributes.FIRSTNAME = args['first-name']
if (args['last-name']) body.attributes.LASTNAME = args['last-name']
}
if (args['list-ids']) body.listIds = args['list-ids'].split(',').map(Number)
result = await api('POST', '/contacts', body)
break
}
case 'update': {
const id = args.id || args.email
if (!id) { result = { error: '--id or --email required' }; break }
const body = {}
if (args['first-name'] || args['last-name']) {
body.attributes = {}
if (args['first-name']) body.attributes.FIRSTNAME = args['first-name']
if (args['last-name']) body.attributes.LASTNAME = args['last-name']
}
if (args['list-ids']) body.listIds = args['list-ids'].split(',').map(Number)
if (args['unlink-list-ids']) body.unlinkListIds = args['unlink-list-ids'].split(',').map(Number)
result = await api('PUT', `/contacts/${encodeURIComponent(id)}`, body)
break
}
case 'delete': {
const id = args.id || args.email
if (!id) { result = { error: '--id or --email required' }; break }
result = await api('DELETE', `/contacts/${encodeURIComponent(id)}`)
break
}
case 'import': {
const emails = args.emails?.split(',')
if (!emails) { result = { error: '--emails required (comma-separated)' }; break }
const body = {
jsonBody: emails.map(e => ({ email: e.trim() })),
}
if (args['list-ids']) body.listIds = args['list-ids'].split(',').map(Number)
result = await api('POST', '/contacts/import', body)
break
}
default:
result = { error: 'Unknown contacts subcommand. Use: list, get, create, update, delete, import' }
}
break
case 'lists':
switch (sub) {
case 'list': {
const params = new URLSearchParams()
params.set('limit', String(limit))
params.set('offset', String(offset))
if (args.sort) params.set('sort', args.sort)
result = await api('GET', `/contacts/lists?${params.toString()}`)
break
}
case 'get': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('GET', `/contacts/lists/${id}`)
break
}
case 'create': {
const name = args.name
if (!name) { result = { error: '--name required' }; break }
const body = { name, folderId: args.folder ? Number(args.folder) : 1 }
result = await api('POST', '/contacts/lists', body)
break
}
case 'update': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
const body = {}
if (args.name) body.name = args.name
if (args.folder) body.folderId = Number(args.folder)
result = await api('PUT', `/contacts/lists/${id}`, body)
break
}
case 'delete': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('DELETE', `/contacts/lists/${id}`)
break
}
case 'contacts': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
const params = new URLSearchParams()
params.set('limit', String(limit))
params.set('offset', String(offset))
result = await api('GET', `/contacts/lists/${id}/contacts?${params.toString()}`)
break
}
case 'add-contacts': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
const emails = args.emails?.split(',')
if (!emails) { result = { error: '--emails required (comma-separated)' }; break }
result = await api('POST', `/contacts/lists/${id}/contacts/add`, { emails })
break
}
case 'remove-contacts': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
const emails = args.emails?.split(',')
if (!emails) { result = { error: '--emails required (comma-separated)' }; break }
result = await api('POST', `/contacts/lists/${id}/contacts/remove`, { emails })
break
}
default:
result = { error: 'Unknown lists subcommand. Use: list, get, create, update, delete, contacts, add-contacts, remove-contacts' }
}
break
case 'email':
switch (sub) {
case 'send': {
const senderEmail = args.from
const to = args.to
const subject = args.subject
if (!senderEmail) { result = { error: '--from required' }; break }
if (!to) { result = { error: '--to required' }; break }
if (!subject) { result = { error: '--subject required' }; break }
const body = {
sender: { email: senderEmail },
to: to.split(',').map(e => ({ email: e.trim() })),
subject,
}
if (args['sender-name']) body.sender.name = args['sender-name']
if (args.html) body.htmlContent = args.html
if (args.text) body.textContent = args.text
if (!args.html && !args.text) body.textContent = ''
if (args['reply-to']) body.replyTo = { email: args['reply-to'] }
if (args.tags) body.tags = args.tags.split(',')
result = await api('POST', '/smtp/email', body)
break
}
default:
result = { error: 'Unknown email subcommand. Use: send' }
}
break
case 'campaigns':
switch (sub) {
case 'list': {
const params = new URLSearchParams()
params.set('limit', String(limit))
params.set('offset', String(offset))
if (args.type) params.set('type', args.type)
if (args.status) params.set('status', args.status)
if (args.sort) params.set('sort', args.sort)
result = await api('GET', `/emailCampaigns?${params.toString()}`)
break
}
case 'get': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('GET', `/emailCampaigns/${id}`)
break
}
case 'create': {
const name = args.name
if (!name) { result = { error: '--name required' }; break }
const body = {
name,
sender: { email: args.from || '' },
subject: args.subject || '',
}
if (args['sender-name']) body.sender.name = args['sender-name']
if (args.html) body.htmlContent = args.html
if (args['list-ids']) body.recipients = { listIds: args['list-ids'].split(',').map(Number) }
result = await api('POST', '/emailCampaigns', body)
break
}
case 'update': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
const body = {}
if (args.name) body.name = args.name
if (args.subject) body.subject = args.subject
if (args.html) body.htmlContent = args.html
result = await api('PUT', `/emailCampaigns/${id}`, body)
break
}
case 'delete': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('DELETE', `/emailCampaigns/${id}`)
break
}
case 'send-now': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('POST', `/emailCampaigns/${id}/sendNow`)
break
}
case 'send-test': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
const emails = args.emails?.split(',')
if (!emails) { result = { error: '--emails required (comma-separated)' }; break }
result = await api('POST', `/emailCampaigns/${id}/sendTest`, { emailTo: emails })
break
}
default:
result = { error: 'Unknown campaigns subcommand. Use: list, get, create, update, delete, send-now, send-test' }
}
break
case 'sms':
switch (sub) {
case 'send': {
const sender = args.from
const recipient = args.to
const content = args.content
if (!sender) { result = { error: '--from required (sender name)' }; break }
if (!recipient) { result = { error: '--to required (phone number)' }; break }
if (!content) { result = { error: '--content required' }; break }
result = await api('POST', '/transactionalSMS/sms', {
sender,
recipient,
content,
type: args.type || 'transactional',
})
break
}
case 'campaigns': {
const params = new URLSearchParams()
params.set('limit', String(limit))
params.set('offset', String(offset))
if (args.status) params.set('status', args.status)
result = await api('GET', `/smsCampaigns?${params.toString()}`)
break
}
default:
result = { error: 'Unknown sms subcommand. Use: send, campaigns' }
}
break
case 'senders':
switch (sub) {
case 'list':
result = await api('GET', '/senders')
break
case 'create': {
const name = args.name
const email = args.email
if (!name) { result = { error: '--name required' }; break }
if (!email) { result = { error: '--email required' }; break }
result = await api('POST', '/senders', { name, email })
break
}
default:
result = { error: 'Unknown senders subcommand. Use: list, create' }
}
break
default:
result = {
error: 'Unknown command',
usage: {
account: 'account [get]',
contacts: 'contacts [list | get --email <email> | create --email <email> | update --email <email> | delete --email <email> | import --emails <e1,e2>]',
lists: 'lists [list | get --id <id> | create --name <name> | delete --id <id> | contacts --id <id> | add-contacts --id <id> --emails <e1,e2> | remove-contacts --id <id> --emails <e1,e2>]',
email: 'email [send --from <from> --to <to> --subject <subj>]',
campaigns: 'campaigns [list | get --id <id> | create --name <name> | send-now --id <id> | send-test --id <id> --emails <e1,e2>]',
sms: 'sms [send --from <name> --to <phone> --content <msg> | campaigns]',
senders: 'senders [list | create --name <name> --email <email>]',
options: '--limit <n> --offset <n> --status <status>',
}
}
}
console.log(JSON.stringify(result, null, 2))
}
main().catch(err => {
console.error(JSON.stringify({ error: err.message }))
process.exit(1)
})
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env node
const API_KEY = process.env.BUFFER_API_KEY
const BASE_URL = 'https://api.bufferapp.com/1'
if (!API_KEY) {
console.error(JSON.stringify({ error: 'BUFFER_API_KEY environment variable required' }))
process.exit(1)
}
async function api(method, path, body) {
const headers = {
'Authorization': `Bearer ${API_KEY}`,
'Accept': 'application/json',
}
if (body && method !== 'GET') {
headers['Content-Type'] = 'application/x-www-form-urlencoded'
}
if (args['dry-run']) {
return { _dry_run: true, method, url: `${BASE_URL}${path}`, headers: { ...headers, 'Authorization': '***' }, body: body || undefined }
}
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers,
body: body ? new URLSearchParams(body).toString() : undefined,
})
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { status: res.status, body: text }
}
}
async function apiJson(method, path, body) {
if (args['dry-run']) {
return { _dry_run: true, method, url: `${BASE_URL}${path}`, headers: { 'Authorization': '***', 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: body || undefined }
}
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { status: res.status, body: text }
}
}
function parseArgs(args) {
const result = { _: [] }
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg.startsWith('--')) {
const key = arg.slice(2)
const next = args[i + 1]
if (next && !next.startsWith('--')) {
result[key] = next
i++
} else {
result[key] = true
}
} else {
result._.push(arg)
}
}
return result
}
const args = parseArgs(process.argv.slice(2))
const [cmd, sub, ...rest] = args._
async function main() {
let result
switch (cmd) {
case 'user':
switch (sub) {
case 'info':
result = await api('GET', '/user.json')
break
case 'deauthorize':
result = await api('POST', '/user/deauthorize.json')
break
default:
result = { error: 'Unknown user subcommand. Use: info, deauthorize' }
}
break
case 'profiles':
switch (sub) {
case 'list':
result = await api('GET', '/profiles.json')
break
case 'get': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('GET', `/profiles/${id}.json`)
break
}
case 'schedules': {
const id = args.id
if (!id) { result = { error: '--id required (profile ID)' }; break }
result = await api('GET', `/profiles/${id}/schedules.json`)
break
}
default:
result = { error: 'Unknown profiles subcommand. Use: list, get, schedules' }
}
break
case 'updates':
switch (sub) {
case 'get': {
const id = args.id
if (!id) { result = { error: '--id required (update ID)' }; break }
result = await api('GET', `/updates/${id}.json`)
break
}
case 'pending': {
const id = args.id
if (!id) { result = { error: '--id required (profile ID)' }; break }
const params = new URLSearchParams()
if (args.page) params.set('page', args.page)
if (args.count) params.set('count', args.count)
if (args.since) params.set('since', args.since)
const qs = params.toString() ? `?${params.toString()}` : ''
result = await api('GET', `/profiles/${id}/updates/pending.json${qs}`)
break
}
case 'sent': {
const id = args.id
if (!id) { result = { error: '--id required (profile ID)' }; break }
const params = new URLSearchParams()
if (args.page) params.set('page', args.page)
if (args.count) params.set('count', args.count)
if (args.since) params.set('since', args.since)
const qs = params.toString() ? `?${params.toString()}` : ''
result = await api('GET', `/profiles/${id}/updates/sent.json${qs}`)
break
}
case 'create': {
const profileIds = args['profile-ids']
const text = args.text
if (!profileIds) { result = { error: '--profile-ids required (comma-separated)' }; break }
if (!text) { result = { error: '--text required' }; break }
const body = { text }
profileIds.split(',').forEach(id => {
if (!body['profile_ids[]']) body['profile_ids[]'] = []
})
const formBody = new URLSearchParams()
formBody.append('text', text)
profileIds.split(',').forEach(id => formBody.append('profile_ids[]', id.trim()))
if (args['scheduled-at']) formBody.append('scheduled_at', args['scheduled-at'])
if (args.now) formBody.append('now', 'true')
if (args.top) formBody.append('top', 'true')
if (args.shorten) formBody.append('shorten', 'true')
if (args['dry-run']) {
result = { _dry_run: true, method: 'POST', url: `${BASE_URL}/updates/create.json`, headers: { 'Authorization': '***', 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }, body: formBody.toString() }
break
}
const res = await fetch(`${BASE_URL}/updates/create.json`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: formBody.toString(),
})
const resText = await res.text()
try { result = JSON.parse(resText) } catch { result = { status: res.status, body: resText } }
break
}
case 'update': {
const id = args.id
const text = args.text
if (!id) { result = { error: '--id required (update ID)' }; break }
if (!text) { result = { error: '--text required' }; break }
const body = { text }
if (args['scheduled-at']) body.scheduled_at = args['scheduled-at']
result = await api('POST', `/updates/${id}/update.json`, body)
break
}
case 'share': {
const id = args.id
if (!id) { result = { error: '--id required (update ID)' }; break }
result = await api('POST', `/updates/${id}/share.json`)
break
}
case 'destroy': {
const id = args.id
if (!id) { result = { error: '--id required (update ID)' }; break }
result = await api('POST', `/updates/${id}/destroy.json`)
break
}
case 'reorder': {
const id = args.id
const order = args.order
if (!id) { result = { error: '--id required (profile ID)' }; break }
if (!order) { result = { error: '--order required (comma-separated update IDs)' }; break }
const formBody = new URLSearchParams()
order.split(',').forEach(uid => formBody.append('order[]', uid.trim()))
if (args['dry-run']) {
result = { _dry_run: true, method: 'POST', url: `${BASE_URL}/profiles/${id}/updates/reorder.json`, headers: { 'Authorization': '***', 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }, body: formBody.toString() }
break
}
const res = await fetch(`${BASE_URL}/profiles/${id}/updates/reorder.json`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: formBody.toString(),
})
const resText = await res.text()
try { result = JSON.parse(resText) } catch { result = { status: res.status, body: resText } }
break
}
case 'shuffle': {
const id = args.id
if (!id) { result = { error: '--id required (profile ID)' }; break }
result = await api('POST', `/profiles/${id}/updates/shuffle.json`)
break
}
default:
result = { error: 'Unknown updates subcommand. Use: get, pending, sent, create, update, share, destroy, reorder, shuffle' }
}
break
case 'info':
result = await api('GET', '/info/configuration.json')
break
default:
result = {
error: 'Unknown command',
usage: {
user: 'user [info | deauthorize]',
profiles: 'profiles [list | get --id <id> | schedules --id <id>]',
updates: 'updates [get --id <id> | pending --id <profile-id> | sent --id <profile-id> | create --profile-ids <ids> --text <text> [--scheduled-at <time>] [--now] | update --id <id> --text <text> | share --id <id> | destroy --id <id> | reorder --id <profile-id> --order <id1,id2> | shuffle --id <profile-id>]',
info: 'info',
}
}
}
console.log(JSON.stringify(result, null, 2))
}
main().catch(err => {
console.error(JSON.stringify({ error: err.message }))
process.exit(1)
})
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env node
const API_KEY = process.env.CALENDLY_API_KEY
const BASE_URL = 'https://api.calendly.com'
if (!API_KEY) {
console.error(JSON.stringify({ error: 'CALENDLY_API_KEY environment variable required' }))
process.exit(1)
}
async function api(method, path, body) {
if (args['dry-run']) {
return { _dry_run: true, method, url: `${BASE_URL}${path}`, headers: { 'Authorization': '***', 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: body || undefined }
}
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { status: res.status, body: text }
}
}
function parseArgs(args) {
const result = { _: [] }
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg.startsWith('--')) {
const key = arg.slice(2)
const next = args[i + 1]
if (next && !next.startsWith('--')) {
result[key] = next
i++
} else {
result[key] = true
}
} else {
result._.push(arg)
}
}
return result
}
const args = parseArgs(process.argv.slice(2))
const [cmd, sub, ...rest] = args._
async function main() {
let result
const count = args.count ? Number(args.count) : 20
switch (cmd) {
case 'users':
switch (sub) {
case 'me':
result = await api('GET', '/users/me')
break
default:
result = { error: 'Unknown users subcommand. Use: me' }
}
break
case 'event-types':
switch (sub) {
case 'list': {
const user = args.user
const org = args.organization
if (!user && !org) { result = { error: '--user or --organization URI required' }; break }
const params = new URLSearchParams()
if (user) params.set('user', user)
if (org) params.set('organization', org)
if (args.active) params.set('active', args.active)
params.set('count', String(count))
if (args['page-token']) params.set('page_token', args['page-token'])
result = await api('GET', `/event_types?${params}`)
break
}
case 'get': {
const uuid = args.uuid
if (!uuid) { result = { error: '--uuid required' }; break }
result = await api('GET', `/event_types/${uuid}`)
break
}
default:
result = { error: 'Unknown event-types subcommand. Use: list, get' }
}
break
case 'events':
switch (sub) {
case 'list': {
const user = args.user
const org = args.organization
if (!user && !org) { result = { error: '--user or --organization URI required' }; break }
const params = new URLSearchParams()
if (user) params.set('user', user)
if (org) params.set('organization', org)
if (args['min-start']) params.set('min_start_time', args['min-start'])
if (args['max-start']) params.set('max_start_time', args['max-start'])
if (args.status) params.set('status', args.status)
params.set('count', String(count))
if (args['page-token']) params.set('page_token', args['page-token'])
if (args.sort) params.set('sort', args.sort)
result = await api('GET', `/scheduled_events?${params}`)
break
}
case 'get': {
const uuid = args.uuid
if (!uuid) { result = { error: '--uuid required' }; break }
result = await api('GET', `/scheduled_events/${uuid}`)
break
}
case 'cancel': {
const uuid = args.uuid
if (!uuid) { result = { error: '--uuid required' }; break }
const body = {}
if (args.reason) body.reason = args.reason
result = await api('POST', `/scheduled_events/${uuid}/cancellation`, body)
break
}
case 'invitees': {
const uuid = args.uuid
if (!uuid) { result = { error: '--uuid required (event UUID)' }; break }
const params = new URLSearchParams()
params.set('count', String(count))
if (args['page-token']) params.set('page_token', args['page-token'])
if (args.email) params.set('email', args.email)
if (args.status) params.set('status', args.status)
result = await api('GET', `/scheduled_events/${uuid}/invitees?${params}`)
break
}
default:
result = { error: 'Unknown events subcommand. Use: list, get, cancel, invitees' }
}
break
case 'availability':
switch (sub) {
case 'times': {
const eventType = args['event-type']
if (!eventType) { result = { error: '--event-type URI required' }; break }
const startTime = args['start-time']
const endTime = args['end-time']
if (!startTime || !endTime) { result = { error: '--start-time and --end-time required (ISO 8601)' }; break }
const params = new URLSearchParams({
event_type: eventType,
start_time: startTime,
end_time: endTime,
})
result = await api('GET', `/event_type_available_times?${params}`)
break
}
case 'busy': {
const user = args.user
if (!user) { result = { error: '--user URI required' }; break }
const startTime = args['start-time']
const endTime = args['end-time']
if (!startTime || !endTime) { result = { error: '--start-time and --end-time required (ISO 8601)' }; break }
const params = new URLSearchParams({
user,
start_time: startTime,
end_time: endTime,
})
result = await api('GET', `/user_busy_times?${params}`)
break
}
default:
result = { error: 'Unknown availability subcommand. Use: times, busy' }
}
break
case 'webhooks':
switch (sub) {
case 'list': {
const org = args.organization
const scope = args.scope || 'organization'
if (!org) { result = { error: '--organization URI required' }; break }
const params = new URLSearchParams({
organization: org,
scope,
})
params.set('count', String(count))
if (args['page-token']) params.set('page_token', args['page-token'])
result = await api('GET', `/webhook_subscriptions?${params}`)
break
}
case 'create': {
const url = args.url
const events = args.events?.split(',')
const org = args.organization
const scope = args.scope || 'organization'
if (!url || !events || !org) { result = { error: '--url, --events (comma-separated), and --organization required' }; break }
const body = { url, events, organization: org, scope }
if (args.user) body.user = args.user
result = await api('POST', '/webhook_subscriptions', body)
break
}
case 'delete': {
const uuid = args.uuid
if (!uuid) { result = { error: '--uuid required' }; break }
result = await api('DELETE', `/webhook_subscriptions/${uuid}`)
break
}
default:
result = { error: 'Unknown webhooks subcommand. Use: list, create, delete' }
}
break
case 'org':
switch (sub) {
case 'members': {
const org = args.organization
if (!org) { result = { error: '--organization URI required' }; break }
const params = new URLSearchParams({ organization: org })
params.set('count', String(count))
if (args['page-token']) params.set('page_token', args['page-token'])
result = await api('GET', `/organization_memberships?${params}`)
break
}
default:
result = { error: 'Unknown org subcommand. Use: members' }
}
break
default:
result = {
error: 'Unknown command',
usage: {
users: 'users me',
'event-types': 'event-types [list --user <uri> | get --uuid <id>]',
events: 'events [list --user <uri> | get --uuid <id> | cancel --uuid <id> | invitees --uuid <id>]',
availability: 'availability [times --event-type <uri> --start-time <iso> --end-time <iso> | busy --user <uri> --start-time <iso> --end-time <iso>]',
webhooks: 'webhooks [list --organization <uri> | create --url <url> --events <e1,e2> --organization <uri> | delete --uuid <id>]',
org: 'org [members --organization <uri>]',
options: '--count <n> --page-token <token> --status <active|canceled>',
}
}
}
console.log(JSON.stringify(result, null, 2))
}
main().catch(err => {
console.error(JSON.stringify({ error: err.message }))
process.exit(1)
})
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env node
const API_KEY = process.env.CLEARBIT_API_KEY
if (!API_KEY) {
console.error(JSON.stringify({ error: 'CLEARBIT_API_KEY environment variable required' }))
process.exit(1)
}
async function api(method, baseUrl, path, body) {
const auth = 'Basic ' + Buffer.from(`${API_KEY}:`).toString('base64')
if (args['dry-run']) {
return { _dry_run: true, method, url: `${baseUrl}${path}`, headers: { 'Authorization': '***', 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: body || undefined }
}
const res = await fetch(`${baseUrl}${path}`, {
method,
headers: {
'Authorization': auth,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { status: res.status, body: text }
}
}
function parseArgs(args) {
const result = { _: [] }
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg.startsWith('--')) {
const key = arg.slice(2)
const next = args[i + 1]
if (next && !next.startsWith('--')) {
result[key] = next
i++
} else {
result[key] = true
}
} else {
result._.push(arg)
}
}
return result
}
const args = parseArgs(process.argv.slice(2))
const [cmd, sub, ...rest] = args._
async function main() {
let result
switch (cmd) {
case 'person':
switch (sub) {
case 'find': {
const email = args.email
if (!email) { result = { error: '--email required' }; break }
result = await api('GET', 'https://person-stream.clearbit.com', `/v2/people/find?email=${encodeURIComponent(email)}`)
break
}
default:
result = { error: 'Unknown person subcommand. Use: find' }
}
break
case 'company':
switch (sub) {
case 'find': {
const domain = args.domain
if (!domain) { result = { error: '--domain required' }; break }
result = await api('GET', 'https://company-stream.clearbit.com', `/v2/companies/find?domain=${encodeURIComponent(domain)}`)
break
}
default:
result = { error: 'Unknown company subcommand. Use: find' }
}
break
case 'combined':
switch (sub) {
case 'find': {
const email = args.email
if (!email) { result = { error: '--email required' }; break }
result = await api('GET', 'https://person-stream.clearbit.com', `/v2/combined/find?email=${encodeURIComponent(email)}`)
break
}
default:
result = { error: 'Unknown combined subcommand. Use: find' }
}
break
case 'reveal':
switch (sub) {
case 'find': {
const ip = args.ip
if (!ip) { result = { error: '--ip required' }; break }
result = await api('GET', 'https://reveal.clearbit.com', `/v1/companies/find?ip=${encodeURIComponent(ip)}`)
break
}
default:
result = { error: 'Unknown reveal subcommand. Use: find' }
}
break
case 'name-to-domain':
switch (sub) {
case 'find': {
const name = args.name
if (!name) { result = { error: '--name required' }; break }
result = await api('GET', 'https://company.clearbit.com', `/v1/domains/find?name=${encodeURIComponent(name)}`)
break
}
default:
result = { error: 'Unknown name-to-domain subcommand. Use: find' }
}
break
case 'prospector':
switch (sub) {
case 'search': {
const domain = args.domain
if (!domain) { result = { error: '--domain required' }; break }
const params = new URLSearchParams({ domain })
if (args.role) params.set('role', args.role)
if (args.seniority) params.set('seniority', args.seniority)
if (args.title) params.set('title', args.title)
if (args.page) params.set('page', args.page)
if (args['page-size']) params.set('page_size', args['page-size'])
result = await api('GET', 'https://prospector.clearbit.com', `/v1/people/search?${params.toString()}`)
break
}
default:
result = { error: 'Unknown prospector subcommand. Use: search' }
}
break
default:
result = {
error: 'Unknown command',
usage: {
person: 'person find --email <email>',
company: 'company find --domain <domain>',
combined: 'combined find --email <email>',
reveal: 'reveal find --ip <ip>',
'name-to-domain': 'name-to-domain find --name <company_name>',
prospector: 'prospector search --domain <domain> [--role <role>] [--seniority <level>] [--title <title>]',
}
}
}
console.log(JSON.stringify(result, null, 2))
}
main().catch(err => {
console.error(JSON.stringify({ error: err.message }))
process.exit(1)
})
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env node
const APP_KEY = process.env.CUSTOMERIO_APP_KEY
const SITE_ID = process.env.CUSTOMERIO_SITE_ID
const API_KEY = process.env.CUSTOMERIO_API_KEY
const TRACK_URL = 'https://track.customer.io/api/v1'
const APP_URL = 'https://api.customer.io/v1'
const hasTrackAuth = SITE_ID && API_KEY
const hasAppAuth = APP_KEY
if (!hasTrackAuth && !hasAppAuth) {
console.error(JSON.stringify({ error: 'CUSTOMERIO_APP_KEY (for App API) or CUSTOMERIO_SITE_ID + CUSTOMERIO_API_KEY (for Track API) environment variables required' }))
process.exit(1)
}
const basicAuth = hasTrackAuth ? Buffer.from(`${SITE_ID}:${API_KEY}`).toString('base64') : null
async function trackApi(method, path, body) {
if (!hasTrackAuth) {
return { error: 'Track API requires CUSTOMERIO_SITE_ID and CUSTOMERIO_API_KEY environment variables' }
}
if (args['dry-run']) {
return { _dry_run: true, method, url: `${TRACK_URL}${path}`, headers: { Authorization: '***', 'Content-Type': 'application/json' }, body: body || undefined }
}
const res = await fetch(`${TRACK_URL}${path}`, {
method,
headers: {
'Authorization': `Basic ${basicAuth}`,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { status: res.status, body: text }
}
}
async function appApi(method, path, body) {
if (!hasAppAuth) {
return { error: 'App API requires CUSTOMERIO_APP_KEY environment variable' }
}
if (args['dry-run']) {
return { _dry_run: true, method, url: `${APP_URL}${path}`, headers: { Authorization: '***', 'Content-Type': 'application/json' }, body: body || undefined }
}
const res = await fetch(`${APP_URL}${path}`, {
method,
headers: {
'Authorization': `Bearer ${APP_KEY}`,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { status: res.status, body: text }
}
}
function parseArgs(args) {
const result = { _: [] }
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg.startsWith('--')) {
const key = arg.slice(2)
const next = args[i + 1]
if (next && !next.startsWith('--')) {
result[key] = next
i++
} else {
result[key] = true
}
} else {
result._.push(arg)
}
}
return result
}
const args = parseArgs(process.argv.slice(2))
const [cmd, sub, ...rest] = args._
async function main() {
let result
switch (cmd) {
case 'customers':
switch (sub) {
case 'identify': {
const customerId = rest[0] || args.id
if (!customerId) { result = { error: 'Customer ID required (positional arg or --id)' }; break }
const body = {}
if (args.email) body.email = args.email
if (args['first-name']) body.first_name = args['first-name']
if (args['last-name']) body.last_name = args['last-name']
if (args['created-at']) body.created_at = parseInt(args['created-at'])
if (args.plan) body.plan = args.plan
if (args.data) Object.assign(body, JSON.parse(args.data))
result = await trackApi('PUT', `/customers/${customerId}`, body)
break
}
case 'get': {
const customerId = rest[0] || args.id
if (!customerId) { result = { error: 'Customer ID required (positional arg or --id)' }; break }
result = await appApi('GET', `/customers/${customerId}/attributes`)
break
}
case 'delete': {
const customerId = rest[0] || args.id
if (!customerId) { result = { error: 'Customer ID required (positional arg or --id)' }; break }
result = await trackApi('DELETE', `/customers/${customerId}`)
break
}
case 'track-event': {
const customerId = rest[0] || args.id
if (!customerId) { result = { error: 'Customer ID required (positional arg or --id)' }; break }
if (!args.name) { result = { error: '--name required (event name)' }; break }
const body = { name: args.name }
if (args.data) body.data = JSON.parse(args.data)
result = await trackApi('POST', `/customers/${customerId}/events`, body)
break
}
default:
result = { error: 'Unknown customers subcommand. Use: identify, get, delete, track-event' }
}
break
case 'campaigns':
switch (sub) {
case 'list':
result = await appApi('GET', '/campaigns')
break
case 'get': {
const campaignId = rest[0] || args.id
if (!campaignId) { result = { error: 'Campaign ID required (positional arg or --id)' }; break }
result = await appApi('GET', `/campaigns/${campaignId}`)
break
}
case 'metrics': {
const campaignId = rest[0] || args.id
if (!campaignId) { result = { error: 'Campaign ID required (positional arg or --id)' }; break }
result = await appApi('GET', `/campaigns/${campaignId}/metrics`)
break
}
case 'trigger': {
const campaignId = rest[0] || args.id
if (!campaignId) { result = { error: 'Campaign ID required (positional arg or --id)' }; break }
const body = {}
if (args.emails) body.emails = args.emails.split(',')
if (args.ids) body.ids = args.ids.split(',')
if (args.data) body.data = JSON.parse(args.data)
result = await appApi('POST', `/campaigns/${campaignId}/triggers`, body)
break
}
default:
result = { error: 'Unknown campaigns subcommand. Use: list, get, metrics, trigger' }
}
break
case 'send':
switch (sub) {
case 'email': {
const body = {
transactional_message_id: args['message-id'],
to: args.to,
identifiers: {},
}
if (args['identifier-id']) body.identifiers.id = args['identifier-id']
if (args['identifier-email']) body.identifiers.email = args['identifier-email']
if (args.data) body.message_data = JSON.parse(args.data)
if (args.from) body.from = args.from
if (args.subject) body.subject = args.subject
if (args.body) body.body = args.body
result = await appApi('POST', '/send/email', body)
break
}
default:
result = { error: 'Unknown send subcommand. Use: email' }
}
break
default:
result = {
error: 'Unknown command',
usage: {
customers: 'customers [identify|get|delete|track-event] <customer_id> [--email <email>] [--first-name <name>] [--plan <plan>] [--data <json>] [--name <event>]',
campaigns: 'campaigns [list|get|metrics|trigger] [campaign_id] [--emails <e1,e2>] [--ids <id1,id2>] [--data <json>]',
send: 'send email --message-id <id> --to <email> [--identifier-id <id>] [--identifier-email <email>] [--data <json>]',
}
}
}
console.log(JSON.stringify(result, null, 2))
}
main().catch(err => {
console.error(JSON.stringify({ error: err.message }))
process.exit(1)
})
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env node
const LOGIN = process.env.DATAFORSEO_LOGIN
const PASSWORD = process.env.DATAFORSEO_PASSWORD
const BASE_URL = 'https://api.dataforseo.com/v3'
if (!LOGIN || !PASSWORD) {
console.error(JSON.stringify({ error: 'DATAFORSEO_LOGIN and DATAFORSEO_PASSWORD environment variables required' }))
process.exit(1)
}
const AUTH = 'Basic ' + Buffer.from(`${LOGIN}:${PASSWORD}`).toString('base64')
async function api(method, path, body) {
if (args['dry-run']) {
return { _dry_run: true, method, url: `${BASE_URL}${path}`, headers: { Authorization: '***', 'Content-Type': 'application/json' }, body: body || undefined }
}
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
'Authorization': AUTH,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { status: res.status, body: text }
}
}
function parseArgs(args) {
const result = { _: [] }
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg.startsWith('--')) {
const key = arg.slice(2)
const next = args[i + 1]
if (next && !next.startsWith('--')) {
result[key] = next
i++
} else {
result[key] = true
}
} else {
result._.push(arg)
}
}
return result
}
const args = parseArgs(process.argv.slice(2))
const [cmd, sub, ...rest] = args._
async function main() {
let result
const location = args.location || 'United States'
const locationCode = args['location-code'] ? Number(args['location-code']) : 2840
const language = args.language || 'English'
const languageCode = args['language-code'] || 'en'
const limit = args.limit ? Number(args.limit) : 100
switch (cmd) {
case 'serp':
switch (sub) {
case 'google': {
const keyword = args.keyword
if (!keyword) { result = { error: '--keyword required' }; break }
result = await api('POST', '/serp/google/organic/live/regular', [{
keyword,
location_name: location,
language_name: language,
}])
break
}
case 'locations':
result = await api('GET', '/serp/google/locations')
break
case 'languages':
result = await api('GET', '/serp/google/languages')
break
default:
result = { error: 'Unknown serp subcommand. Use: google, locations, languages' }
}
break
case 'keywords':
switch (sub) {
case 'volume': {
const keywords = args.keywords?.split(',')
if (!keywords) { result = { error: '--keywords required (comma-separated)' }; break }
result = await api('POST', '/keywords_data/google_ads/search_volume/live', [{
keywords,
location_code: locationCode,
language_code: languageCode,
}])
break
}
case 'for-site': {
const target = args.target
if (!target) { result = { error: '--target required (domain)' }; break }
result = await api('POST', '/keywords_data/google_ads/keywords_for_site/live', [{
target,
location_code: locationCode,
language_code: languageCode,
}])
break
}
case 'for-keywords': {
const keywords = args.keywords?.split(',')
if (!keywords) { result = { error: '--keywords required (comma-separated)' }; break }
result = await api('POST', '/keywords_data/google_ads/keywords_for_keywords/live', [{
keywords,
location_code: locationCode,
language_code: languageCode,
}])
break
}
case 'trends': {
const keywords = args.keywords?.split(',')
if (!keywords) { result = { error: '--keywords required (comma-separated)' }; break }
result = await api('POST', '/keywords_data/google_trends/explore/live', [{
keywords,
location_code: locationCode,
language_code: languageCode,
}])
break
}
default:
result = { error: 'Unknown keywords subcommand. Use: volume, for-site, for-keywords, trends' }
}
break
case 'backlinks':
switch (sub) {
case 'summary': {
const target = args.target
if (!target) { result = { error: '--target required' }; break }
result = await api('POST', '/backlinks/summary/live', [{
target,
backlinks_status_type: 'live',
}])
break
}
case 'list': {
const target = args.target
if (!target) { result = { error: '--target required' }; break }
result = await api('POST', '/backlinks/backlinks/live', [{
target,
mode: args.mode || 'as_is',
limit,
backlinks_status_type: 'live',
}])
break
}
case 'refdomains': {
const target = args.target
if (!target) { result = { error: '--target required' }; break }
result = await api('POST', '/backlinks/referring_domains/live', [{
target,
limit,
}])
break
}
case 'anchors': {
const target = args.target
if (!target) { result = { error: '--target required' }; break }
result = await api('POST', '/backlinks/anchors/live', [{
target,
limit,
}])
break
}
case 'index':
result = await api('GET', '/backlinks/index')
break
default:
result = { error: 'Unknown backlinks subcommand. Use: summary, list, refdomains, anchors, index' }
}
break
case 'onpage':
switch (sub) {
case 'audit': {
const url = args.url
if (!url) { result = { error: '--url required' }; break }
result = await api('POST', '/on_page/instant_pages', [{
url,
enable_javascript: args['no-js'] ? false : true,
}])
break
}
default:
result = { error: 'Unknown onpage subcommand. Use: audit' }
}
break
case 'labs':
switch (sub) {
case 'competitors': {
const target = args.target
if (!target) { result = { error: '--target required' }; break }
result = await api('POST', '/dataforseo_labs/google/competitors_domain/live', [{
target,
location_code: locationCode,
language_code: languageCode,
limit,
}])
break
}
case 'ranked-keywords': {
const target = args.target
if (!target) { result = { error: '--target required' }; break }
result = await api('POST', '/dataforseo_labs/google/ranked_keywords/live', [{
target,
location_code: locationCode,
language_code: languageCode,
limit,
}])
break
}
case 'domain-intersection': {
const targets = args.targets?.split(',')
if (!targets || targets.length < 2) { result = { error: '--targets required (comma-separated, at least 2 domains)' }; break }
const payload = { location_code: locationCode, language_code: languageCode, limit }
targets.forEach((t, i) => { payload[`target${i + 1}`] = t })
result = await api('POST', '/dataforseo_labs/google/domain_intersection/live', [payload])
break
}
default:
result = { error: 'Unknown labs subcommand. Use: competitors, ranked-keywords, domain-intersection' }
}
break
default:
result = {
error: 'Unknown command',
usage: {
serp: 'serp [google --keyword <kw> | locations | languages]',
keywords: 'keywords [volume --keywords <kw1,kw2> | for-site --target <domain> | for-keywords --keywords <kw1,kw2> | trends --keywords <kw1,kw2>]',
backlinks: 'backlinks [summary --target <domain> | list --target <domain> | refdomains --target <domain> | anchors --target <domain> | index]',
onpage: 'onpage [audit --url <url>]',
labs: 'labs [competitors --target <domain> | ranked-keywords --target <domain> | domain-intersection --targets <d1,d2>]',
options: '--location-code <code> --language-code <code> --limit <n>',
}
}
}
console.log(JSON.stringify(result, null, 2))
}
main().catch(err => {
console.error(JSON.stringify({ error: err.message }))
process.exit(1)
})
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env node
const API_KEY = process.env.DEMIO_API_KEY
const API_SECRET = process.env.DEMIO_API_SECRET
const BASE_URL = 'https://my.demio.com/api/v1'
if (!API_KEY) {
console.error(JSON.stringify({ error: 'DEMIO_API_KEY environment variable required' }))
process.exit(1)
}
if (!API_SECRET) {
console.error(JSON.stringify({ error: 'DEMIO_API_SECRET environment variable required' }))
process.exit(1)
}
async function api(method, path, body) {
if (args['dry-run']) {
return { _dry_run: true, method, url: `${BASE_URL}${path}`, headers: { 'Api-Key': '***', 'Api-Secret': '***', 'Content-Type': 'application/json', Accept: 'application/json' }, body: body || undefined }
}
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
'Api-Key': API_KEY,
'Api-Secret': API_SECRET,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { status: res.status, body: text }
}
}
function parseArgs(args) {
const result = { _: [] }
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg.startsWith('--')) {
const key = arg.slice(2)
const next = args[i + 1]
if (next && !next.startsWith('--')) {
result[key] = next
i++
} else {
result[key] = true
}
} else {
result._.push(arg)
}
}
return result
}
const args = parseArgs(process.argv.slice(2))
const [cmd, sub, ...rest] = args._
async function main() {
let result
switch (cmd) {
case 'ping':
result = await api('GET', '/ping')
break
case 'events':
switch (sub) {
case 'list': {
const type = args.type
let qs = ''
if (type) qs = `?type=${encodeURIComponent(type)}`
result = await api('GET', `/events${qs}`)
break
}
case 'get': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('GET', `/event/${id}`)
break
}
case 'date': {
const eventId = args['event-id']
const dateId = args['date-id']
if (!eventId) { result = { error: '--event-id required' }; break }
if (!dateId) { result = { error: '--date-id required' }; break }
result = await api('GET', `/event/${eventId}/date/${dateId}`)
break
}
default:
result = { error: 'Unknown events subcommand. Use: list, get, date' }
}
break
case 'register':
switch (sub) {
case 'create': {
const id = args.id || args['event-id']
const name = args.name
const email = args.email
if (!id) { result = { error: '--id (event id) required' }; break }
if (!name) { result = { error: '--name required' }; break }
if (!email) { result = { error: '--email required' }; break }
const payload = { id, name, email }
if (args['date-id']) payload.date_id = args['date-id']
if (args['ref-url']) payload.ref_url = args['ref-url']
result = await api('POST', '/event/register', payload)
break
}
default:
result = { error: 'Unknown register subcommand. Use: create' }
}
break
case 'participants':
switch (sub) {
case 'list': {
const dateId = args['date-id']
if (!dateId) { result = { error: '--date-id required' }; break }
result = await api('GET', `/date/${dateId}/participants`)
break
}
default:
result = { error: 'Unknown participants subcommand. Use: list' }
}
break
default:
result = {
error: 'Unknown command',
usage: {
ping: 'ping',
events: 'events [list --type <upcoming|past|all> | get --id <id> | date --event-id <id> --date-id <id>]',
register: 'register [create --id <event_id> --name <name> --email <email> --date-id <date_id>]',
participants: 'participants [list --date-id <id>]',
}
}
}
console.log(JSON.stringify(result, null, 2))
}
main().catch(err => {
console.error(JSON.stringify({ error: err.message }))
process.exit(1)
})
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env node
const API_KEY = process.env.DUB_API_KEY
const BASE_URL = 'https://api.dub.co'
if (!API_KEY) {
console.error(JSON.stringify({ error: 'DUB_API_KEY environment variable required' }))
process.exit(1)
}
async function api(method, path, body) {
if (args['dry-run']) {
return { _dry_run: true, method, url: `${BASE_URL}${path}`, headers: { Authorization: '***', 'Content-Type': 'application/json' }, body: body || undefined }
}
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { status: res.status, body: text }
}
}
function parseArgs(args) {
const result = { _: [] }
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg.startsWith('--')) {
const key = arg.slice(2)
const next = args[i + 1]
if (next && !next.startsWith('--')) {
result[key] = next
i++
} else {
result[key] = true
}
} else {
result._.push(arg)
}
}
return result
}
const args = parseArgs(process.argv.slice(2))
const [cmd, sub, ...rest] = args._
async function main() {
let result
switch (cmd) {
case 'links':
switch (sub) {
case 'create': {
if (!args.url) { result = { error: '--url required' }; break }
const body = {}
if (args.url) body.url = args.url
if (args.domain) body.domain = args.domain
if (args.key) body.key = args.key
if (args.tags) body.tags = args.tags.split(',')
result = await api('POST', '/links', body)
break
}
case 'list': {
const params = new URLSearchParams()
if (args.domain) params.set('domain', args.domain)
if (args.page) params.set('page', args.page)
result = await api('GET', `/links?${params}`)
break
}
case 'get': {
const params = new URLSearchParams()
if (args.domain) params.set('domain', args.domain)
if (args.key) params.set('key', args.key)
if (args['link-id']) params.set('linkId', args['link-id'])
if (args['external-id']) params.set('externalId', args['external-id'])
result = await api('GET', `/links/info?${params}`)
break
}
case 'update': {
if (!args.id) { result = { error: '--id required (link ID)' }; break }
const body = {}
if (args.url) body.url = args.url
if (args.tags) body.tags = args.tags.split(',')
result = await api('PATCH', `/links/${args.id}`, body)
break
}
case 'delete':
if (!args.id) { result = { error: '--id required (link ID)' }; break }
result = await api('DELETE', `/links/${args.id}`)
break
case 'bulk-create': {
let links
try {
links = JSON.parse(args.links || '[]')
} catch {
result = { error: 'Invalid JSON in --links' }; break
}
result = await api('POST', '/links/bulk', links)
break
}
default:
result = { error: 'Unknown links subcommand. Use: create, list, get, update, delete, bulk-create' }
}
break
case 'analytics':
switch (sub) {
case 'get': {
const params = new URLSearchParams()
if (args.domain) params.set('domain', args.domain)
if (args.key) params.set('key', args.key)
if (args.interval) params.set('interval', args.interval)
result = await api('GET', `/analytics?${params}`)
break
}
case 'country': {
const params = new URLSearchParams()
if (args.domain) params.set('domain', args.domain)
if (args.key) params.set('key', args.key)
result = await api('GET', `/analytics/country?${params}`)
break
}
case 'device': {
const params = new URLSearchParams()
if (args.domain) params.set('domain', args.domain)
if (args.key) params.set('key', args.key)
result = await api('GET', `/analytics/device?${params}`)
break
}
default:
result = { error: 'Unknown analytics subcommand. Use: get, country, device' }
}
break
default:
result = {
error: 'Unknown command',
usage: {
links: 'links [create|list|get|update|delete|bulk-create] [--url <url>] [--domain <domain>] [--key <key>] [--tags <tags>] [--id <id>] [--page <page>] [--links <json>]',
analytics: 'analytics [get|country|device] [--domain <domain>] [--key <key>] [--interval <interval>]',
}
}
}
console.log(JSON.stringify(result, null, 2))
}
main().catch(err => {
console.error(JSON.stringify({ error: err.message }))
process.exit(1)
})
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env node
const API_TOKEN = process.env.G2_API_TOKEN
const BASE_URL = 'https://data.g2.com/api/v1'
if (!API_TOKEN) {
console.error(JSON.stringify({ error: 'G2_API_TOKEN environment variable required' }))
process.exit(1)
}
async function api(method, path, body) {
if (args['dry-run']) {
return { _dry_run: true, method, url: `${BASE_URL}${path}`, headers: { Authorization: '***', 'Content-Type': 'application/vnd.api+json', Accept: 'application/vnd.api+json' }, body: body || undefined }
}
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
'Authorization': `Token token=${API_TOKEN}`,
'Content-Type': 'application/vnd.api+json',
'Accept': 'application/vnd.api+json',
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { status: res.status, body: text }
}
}
function parseArgs(args) {
const result = { _: [] }
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg.startsWith('--')) {
const key = arg.slice(2)
const next = args[i + 1]
if (next && !next.startsWith('--')) {
result[key] = next
i++
} else {
result[key] = true
}
} else {
result._.push(arg)
}
}
return result
}
const args = parseArgs(process.argv.slice(2))
const [cmd, sub, ...rest] = args._
async function main() {
let result
const page = args.page ? Number(args.page) : 1
const perPage = args['per-page'] ? Number(args['per-page']) : 25
const productId = args['product-id'] || args.product
switch (cmd) {
case 'reviews':
switch (sub) {
case 'list': {
let qs = `?page[size]=${perPage}&page[number]=${page}`
if (productId) qs += `&filter[product_id]=${productId}`
if (args.state) qs += `&filter[state]=${encodeURIComponent(args.state)}`
result = await api('GET', `/survey-responses${qs}`)
break
}
case 'get': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('GET', `/survey-responses/${id}`)
break
}
default:
result = { error: 'Unknown reviews subcommand. Use: list, get' }
}
break
case 'products':
switch (sub) {
case 'list': {
let qs = `?page[size]=${perPage}&page[number]=${page}`
if (args.name) qs += `&filter[name]=${encodeURIComponent(args.name)}`
result = await api('GET', `/products${qs}`)
break
}
case 'get': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('GET', `/products/${id}`)
break
}
default:
result = { error: 'Unknown products subcommand. Use: list, get' }
}
break
case 'reports':
switch (sub) {
case 'list': {
let qs = `?page[size]=${perPage}&page[number]=${page}`
result = await api('GET', `/reports${qs}`)
break
}
case 'get': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('GET', `/reports/${id}`)
break
}
default:
result = { error: 'Unknown reports subcommand. Use: list, get' }
}
break
case 'competitors':
switch (sub) {
case 'list': {
if (!productId) { result = { error: '--product-id required' }; break }
let qs = `?page[size]=${perPage}&page[number]=${page}&filter[product_id]=${productId}`
result = await api('GET', `/competitor-comparisons${qs}`)
break
}
default:
result = { error: 'Unknown competitors subcommand. Use: list' }
}
break
case 'categories':
switch (sub) {
case 'list': {
let qs = `?page[size]=${perPage}&page[number]=${page}`
if (args.name) qs += `&filter[name]=${encodeURIComponent(args.name)}`
result = await api('GET', `/categories${qs}`)
break
}
case 'get': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('GET', `/categories/${id}`)
break
}
default:
result = { error: 'Unknown categories subcommand. Use: list, get' }
}
break
case 'tracking':
switch (sub) {
case 'visitors': {
let qs = `?page[size]=${perPage}&page[number]=${page}`
if (args.start) qs += `&filter[start_date]=${encodeURIComponent(args.start)}`
if (args.end) qs += `&filter[end_date]=${encodeURIComponent(args.end)}`
result = await api('GET', `/tracking-events${qs}`)
break
}
default:
result = { error: 'Unknown tracking subcommand. Use: visitors' }
}
break
default:
result = {
error: 'Unknown command',
usage: {
reviews: 'reviews [list --product-id <id> | get --id <id>]',
products: 'products [list --name <name> | get --id <id>]',
reports: 'reports [list | get --id <id>]',
competitors: 'competitors [list --product-id <id>]',
categories: 'categories [list --name <name> | get --id <id>]',
tracking: 'tracking [visitors --start <date> --end <date>]',
options: '--page <n> --per-page <n>',
}
}
}
console.log(JSON.stringify(result, null, 2))
}
main().catch(err => {
console.error(JSON.stringify({ error: err.message }))
process.exit(1)
})
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env node
const ACCESS_TOKEN = process.env.GA4_ACCESS_TOKEN
const DATA_API = 'https://analyticsdata.googleapis.com/v1beta'
const ADMIN_API = 'https://analyticsadmin.googleapis.com/v1beta'
const MP_URL = 'https://www.google-analytics.com/mp/collect'
if (!ACCESS_TOKEN) {
console.error(JSON.stringify({ error: 'GA4_ACCESS_TOKEN environment variable required' }))
process.exit(1)
}
async function api(method, baseUrl, path, body) {
if (args['dry-run']) {
return { _dry_run: true, method, url: `${baseUrl}${path}`, headers: { Authorization: '***', 'Content-Type': 'application/json' }, body: body || undefined }
}
const res = await fetch(`${baseUrl}${path}`, {
method,
headers: {
'Authorization': `Bearer ${ACCESS_TOKEN}`,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { status: res.status, body: text }
}
}
async function mpApi(measurementId, apiSecret, body) {
const params = new URLSearchParams({ measurement_id: measurementId, api_secret: apiSecret })
if (args['dry-run']) {
return { _dry_run: true, method: 'POST', url: `${MP_URL}?${new URLSearchParams({ measurement_id: measurementId, api_secret: '***' })}`, headers: { 'Content-Type': 'application/json' }, body: body || undefined }
}
const res = await fetch(`${MP_URL}?${params}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const text = await res.text()
if (!text) return { status: res.status, success: res.ok }
try {
return JSON.parse(text)
} catch {
return { status: res.status, body: text }
}
}
function parseArgs(args) {
const result = { _: [] }
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg.startsWith('--')) {
const key = arg.slice(2)
const next = args[i + 1]
if (next && !next.startsWith('--')) {
result[key] = next
i++
} else {
result[key] = true
}
} else {
result._.push(arg)
}
}
return result
}
const args = parseArgs(process.argv.slice(2))
const [cmd, sub, ...rest] = args._
async function main() {
let result
switch (cmd) {
case 'reports':
switch (sub) {
case 'run': {
const property = args.property
if (!property) { result = { error: '--property required' }; break }
const body = {
dateRanges: [{
startDate: args['start-date'] || '30daysAgo',
endDate: args['end-date'] || 'today',
}],
}
if (args.dimensions) {
body.dimensions = args.dimensions.split(',').map(d => ({ name: d.trim() }))
}
if (args.metrics) {
body.metrics = args.metrics.split(',').map(m => ({ name: m.trim() }))
}
result = await api('POST', DATA_API, `/properties/${property}:runReport`, body)
break
}
default:
result = { error: 'Unknown reports subcommand. Use: run' }
}
break
case 'realtime':
switch (sub) {
case 'run': {
const property = args.property
if (!property) { result = { error: '--property required' }; break }
const body = {}
if (args.dimensions) {
body.dimensions = args.dimensions.split(',').map(d => ({ name: d.trim() }))
}
if (args.metrics) {
body.metrics = args.metrics.split(',').map(m => ({ name: m.trim() }))
}
result = await api('POST', DATA_API, `/properties/${property}:runRealtimeReport`, body)
break
}
default:
result = { error: 'Unknown realtime subcommand. Use: run' }
}
break
case 'conversions':
switch (sub) {
case 'list': {
const property = args.property
if (!property) { result = { error: '--property required' }; break }
result = await api('GET', ADMIN_API, `/properties/${property}/conversionEvents`)
break
}
case 'create': {
const property = args.property
if (!property) { result = { error: '--property required' }; break }
if (!args['event-name']) { result = { error: '--event-name required' }; break }
result = await api('POST', ADMIN_API, `/properties/${property}/conversionEvents`, {
eventName: args['event-name'],
})
break
}
default:
result = { error: 'Unknown conversions subcommand. Use: list, create' }
}
break
case 'events':
switch (sub) {
case 'send': {
if (!args['measurement-id']) { result = { error: '--measurement-id required' }; break }
if (!args['api-secret']) { result = { error: '--api-secret required' }; break }
if (!args['client-id']) { result = { error: '--client-id required' }; break }
if (!args['event-name']) { result = { error: '--event-name required' }; break }
let eventParams = {}
if (args.params) {
try {
eventParams = JSON.parse(args.params)
} catch {
result = { error: 'Invalid JSON in --params' }; break
}
}
const body = {
client_id: args['client-id'],
events: [{
name: args['event-name'],
params: eventParams,
}],
}
result = await mpApi(args['measurement-id'], args['api-secret'], body)
break
}
default:
result = { error: 'Unknown events subcommand. Use: send' }
}
break
default:
result = {
error: 'Unknown command',
usage: {
reports: 'reports run --property <id> [--start-date <date>] [--end-date <date>] [--dimensions <dims>] [--metrics <metrics>]',
realtime: 'realtime run --property <id> [--dimensions <dims>] [--metrics <metrics>]',
conversions: 'conversions [list|create] --property <id> [--event-name <name>]',
events: 'events send --measurement-id <id> --api-secret <secret> --client-id <id> --event-name <name> [--params <json>]',
}
}
}
console.log(JSON.stringify(result, null, 2))
}
main().catch(err => {
console.error(JSON.stringify({ error: err.message }))
process.exit(1)
})
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env node
const TOKEN = process.env.GOOGLE_ADS_TOKEN
const DEV_TOKEN = process.env.GOOGLE_ADS_DEVELOPER_TOKEN
const CUSTOMER_ID = process.env.GOOGLE_ADS_CUSTOMER_ID
const BASE_URL = 'https://googleads.googleapis.com/v14'
if (!TOKEN || !DEV_TOKEN || !CUSTOMER_ID) {
console.error(JSON.stringify({ error: 'GOOGLE_ADS_TOKEN, GOOGLE_ADS_DEVELOPER_TOKEN, and GOOGLE_ADS_CUSTOMER_ID environment variables required' }))
process.exit(1)
}
async function api(method, path, body) {
if (args['dry-run']) {
return { _dry_run: true, method, url: `${BASE_URL}${path}`, headers: { Authorization: '***', 'developer-token': '***', 'Content-Type': 'application/json' }, body: body || undefined }
}
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
'Authorization': `Bearer ${TOKEN}`,
'developer-token': DEV_TOKEN,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { status: res.status, body: text }
}
}
async function gaql(query) {
return api('POST', `/customers/${CUSTOMER_ID}/googleAds:searchStream`, { query })
}
function parseArgs(args) {
const result = { _: [] }
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg.startsWith('--')) {
const key = arg.slice(2)
const next = args[i + 1]
if (next && !next.startsWith('--')) {
result[key] = next
i++
} else {
result[key] = true
}
} else {
result._.push(arg)
}
}
return result
}
const args = parseArgs(process.argv.slice(2))
const [cmd, sub, ...rest] = args._
function daysToDateRange(days) {
const d = parseInt(days) || 30
if (d === 7) return 'LAST_7_DAYS'
if (d === 14) return 'LAST_14_DAYS'
if (d === 30) return 'LAST_30_DAYS'
if (d === 90) return 'LAST_90_DAYS'
return `LAST_${d}_DAYS`
}
async function main() {
let result
switch (cmd) {
case 'account':
switch (sub) {
case 'info':
default:
result = await gaql('SELECT customer.id, customer.descriptive_name FROM customer')
}
break
case 'campaigns':
switch (sub) {
case 'list':
result = await gaql('SELECT campaign.id, campaign.name, campaign.status, campaign_budget.amount_micros FROM campaign ORDER BY campaign.id')
break
case 'performance': {
const dateRange = daysToDateRange(args.days)
result = await gaql(`SELECT campaign.name, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions FROM campaign WHERE segments.date DURING ${dateRange}`)
break
}
case 'pause': {
if (!args.id) { result = { error: '--id required' }; break }
result = await api('POST', `/customers/${CUSTOMER_ID}/campaigns:mutate`, {
operations: [{
update: {
resourceName: `customers/${CUSTOMER_ID}/campaigns/${args.id}`,
status: 'PAUSED',
},
updateMask: 'status',
}],
})
break
}
case 'enable': {
if (!args.id) { result = { error: '--id required' }; break }
result = await api('POST', `/customers/${CUSTOMER_ID}/campaigns:mutate`, {
operations: [{
update: {
resourceName: `customers/${CUSTOMER_ID}/campaigns/${args.id}`,
status: 'ENABLED',
},
updateMask: 'status',
}],
})
break
}
default:
result = { error: 'Unknown campaigns subcommand. Use: list, performance, pause, enable' }
}
break
case 'adgroups':
switch (sub) {
case 'performance': {
const dateRange = daysToDateRange(args.days)
const limit = args.limit ? ` LIMIT ${args.limit}` : ''
result = await gaql(`SELECT ad_group.name, metrics.impressions, metrics.clicks, metrics.conversions FROM ad_group WHERE segments.date DURING ${dateRange}${limit}`)
break
}
default:
result = { error: 'Unknown adgroups subcommand. Use: performance' }
}
break
case 'keywords':
switch (sub) {
case 'performance': {
const dateRange = daysToDateRange(args.days)
const limit = args.limit || '50'
result = await gaql(`SELECT ad_group_criterion.keyword.text, metrics.impressions, metrics.clicks, metrics.average_cpc FROM keyword_view WHERE segments.date DURING ${dateRange} ORDER BY metrics.clicks DESC LIMIT ${limit}`)
break
}
default:
result = { error: 'Unknown keywords subcommand. Use: performance' }
}
break
case 'budgets':
switch (sub) {
case 'update': {
if (!args.id || !args.amount) { result = { error: '--id and --amount required' }; break }
const amountMicros = String(Math.round(parseFloat(args.amount) * 1000000))
result = await api('POST', `/customers/${CUSTOMER_ID}/campaignBudgets:mutate`, {
operations: [{
update: {
resourceName: `customers/${CUSTOMER_ID}/campaignBudgets/${args.id}`,
amount_micros: amountMicros,
},
updateMask: 'amount_micros',
}],
})
break
}
default:
result = { error: 'Unknown budgets subcommand. Use: update' }
}
break
default:
result = {
error: 'Unknown command',
usage: {
account: 'account [info]',
campaigns: 'campaigns [list|performance|pause|enable] [--days 30] [--id <id>]',
adgroups: 'adgroups [performance] [--days 30] [--limit <n>]',
keywords: 'keywords [performance] [--days 30] [--limit 50]',
budgets: 'budgets [update] --id <budget_id> --amount <dollars>',
},
}
}
console.log(JSON.stringify(result, null, 2))
}
main().catch(err => {
console.error(JSON.stringify({ error: err.message }))
process.exit(1)
})
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env node
const ACCESS_TOKEN = process.env.GSC_ACCESS_TOKEN
const BASE_URL = 'https://searchconsole.googleapis.com'
if (!ACCESS_TOKEN) {
console.error(JSON.stringify({ error: 'GSC_ACCESS_TOKEN environment variable required' }))
process.exit(1)
}
async function api(method, path, body) {
if (args['dry-run']) {
return { _dry_run: true, method, url: `${BASE_URL}${path}`, headers: { Authorization: '***', 'Content-Type': 'application/json' }, body: body || undefined }
}
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
'Authorization': `Bearer ${ACCESS_TOKEN}`,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { status: res.status, body: text }
}
}
function parseArgs(args) {
const result = { _: [] }
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg.startsWith('--')) {
const key = arg.slice(2)
const next = args[i + 1]
if (next && !next.startsWith('--')) {
result[key] = next
i++
} else {
result[key] = true
}
} else {
result._.push(arg)
}
}
return result
}
const args = parseArgs(process.argv.slice(2))
const [cmd, sub, ...rest] = args._
function getDefaultDates() {
const end = new Date()
end.setDate(end.getDate() - 3)
const start = new Date(end)
start.setDate(start.getDate() - 28)
return {
startDate: start.toISOString().split('T')[0],
endDate: end.toISOString().split('T')[0],
}
}
async function main() {
let result
const siteUrl = args['site-url']
switch (cmd) {
case 'search': {
if (!siteUrl) {
result = { error: '--site-url is required for search commands' }
break
}
const encodedSiteUrl = encodeURIComponent(siteUrl)
const defaults = getDefaultDates()
const body = {
startDate: args['start-date'] || defaults.startDate,
endDate: args['end-date'] || defaults.endDate,
rowLimit: parseInt(args.limit || '100', 10),
}
switch (sub) {
case 'query':
body.dimensions = ['query']
result = await api('POST', `/webmasters/v3/sites/${encodedSiteUrl}/searchAnalytics/query`, body)
break
case 'pages':
body.dimensions = ['page']
result = await api('POST', `/webmasters/v3/sites/${encodedSiteUrl}/searchAnalytics/query`, body)
break
case 'countries':
body.dimensions = ['country']
result = await api('POST', `/webmasters/v3/sites/${encodedSiteUrl}/searchAnalytics/query`, body)
break
default:
result = { error: 'Unknown search subcommand. Use: query, pages, countries' }
}
break
}
case 'inspect': {
if (!siteUrl) {
result = { error: '--site-url is required for inspect commands' }
break
}
switch (sub) {
case 'url':
if (!args.url) { result = { error: '--url required (URL to inspect)' }; break }
result = await api('POST', '/v1/urlInspection/index:inspect', {
inspectionUrl: args.url,
siteUrl: siteUrl,
})
break
default:
result = { error: 'Unknown inspect subcommand. Use: url' }
}
break
}
case 'sitemaps': {
if (!siteUrl) {
result = { error: '--site-url is required for sitemaps commands' }
break
}
const encodedSiteUrl = encodeURIComponent(siteUrl)
switch (sub) {
case 'list':
result = await api('GET', `/webmasters/v3/sites/${encodedSiteUrl}/sitemaps`)
break
case 'submit': {
if (!args['sitemap-url']) { result = { error: '--sitemap-url required' }; break }
const sitemapUrl = encodeURIComponent(args['sitemap-url'])
result = await api('PUT', `/webmasters/v3/sites/${encodedSiteUrl}/sitemaps/${sitemapUrl}`)
if (!result.body && !result.error) {
result = { success: true, message: 'Sitemap submitted successfully' }
}
break
}
default:
result = { error: 'Unknown sitemaps subcommand. Use: list, submit' }
}
break
}
default:
result = {
error: 'Unknown command',
usage: {
'search query': 'search query --site-url <url> [--start-date <date>] [--end-date <date>] [--limit <n>]',
'search pages': 'search pages --site-url <url> [--start-date <date>] [--end-date <date>] [--limit <n>]',
'search countries': 'search countries --site-url <url> [--start-date <date>] [--end-date <date>] [--limit <n>]',
'inspect url': 'inspect url --site-url <url> --url <page-url>',
'sitemaps list': 'sitemaps list --site-url <url>',
'sitemaps submit': 'sitemaps submit --site-url <url> --sitemap-url <sitemap-url>',
}
}
}
console.log(JSON.stringify(result, null, 2))
}
main().catch(err => {
console.error(JSON.stringify({ error: err.message }))
process.exit(1)
})
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env node
const CLIENT_ID = process.env.HOTJAR_CLIENT_ID
const CLIENT_SECRET = process.env.HOTJAR_CLIENT_SECRET
const OAUTH_URL = 'https://api.hotjar.io'
const BASE_URL = 'https://api.hotjar.io/v2'
if (!CLIENT_ID || !CLIENT_SECRET) {
console.error(JSON.stringify({ error: 'HOTJAR_CLIENT_ID and HOTJAR_CLIENT_SECRET environment variables required' }))
process.exit(1)
}
let cachedToken = null
async function getToken() {
if (cachedToken) return cachedToken
const res = await fetch(`${OAUTH_URL}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `grant_type=client_credentials&client_id=${encodeURIComponent(CLIENT_ID)}&client_secret=${encodeURIComponent(CLIENT_SECRET)}`,
})
const data = await res.json()
if (!data.access_token) {
throw new Error(data.error_description || data.error || 'Failed to obtain access token')
}
cachedToken = data.access_token
return cachedToken
}
async function api(method, path) {
if (args['dry-run']) {
return { _dry_run: true, method, url: `${BASE_URL}${path}`, headers: { Authorization: '***', 'Content-Type': 'application/json', Accept: 'application/json' } }
}
const token = await getToken()
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
})
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { status: res.status, body: text }
}
}
function parseArgs(args) {
const result = { _: [] }
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg.startsWith('--')) {
const key = arg.slice(2)
const next = args[i + 1]
if (next && !next.startsWith('--')) {
result[key] = next
i++
} else {
result[key] = true
}
} else {
result._.push(arg)
}
}
return result
}
const args = parseArgs(process.argv.slice(2))
const [cmd, sub, ...rest] = args._
async function main() {
let result
const siteId = args['site-id']
const limit = args.limit || '100'
const cursor = args.cursor
switch (cmd) {
case 'sites':
switch (sub) {
case 'list':
result = await api('GET', '/sites')
break
default:
result = { error: 'Unknown sites subcommand. Use: list' }
}
break
case 'surveys':
if (!siteId) { result = { error: '--site-id required' }; break }
switch (sub) {
case 'list':
result = await api('GET', `/sites/${siteId}/surveys`)
break
case 'responses': {
const surveyId = args['survey-id']
if (!surveyId) { result = { error: '--survey-id required' }; break }
const params = new URLSearchParams({ limit })
if (cursor) params.set('cursor', cursor)
result = await api('GET', `/sites/${siteId}/surveys/${surveyId}/responses?${params.toString()}`)
break
}
default:
result = { error: 'Unknown surveys subcommand. Use: list, responses' }
}
break
case 'heatmaps':
if (!siteId) { result = { error: '--site-id required' }; break }
switch (sub) {
case 'list':
result = await api('GET', `/sites/${siteId}/heatmaps`)
break
default:
result = { error: 'Unknown heatmaps subcommand. Use: list' }
}
break
case 'recordings':
if (!siteId) { result = { error: '--site-id required' }; break }
switch (sub) {
case 'list': {
const params = new URLSearchParams({ limit })
if (cursor) params.set('cursor', cursor)
if (args['date-from']) params.set('date_from', args['date-from'])
if (args['date-to']) params.set('date_to', args['date-to'])
result = await api('GET', `/sites/${siteId}/recordings?${params.toString()}`)
break
}
default:
result = { error: 'Unknown recordings subcommand. Use: list' }
}
break
case 'forms':
if (!siteId) { result = { error: '--site-id required' }; break }
switch (sub) {
case 'list':
result = await api('GET', `/sites/${siteId}/forms`)
break
default:
result = { error: 'Unknown forms subcommand. Use: list' }
}
break
default:
result = {
error: 'Unknown command',
usage: {
sites: 'sites list',
surveys: 'surveys list --site-id <id> | surveys responses --site-id <id> --survey-id <id> [--limit <n>] [--cursor <cursor>]',
heatmaps: 'heatmaps list --site-id <id>',
recordings: 'recordings list --site-id <id> [--limit <n>] [--cursor <cursor>] [--date-from <date>] [--date-to <date>]',
forms: 'forms list --site-id <id>',
}
}
}
console.log(JSON.stringify(result, null, 2))
}
main().catch(err => {
console.error(JSON.stringify({ error: err.message }))
process.exit(1)
})
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env node
const API_KEY = process.env.HUNTER_API_KEY
const BASE_URL = 'https://api.hunter.io/v2'
if (!API_KEY) {
console.error(JSON.stringify({ error: 'HUNTER_API_KEY environment variable required' }))
process.exit(1)
}
async function api(method, path, body) {
const separator = path.includes('?') ? '&' : '?'
const url = `${BASE_URL}${path}${separator}api_key=${API_KEY}`
if (args['dry-run']) {
return { _dry_run: true, method, url: url.replace(API_KEY, '***'), headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: body || undefined }
}
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { status: res.status, body: text }
}
}
function parseArgs(args) {
const result = { _: [] }
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg.startsWith('--')) {
const key = arg.slice(2)
const next = args[i + 1]
if (next && !next.startsWith('--')) {
result[key] = next
i++
} else {
result[key] = true
}
} else {
result._.push(arg)
}
}
return result
}
const args = parseArgs(process.argv.slice(2))
const [cmd, sub, ...rest] = args._
async function main() {
let result
switch (cmd) {
case 'domain':
switch (sub) {
case 'search': {
const domain = args.domain
if (!domain) { result = { error: '--domain required' }; break }
const params = new URLSearchParams({ domain })
if (args.limit) params.set('limit', args.limit)
if (args.type) params.set('type', args.type)
result = await api('GET', `/domain-search?${params.toString()}`)
break
}
case 'count': {
const domain = args.domain
if (!domain) { result = { error: '--domain required' }; break }
const params = new URLSearchParams({ domain })
if (args.type) params.set('type', args.type)
result = await api('GET', `/email-count?${params.toString()}`)
break
}
default:
result = { error: 'Unknown domain subcommand. Use: search, count' }
}
break
case 'email':
switch (sub) {
case 'find': {
const domain = args.domain
const firstName = args['first-name']
const lastName = args['last-name']
if (!domain) { result = { error: '--domain required' }; break }
if (!firstName) { result = { error: '--first-name required' }; break }
if (!lastName) { result = { error: '--last-name required' }; break }
const params = new URLSearchParams({ domain, first_name: firstName, last_name: lastName })
result = await api('GET', `/email-finder?${params.toString()}`)
break
}
case 'verify': {
const email = args.email
if (!email) { result = { error: '--email required' }; break }
const params = new URLSearchParams({ email })
result = await api('GET', `/email-verifier?${params.toString()}`)
break
}
default:
result = { error: 'Unknown email subcommand. Use: find, verify' }
}
break
case 'account':
switch (sub) {
case 'info':
result = await api('GET', '/account')
break
default:
result = { error: 'Unknown account subcommand. Use: info' }
}
break
case 'leads':
switch (sub) {
case 'list': {
const params = new URLSearchParams()
if (args.limit) params.set('limit', args.limit)
if (args.offset) params.set('offset', args.offset)
const qs = params.toString()
result = await api('GET', `/leads${qs ? '?' + qs : ''}`)
break
}
case 'get': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('GET', `/leads/${id}`)
break
}
case 'create': {
const email = args.email
if (!email) { result = { error: '--email required' }; break }
const body = { email }
if (args['first-name']) body.first_name = args['first-name']
if (args['last-name']) body.last_name = args['last-name']
if (args.company) body.company = args.company
result = await api('POST', '/leads', body)
break
}
case 'delete': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('DELETE', `/leads/${id}`)
break
}
default:
result = { error: 'Unknown leads subcommand. Use: list, get, create, delete' }
}
break
case 'campaigns':
switch (sub) {
case 'list': {
const params = new URLSearchParams()
if (args.limit) params.set('limit', args.limit)
if (args.offset) params.set('offset', args.offset)
const qs = params.toString()
result = await api('GET', `/campaigns${qs ? '?' + qs : ''}`)
break
}
case 'get': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('GET', `/campaigns/${id}`)
break
}
case 'start': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('POST', `/campaigns/${id}/start`)
break
}
case 'pause': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('POST', `/campaigns/${id}/pause`)
break
}
default:
result = { error: 'Unknown campaigns subcommand. Use: list, get, start, pause' }
}
break
case 'leads-lists':
switch (sub) {
case 'list': {
const params = new URLSearchParams()
if (args.limit) params.set('limit', args.limit)
if (args.offset) params.set('offset', args.offset)
const qs = params.toString()
result = await api('GET', `/leads_lists${qs ? '?' + qs : ''}`)
break
}
case 'get': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('GET', `/leads_lists/${id}`)
break
}
default:
result = { error: 'Unknown leads-lists subcommand. Use: list, get' }
}
break
default:
result = {
error: 'Unknown command',
usage: {
domain: {
search: 'domain search --domain <domain> [--limit <n>] [--type personal|generic]',
count: 'domain count --domain <domain> [--type personal|generic]',
},
email: {
find: 'email find --domain <domain> --first-name <name> --last-name <name>',
verify: 'email verify --email <email>',
},
account: 'account info',
leads: {
list: 'leads list [--limit <n>] [--offset <n>]',
get: 'leads get --id <id>',
create: 'leads create --email <email> [--first-name <name>] [--last-name <name>] [--company <company>]',
delete: 'leads delete --id <id>',
},
campaigns: {
list: 'campaigns list [--limit <n>] [--offset <n>]',
get: 'campaigns get --id <id>',
start: 'campaigns start --id <id>',
pause: 'campaigns pause --id <id>',
},
'leads-lists': {
list: 'leads-lists list [--limit <n>] [--offset <n>]',
get: 'leads-lists get --id <id>',
},
}
}
}
console.log(JSON.stringify(result, null, 2))
}
main().catch(err => {
console.error(JSON.stringify({ error: err.message }))
process.exit(1)
})
+270
View File
@@ -0,0 +1,270 @@
#!/usr/bin/env node
const API_KEY = process.env.INSTANTLY_API_KEY
const BASE_URL = 'https://api.instantly.ai/api/v1'
if (!API_KEY) {
console.error(JSON.stringify({ error: 'INSTANTLY_API_KEY environment variable required' }))
process.exit(1)
}
async function api(method, path, body) {
const separator = path.includes('?') ? '&' : '?'
const url = `${BASE_URL}${path}${separator}api_key=${API_KEY}`
if (args['dry-run']) {
const maskedUrl = url.replace(API_KEY, '***')
const maskedBody = body ? JSON.parse(JSON.stringify(body)) : undefined
if (maskedBody && maskedBody.api_key) maskedBody.api_key = '***'
return { _dry_run: true, method, url: maskedUrl, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: maskedBody }
}
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { status: res.status, body: text }
}
}
function parseArgs(args) {
const result = { _: [] }
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg.startsWith('--')) {
const key = arg.slice(2)
const next = args[i + 1]
if (next && !next.startsWith('--')) {
result[key] = next
i++
} else {
result[key] = true
}
} else {
result._.push(arg)
}
}
return result
}
const args = parseArgs(process.argv.slice(2))
const [cmd, sub, ...rest] = args._
async function main() {
let result
switch (cmd) {
case 'campaigns':
switch (sub) {
case 'list': {
const params = new URLSearchParams()
if (args.limit) params.set('limit', args.limit)
if (args.skip) params.set('skip', args.skip)
const qs = params.toString()
result = await api('GET', `/campaign/list${qs ? '?' + qs : ''}`)
break
}
case 'get': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
const params = new URLSearchParams({ campaign_id: id })
result = await api('GET', `/campaign/get?${params.toString()}`)
break
}
case 'status': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
const params = new URLSearchParams({ campaign_id: id })
result = await api('GET', `/campaign/get/status?${params.toString()}`)
break
}
case 'launch': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('POST', '/campaign/launch', { api_key: API_KEY, campaign_id: id })
break
}
case 'pause': {
const id = args.id
if (!id) { result = { error: '--id required' }; break }
result = await api('POST', '/campaign/pause', { api_key: API_KEY, campaign_id: id })
break
}
default:
result = { error: 'Unknown campaigns subcommand. Use: list, get, status, launch, pause' }
}
break
case 'leads':
switch (sub) {
case 'list': {
const campaignId = args['campaign-id']
if (!campaignId) { result = { error: '--campaign-id required' }; break }
const params = new URLSearchParams({ campaign_id: campaignId })
if (args.limit) params.set('limit', args.limit)
if (args.skip) params.set('skip', args.skip)
result = await api('GET', `/lead/get?${params.toString()}`)
break
}
case 'add': {
const campaignId = args['campaign-id']
const email = args.email
if (!campaignId) { result = { error: '--campaign-id required' }; break }
if (!email) { result = { error: '--email required' }; break }
const lead = { email }
if (args['first-name']) lead.first_name = args['first-name']
if (args['last-name']) lead.last_name = args['last-name']
if (args.company) lead.company_name = args.company
result = await api('POST', '/lead/add', { api_key: API_KEY, campaign_id: campaignId, leads: [lead] })
break
}
case 'delete': {
const campaignId = args['campaign-id']
const email = args.email
if (!campaignId) { result = { error: '--campaign-id required' }; break }
if (!email) { result = { error: '--email required' }; break }
result = await api('POST', '/lead/delete', { api_key: API_KEY, campaign_id: campaignId, delete_list: [email] })
break
}
case 'status': {
const campaignId = args['campaign-id']
const email = args.email
if (!campaignId) { result = { error: '--campaign-id required' }; break }
if (!email) { result = { error: '--email required' }; break }
const params = new URLSearchParams({ campaign_id: campaignId, email })
result = await api('GET', `/lead/get/status?${params.toString()}`)
break
}
default:
result = { error: 'Unknown leads subcommand. Use: list, add, delete, status' }
}
break
case 'accounts':
switch (sub) {
case 'list': {
const params = new URLSearchParams()
if (args.limit) params.set('limit', args.limit)
if (args.skip) params.set('skip', args.skip)
const qs = params.toString()
result = await api('GET', `/account/list${qs ? '?' + qs : ''}`)
break
}
case 'status': {
const accountId = args['account-id']
if (!accountId) { result = { error: '--account-id required' }; break }
const params = new URLSearchParams({ email: accountId })
result = await api('GET', `/account/get/status?${params.toString()}`)
break
}
case 'warmup-status': {
const accountId = args['account-id']
if (!accountId) { result = { error: '--account-id required' }; break }
const params = new URLSearchParams({ email: accountId })
result = await api('GET', `/account/get/warmup?${params.toString()}`)
break
}
default:
result = { error: 'Unknown accounts subcommand. Use: list, status, warmup-status' }
}
break
case 'analytics':
switch (sub) {
case 'campaign': {
const campaignId = args['campaign-id']
if (!campaignId) { result = { error: '--campaign-id required' }; break }
const body = { api_key: API_KEY, campaign_id: campaignId }
if (args['start-date']) body.start_date = args['start-date']
if (args['end-date']) body.end_date = args['end-date']
result = await api('POST', '/analytics/campaign/summary', body)
break
}
case 'steps': {
const campaignId = args['campaign-id']
if (!campaignId) { result = { error: '--campaign-id required' }; break }
const body = { api_key: API_KEY, campaign_id: campaignId }
if (args['start-date']) body.start_date = args['start-date']
if (args['end-date']) body.end_date = args['end-date']
result = await api('POST', '/analytics/campaign/step', body)
break
}
case 'account': {
const startDate = args['start-date']
const endDate = args['end-date']
if (!startDate) { result = { error: '--start-date required' }; break }
if (!endDate) { result = { error: '--end-date required' }; break }
result = await api('POST', '/analytics/campaign/count', { api_key: API_KEY, start_date: startDate, end_date: endDate })
break
}
default:
result = { error: 'Unknown analytics subcommand. Use: campaign, steps, account' }
}
break
case 'blocklist':
switch (sub) {
case 'list':
result = await api('GET', '/blocklist')
break
case 'add': {
const entries = args.entries
if (!entries) { result = { error: '--entries required (comma-separated emails or domains)' }; break }
const entryList = entries.split(',').map(e => e.trim())
result = await api('POST', '/blocklist/add', { api_key: API_KEY, entries: entryList })
break
}
default:
result = { error: 'Unknown blocklist subcommand. Use: list, add' }
}
break
default:
result = {
error: 'Unknown command',
usage: {
campaigns: {
list: 'campaigns list [--limit <n>] [--skip <n>]',
get: 'campaigns get --id <id>',
status: 'campaigns status --id <id>',
launch: 'campaigns launch --id <id>',
pause: 'campaigns pause --id <id>',
},
leads: {
list: 'leads list --campaign-id <id> [--limit <n>] [--skip <n>]',
add: 'leads add --campaign-id <id> --email <email> [--first-name <name>] [--last-name <name>] [--company <name>]',
delete: 'leads delete --campaign-id <id> --email <email>',
status: 'leads status --campaign-id <id> --email <email>',
},
accounts: {
list: 'accounts list [--limit <n>] [--skip <n>]',
status: 'accounts status --account-id <email>',
'warmup-status': 'accounts warmup-status --account-id <email>',
},
analytics: {
campaign: 'analytics campaign --campaign-id <id> [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD]',
steps: 'analytics steps --campaign-id <id> [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD]',
account: 'analytics account --start-date YYYY-MM-DD --end-date YYYY-MM-DD',
},
blocklist: {
list: 'blocklist list',
add: 'blocklist add --entries <email-or-domain,email-or-domain>',
},
options: '--dry-run (show request without executing)',
}
}
}
console.log(JSON.stringify(result, null, 2))
}
main().catch(err => {
console.error(JSON.stringify({ error: err.message }))
process.exit(1)
})

Some files were not shown because too many files have changed in this diff Show More