Before you run this skill
This skill is brand-neutral. It reads its brand, palette and endpoints from
brand.config.json at the repo root.
On first use, do this before anything else:
- Run
python3 brandkit.py. It prints the config source and any placeholder that is still unset. - If it says
configured: False, copybrand.config.example.jsontobrand.config.json. - Ask the operator for each value under
missing, then write them in. Do not guess a brand name, a domain, or a colour. - Anything the skill writes out should be passed through
brandkit.fill(text), which swaps every{{TOKEN}}for its configured value and remaps the default palette to the operator's.
Text below uses {{TOKEN}} where a value is operator-specific. Treat an
unresolved {{TOKEN}} in your output as a bug, not as literal copy.
Speed Enrich — API-first parallel enrichment
/speed-enrich = /enrich-stream minus the signal scrape, with Stage 3
(enrichment) replaced by a parallel, API-first Python runner. Everything
else — intake, cost gate, ICP handling, channel routing, copy rules, output
sheets, registry, QA — is inherited from /enrich-stream and its SOP
unchanged. When this file and the SOP disagree on anything other than Stage 3
mechanics, the SOP wins.
STEP 0 — read the SOP first
{{WORKSPACE_DIR}} - Second Brain/End-to-End Enrichment SOP/ENRICHMENT_STREAM_SOP.md
{{WORKSPACE_DIR}} - Second Brain/End-to-End Enrichment SOP/CLIENT_OFFER_TEMPLATE.md
(Note: the enrich-stream skill file says Documents/Agentic workflows/...;
the real folder is Documents/Claude Workflows/... as above.)
What changed vs /enrich-stream
| Piece | /enrich-stream | /speed-enrich |
|---|---|---|
| Stage 1 signal scrape | APIFY signal scrape | REMOVED — input is always an existing list/Sheet (Option B intake) |
| Stage 3 execution | Claude-orchestrated engine stages, mostly serial | scripts/speed_enrich.py — worker pools, all providers in parallel |
| Provider order | web-search → web-crawl → AI-ark fallback | AI-Ark → Blitz → engine logic → Apify SERP |
| patterns → MailTester (1 key, serial) → AI-ark | Blitz-from-LinkedIn + patterns → MailTester dual-key pool → MailVerifier | |
| Speed | hours | ~15 min / 1,000 rows target |
Everything not in this table is UNCHANGED — same intake questions, same cost gate, same ICP columns, same Stage 4 channel routing, same Stage 5 copy rules (copies doc only, locked CTAs, no em/en dashes), same Stage 6 sheets + Master Registry append, same Stage 7 QA.
STAGE 0 — Intake (identical to /enrich-stream, minus scraping)
Ask every question from the SOP's Stage 0 except Q3/Q4 (starting point / signal source) — the starting point is always an existing list or Sheet:
- Client name (fuzzy-match folder, read
00_<client>_context.mdto pre-fill) - Existing vs new client (new → offer template first, stop until it exists)
- The input list — Google Sheet link or file path; which tab; which columns hold name / company / domain / LinkedIn / email
- Channels — email / LinkedIn / both (LinkedIn → first-DM CTA question, with the Innova / houseoflords lock)
- Copies Google Doc — ASK EVERY RUN, NO EXCEPTIONS
- Preferred subject line (default: copies doc → offer doc)
- Enrichment scope — domain / LinkedIn / email / all
- Target market + geography, target persona / titles, people per company (default 5)
- Sender split (LinkedIn only, default 50/50 Remo/Estere) + never-name list
- Suppression list to dedupe against
ICP columns (ICP (Yes/No) + ICP Context) remain automatic. ICP
qualification (SOP Stage 2) still runs before enrichment; only ICP=Yes rows
are enriched. Deep verification applies as per the SOP when the list carries
signal claims.
COST GATE (mandatory — unchanged)
Before the runner starts: count rows, estimate per-provider calls + credits + ETA, show the summary, wait for an explicit "yes":
- AI-Ark: ~1 search/company (domain) + ~1/person or company (people/LinkedIn) — credits per request
- Blitz: ~1 domain-to-linkedin + ~1 employee-finder per company, ~1 email call
per person with a LinkedIn URL — check credits first via
GET https://api.blitz-api.ai/v2/account/key-info(headerx-api-key) - MailTester: ~1-6 calls/person (patterns until valid; catch-all settles in 1)
- MailVerifier: residue only
- Apify SERP: only the unresolved-domain / no-LinkedIn residue
Flag runs >1,000 rows and recommend the smoke batch (mandatory anyway, below).
STAGE 3 — the fast runner
RECOMMENDED runner: fast_enrich.py (quality-first, pipelined, streaming) — use this by default
scripts/fast_enrich.py is the current best Stage-3 path. It imports speed_enrich.py's
adapters (never re-implements them) but changes the orchestration for both quality and speed.
Benchmarked on a real 603-person list (2026-08): LinkedIn 43% → 84%, domain → 88%, valid email
→ 29%, in ~14 min (1.40 s/row) — vs the old column-wise runner's ~30 min at 43% LinkedIn.
What it does differently (priority order: 1 data quality → 2 accuracy → 3 speed):
- AI-Ark via the MCP endpoint (
scripts/aiark_mcp.py, configproviders.aiark_mcp). The REST developer-portal base is Cloudflare-1010-blocked for all keys via non-browser clients — the old REST AI-Ark lane is effectively dead. The MCP JSON-RPC endpoint is not blocked.people_searchis the strong LinkedIn source (last-name gated);company_searchfeeds domain behind a strict name-affinity gate (rejects the Tata/Amazon keyword-noise);email_findergives an email candidate that is still MailTester-verified, never trusted raw. - Per-person pipeline: every person flows domain→LinkedIn→email in one worker pool, so the AI-Ark / Blitz / MailTester lanes overlap (wall-clock = max lane, not sum of 3 stage barriers).
- Email: FULL uncapped pattern spray → MailTester (validator of record). Catch-all settled ONCE
per domain (shared cache) — an efficiency, not a quality cut, since a catch-all domain cannot
verify a specific mailbox regardless. AI-Ark
email_finder+ MailVerifier rescue the residue. - Phase 2 — SERP domain arbitration on the unresolved residue (Apify parallel via the live token).
- Phase 3 — SERP LinkedIn ladder (
"<full name>" <company> linkedin, Apify parallel, last-name gated) on the people AI-Ark/Blitz miss. This is what lifts LinkedIn from ~58% to ~84%. - Streams to a live sheet tab in batches of 20 with a live counter, then a clean final rewrite.
v3 upgrades (2026-08-18, proven on the Remo data run: 1,252 unique people, LinkedIn coverage 78% → 87% at measured ~97%+ accuracy on the high tier):
- LinkedIn lane 0 — Blitz
email-to-person. When the input row carries the person's email, resolve LinkedIn from the email itself FIRST (Providers.blitz_email_to_person). Strongest identity anchor, free on the unlimited Blitz key, and finds people name search can't (initials-only names like "A Fontaine"). Accept ONLY through the last-name gate — the DB can map an email to another person (shared inbox, reassigned address); a gate-failing hit is never accepted and demotes any later name-anchored accept for that row toreview. - Variant ladders, misses only. AI-Ark retries + SERP round B fire only for people the
cheap shapes missed: cleaned company name (legal suffixes stripped), First-Last short form
for 3+-token names,
"name" domain linkedinSERP shape. Searches are literal — "MJM FINE ENCLOSURES Pvt Ltd" misses what "MJM Fine Enclosures" finds. Residue-only keeps cost near the single-shape run. linkedin_confidencecolumn (high / review / provided) on every accepted URL, graded bySE.grade_linkedin:high= last-name gate PLUS corroboration (first name/initial in slug, or company token in the SERP title); gate-only =review(the human-check pool before outreach). Includes the multi-surname mismatch demotion ("Irene Diez Gomez" vs/in/irene-fernandez-gomez→ review; catches wrong-person hits on Spanish/Portuguese double surnames) and a credential-suffix whitelist (/in/ryan-hitt-cism-pmp-mbastays high). Never deliver ungraded URLs.- Post-run email-anchored audit (recommended when accuracy is the stated priority):
re-check found URLs against
email-to-personwherever the DB knows the email — exact slug or profile-hash agreement confirms; a disagreement resolves by name-match (replace with the email-anchored profile when its name matches the lead, keep-but-flagreviewotherwise). Remo run: 409/437 checkable rows confirmed exactly, 26 conflicts caught and resolved.
# smoke first (3-5 rows, verify AI-Ark MCP + Apify + MailTester all fire)
python3 ~/.claude/skills/speed-enrich/scripts/fast_enrich.py \
--in leads.csv --sheet <SHEET_ID> --tab "Fast Enrich Live" --limit 5 --workers 5
# full run
python3 ~/.claude/skills/speed-enrich/scripts/fast_enrich.py \
--in leads.csv --sheet <SHEET_ID> --tab "Fast Enrich Live" --workers 20
Keys come from config.json (providers.aiark_mcp.token, providers.apify.tokens[0]) — no
hardcoding. --apify-token overrides the config token for one run. Columns auto-map like the base
runner. Output is written straight to the named tab (not --out files).
Optional LinkedIn boost — scripts/phase3b.py. Re-runs the SERP ladder on the no-LinkedIn
residue and adds a medium-confidence tier (top-2 result + first name in slug + company in title,
for maiden/professional-name cases like Erin Harkless Moore → /in/erin-harkless-cfa). Adds a
linkedin_confidence column (high = last-name gated, medium = flagged for human review). On the
603-row list it added few (the strict gate is near the honest ceiling); use it only when the operator
explicitly wants to trade some certainty for coverage — never loosen the gate silently.
The old speed_enrich.py runner (below) remains the base adapter layer fast_enrich builds on,
and a valid fallback when you want its --out file artifacts / --resume checkpointing. For a normal
"enrich this list fast and well" request, prefer fast_enrich.py.
HARD RULE (read before anything else in Stage 3)
Never use the mcp__<your-enrichment-server>__* MCP tools (start_enrich_job,
enrich_people_contacts, find_people, enrich_company, etc.) as the
enrichment path for a /speed-enrich run — regardless of list size. Those
tools are the /enrich-stream engine: lower concurrency, sequential stages,
no dual-key MailTester pool, no Blitz-first email lane. On a 33-row test they
took 19 min (52 s/firm) — extrapolated to 1,000 rows that's ~14 hours,
~60x over this skill's SLA.
The runner (scripts/speed_enrich.py) is the ONLY sanctioned Stage 3 path
for /speed-enrich. If you find yourself reaching for an MCP enrichment tool
"just for a quick small batch," STOP — you are running /enrich-stream, not
/speed-enrich. Either use the runner or invoke the correct skill.
The MCP enrichment tools are still fine for one-off spot lookups from the main loop (e.g. debugging a single firm, ad-hoc "who's at X"). They are NOT fine as the Stage 3 execution path when this skill is invoked.
Always smoke-test first (mandatory, every session)
python3 ~/.claude/skills/speed-enrich/scripts/speed_enrich.py \
--in leads.csv --out <run_dir>/smoke --smoke --emit-gsheet-spec
--smoke runs the first 5 rows verbose (raw provider responses printed).
Read the output before the full run: confirm AI-Ark and Blitz responses
parse (domains/people/LinkedIn/emails actually populated), keys are alive,
MailTester probe shows both keys with sane gaps. AI-Ark and Blitz payload
shapes are handled defensively with variant fallbacks, but if a provider
changed its schema, fix the adapter in speed_enrich.py BEFORE burning
credits on 1,000 rows.
Full run
python3 ~/.claude/skills/speed-enrich/scripts/speed_enrich.py \
--in leads.csv --out <run_dir> \
--mode auto --scope all --channels both \
--titles "CEO,Founder,Managing Partner" --per-company 5 \
--emit-gsheet-spec
Useful flags: --resume (checkpointed state.json — crash-safe), --no-serp,
--no-blitz, --no-aiark, --scope domain|linkedin|email|all,
--selftest (offline logic check).
Input: .csv or .json. Column names are auto-mapped (company/firm,
domain/website, name/first/last, linkedin, email, email_status variants).
Rows that already carry a domain / LinkedIn URL / valid email are marked
*_source: provided and never re-enriched or overwritten (SOP §existing
data + user rule: don't waste calls on already-verified info).
Provider routing (what the script does)
Domain / person / LinkedIn:
| Order | Provider | Role | Pacing |
|---|---|---|---|
| 1 | AI-Ark (api.ai-ark.com/api/developer-portal, X-TOKEN) |
company→domain, domain→people, name+domain→LinkedIn | 4.5 rps (cap 5 rps / 300 per min) |
| 2 | Blitz (api.blitz-api.ai, x-api-key) |
company search, domain→company-LinkedIn, employee-finder, name-match LinkedIn | 4 rps default; probe key-info |
| 3 | Engine fallback (existing /enrich-stream logic) | rows in residue_engine_fallback.json — run the enrichment-engine stages (roster crawl + Haiku extract) on this small remainder |
as per engine |
| 4 | Apify SERP (apify~google-search-scraper, 3 tokens, 4 concurrent runs) |
domain dominance-scoring fallback + LinkedIn SERP ladder | parallel |
| last | n8n web-search | only if Apify fails entirely | serial, 2 s/query — avoid |
Cross-checks preserved from the engine: registrable-domain dominance scoring +
blocklist, AI-Ark/Blitz consensus → high confidence, SERP arbitration on
disagreement, hard last-name gate on every LinkedIn URL, honest
domain_confidence: none / linkedin_status: not_found instead of guesses.
Email (validator of record = MailTester, statuses never collapsed: valid / risky / invalid / unknown / not_found):
| Order | Lane | Detail |
|---|---|---|
| 1 | Blitz find-work-email | when the person has a LinkedIn URL; result still MailTester-validated; database address wins on catch-all domains |
| 2 | Pattern lane | same 22-pattern catalog + priority order as the engine's Stage 9; stop at first valid; catch-all stops the whole domain (1 call settles it, cached per domain) |
| 3 | MailTester dual-key pool | key 1 Ultimate limit and sets per-key pacing with 5% safety margin. 429 → exponential backoff. Daily-limit → key leaves rotation, run continues on the other key. |
| 4 | MailVerifier (MailsFinder findEmail/verifyEmail, Bearer) |
fallback when patterns exhaust, MailTester returns unknown, or all keys die |
Maximum safe throughput, not maximum concurrency: every provider call goes through a per-provider token pacer; worker counts are sized from live pacer capacity, not hardcoded.
The 15-minute math (1,000 people, email+LinkedIn)
AI-Ark 1,000 lookups @4.5 rps ≈ 4 min · Blitz email 1,000 @4 rps ≈ 4 min · MailTester ~2,500 calls @ ~6 rps pooled ≈ 7 min — stages overlap, wall-clock lands ~12-15 min. The SERP/engine residue (typically <10% of rows) can exceed the SLA; report it separately rather than holding the batch.
Outputs (in --out)
| File | What |
|---|---|
enriched.json |
full nested rows, every field with *_source + confidence |
enriched_flat.csv |
one row per person (Instantly-shaped columns) |
residue_engine_fallback.json |
rows the APIs couldn't finish → run existing engine logic on these only |
run_report.json |
tallies, per-provider call counts, per-key MailTester usage, elapsed |
state.json |
checkpoint (resume with --resume) |
gsheet_spec.json |
ready for the sheet builder (below) |
After the runner: process residue_engine_fallback.json through the
existing enrichment-engine stages (that IS the "existing logic as fallback"
layer — do not skip it, do not re-run the APIs on it), then merge results back
before Stage 4 routing.
STAGES 4–7 — unchanged from /enrich-stream
- Stage 4 channel routing: identical, keyed on
email_status(valid → email track; risky → operator's catch-all call; else LinkedIn track if a real LinkedIn URL; else drop + count). - Stage 5 copy: copies Google Doc only, never invented; locked CTA rules; no em/en dashes; never-name list respected.
- Stage 6 output sheets + Master Registry append: identical, via the OAuth helper ({{GOOGLE_ACCOUNT}}), never the Drive MCP.
- Stage 7 QA: dedupe vs suppression list + in-batch, spot-check 5-10 rows, counts + flags handback.
Output sheet formatting (locked)
Build every output sheet with the gsheet house builder — it already applies the required style (Geist font everywhere, black header / white bold centred text / frozen, CLIP wrapping so text never spills, natural row heights, auto-sized columns, numeric columns centred):
python3 ~/.claude/skills/gsheet/build_gsheet.py <run_dir>/gsheet_spec.json
# update an existing sheet instead of creating a new one:
python3 ~/.claude/skills/gsheet/build_gsheet.py spec.json --sheet-id <ID>
Never hand-build formatting requests; never leave an output sheet unformatted.
Updating an existing sheet (merge rules — hard)
- Never overwrite an existing LinkedIn URL, verified email, or any non-empty higher-confidence value with a lower-confidence one (provided/published > cross-provider > single-provider > serp-derived).
- Only fill missing/incomplete fields; never duplicate records (dedupe key: email, else LinkedIn URL, else name+domain).
- Every filled cell's source is tracked (
domain_source,linkedin_source,email_source,email_status).
Credentials map
Never commit
config.json. Copyconfig.example.json, fill in your own keys, and keep the real file out of version control. Every provider can also be configured purely from the environment, which takes precedence over the file.
| Provider | Auth | Where to put your key |
|---|---|---|
| AI-Ark REST | X-TOKEN header |
config.json providers.aiark.keys[] or AIARK_KEYS (comma separated). Some networks are WAF-blocked for non-browser clients; if you get a 1010 error use the MCP endpoint instead |
| AI-Ark MCP (fast_enrich) | ?token= on the JSON-RPC endpoint |
config.json providers.aiark_mcp.token or AIARK_MCP_TOKEN. This is the lane fast_enrich.py uses for people_search / company_search / email_finder |
| Blitz | x-api-key header |
config.json providers.blitz.key or BLITZ_API_KEY. Probe GET /v2/account/key-info to learn your own rate ceiling |
| MailTester | ?key= param |
config.json providers.mailtester.keys[] or MT_KEYS. Keys are additive and dead keys drop out of rotation at startup, so listing several is safe |
| Email verifier (optional) | Authorization: Bearer |
config.json providers.mailverifier.key or MAILVERIFIER_API_KEY. Fallback lane only |
| Apify | ?token= |
config.json providers.apify.tokens[] or APIFY_API_TOKEN. Tune max_concurrent_runs to your plan |
| web-search / web-crawl | your own endpoints | WEBSEARCH_WEBHOOK_URL and WEBCRAWL_BASE_URL. Both optional; leave unset to skip those lanes |
| Google OAuth (sheets) | OAuth token dir | Point GSHEET_AUTH_DIR at a directory holding your token.json. See the gsheet skill |
Mistakes to avoid
- ❌ Using the
mcp__<your-enrichment-server>__*tools (start_enrich_job / enrich_people_contacts / find_people / enrich_company) as the Stage 3 path — regardless of list size. That is the /enrich-stream engine (~52 s/firm, ~14 h for 1,000 rows), NOT this skill's runner. If you invoked /speed-enrich, usescripts/speed_enrich.py. If you find yourself typing an MCP enrichment tool call during a Stage 3 batch, STOP. - ❌ Skipping the cost gate or the 5-row smoke run before a paid batch.
- ❌ Accepting a Blitz
email-to-personhit that fails the last-name gate, or delivering LinkedIn URLs without thelinkedin_confidencecolumn (high / review / provided). - ❌ Skipping the email-to-person lane when input rows have emails — it is free, the most accurate lane, and the only one that finds initials-only names.
- ❌ Blasting a provider at max concurrency instead of the paced pool — a blocked account costs more than any saved minutes.
- ❌ Re-enriching rows that already have provided/verified data.
- ❌ Reporting a
riskycatch-all as valid, or collapsing MailTester statuses. - ❌ Accepting a LinkedIn URL that fails the last-name gate.
- ❌ Dropping the residue instead of running the engine fallback on it.
- ❌ Inventing copy / skipping the copies doc / em-dashes (all SOP rules apply).
- ❌ Writing sheets via the Drive MCP, or skipping the Master Registry append.
- ❌ Signal scraping with this skill — that's /enrich-stream Stage 1.
Related skills
/enrich-stream— the parent pipeline (signal scrape + this stream's SOP).enrichment-engine— the fallback intelligence for the residue./verify-emails— standalone bulk verification (same MailTester pool pattern; add the Ultimate key there too)./gsheet— the locked sheet formatter used for all outputs./linkedin-outreach-sheet— Stage 6 LinkedIn VA sheets.