# Speed Enrich

> High-speed version of /enrich-stream for pure enrichment runs. Same intake, same enrichment intelligence, same output standards, but API-first provider routing (AI-Ark + Blitz primary, existing engine logic as fallback, Apify SERP as the scriptable fallback lane) executed by a parallel Python worker-pool runner with dual-key MailTester pacing and MailVerifier fallback. Default Stage-3 path is now scripts/fast_enrich.py - a quality-first, per-person PIPELINED + streaming runner (AI-Ark via the MCP endpoint since the REST base is Cloudflare-blocked, Blitz domain, Apify parallel SERP ladder for LinkedIn + domain arbitration, uncapped MailTester patterns, catch-all-per-domain). Benchmarked LinkedIn 43%->84%, domain 88%, ~14 min / 603 rows. Target SLA - 1,000 domains or people enriched in about 15 minutes where provider limits allow. Use when the user wants a list enriched FAST (domain / people / LinkedIn / email), types /speed-enrich, or asks for "fast enrichment" / "speed enrich" of an existing list or sheet. No

- Skill: `harsh719/speed-enrich` (Agent Skill, multi-file: 6 files)
- Install (CLI): `npx skillmds@latest add harsh719/speed-enrich`
- Raw SKILL.md: https://api.skillmd.com/api/skills/harsh719/speed-enrich/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: harsh719 (https://skillmd.com/u/harsh719)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/harsh719/speed-enrich

---

<!-- SETUP:BEGIN -->
## 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:**

1. Run `python3 brandkit.py`. It prints the config source and any placeholder
   that is still unset.
2. If it says `configured: False`, copy `brand.config.example.json` to
   `brand.config.json`.
3. Ask the operator for each value under `missing`, then write them in. Do not
   guess a brand name, a domain, or a colour.
4. 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.

<!-- SETUP:END -->

# 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** |
| Email | 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:

1. Client name (fuzzy-match folder, read `00_<client>_context.md` to pre-fill)
2. Existing vs new client (new → offer template first, stop until it exists)
3. **The input list** — Google Sheet link or file path; which tab; which
   columns hold name / company / domain / LinkedIn / email
4. Channels — email / LinkedIn / both (LinkedIn → first-DM CTA question, with
   the Innova / houseoflords lock)
5. **Copies Google Doc — ASK EVERY RUN, NO EXCEPTIONS**
6. Preferred subject line (default: copies doc → offer doc)
7. Enrichment scope — domain / LinkedIn / email / all
8. Target market + geography, target persona / titles, people per company
   (default 5)
9. Sender split (LinkedIn only, default 50/50 Remo/Estere) + never-name list
10. 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` (header `x-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`, config `providers.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_search`
  is the **strong LinkedIn source** (last-name gated); `company_search` feeds domain behind a strict
  name-affinity gate (rejects the Tata/Amazon keyword-noise); `email_finder` gives 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 to `review`.
- **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 linkedin` SERP 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_confidence` column (high / review / provided) on every accepted URL**, graded
  by `SE.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-mba` stays
  high). Never deliver ungraded URLs.
- **Post-run email-anchored audit (recommended when accuracy is the stated priority):**
  re-check found URLs against `email-to-person` wherever 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-flag `review` otherwise).
  Remo run: 409/437 checkable rows confirmed exactly, 26 conflicts caught and resolved.

```bash
# 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)

```bash
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

```bash
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 ~500k/day (~5 rps), key 2 Pro ~100k/day (~1 rps). Startup probe reads each key's real `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):

```bash
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`. Copy `config.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, use `scripts/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-person` hit that fails the last-name gate, or delivering
  LinkedIn URLs without the `linkedin_confidence` column (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 `risky` catch-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.

