# AUTONOMOUS BUILD PROMPT: Website visitor → Clay → EmailBison evergreen outbound (end to end)

> **Audience:** an autonomous AI agent (or a person acting like one) rebuilding the whole system from zero context, with **zero human intervention after pre-flight**.
> **Source:** the system Apero Advisors (aperoadvisors.com) built and tested in Oct 2026: two Clay Workflows (WF-A: company-level Web Intent → buyers; WF-B: person pipeline → EmailBison), a small Salesforce suppression API, EmailBison campaigns 83-90, and every bug we hit.
> **Scrubbed:** all Clay object IDs are `<CLAY_ID>`, all secrets, phone numbers, calendar links and internal URL tokens are `<PLACEHOLDERS>`, and no real prospects are named. The agent fills these in from pre-flight inputs or from objects it creates.

---

## PART A. Operating contract (read first, follow always)

### A.1 Mandate
You run every phase below **end to end without asking a human anything**. The only human input is the pre-flight packet (Part B), collected once, before you start. After that you never pause for approval, review or clarification. When something is ambiguous, pick the safest option that satisfies the non-negotiables (A.5), log the decision with your reasoning in BUILD-LOG.md, and continue.

### A.2 The BTER loop (run after every single step)
Every step is **Build → Test → Evaluate → Review**:
1. **Build:** do the step's Actions.
2. **Test:** run the step's Verification test exactly as written (command, API call, page check). Capture the raw output as evidence.
3. **Evaluate:** compare the evidence to every Acceptance criterion. Each one is binary: PASS or FAIL. "Probably fine" is a FAIL.
4. **Review:** run the step's adversarial self-review. Ask "what would break this in production, at 3am, with a weird payload?" Write at least one concrete attack per rubric line, try it where it can be tried for free (code-node test, dry run), and record the result.

**On FAIL:**
- Diagnose (read the error, the node output and the logs; state a root-cause hypothesis), fix, and retest. Retry **up to N = 3 attempts** with the same approach.
- After 3 failed attempts, switch to the step's **alternative approach** (each step lists one, or uses its Rollback plus a different route), and give it up to 3 more attempts.
- If it still fails, run the step's Rollback. Mark the step `BLOCKED` with full evidence, and continue any later steps that don't depend on it. **Never launch (Phase 8) with any step BLOCKED.** If launch is impossible, finish everything else and end with the handoff report (Phase 10), listing exactly what is blocked and why.
- Never "fix" a failure by weakening a non-negotiable, deleting a test or loosening an acceptance criterion.

### A.3 BUILD-LOG.md (keep it current; it is the source of truth)
Create `BUILD-LOG.md` in the working repo at the start. Update it after every BTER loop. Sections:
1. **Status table:** one row per step: `| step | status (TODO/IN_PROGRESS/PASS/FAIL/BLOCKED) | attempts | evidence link | last updated (ET) |`.
2. **Decisions log:** `| time | decision | options considered | why | reversible? |`.
3. **Evidence:** per step, raw outputs (trimmed), run ids, counts, HTTP status codes, screenshots of UI-only steps (saved under `evidence/<step>/`), and credit balance before and after.
4. **Credits ledger:** running total vs the budget cap (pre-flight P8).
5. **Secrets register:** *names* and storage locations only, never values.
6. **Open risks.**

### A.4 Phase gates
A phase cannot start until **every** acceptance criterion of the previous phase is PASS and logged with evidence. At each gate, re-read the non-negotiables and the status table, and write a short gate note: "Phase N gate: all PASS, evidence at …". Phases: 0 Pre-flight check → 1 Accounts → 2 Site tracking → 3 Shared infra (CRM check, Slack, Audiences fields) → 4 EmailBison campaigns → 5 WF-B person pipeline → 6 WF-A company → buyers → 7 Integrated dry runs and draft pushes → 8 Autonomous launch gate and launch → 9 Monitoring with kill switches → 10 Final E2E acceptance and handoff.

### A.5 Non-negotiables (violating any of these is an automatic FAIL)
1. **Nothing reaches a real prospect before the Phase 8 launch gate passes.** Until then: campaigns stay draft, workflows stay unpublished, `dry_run` defaults TRUE, and the only real sends go to the agent's own seed inboxes.
2. **Never say or imply that the person visited the website.** No "saw you on our site", "noticed you checking out", "thanks for stopping by" or "your interest in". The visit is only the *trigger*. The copy must read like ordinary, well-targeted cold email.
3. **Never tell them what they already know**, like "you're a packaging company" or "you sell to X". Offer something instead: a short list of *their* buyers showing a live buying signal.
4. **Zero human intervention once launched.** The pipeline runs evergreen, fully inside Clay natively, including the EmailBison push (Clay's native EmailBison actions). The only external hop is the owner's small API (Muthur, on Vercel) for the Salesforce check and Slack cards.
5. **Every lead pushed to EmailBison has** a first name, a full last name, a LinkedIn `linkedin.com/in/...` URL and a **LeadMagic-validated** email (status `valid`). No exceptions.
6. **No invented people, no sample leads, no fake test data.** Test only on real visitors already in the system, plus the agent's own seed inboxes for send tests. Never generate lookalike leads before a prospect says yes.
7. **Style:** no em dashes or dashes in AI output, lowercase AI variables, no meetings before 9am ET, and Apero's market is "PE- and VC-backed companies". Follow the owner's style guide.
8. **Cost discipline:** free filters first, paid steps last, single-node tests before full runs. Log credits before and after every test session. Stay under the budget cap.
9. **Blocklists everywhere:** your own domain (`aperoadvisors.com`) and the owner's personal or employer domain (`<OWNER_PERSONAL_DOMAIN>`) are blocked in Clay *and* in the Slack notifier.
10. **Secrets:** never print, log, commit or paste a secret value. Read keys from files or secret stores (strip `\r`; never `source` or `echo` them). The BUILD-LOG names secrets but never shows them.
11. **No destructive actions on things you didn't create** (existing campaigns, leads, CRM records). Salesforce access is read-only.

---

## PART B. Pre-flight (the single human input, collected once, up front)

The run does not start until every **required** item below is in the pre-flight packet. Once it starts, no more questions are asked.

| # | Input | Required | Used for |
|---|---|---|---|
| P1 | **Payment methods and plan approvals:** a card or billing account authorized for Clay (plan with Web Intent, Audiences, Workflows, Claygent and native EmailBison/LeadMagic actions), Squid (plan with the Clay integration), EmailBison, LeadMagic, OpenAI API, and Vercel if used, with a max monthly spend per vendor | yes | Phase 1 signups |
| P2 | **Agent mailbox access:** an inbox the agent controls (for example `ops@<COMPANY_DOMAIN>`), with IMAP/API or mail-connector access, used for every signup and to read verification emails and magic links | yes | Phase 1 |
| P3 | **Domain and site repo access:** write access to the website repo (branch + PR + merge rights, or a deploy token) and the hosting/deploy target, plus DNS access if sending domains must be configured | yes | Phase 2, Phase 4 |
| P4 | **Sender inboxes:** either existing warmed EmailBison sending inboxes (credentials or the EmailBison workspace invite), or approval plus budget to buy domains and inboxes and wait out warmup (2-3 weeks, which is a known schedule dependency) | yes | Phase 4 |
| P5 | **Seed test inboxes:** 2-3 inboxes the agent controls on different providers (Google, Microsoft, one other) for seed sends | yes | Phase 8 |
| P6 | **Owner contact details for copy:** sender phone `<SENDER_PHONE>` (pricing campaign), calendar link `<CALENDAR_LINK>`, sender names and signatures | yes | Phase 4 |
| P7 | **Salesforce read-only integration user** (or OAuth client + refresh token), and **Slack** bot install rights for the visitor channel `<SLACK_CHANNEL_ID>` | yes | Phase 3 |
| P8 | **Budget cap:** max Clay credits and max dollars for the build + first 30 days, plus the kill-switch thresholds if they differ from the defaults in Phase 9 | yes | All phases |
| P9 | **Policy decisions with defaults** (the agent uses the default if this is left blank): visit-time floor (default **off**; if on: drop if session < 20s and < 2 unique pages); size cap (default **1,000 employees**, unknown passes); Lemlist LinkedIn touch (default **off**); daily new-lead caps (default **150/campaign**) | no | Phases 5-8 |
| P10 | **Things that cannot be automated, done by the human during pre-flight:** any captcha on signup pages, SMS/2FA codes or authenticator enrollment for each vendor (or a shared TOTP secret placed in the team vault for the agent), card 3-D Secure confirmations, Salesforce connected-app admin approval, Slack app admin approval, vendor "talk to sales" plan unlocks (Clay Web Intent/Workflows may require one), and the owner's OAuth consent for Google/Microsoft mail access | yes, before the run | Phase 1 |

**Pre-flight rule:** if an item in P10 appears *mid-run* that wasn't anticipated (a surprise captcha, for example), do **not** wait. Mark that step BLOCKED with evidence, try the alternative route (API signup, a different auth method, vendor support via email from the agent mailbox), continue everything that doesn't depend on it, and report it in the handoff.


### Step 0.1: Validate the pre-flight packet

**Goal.** Prove every pre-flight input works before spending anything.

**Inputs.** The pre-flight packet P1-P10.

**Actions.**

- Log in to (or test) each credential: mailbox read (P2), repo push to a scratch branch (P3), EmailBison workspace if one exists (P4), seed inboxes receive mail (P5), Salesforce SOQL `SELECT Id FROM Account LIMIT 1` (P7), Slack `auth.test` (P7).
- Write the secrets register (names and locations only).
- Record the starting balances and the budget cap.

**Verification test.**

- Run each check and save the outcome. Example: `git push origin HEAD:preflight-check && git push origin --delete preflight-check`.
- Send a test email from the agent mailbox to each seed inbox and confirm receipt via the seed inbox API.

**Acceptance criteria** (all must pass and be logged):
- [ ] Every required P-item is present
- [ ] Every credential check returned success (evidence saved)
- [ ] Secrets register lists names only (grep the log for key-like strings: none)
- [ ] Budget cap recorded

**Self-review (adversarial: what would break this?)**
- Is any credential personal to a human, so it could trigger a 2FA prompt mid-run? If so, fix it now (move TOTP into the vault).
- Does the mailbox receive vendor mail, or does it go to spam? Whitelist the vendor domains.

**Rollback.** Nothing to roll back. Delete the scratch branch if it was left behind.

> **PHASE 0 GATE.** Phase 1 must not start until every acceptance box in Phase 0 is checked in BUILD-LOG.md with evidence. Gate checks: all P-items valid; no secrets in the log. If any check fails, re-enter the failing step's BTER loop.

---

## PART C. Architecture (reference for every phase)

### C.1 Goal
When a company or a person from a target account lands on the website, automatically:
1. identify them (company-level via **Clay Web Intent**, person-level via **Squid**),
2. throw out junk for free (own domain, gov/edu, agencies, GTM vendors and competitors, companies over 1,000 employees),
3. pick 2-3 likely buyers (company-level only),
4. find and validate a work email (LeadMagic waterfall),
5. suppress anyone in an open opportunity, existing customers, opt-outs and recent activity (Salesforce check),
6. enforce cooldowns (one sequence per person ever, at most one 3-person wave per company per 30 days),
7. generate 2 short AI variables and map a case study deterministically,
8. lint the AI output,
9. upsert the lead into EmailBison with custom variables and import them to the right evergreen campaign (83-89 by page angle for Squid people, 90 for company-level Web Intent),
10. stamp the person and company as pushed, and post a Slack card.

### C.2 Architecture diagram
```mermaid
flowchart TD
  subgraph Site["aperoadvisors.com (site code, no GTM)"]
    CT["Clay Web Intent tracker"]
    SQ["Squid person-ID tracker"]
  end
  CT --> WI["Clay Web Intent: company records in Audiences + table 'Website Visitors - Clay'"]
  SQ --> SQW["Squid → Clay webhook (person payload)"]
  WI --> SEG["Audiences segment (ACCOUNT) audseg_..."]
  SEG --> A0

  subgraph WFA["WF-A: Web Intent company → buyers (Clay Workflow)"]
    A0(["Segment trigger"]) --> A1[["Company filters: blocklists, gov/edu, agency, excluded industry, &lt;10 or &gt;1000 employees"]]
    A1 -->|pass| A2["Claygent (own OpenAI key): pick 2-3 buyers with LinkedIn URLs"]
    A2 --> A3[["Require LinkedIn + first/last, dedupe, cap 3, build payloads"]]
    A3 -->|has people| A4("HTTP POST each person → WF-B webhook")
  end

  A4 --> B0
  SQW --> B0

  subgraph WFB["WF-B: person pipeline → EmailBison (Clay Workflow)"]
    B0(["Webhook + Manual trigger"]) --> B1[["Normalize + hard filters + page angle → campaign_id; dry_run default TRUE"]]
    B1 -->|pass| B2("Audiences lookup: person") --> B3("Audiences lookup: company")
    B3 --> BS[["Company size gate: &gt;1000 employees ends run; unknown passes"]]
    BS -->|size_ok| B4[["Cooldown precheck"]]
    B4 -->|ok| B5("LeadMagic find work email") --> B6("LeadMagic validate") --> B7[["Pattern guesses"]] --> B8("LeadMagic validate guesses") --> B9[["Final valid email"]]
    B9 -->|valid| B10("HTTP: Muthur /api/clay/sf-check") --> B11[["Suppression decide (fail closed)"]]
    B11 -->|clear| B12["AI variables (own key): signal_line, peer_group, industry_bucket"]
    B12 --> B13[["Content map: bucket → CONTENT_URL / CONTENT_WHY"]] --> B14[["Lint gate"]]
    B14 -->|ok| B15[["Final cooldown + pick wave slot"]] --> B16("Claim token on person + company slot") --> B17(["Wait 30s"]) --> B18[["Verify claim still ours"]]
    B18 -->|held| B19{"dry_run?"}
    B19 -->|yes| B20[["DRY RUN stop"]]
    B19 -->|no| B21("Slack card via Muthur") --> B22("EmailBison: create or update lead (PATCH + custom vars)") --> B23("EmailBison: import lead to campaign {{campaign_id}}") --> B24("Stamp WV Pushed At / WV Campaign (person), WV Last Pushed At (company)")
  end

  B10 -. read only SOQL .-> SF[("Salesforce")]
  B23 --> EB["EmailBison evergreen campaigns 83-90 (24/7 schedule)"]
  EB --> RPL["Replies → reply desk drafts for owner approval"]
```

### C.3 Why two workflows
- **Web Intent gives companies, not people.** WF-A turns a company into 2-3 named buyers, then hands each one to WF-B as a person payload with `source=clay_web_intent`.
- **Squid gives people.** Their payload goes straight into WF-B with `source=squid`.
- WF-B is the one place where email, suppression, cooldown, AI, lint and push logic lives, so both sources get identical guardrails.

### C.4 Object registry (create your own and record it in `ids.json`)
| Thing | ID |
|---|---|
| Clay workspace | `<CLAY_WORKSPACE_ID>` |
| WF-B person pipeline "Website Visitors - person pipeline to EmailBison (draft)" | `<CLAY_ID>` |
| WF-A "Website Visitors - Web Intent company to buyers (draft)" | `<CLAY_ID>` |
| Audiences segment (ACCOUNT, Web Intent companies; WF-A trigger) | `<CLAY_ID>` |
| Own OpenAI connection in Clay | `<CLAY_ID>` (name it "own-openai-key") |
| EmailBison instance | `<EMAILBISON_BASE_URL>` |
| EmailBison campaigns | 83-89 (Squid, by page), 90 "Clay visitor AI" (company-level). New instances get new numbers; record them |
| EmailBison custom variables | `signal_line`, `peer_group`, `content_url`, `content_why`, `linkedin_url` |
| Muthur (owner's API on Vercel) | `https://muthur-slack.vercel.app` (repo `Apero-Advisors/muthur-slack`) |
| Slack visitor channel | `<SLACK_CHANNEL_ID>` |
| Audiences person fields | WV Pushed At, WV Campaign, WV Claim Token: each `<CLAY_ID>` |
| Audiences company fields | WV Last Pushed At, WV Wave Slot 1/2/3: each `<CLAY_ID>` |

Every code block below references field ids as `<CLAY_ID>`. Replace each with the id you created, and record the mapping in `ids.json`.

---

## PART D. Phases

## Phase 1. Accounts and connections (agent signs up itself)

For every signup: use the agent mailbox (P2) and the approved payment method (P1). Read verification emails and magic links from the mailbox via its API. Enroll 2FA using the vault TOTP (P10). Create API keys and store them in the secret store, never in the log.

### Step 1.1: Clay (core platform) and own OpenAI key

**Goal.** Have a working, verified Clay (core platform) and own OpenAI key account with credentials in the secret store.

**Inputs.** Pre-flight P1, P2 and P10, plus earlier steps.

**Actions.**

- **Sign up:** https://www.clay.com. You need a paid plan that includes **Web Intent** (website visitor de-anonymization), **Audiences**, **Workflows** (the "terracotta / tc-workflows" builder), Claygent and the native **EmailBison** and **LeadMagic** actions. Confirm with Clay sales that your plan includes Web Intent and Workflows. Plan names change, so check the current pricing page. The agent records the plan name and price from the pricing page (approved via P1).
- **Own OpenAI key (required):**
  1. Create an OpenAI API key at https://platform.openai.com (Project → API keys). Set a monthly budget limit.
  2. In Clay: Settings → Connections (Accounts) → Add → OpenAI → paste `<OPENAI_API_KEY>`. Name it clearly (Apero used "own-openai-key").
  3. **Every** AI node (Claygent and Agent) must use this connection (`authAccountId` = your connection id), not "Clay-managed". Clay-managed AI burns Clay credits. Our nodes were silently on Clay-managed until we checked `authAccountId` on each node.
  4. Model: a small fast model is enough (we use `gpt-5.4-nano` on our key). Pick one available on *your* key.
- **Clay CLI (optional, strongly recommended):** the Clay CLI (`clay`, OAuth login) can read and update workflow nodes, run tests and validate graphs. It **cannot** create tables, Web Intent connections or webhook sources. Those are UI-only.
- **Credits:** note the starting balance (`clay credits balance`). Budget roughly 5-15 credits per person who reaches the AI step (see §9).

**Verification test.**

`clay credits balance` returns a number. `clay workflows list` works. In Clay Connections, the OpenAI connection shows as valid. A 1-row test of any AI node (later) reports `authAccountId` = your connection id.

**Acceptance criteria** (all must pass and be logged):
- [ ] Clay plan includes Web Intent, Audiences, Workflows, Claygent and native EmailBison/LeadMagic (screenshot of the plan page)
- [ ] OpenAI key created with a monthly budget limit
- [ ] OpenAI connection saved in Clay
- [ ] Clay CLI authenticated
- [ ] Starting credit balance logged

**Self-review (adversarial: what would break this?)**
- Will an AI node silently fall back to Clay-managed AI? (Check `authAccountId` on every agent node in Phases 5-6.)
- Is the OpenAI budget limit lower than a runaway day? Set it to roughly 2x the expected daily spend.

**Rollback.** Cancel the plan within its trial window. Revoke the OpenAI key.

### Step 1.2: Clay Web Intent connection

**Goal.** Have a working, verified Clay Web Intent connection account with credentials in the secret store.

**Inputs.** Pre-flight P1, P2 and P10, plus earlier steps.

**Actions.**

1. In Clay: Account name → Settings → **Web intent** → Add connection. Website name: your domain.
2. Open Web intent setup → **JavaScript Snippet** tab → copy the snippet. Its form is:
   ```html
   <script defer src="https://app.clay.com/tracker.js"></script>
   <script>window.clay={connectionKey:"<CLAY_WEB_INTENT_CONNECTION_KEY>"};</script>
   ```
3. Providers: **Recommended**, mode **Waterfall**. Countries: the US, Canada and the UK (keep it loose). Pages: all.
4. Enable the connection → Save.
5. Records land in **Audiences** as company records and can also feed a table. Create an **Audiences segment** of type ACCOUNT for "web intent companies" (ours: `<CLAY_ID>`). This segment triggers WF-A.
6. Docs: https://university.clay.com/docs/website-tracking

**Verification test.**

The connection page shows status Enabled, and the snippet is saved to a `chmod 600` file. Data receipt is verified in Phase 2.

**Acceptance criteria** (all must pass and be logged):
- [ ] Connection enabled with providers Recommended/Waterfall
- [ ] Countries set
- [ ] Snippet stored (key not logged)
- [ ] Audiences ACCOUNT segment for Web Intent companies created, id in `ids.json`

**Self-review (adversarial: what would break this?)**
- Is the connection key stored where the site build can read it as a public env var?
- Will internal or staging traffic be identified and pollute the data? (Handled in Phase 2.)

**Rollback.** Disable the connection.

### Step 1.3: Squid (person-level)

**Goal.** Have a working, verified Squid (person-level) account with credentials in the secret store.

**Inputs.** Pre-flight P1, P2 and P10, plus earlier steps.

**Actions.**

- **Sign up:** https://www.squid-id.com (docs https://docs.squid-id.com). Pick the plan that includes the **Clay integration** (webhook). The agent records the plan name and price (approved via P1).
- Install the Squid pixel (see §3). The snippet Squid gives you looks like `<script src="https://static.claydar.com/init.v1.js?id=<SQUID_TRACKER_ID>"></script>`. Copy the exact snippet from your Squid dashboard; don't hand-type it.
- Integration: Squid → Settings → Integrations → **Clay** → paste the **WF-B webhook URL** `<WFB_WEBHOOK_URL>` and its auth token `<WFB_WEBHOOK_TOKEN>` (sent as header `x-clay-webhook-auth`). Docs: https://docs.squid-id.com/integrations/clay
- **The Squid payload must include** `source: "squid"`, first/last or full name, `linkedin_url`, `title`, `company_name`, `company_domain`, `pages` (list or comma string), `session_count`, and ideally `employee_count` or `company_size`. It must set `dry_run: false` **explicitly** to go live. WF-B defaults to dry run. *Step 1.3 documents the exact field names Squid sends; map them in the trigger. If Squid sends no headcount, the size gate falls back to the Audiences company record.*
- The agent pastes the webhook into Squid itself (Phase 7, step 7.1).

**Verification test.**

Squid dashboard login works, the pixel snippet is saved, and the Clay integration page is reachable. The webhook is pasted in step 7.1 once WF-B exists.

**Acceptance criteria** (all must pass and be logged):
- [ ] Squid account on a plan with the Clay integration
- [ ] Pixel snippet stored
- [ ] Payload field list documented from Squid docs/sample into `ids.json` (name fields, linkedin, company, domain, pages, sessions, headcount if any)

**Self-review (adversarial: what would break this?)**
- Does Squid send a headcount field? If not, the size gate relies on the Audiences fallback. Log that.
- Does Squid send `dry_run`? It must be added or defaulted. WF-B defaults to TRUE, so Squid traffic will be dry until launch (by design).

**Rollback.** Cancel the trial.

### Step 1.4: EmailBison (sequencer)

**Goal.** Have a working, verified EmailBison (sequencer) account with credentials in the secret store.

**Inputs.** Pre-flight P1, P2 and P10, plus earlier steps.

**Actions.**

- **Sign up:** https://emailbison.com. You get a dedicated instance URL `<EMAILBISON_BASE_URL>`. Buy or connect sending inboxes on secondary domains (never the main domain). Warm them up for 2-3 weeks before launch.
- Create an **API key** (Settings → API) `<EMAILBISON_API_KEY>`. Store it in Clay as an **EmailBison connection** (Clay Settings → Connections → EmailBison → API key + base URL). Apero's is named "Apero EmailBison".
- Create the **custom variables** (Settings → Custom variables): `signal_line`, `peer_group`, `content_url`, `content_why`, `linkedin_url`. The Clay action rejects variable names that don't exist in the workspace.
- Create the tag `evergreen`.
- Campaign setup: §6.

**Verification test.**

`GET <EMAILBISON_BASE_URL>/api/campaigns` with `Authorization: Bearer <EMAILBISON_API_KEY>` returns 200. In Clay, the EmailBison connection test passes. The custom variables list includes all 5 names.

**Acceptance criteria** (all must pass and be logged):
- [ ] Instance + API key
- [ ] Clay EmailBison connection saved
- [ ] Custom variables `signal_line`, `peer_group`, `content_url`, `content_why`, `linkedin_url` exist
- [ ] Tag `evergreen` exists
- [ ] Sending inboxes connected and warm (warmup score / age logged)

**Self-review (adversarial: what would break this?)**
- Are the inboxes on the main domain? They must be on secondary domains.
- Is the warmup long enough? If under 14 days, Phase 8 is scheduled later (log the date) and other phases continue.

**Rollback.** Remove the API key from Clay. Revoke the key.

### Step 1.5: LeadMagic

**Goal.** Have a working, verified LeadMagic account with credentials in the secret store.

**Inputs.** Pre-flight P1, P2 and P10, plus earlier steps.

**Actions.**

- **Sign up:** https://leadmagic.io. Get an API key `<LEADMAGIC_API_KEY>`.
- In Clay: Connections → LeadMagic → paste the key (or use Clay's managed LeadMagic credits if your plan includes them; own key is usually cheaper at volume).
- We use: **Find work email** (name + domain) and **Validate email**. Only status `valid` counts. Catch-all is **off**.

**Verification test.**

In Clay, run the LeadMagic validate action on the agent's own mailbox address: status `valid`.

**Acceptance criteria** (all must pass and be logged):
- [ ] Key stored and the Clay connection saved (or managed credits confirmed)
- [ ] Catch-all option noted as OFF for later nodes

**Self-review (adversarial: what would break this?)**
- Will LeadMagic return `catch_all` and get treated as valid? Only `valid` passes (enforced in step 5.6).

**Rollback.** Remove the connection.

### Step 1.6: Salesforce read-only access

**Goal.** Have a working, verified Salesforce read-only access account with credentials in the secret store.

**Inputs.** Pre-flight P1, P2 and P10, plus earlier steps.

**Actions.**

- You need a Salesforce integration user with **read-only** API access to Account, Contact, Lead, Opportunity, OpportunityContactRole and Task/Activity.
- Connect it to Muthur (§2.7), not Clay. Muthur holds `<SALESFORCE_CLIENT_ID>`, `<SALESFORCE_CLIENT_SECRET>` and `<SALESFORCE_REFRESH_TOKEN>` (or JWT) in Vercel env vars.

**Verification test.**

SOQL `SELECT Id FROM Opportunity LIMIT 1` via the integration user succeeds. A write attempt in a sandbox, or a permission check, fails.

**Acceptance criteria** (all must pass and be logged):
- [ ] Read-only permission set confirmed
- [ ] Credentials stored for Muthur

**Self-review (adversarial: what would break this?)**
- Could this user write? Verify that the profile lacks create/edit.

**Rollback.** Deactivate the integration user.

### Step 1.7: Muthur on Vercel (sf-check + Slack card host)

**Goal.** Have a working, verified Muthur on Vercel (sf-check + Slack card host) account with credentials in the secret store.

**Inputs.** Pre-flight P1, P2 and P10, plus earlier steps.

**Actions.**

- Repo: `Apero-Advisors/muthur-slack` (Node/TypeScript, Express, deployed on Vercel at `https://muthur-slack.vercel.app`). If you are rebuilding elsewhere, any small serverless API works.
- Routes used by this system:
  - `POST /api/clay/sf-check`: the Salesforce suppression gate (spec in §4.9).
  - `POST /webhooks/website-visitors`: the Slack visitor card (spec in §4.15).
- Vercel env vars (names only):
  - `CLAY_SF_CHECK_SECRET=<CLAY_SF_CHECK_SECRET>`
  - `WEBSITE_VISITORS_WEBHOOK_SECRET=<MUTHUR_WEBSITE_VISITORS_SECRET>`
  - `SLACK_BOT_TOKEN=<SLACK_BOT_TOKEN>`
  - `SLACK_WEBSITE_VISITORS_CHANNEL_ID=<SLACK_CHANNEL_ID>`
  - `WEBSITE_VISITORS_BLOCKED_DOMAINS=aperoadvisors.com,<OWNER_PERSONAL_DOMAIN>`
  - `WEBSITE_VISITORS_BLOCKLIST=<emails or linkedin urls>`
  - plus the Salesforce credentials.
- Invite the Muthur Slack bot to the visitor channel.

**Verification test.**

`curl -s -o /dev/null -w '%{http_code}' -X POST https://<MUTHUR_HOST>/api/clay/sf-check` → 401 (no auth). The full test is in Phase 3.

**Acceptance criteria** (all must pass and be logged):
- [ ] Repo builds on Vercel
- [ ] All env var names set (values from the secret store)

**Self-review (adversarial: what would break this?)**
- Does an empty or unset secret accept requests? It must reject (Phase 3 test).

**Rollback.** Roll back to the previous Vercel deployment.

### Step 1.8: Slack

**Goal.** Have a working, verified Slack account with credentials in the secret store.

**Inputs.** Pre-flight P1, P2 and P10, plus earlier steps.

**Actions.**

- One channel for visitor cards (ours: `<SLACK_CHANNEL_ID>`). The Slack app/bot posts there via Muthur.

**Verification test.**

Slack `auth.test` with the bot token is ok. `conversations.info` on the channel works. The bot is a member.

**Acceptance criteria** (all must pass and be logged):
- [ ] Bot is in `<SLACK_CHANNEL_ID>`

**Self-review (adversarial: what would break this?)**
- Will the cards go to a public channel by mistake? Confirm the channel id.

**Rollback.** Remove the bot from the channel.

### Step 1.9: Lemlist (optional, default OFF)

**Goal.** Have a working, verified Lemlist (optional, default OFF) account with credentials in the secret store.

**Inputs.** Pre-flight P1, P2 and P10, plus earlier steps.

**Actions.**

- Evergreen Lemlist campaign "WEB - Squid visitors - LI connect (evergreen)" `<LEMLIST_CAMPAIGN_ID>`, built and **paused**.
- Push spec: `POST https://api.lemlist.com/api/campaigns/<LEMLIST_CAMPAIGN_ID>/leads/?deduplicate=true`, Basic auth with an empty user and `<LEMLIST_API_KEY>`. Body: `linkedinUrl, firstName, lastName, companyName` (+ `email` only if validated).
- Run it only if LinkedIn is present, the Squid ID is positive, the person is not a SF opp or customer, and `Lemlist Pushed At` is empty. A 409 or 400 counts as skipped; stamp anyway.
- **Do not enable** until the owner answers: test lead first? 20/day cap? 50/50 sender split? dedupe vs any campaign? reply scoring?

**Verification test.**

Only if P9 enables it: `GET https://api.lemlist.com/api/campaigns` → 200.

**Acceptance criteria** (all must pass and be logged):
- [ ] If P9 = off: logged as skipped
- [ ] If on: the campaign exists and is paused

**Self-review (adversarial: what would break this?)**
- Double touch: email + LinkedIn on the same day may feel spammy. Respect the P9 caps.

**Rollback.** Pause the campaign.

Other pre-flight values used later: calendar `<CALENDAR_LINK>` and sender phone `<SENDER_PHONE>` (campaign 84/85/86 step 2 and campaign 84 step 1).

> **PHASE 1 GATE.** Phase 2 must not start until every acceptance box in Phase 1 is checked in BUILD-LOG.md with evidence. Gate checks: every account verified; all keys in the secret store; credits ledger started. If any check fails, re-enter the failing step's BTER loop.

## Phase 2. Install tracking in the site code (no Google Tag Manager)

### Step 2.1: Add Clay Web Intent and Squid scripts via a reviewed PR

**Goal.** Both trackers load on every production page, are version controlled and skip internal traffic.

**Inputs.** P3 repo access, snippets from steps 1.2 and 1.3.

**Actions.**

1. **Branch:** in the website repo, create `feat/visitor-tracking`.
2. **Global layout:** find the one template rendered on every page. Examples: Next.js `app/layout.tsx` or `pages/_document.tsx`, Astro `src/layouts/Base.astro`, Webflow Project Settings → Custom Code → Footer (version-controlled export), WordPress the theme's `footer.php` via a child theme.
3. **Insert both snippets just before `</body>`:**
   ```html
   <!-- Clay Web Intent (company-level) -->
   <script defer src="https://app.clay.com/tracker.js"></script>
   <script>window.clay={connectionKey:"<CLAY_WEB_INTENT_CONNECTION_KEY>"};</script>
   <!-- Squid (person-level) -->
   <script src="https://static.claydar.com/init.v1.js?id=<SQUID_TRACKER_ID>"></script>
   ```
   In Next.js use `next/script` with `strategy="afterInteractive"`, and put the keys in **public** env vars (`NEXT_PUBLIC_CLAY_CONNECTION_KEY`, `NEXT_PUBLIC_SQUID_ID`). These keys are designed to be public in the browser, but keep them out of hard-coded strings so you can rotate them.
4. **Exclude internal traffic.** Skip loading the scripts when the hostname is a preview or staging domain (`*.vercel.app`, `localhost`) or when a `?internal=1` cookie is set. Blocking your own domains in Clay (§4.3) is the real safety net.
5. **Privacy and consent:** add both vendors to the privacy policy and subprocessor list. If you run a consent banner (EU/UK visitors), load the scripts only after the relevant consent.
6. **CSP:** if the site sends a Content-Security-Policy, add `https://app.clay.com` and `https://static.claydar.com` (plus any connect-src hosts the scripts call; check the browser console for CSP violations) to `script-src` and `connect-src`.
7. **Pull request:** title "Add Clay Web Intent and Squid visitor tracking". In the description, list both snippets with the keys redacted, where they load, and how they were verified. Self-review the diff against the rubric below, merge, and deploy. (If branch protection requires a human reviewer, that approval belongs in pre-flight P3.)

**Verification test.**

1. Open the production site in a clean browser profile with ad blockers off. Open DevTools → Network and filter on `clay` and then on `claydar`.
   - `tracker.js` → 200, followed by beacon/XHR calls to Clay.
   - `init.v1.js` → 200, followed by Squid calls.
2. Console: type `window.clay`. It should show the connection key object. There should be no CSP errors.
3. View the page source to confirm both tags are on every template: homepage, pricing, blog post, case study and contact.
4. In Clay: Web Intent connection page → status "Receiving data" within about 30-60 minutes. Records appear in Audiences/the table after identification (company-level only resolves for business networks; home Wi-Fi often won't resolve).
5. In Squid's dashboard → recent visitors. Then check that the Clay webhook source shows received payloads.
6. Run the same check with an ad blocker on to see the expected loss rate. Note it, but don't try to evade blockers.
7. Re-verify after every site redesign. Add a one-line smoke test to CI that greps the built HTML for `app.clay.com/tracker.js` and `static.claydar.com`.

**Acceptance criteria** (all must pass and be logged):
- [ ] `tracker.js` and `init.v1.js` → 200 on homepage, pricing, blog post, case study and contact (evidence: HAR or network log)
- [ ] `window.clay` is defined, with no CSP errors
- [ ] Scripts absent on preview/staging hosts
- [ ] CI smoke test added and green
- [ ] Within 60 min, the Clay Web Intent connection shows data received (or the first Audiences record appears). Squid shows a recent visitor from a business network test, if one is available

**Self-review (adversarial: what would break this?)**
- Does a CSP header block a beacon host you didn't list? Check the console on every template.
- Does the SPA router skip pageview events on client-side navigation? Navigate between pages and confirm the beacons fire.
- Do the scripts run on staging and pollute the data? Load staging and confirm they're absent.
- Does consent mode block the scripts for US visitors by mistake?

**Rollback.** Revert the PR and redeploy.

> **PHASE 2 GATE.** Phase 3 must not start until every acceptance box in Phase 2 is checked in BUILD-LOG.md with evidence. Gate checks: trackers verified on all templates; CI smoke test green. If any check fails, re-enter the failing step's BTER loop.

## Phase 3. Shared infrastructure: CRM check, Slack card, Audiences fields

### Step 3.1: Deploy and test the Salesforce suppression endpoint

**Goal.** `POST /api/clay/sf-check` answers `{suppress, reasons}` read-only, fails closed and requires the Bearer secret.

**Inputs.** Steps 1.6 and 1.7.

**Actions.**

Implement or confirm the endpoint per the spec below, then deploy.

**HTTP API node:**
- URL and method: `POST https://muthur-slack.vercel.app/api/clay/sf-check`
- Headers: `Content-Type: application/json` and `Authorization: Bearer <CLAY_SF_CHECK_SECRET>`
- Body: `{"email":"{{email}}","domain":"{{domain}}","linkedin_url":"{{li}}","company_name":"{{company}}"}`
- Settings: timeout 20000 ms, retry off, return response metadata on.

Set the secret with a script that reads it from a file. Never type it into chat or logs.

**Endpoint behavior** (read-only SOQL, 20s deadline, empty or unset secret means 401):
- It normalizes the email and domain, rejects unsafe hosts, and ignores free-mail domains as company domains.
- It matches Accounts by Website domain, and Contacts and Leads by email.
- Responds `{"suppress": bool, "reasons": [..]}`. Reasons include:
  - Account has an open Opportunity
  - Account has a Closed Won Opportunity
  - Account Type indicates customer or partner
  - Contact or Lead opted out of email or marked Do Not Call
  - Contact tied to an open or Closed Won Opportunity
  - Contact or Lead had Salesforce activity in the recent window
- Tests assert the endpoint never calls Salesforce write APIs.



**Verification test.**

- `curl -X POST <host>/api/clay/sf-check` → **401**.
- With `Authorization: Bearer <CLAY_SF_CHECK_SECRET>` and the body `{"email":"<agent mailbox>","domain":"<COMPANY_DOMAIN>"}` → **200** with `suppress` boolean.
- A known customer domain from Salesforce → `suppress:true` with reason.
- Unit test: no write API calls (the repo test asserts this).

**Acceptance criteria** (all must pass and be logged):
- [ ] 401 unauthenticated
- [ ] 200 authenticated
- [ ] Known customer → suppress true
- [ ] Unknown domain → suppress false
- [ ] Injection strings (`acme.com' OR Name != '`) are rejected or ignored
- [ ] Response < 20s

**Self-review (adversarial: what would break this?)**
- What happens on a Salesforce outage? It must return non-200 or a timeout, which WF-B treats as suppress.
- Free-mail domains matching random Accounts? They must be ignored.
- Is the secret compared in constant time and is an empty value rejected?

**Rollback.** Roll back the Vercel deployment.

### Step 3.2: Deploy and test the Slack visitor card route

**Goal.** `POST /webhooks/website-visitors` posts one deduped card per domain/session and drops blocked domains.

**Inputs.** Steps 1.7 and 1.8.

**Actions.**

HTTP POST `https://muthur-slack.vercel.app/webhooks/website-visitors` with `Authorization: Bearer <MUTHUR_WEBSITE_VISITORS_SECRET>`.
- Body: `{source, web_intent, enrich_company, contacts}` for company cards, or the person fields for Squid. Only `web_intent` is required.
- Muthur dedupes per domain + session and applies the domain blocklist.
- Cards show pages viewed, sessions, time on site, referrer, UTMs and contacts found. These cards are internal, so they can mention the visit. The emails never do.

**Verification test.**

- No auth → 401.
- Authenticated POST with a synthetic **company-only** payload for your own test domain on the blocklist → 200 with `skipped` (blocked), and no card.
- A second identical POST → deduped.
- (A real card is checked in Phase 7 on a real visitor.)

**Acceptance criteria** (all must pass and be logged):
- [ ] 401 without auth
- [ ] Blocked domain produces no card
- [ ] Dedupe works

**Self-review (adversarial: what would break this?)**
- Could a card leak to a public channel? Check the channel id.
- Does the card ever get copied into the email? No; only the internal card mentions the visit.

**Rollback.** Roll back the deployment.

### Step 3.3: Create the Audiences tracking fields

**Goal.** Person and company fields exist for cooldowns and stamping.

**Inputs.** Step 1.1.

**Actions.**

In Audiences, create these custom fields (all text):
- **Person:** `WV Pushed At`, `WV Campaign`, `WV Claim Token`.
- **Company:** `WV Last Pushed At`, `WV Wave Slot 1`, `WV Wave Slot 2`, `WV Wave Slot 3`. A slot value is `token|claimed_at_iso|live|dry`.

Record their `audf_...` ids. The code below references Apero's ids, so replace them with yours.

**Verification test.**

List the Audiences fields via the CLI or UI. Write a test value to one field on **your own company record** (aperoadvisors.com), read it back, and clear it.

**Acceptance criteria** (all must pass and be logged):
- [ ] All 7 fields exist
- [ ] ids recorded in `ids.json`
- [ ] Write/read/clear round trip works

**Self-review (adversarial: what would break this?)**
- Are the field types text? Slot strings must not be coerced.
- Does the upsert treat `""` as skip, not clear? This is verified in step 5.7.

**Rollback.** Delete the fields you created.

> **PHASE 3 GATE.** Phase 4 must not start until every acceptance box in Phase 3 is checked in BUILD-LOG.md with evidence. Gate checks: sf-check 401/200 + fail closed; Slack route verified; fields created. If any check fails, re-enter the failing step's BTER loop.

## Phase 4. EmailBison campaigns (draft) and copy

#### Copy rules
1. **Never imply they visited the site.** No visit, browse, "saw", "noticed", "came across", "your interest", "checking out" or "stopped by". The lint enforces this on the AI variables, and the boilerplate must follow it too. The pricing campaign (84) is the one place where the angle is "were you thinking about working with us", which is a natural question without saying we saw them.
2. **Never tell them what they already know.** Don't describe their own company or customers back to them. Offer them something.
3. **Core line:** `at apero advisors, we help {PEER_GROUP} {OUTCOME}`. `{PEER_GROUP}` is *their own* industry, and the outcome is fixed per campaign: "get more pipeline without adding sales headcount" (83, 85, 86, 88, 90) or "lower their CAC" (87, 89).
4. **Offer line:** `happy to send you a short list of {SIGNAL_LINE}, on us. each one comes with the right contact.` `{SIGNAL_LINE}` names *their buyers* plus a live buying signal.
5. **Subjects: 2-3 words, lowercase**, optionally spintax (`{new accounts|quick offer}`). Step 2 is `Re: <same>`.
6. **No opt-out line.** No em dashes. Lowercase, casual, short. No flattery and no exclamation marks.
7. **One AI line + stock boilerplate.** The AI fills only the variables. Never use whole-email AI copy.
8. **Content links come from the deterministic map.** The AI never picks them.
9. **No meetings before 9am ET.** Use the calendar link `<CALENDAR_LINK>`.

#### Angles
- **Offer / free list (83, 87, 88, 89, 90):** offer a short list of their buyers with signals, plus a matching case study link. The CTA is "want me to send yours over?"
- **Content (85 blog, 86 media):** "thought you might like this one we put together", then the peer group line, then `here's {CONTENT_WHY}: {CONTENT_URL}`. Low-pressure CTA.
- **Pricing (84):** direct. "were you guys thinking about working with us?" plus a phone number, then the calendar link in step 2.
- **Company-level (90):** the same offer angle. These people did *not* necessarily visit themselves; a colleague may have. That's one more reason to never reference a visit.

#### On a "yes" (post-launch operating policy; out of build scope)
No sample leads are pre-generated.
1. On a yes, the reply desk pulls a few **real** lookalike leads for that prospect in Clay (LeadMagic-validated, never invented, never a promised count).
2. It drafts the reply for the owner's approval. Nothing is sent automatically.
3. The follow-up angle: the list was a taste, and the real thing is a CRM full of validated, dialable accounts, plus `<CALENDAR_LINK>`.
4. Other replies (no, not now, wrong person) get normal drafts, also for the owner's approval.

### Step 4.1: Create the 8 campaigns with settings

**Goal.** Campaigns 83-90 exist as draft with the exact settings.

**Inputs.** Steps 1.4 and P4, P6.

**Actions.**

| ID | Name | Fed by |
|---|---|---|
| 83 | WEB - homepage - 1:1 visitor | Squid, default angle |
| 84 | WEB - pricing - 1:1 visitor | Squid, pricing pages |
| 85 | WEB - blog - 1:1 visitor | Squid, blog |
| 86 | WEB - media - 1:1 visitor | Squid, media |
| 87 | WEB - case studies - 1:1 visitor | Squid, case studies |
| 88 | WEB - contact no submit - 1:1 visitor | Squid, contact page with no form submit |
| 89 | WEB - repeat visitor - 1:1 visitor | Squid, 2+ sessions on the homepage angle |
| 90 | Clay visitor AI | Web Intent company-level (WF-A → WF-B) |

If you rebuild in a fresh EmailBison, the IDs will differ. Update the `ANGLES` map and the `90` constant in Normalize to match.

- **Sending window: website visitor campaigns run 24/7.** Mon-Sun 00:00-23:59, timezone America/New_York. Speed matters because the trigger is fresh. (An older spec said Mon-Fri 9-6; the owner chose 24/7 for visitor campaigns.)
- `plain_text: true`, `open_tracking: false`, click tracking off.
- `can_unsubscribe: false`: **no opt-out line** in the copy. Handle opt-outs by reply.
- `max_emails_per_day: 300`, `max_new_leads_per_day: 150`, `daily_max_sends_per_receiving_domain: 3`, `sequence_prioritization: followups`.
- **Stop on reply on.** Don't include auto-replies in stats.
- **Sender inboxes:** attach all warmed inboxes (Apero attached 19). Some page campaigns still needed 4 more inboxes before launch.
- **Evergreen behavior:** launch once and leave it active. An active campaign with no leads just idles, and leads imported later start on the next window. Do not pause a campaign because it "finished".
- **Tags:** `evergreen` on all 8.
- **Custom variables used in copy:** `{SIGNAL_LINE}`, `{PEER_GROUP}`, `{CONTENT_URL}`, `{CONTENT_WHY}`, plus built-ins `{FIRST_NAME}` and `{SENDER_EMAIL_SIGNATURE}`. `linkedin_url` is stored for the reply desk and not used in copy.

**Verification test.**

For each id: `GET /api/campaigns/{id}` and `/schedule`. Assert `status=draft`, `plain_text=true`, `open_tracking=false`, `can_unsubscribe=false`, `max_new_leads_per_day=150` (or P9), `daily_max_sends_per_receiving_domain=3`, `sequence_prioritization=followups`, tag `evergreen`, schedule Mon-Sun 00:00-23:59 America/New_York, and inboxes attached ≥ the planned count.

**Acceptance criteria** (all must pass and be logged):
- [ ] All 8 pass every assertion (table in the log)
- [ ] Campaign ids recorded, and the Normalize `ANGLES` map and campaign 90 constant updated if the ids differ

**Self-review (adversarial: what would break this?)**
- Did a campaign get launched by a UI default? Status must be draft.
- Is any inbox attached to every campaign at once with too little capacity? Compute daily capacity vs caps.

**Rollback.** Delete the campaigns you created.

### Step 4.2: Write the sequences (verbatim)

**Goal.** Each campaign has step 1 and the threaded step 2 exactly as below, with placeholders filled from P6.

**Inputs.** Step 4.1.

**Actions.**

Back up the existing steps first:
`GET /api/campaigns/{id}`, `/api/campaigns/{id}/schedule`, `/api/campaigns/{id}/leads`, `/api/campaigns/{id}/sequence-steps`, and `/api/scheduled-emails?status=sent`. Call them with `Authorization: Bearer <EMAILBISON_API_KEY>` against `<EMAILBISON_BASE_URL>`, from a helper script that reads the key from a file and never prints it.

Then create the steps:

##### Campaign 83: WEB - homepage - 1:1 visitor
Routing: Squid, homepage (default).

**Step 1**: subject `outbound setup`, wait 1 day(s), thread_reply=false
```text
hey {FIRST_NAME}, happy to send you a short list of {SIGNAL_LINE}, on us. each one comes with the right contact.

at apero advisors, we help {PEER_GROUP} get more pipeline without adding sales headcount.

here's how we built a list like that for a client: {CONTENT_URL}

want me to send yours over?

{SENDER_EMAIL_SIGNATURE}
```

**Step 2**: subject `Re: outbound setup`, wait 3 day(s), thread_reply=true
```text
hey {FIRST_NAME}, bumping this. if you're ever interested in chatting, just let me know.
```

##### Campaign 84: WEB - pricing - 1:1 visitor
Routing: Squid, any /pricing page.

**Step 1**: subject `pricing question`, wait 1 day(s), thread_reply=false
```text
hey {FIRST_NAME}, were you guys thinking about working with us?

call me if you want to chat, <SENDER_PHONE>.

{SENDER_EMAIL_SIGNATURE}
```

**Step 2**: subject `Re: pricing question`, wait 3 day(s), thread_reply=true
```text
{if it's easier|if that's easier}, grab a time here: <CALENDAR_LINK>
```

##### Campaign 85: WEB - blog - 1:1 visitor
Routing: Squid, /blog.

**Step 1**: subject `{worth a look|thought of you}`, wait 1 day(s), thread_reply=false
```text
hey {FIRST_NAME}, thought you might like this one we put together.

at apero advisors, we help {PEER_GROUP} get more pipeline without adding sales headcount.

here's {CONTENT_WHY}: {CONTENT_URL}

if you're ever interested in chatting, just let me know.

{SENDER_EMAIL_SIGNATURE}
```

**Step 2**: subject `Re: {worth a look|thought of you}`, wait 3 day(s), thread_reply=true
```text
{if it's easier|if that's easier}, grab a time here: <CALENDAR_LINK>
```

##### Campaign 86: WEB - media - 1:1 visitor
Routing: Squid, /media.

**Step 1**: subject `gtm engineering`, wait 1 day(s), thread_reply=false
```text
hey {FIRST_NAME}, thought you might like this one we put together.

at apero advisors, we help {PEER_GROUP} get more pipeline without adding sales headcount.

here's {CONTENT_WHY}: {CONTENT_URL}

if you're ever interested in chatting, just let me know.

{SENDER_EMAIL_SIGNATURE}
```

**Step 2**: subject `Re: gtm engineering`, wait 3 day(s), thread_reply=true
```text
{if it's easier|if that's easier}, grab a time here: <CALENDAR_LINK>
```

##### Campaign 87: WEB - case studies - 1:1 visitor
Routing: Squid, /case-studies.

**Step 1**: subject `similar setup`, wait 1 day(s), thread_reply=false
```text
hey {FIRST_NAME}, happy to send you a short list of {SIGNAL_LINE}, on us. each one comes with the right contact.

at apero advisors, we help {PEER_GROUP} lower their CAC.

here's how we built a list like that for a client: {CONTENT_URL}

want me to send yours over?

{SENDER_EMAIL_SIGNATURE}
```

**Step 2**: subject `Re: similar setup`, wait 3 day(s), thread_reply=true
```text
hey {FIRST_NAME}, bumping this. if you're ever interested in chatting, just let me know.
```

##### Campaign 88: WEB - contact no submit - 1:1 visitor
Routing: Squid, /contact without a form submit.

**Step 1**: subject `{free list|quick offer}`, wait 1 day(s), thread_reply=false
```text
hey {FIRST_NAME}, happy to send you a short list of {SIGNAL_LINE}, on us. each one comes with the right contact.

at apero advisors, we help {PEER_GROUP} get more pipeline without adding sales headcount.

here's how we built a list like that for a client: {CONTENT_URL}

want me to send yours over?

{SENDER_EMAIL_SIGNATURE}
```

**Step 2**: subject `Re: {free list|quick offer}`, wait 3 day(s), thread_reply=true
```text
hey {FIRST_NAME}, bumping this. if you're ever interested in chatting, just let me know.
```

##### Campaign 89: WEB - repeat visitor - 1:1 visitor
Routing: Squid, 2+ sessions and homepage angle.

**Step 1**: subject `{new accounts|on us}`, wait 1 day(s), thread_reply=false
```text
hey {FIRST_NAME}, happy to send you a short list of {SIGNAL_LINE}, on us. each one comes with the right contact.

at apero advisors, we help {PEER_GROUP} lower their CAC.

here's how we built a list like that for a client: {CONTENT_URL}

want me to send yours over?

{SENDER_EMAIL_SIGNATURE}
```

**Step 2**: subject `Re: {new accounts|on us}`, wait 3 day(s), thread_reply=true
```text
hey {FIRST_NAME}, bumping this. if you're ever interested in chatting, just let me know.
```

##### Campaign 90: Clay visitor AI
Routing: all company-level Web Intent people (via WF-A).

**Step 1**: subject `{new accounts|quick offer}`, wait 1 day(s), thread_reply=false
```text
hey {FIRST_NAME}, happy to send you a short list of {SIGNAL_LINE}, on us. each one comes with the right contact.

at apero advisors, we help {PEER_GROUP} get more pipeline without adding sales headcount.

here's how we built a list like that for a client: {CONTENT_URL}

want me to send yours over?

{SENDER_EMAIL_SIGNATURE}
```

**Step 2**: subject `Re: {new accounts|quick offer}`, wait 3 day(s), thread_reply=true
```text
hey {FIRST_NAME}, bumping this. if you're ever interested in chatting, just let me know.
```

#### Rendered example
```text
subject: new accounts
hey <first_name>, happy to send you a short list of <signal_line>, on us. each one comes with the right contact.

at apero advisors, we help <peer_group> get more pipeline without adding sales headcount.

here's how we built a list like that for a client: https://aperoadvisors.com/case-studies/environmental-services-outbound

want me to send yours over?

<signature>
```
A good pair (from a test on a large payroll provider): `peer_group` = "hr and payroll software companies". That company is now dropped by the size gate as over 1,000 employees.

#### Reply in thread
- Step 2 is `thread_reply: true` with subject `Re: <step 1 subject>` (spintax repeated identically). It sends in the same thread.
- With spintax subjects, step 2's `Re: {a|b}` resolves to the same variant as step 1 in practice. Verify on a seed inbox.

**Verification test.**

`GET /api/campaigns/{id}/sequence-steps` for each campaign, then diff against the text above (after placeholder fill). Grep the bodies for `, `, `visit`, `website`, `saw you` and `unsubscribe`.

**Acceptance criteria** (all must pass and be logged):
- [ ] Exact match for all 16 steps
- [ ] Step 2 `thread_reply=true`, wait 3 days; step 1 wait 1 day
- [ ] Grep finds zero banned words or dashes
- [ ] Spintax braces intact
- [ ] Variables referenced exist in the custom variable list

**Self-review (adversarial: what would break this?)**
- Does a `{VARIABLE}` typo render literally? Compare every `{X}` against the variable list.
- Does the preview show `Re: Re:`? It's a known preview artifact (see below); the seed test in Phase 8 is the truth.

**Rollback.** Restore the steps from the JSON backup.

#### Preview gotchas
1. **"Re: Re:" in the preview is not what sends.** The UI preview of a threaded step 2 showed "Re: Re: list building". Sent history from older campaigns shows a single "Re:". We left the step 2 subjects unchanged and did **not** blank them: blanking is only safe if you verify on a seed send, and we didn't.
2. **Custom variables render blank in the preview** unless you preview against a real lead that has them set. Preview using a lead that has already been pushed in draft.
3. **Line breaks:** the campaigns are plain_text, but the bodies use `<br><br>`. That's what EmailBison expects, and it renders as paragraph breaks. Check on a seed send.
4. **Spintax** `{a|b}` is EmailBison syntax. Don't let Clay or a template engine eat the braces.
5. **Variable case:** in the body, variables are `{UPPER_CASE}`. In the API/Clay, custom variable names are lowercase (`signal_line`).
6. Before any edit, back up every campaign's steps via the API (`GET /api/campaigns/{id}/sequence-steps`) to JSON. We kept `eb-backup/steps_83..90.json`.

> **PHASE 4 GATE.** Phase 5 must not start until every acceptance box in Phase 4 is checked in BUILD-LOG.md with evidence. Gate checks: 8 draft campaigns verified; 16 steps verified; backups saved. If any check fails, re-enter the failing step's BTER loop.

## Phase 5. WF-B person pipeline (Clay Workflow)

Build in **Clay Workflows** (not tables). Tables were our first attempt and failed for reasons that matter:
- The CLI can't edit table columns.
- Table AI columns defaulted to Clay-managed AI.
- "Send table data" columns wrote to the wrong table (the company table was sending rows into the Squid table).
- Clay pages crashed constantly while editing.

Workflows give you versioned nodes, code nodes, conditionals, node-level tests and `graph validate`.

**Node types used:** trigger (webhook + manual, or audience segment), `code` (Python `handler(context)`, read inputs with `context.get_input("name")`), `conditional` (rules mode), `tool` (Clay actions: LeadMagic, HTTP API, Audiences lookup/upsert, EmailBison), `agent` (Claygent / AI with structured output), `delay`.

**Wiring:** each node declares `inputSchema.properties.<name> = {sourceNodeId, sourcePath}` (for example `$.fields.Domain` from a trigger, `$.result` from a tool). A conditional's downstream edge carries `ruleId`. `incomingEdges` is a **full replacement** when you update it.

**Defaults:** set `dry_run` to default **TRUE** everywhere. A real send requires the payload to say `dry_run: false` explicitly.

**Per-node test method for every step in this phase:**
- Code node: `clay workflows nodes test <wf> <node> --inputs -` with inputs keyed by the node's **input names**, then `clay workflows runs get <wf> <run> --wait --verbose`. Check the outputs and that `actionCreditsUsed=0` and `dataCreditsUsed=0`.
- Multi-source nodes can't be node-tested ("no completed step found for source node"), so test them in the full dry run (step 5.12).
- After every structural change: `clay workflows graph validate <wf>` → valid, 0 errors, 0 warnings.

### Step 5.1: Trigger (webhook + manual)

**Goal.** Build and verify: Trigger (webhook + manual).

**Inputs.** Earlier Phase 5 steps, `ids.json`.

**Actions.**

- Add a **Webhook** trigger and a **Manual** trigger feeding the same first node. Copy the webhook URL and token (`<WFB_WEBHOOK_URL>`, `<WFB_WEBHOOK_TOKEN>`, header `x-clay-webhook-auth`) into a `chmod 600` file.
- Accepted payload (from Squid or from WF-A):
```json
{"source":"squid|clay_web_intent","first_name":"","last_name":"","full_name":"","title":"","linkedin_url":"https://www.linkedin.com/in/...","email":"","company_name":"","company_domain":"acme.com","company_description":"","industry":"","pages":["/pricing","/blog/x"],"page_angle":"","session_count":2,"employee_count":250,"company_size":"201-500","dry_run":true}
```
- **Important:** a WF-B webhook only creates runs once WF-B is **published**. Until then, WF-A's POSTs return success but nothing runs. Test WF-B with `runs test --inputs` instead.

**Verification test.**

`graph validate` passes, and the webhook URL and token are stored. A POST before publish returns success but creates no run (expected).

**Acceptance criteria** (all must pass and be logged):
- [ ] Webhook + manual triggers feed the same first node
- [ ] URL/token stored with `chmod 600`

**Self-review (adversarial: what would break this?)**
- Is the token sent in the right header (`x-clay-webhook-auth`)?

**Rollback.** Restore the node from the previous file version (every node's code/prompt lives in a file; update nodes from files), or delete the node and re-wire the previous edges.

### Step 5.2: Normalize + hard filters (free)

**Goal.** Build and verify: Normalize + hard filters (free).

**Inputs.** Earlier Phase 5 steps, `ids.json`.

**Actions.**

This node:
- cleans names, LinkedIn URLs and domains,
- applies the blocklists,
- derives the page angle and campaign_id,
- **routes Web Intent to campaign 90 always, and routes Squid by page to 83-89** (2+ sessions on the homepage counts as "repeat", which goes to 89).

The person's **title is deliberately excluded** from the agency keyword blob. A "Lead Generation Manager" at a real company is a buyer, not an agency. That false positive filtered out a real buyer for us.

```python
import re
BLOCK_DOMAINS = {"<OWNER_PERSONAL_DOMAIN>", "aperoadvisors.com"}
# Lead-gen / GTM agencies and GTM software vendors / competitors (extend as needed)
BLOCK_COMPANY_DOMAINS = {"clay.com","apollo.io","zoominfo.com","outreach.io","salesloft.com","lemlist.com","instantly.ai","smartlead.ai","emailbison.com","hubspot.com","gong.io","6sense.com","demandbase.com","leadmagic.io","rb2b.com","warmly.ai","commonroom.io","unify.ai","unifygtm.com","fortunecreativeai.com","cleverly.co","belkins.io","martal.ca","callbox.com","growthx.ai"}
AGENCY_WORDS = ["lead generation","lead gen","outbound agency","appointment setting","sales development agency","growth agency","gtm agency","gtm engineering","demand generation agency","marketing agency","cold email","automation systems","revenue agency","sdr as a service","outsourced sdr","sales automation","sales engagement platform","b2b data provider","intent data"]
GOV_EDU = re.compile(r"(\.gov$|\.mil$|\.edu$|\.k12\.|\.gov\.[a-z]{2}$|\.edu\.[a-z]{2}$|\.ac\.[a-z]{2}$)")
ANGLES = {"homepage":83,"pricing":84,"blog":85,"media":86,"case studies":87,"contact no submit":88,"repeat":89}

def s(x):
    return "" if x is None else str(x).strip()

def handler(context):
    g = lambda k: context.get_input(k)
    source = s(g("source")).lower() or "clay_web_intent"
    first, last, full = s(g("first_name")), s(g("last_name")), s(g("full_name"))
    if full and not (first and last):
        parts = full.split()
        if len(parts) >= 2:
            first = first or parts[0]; last = last or " ".join(parts[1:])
    for suf in [", mba", ", cpa", ", phd", " mba", " phd", " jr.", " sr."]:
        if last.lower().endswith(suf): last = last[: -len(suf)].strip()
    li = s(g("linkedin_url")).split("?")[0].rstrip("/")
    if li and not li.startswith("http"): li = "https://" + li
    li_key = re.sub(r"^https?://(www\.)?", "", li.lower())
    dom = s(g("company_domain")).lower()
    dom = re.sub(r"^https?://", "", dom); dom = re.sub(r"^www\.", "", dom).split("/")[0]
    email = s(g("email")).lower()
    email_dom = email.split("@")[-1] if "@" in email else ""
    pages = g("pages")
    if isinstance(pages, list): pages_s = ",".join(s(p) for p in pages)
    else: pages_s = s(pages)
    blob = " ".join([s(g("company_name")), s(g("company_description")), s(g("industry"))]).lower()  # title excluded: a lead gen manager at a real company is a buyer, not an agency
    angle = s(g("page_angle")).lower()
    pl = pages_s.lower()
    if not angle:
        if "pricing" in pl: angle = "pricing"
        elif "case-stud" in pl: angle = "case studies"
        elif "/media" in pl: angle = "media"
        elif "/blog" in pl: angle = "blog"
        elif "/contact" in pl: angle = "contact no submit"
        else: angle = "homepage"
    if source == "squid":
        try: sessions = int(float(s(g("session_count")) or 0))
        except Exception: sessions = 0
        if sessions >= 2 and angle == "homepage": angle = "repeat"
        campaign_id = ANGLES.get(angle, 83)
    else:
        campaign_id = 90
    reasons = []
    if not first: reasons.append("missing first name")
    if not last or re.fullmatch(r"[A-Za-z]\.?", last): reasons.append("missing full last name")
    if not li_key.startswith("linkedin.com/in/"): reasons.append("missing linkedin url")
    if not dom: reasons.append("missing company domain")
    for d in {dom, email_dom}:
        if not d: continue
        if d in BLOCK_DOMAINS or any(d.endswith("." + b) for b in BLOCK_DOMAINS): reasons.append("blocked domain " + d)
        if d in BLOCK_COMPANY_DOMAINS: reasons.append("agency/vendor/competitor domain " + d)
        if GOV_EDU.search(d): reasons.append("government/school domain " + d)
    hits = [w for w in AGENCY_WORDS if w in blob]
    if hits: reasons.append("agency/vendor keywords: " + ",".join(hits[:3]))
    return {
        "source": source, "first_name": first, "last_name": last,
        "first_name_lower": first.lower(), "full_name": (first + " " + last).strip(),
        "title": s(g("title")), "linkedin_url": li, "linkedin_key": li_key,
        "company_name": s(g("company_name")), "company_domain": dom,
        "email_in": email, "pages": pages_s, "page_angle": angle,
        "is_pricing": angle == "pricing", "campaign_id": str(campaign_id),
        "dry_run": str(g("dry_run")).lower() not in ("false", "0", "no"),
        "pass": len(reasons) == 0, "skip_reason": "; ".join(reasons),
    }
```
Follow it with a conditional "Passes filters?" with the rule `pass == True`. No match ends the run.

**Verification test.**

Node tests: own domain → fail; `.gov`/`.edu`/`.k12.` → fail; company named with an agency word → fail; a title 'Lead Generation Manager' at a normal company → **pass**; Squid + `/pricing` → campaign 84; Squid with 2 sessions on the homepage → 89; Web Intent → 90; `dry_run` missing → true.

**Acceptance criteria** (all must pass and be logged):
- [ ] All 8 cases behave as stated
- [ ] 0 credits

**Self-review (adversarial: what would break this?)**
- Unicode names, `Jr.` suffixes, a LinkedIn URL with query params, `www.` and paths in the domain.
- Does missing `source` default to Web Intent (campaign 90)? Intended.

**Rollback.** Restore the node from the previous file version (every node's code/prompt lives in a file; update nodes from files), or delete the node and re-wire the previous edges.

### Step 5.3: Company size gate (free)

**Goal.** Build and verify: Company size gate (free).

**Inputs.** Earlier Phase 5 steps, `ids.json`.

**Actions.**

Placed after the two **free** Audiences lookups (person by `linkedin_url`, company by domain) and **before** cooldown, LeadMagic and AI.
- Headcount order: payload `employee_count` / `company_size` / `employees`, then the Audiences company record "Employee count". Both are free.
- Ranges like "1,001-5,000" use the low end, and "10,001+" counts as 10,001.
- **Unknown headcount passes like any normal lead.** There is no flag, no hold and no branching (owner's decision: "dont flag them just send").

```python
# Company size gate (Oct 31): skip companies with more than 1,000 employees.
# Headcount source order (no credits): 1) payload employee_count / company_size / employees, 2) Audiences company record "Employee count".
# Unknown headcount: passes like any normal lead.
import json, re
MAX_EMP = 1000
def parse(v):
    if v is None: return None
    if isinstance(v, (int, float)): return int(v) if v > 0 else None
    s = str(v).strip().lower().replace(",", "")
    if not s or s in ("none", "null", "unknown"): return None
    nums = [int(float(x)) for x in re.findall(r"\d+(?:\.\d+)?", s)]
    if not nums: return None
    if "k" in s and max(nums) < 1000: nums = [n * 1000 for n in nums]
    # ranges like "1001-5000" or "10001+": the low end decides
    return min(nums) if len(nums) > 1 else nums[0]
def acct_emp(raw):
    try:
        if isinstance(raw, str): raw = json.loads(raw)
    except Exception: return None
    recs = (raw or {}).get("records") if isinstance(raw, dict) else None
    if not recs: return None
    f = recs[0].get("fields", recs[0]) if isinstance(recs[0], dict) else {}
    for k in ("Employee count", "Employees", "Employee Count", "Headcount"):
        if f.get(k) not in (None, ""): return parse(f.get(k))
    return None
def handler(context):
    src, emp = "", None
    for k in ("payload_emp", "payload_size", "payload_employees"):
        emp = parse(context.get_input(k))
        if emp: src = "payload"; break
    if not emp:
        emp = acct_emp(context.get_input("account"))
        if emp: src = "audiences company"
    unknown = not emp
    ok = unknown or emp <= MAX_EMP
    return {"size_ok": ok, "employees": emp or 0, "size_source": src or "unknown", "size_reason": "" if ok else "over %d employees (%d, %s)" % (MAX_EMP, emp, src)}
```
Follow it with a conditional "Size ok?" with the rule `size_ok == True`. No match ends the run.

**Verification test.**

Node tests: 7100 → drop; 855 → pass; blank → pass with no extra field; '1,001-5,000' → drop; '10,001+' → drop; Audiences record 279 → pass.

**Acceptance criteria** (all must pass and be logged):
- [ ] All 6 cases correct
- [ ] Output has no `size_unknown` field
- [ ] Wired after the Audiences lookups and before the cooldown precheck

**Self-review (adversarial: what would break this?)**
- '1.2k' → 1200, drop. 'self-employed' → unknown, pass.
- Does the gate run before any paid node? Check the edge order in `graph validate` / the diagram.

**Rollback.** Restore the node from the previous file version (every node's code/prompt lives in a file; update nodes from files), or delete the node and re-wire the previous edges.

### Step 5.4: Visit-time floor (per P9)

**Goal.** Build and verify: Visit-time floor (per P9).

**Inputs.** Earlier Phase 5 steps, `ids.json`.

**Actions.**

**Decided by pre-flight input P9 (default: off).** The original table design proposed "min session 20s OR >=2 unique pages" as a credit saver. It is not in the current workflows. If pre-flight input P9 says yes, add it to Normalize as `reasons.append("visit too short")` when `session_seconds < 20 and unique_pages < 2`. That requires the source payload to carry session seconds and unique pages.

**Verification test.**

If enabled: node test with session 5s and 1 page → drop; 30s → pass.

**Acceptance criteria** (all must pass and be logged):
- [ ] Matches P9 (off by default, logged)

**Self-review (adversarial: what would break this?)**
- Does the source actually send session seconds? If not, keep it off and log that.

**Rollback.** Restore the node from the previous file version (every node's code/prompt lives in a file; update nodes from files), or delete the node and re-wire the previous edges.

### Step 5.5: CRM and cooldown checks

**Goal.** Build and verify: CRM and cooldown checks.

**Inputs.** Earlier Phase 5 steps, `ids.json`.

**Actions.**

**Rules:**
- **Person:** one sequence **ever** across campaigns 83-90. If `WV Pushed At` is set, the person is blocked forever.
- **Company:** a 30-day window and **one wave of up to 3 people**, all enrolled within 24h of the wave's first claim. After that, no new person from that company for 30 days. A legacy `WV Last Pushed At` under 30 days also blocks.
- Dry-run claims count only for dry runs and only for 10 minutes, so tests never block live sends.

**Precheck (cheap, before LeadMagic):**
`Audiences lookup person (linkedin_url)` → `Audiences lookup company (domain)` → size gate → **Cooldown decide** (code, shared logic below) → conditional `ok`.

**Final check and race safety (after lint, before push):**
1. Re-read the person and company.
2. **Final decide** picks the first free wave slot and mints a uuid token.
3. **Claim:** upsert the token onto the person (`WV Claim Token`), and write `token|now|live|dry` into only that company slot. Leave the other slot inputs as `""`; the upsert skips blanks, which we verified.
4. **Wait 30s.**
5. **Verify:** re-read both records. Proceed only if the person token and our company slot still hold our token and the person is not pushed. Two runs racing for one slot means one wins and the other ends.

Cooldown decide code (used three times: precheck, final and verify, with different inputs):
```python
import json, datetime, uuid
# v2 (2026-10-31): company wave rule. 30-day window per company; first wave = up to 3 people
# enrolled within 24h of the wave's first claim; after that no NEW person for 30 days.
# Race safety: each person claims one of 3 company slot fields (token|iso|mode); verify re-reads
# that slot after the 30s wait. Two runs racing for the same slot -> only one keeps it.
P_PUSHED="<CLAY_ID>"; A_PUSHED="<CLAY_ID>"; P_TOK="<CLAY_ID>"
SLOTS=["<CLAY_ID>","<CLAY_ID>","<CLAY_ID>"]
WINDOW_DAYS=30; WAVE_HOURS=24; WAVE_MAX=3
NAMES={P_PUSHED:"WV Pushed At",A_PUSHED:"WV Last Pushed At",P_TOK:"WV Claim Token",
       SLOTS[0]:"WV Wave Slot 1",SLOTS[1]:"WV Wave Slot 2",SLOTS[2]:"WV Wave Slot 3"}
def recs(x):
    if isinstance(x,str):
        try: x=json.loads(x)
        except Exception: return []
    if isinstance(x,dict):
        x = x.get("result", x)
        if isinstance(x,dict): x = x.get("records", [])
    return x if isinstance(x,list) else []
def fval(r,fid):
    f = r.get("fields", r) if isinstance(r,dict) else {}
    v = f.get(fid)
    if v is None: v = f.get(NAMES.get(fid,""))
    if isinstance(v,dict): v = v.get("value", v.get("v"))
    return "" if v is None else str(v)
def ts(v):
    try: return datetime.datetime.fromisoformat(v.replace("Z","+00:00"))
    except Exception: return None
def handler(context):
    p = recs(context.get_input("person")); a = recs(context.get_input("account"))
    dry = str(context.get_input("dry_run")).lower() not in ("false","0","no")
    tok = context.get_input("token") or ""; my_slot = str(context.get_input("slot") or "")
    now = datetime.datetime.now(datetime.timezone.utc); reasons = []
    for r in p:   # one sequence ever per person (83-90)
        if fval(r,P_PUSHED): reasons.append("person already pushed " + fval(r,P_PUSHED))
    acct = a[0] if a else {}
    live = {}; free = []
    for i,fid in enumerate(SLOTS, start=1):
        parts = fval(acct,fid).split("|")
        t = ts(parts[1]) if len(parts) >= 3 else None
        counts = t is not None and (now - t).days < WINDOW_DAYS and (parts[2] == "live" or (dry and now - t < datetime.timedelta(minutes=10)))
        if counts: live[i] = (parts[0], t)
        else: free.append(i)
    if tok:   # verify after wait: our slot must still hold our token
        if not my_slot or live.get(int(my_slot), ("",))[0] != tok:
            reasons.append("lost wave-slot race (slot %s)" % my_slot)
        for r in p:
            if fval(r,P_TOK) and fval(r,P_TOK) != tok: reasons.append("lost person claim race")
        slot = int(my_slot or 0)
    else:
        slot = 0
        if live:
            start = min(t for _,t in live.values())
            if now - start > datetime.timedelta(hours=WAVE_HOURS):
                reasons.append("company in 30-day cooldown (wave started %s)" % start.strftime("%Y-%m-%dT%H:%M:%SZ"))
            elif len(live) >= WAVE_MAX:
                reasons.append("company wave full (%d/%d)" % (len(live), WAVE_MAX))
        else:   # legacy pushes before slots existed
            lp = ts(fval(acct,A_PUSHED))
            if lp and (now - lp).days < WINDOW_DAYS: reasons.append("company pushed within 30 days (%s)" % fval(acct,A_PUSHED))
        if not reasons and free: slot = free[0]
        elif not reasons: reasons.append("no free wave slot")
    new_token = "wv-" + uuid.uuid4().hex[:16]; now_iso = now.strftime("%Y-%m-%dT%H:%M:%SZ")
    val = "%s|%s|%s" % (new_token, now_iso, "dry" if dry else "live")
    out = {"ok": len(reasons)==0, "reason": "; ".join(reasons), "new_token": new_token, "now_iso": now_iso,
           "person_records": len(p), "account_records": len(a), "slot": str(slot), "wave_count": len(live),
           "debug": "dry=%s live_slots=%s" % (dry, sorted(live))}
    for i in (1,2,3): out["slot%d_val" % i] = val if (i == slot and not tok) else ""
    return out
```

**Verification test.**

Node tests on the decide code: fresh → slot 1; full → 'wave full (3/3)'; wave started 30h ago → '30-day cooldown'; person already pushed → blocked; verify with our token → ok; another token in our slot → 'lost wave-slot race'. Dry claims stop counting after 10 min.

**Acceptance criteria** (all must pass and be logged):
- [ ] All 6 cases correct
- [ ] Precheck, final and verify all use the same code version

**Self-review (adversarial: what would break this?)**
- Two simultaneous runs for one company: only one can hold a slot (verify path).
- Clock skew in ISO parsing: are timestamps UTC with Z?

**Rollback.** Restore the node from the previous file version (every node's code/prompt lives in a file; update nodes from files), or delete the node and re-wire the previous edges.

### Step 5.6: Email waterfall + validation

**Goal.** Build and verify: Email waterfall + validation.

**Inputs.** Earlier Phase 5 steps, `ids.json`.

**Actions.**

1. **LeadMagic: find work email** (`name` = full_name, `domain` = company_domain, catch-all **off**).
2. **LeadMagic: validate email**, on the found email or the payload email.
3. **Pick or build pattern guesses (code)**: if not valid, try `flast@`, `first.last@` and `firstlast@`.
```python
import re
def s(x): return "" if x is None else str(x).strip().lower()
def handler(context):
    g=context.get_input
    st=s(g("v_status")); ve=s(g("v_email"))
    first=re.sub(r"[^a-z]","",s(g("first"))); last=re.sub(r"[^a-z]","",s(g("last"))); dom=s(g("domain"))
    if st=="valid" and ve:
        return {"email":ve,"email_source":"leadmagic find/given + leadmagic validate","guesses":[],"first_status":st}
    tried={ve, s(g("found_email")), s(g("email_in"))}
    guesses=[]
    if first and last and dom:
        for c in [first[0]+last, first+"."+last, first+last]:
            e=c+"@"+dom
            if e not in tried: guesses.append(e)
    return {"email":"","email_source":"","guesses":guesses,"first_status":st}
```
4. **LeadMagic: validate pattern guesses**, in list mode, once per guess.
5. **Final valid email (code)**:
```python
import json
def walk(x,out):
    if isinstance(x,dict):
        if "email" in x and "status" in x and isinstance(x.get("email"),str): out.append((x["email"].lower(),str(x["status"]).lower()))
        for v in x.values(): walk(v,out)
    elif isinstance(x,list):
        for v in x: walk(v,out)
def handler(context):
    e=(context.get_input("email") or "").strip().lower()
    if e: return {"final_email":e,"email_ok":True,"email_source":context.get_input("email_source") or ""}
    raw=context.get_input("guess_results")
    if isinstance(raw,str):
        try: raw=json.loads(raw)
        except Exception: pass
    found=[]; walk(raw,found)
    for em,st in found:
        if st=="valid": return {"final_email":em,"email_ok":True,"email_source":"pattern guess + leadmagic validate"}
    return {"final_email":"","email_ok":False,"email_source":"none valid: "+json.dumps(found)[:300]}
```
6. Conditional "Has LeadMagic-valid email?" (`email_ok == True`). If there is no valid email, the run ends. In our tests 4 of 11 real people had no valid email, and that's expected.

Optional (from the table design, not in the current workflow): skip people whose mail is behind Sophos, Trend Micro, Barracuda or self-hosted gateways, which hurt deliverability. Route them to LinkedIn only.

**Verification test.**

Node tests on the pick code: valid status → returns email; invalid → 3 guesses; final with one guess valid → that guess; none → email_ok false. Paid nodes are tested in the 5.12 dry run on 1 real lead.

**Acceptance criteria** (all must pass and be logged):
- [ ] Only `valid` passes
- [ ] Catch-all OFF
- [ ] Guesses never duplicate tried emails

**Self-review (adversarial: what would break this?)**
- Hyphenated or apostrophe names in guesses (stripped to letters).
- A LeadMagic outage yields an empty response, so the run ends with no email, never a push.

**Rollback.** Restore the node from the previous file version (every node's code/prompt lives in a file; update nodes from files), or delete the node and re-wire the previous edges.

### Step 5.7: Salesforce suppression node + fail-closed decide

**Goal.** Build and verify: Salesforce suppression node + fail-closed decide.

**Inputs.** Earlier Phase 5 steps, `ids.json`.

**Actions.**

**HTTP API node:**
- URL and method: `POST https://muthur-slack.vercel.app/api/clay/sf-check`
- Headers: `Content-Type: application/json` and `Authorization: Bearer <CLAY_SF_CHECK_SECRET>`
- Body: `{"email":"{{email}}","domain":"{{domain}}","linkedin_url":"{{li}}","company_name":"{{company}}"}`
- Settings: timeout 20000 ms, retry off, return response metadata on.

Set the secret with a script that reads it from a file. Never type it into chat or logs.

**Endpoint behavior** (read-only SOQL, 20s deadline, empty or unset secret means 401):
- It normalizes the email and domain, rejects unsafe hosts, and ignores free-mail domains as company domains.
- It matches Accounts by Website domain, and Contacts and Leads by email.
- Responds `{"suppress": bool, "reasons": [..]}`. Reasons include:
  - Account has an open Opportunity
  - Account has a Closed Won Opportunity
  - Account Type indicates customer or partner
  - Contact or Lead opted out of email or marked Do Not Call
  - Contact tied to an open or Closed Won Opportunity
  - Contact or Lead had Salesforce activity in the recent window
- Tests assert the endpoint never calls Salesforce write APIs.

**Suppression decide (code, fail closed):** anything except HTTP 200 with an explicit `suppress:false` counts as suppress.
```python
import json
def handler(context):
    raw = context.get_input("resp")
    if isinstance(raw, str):
        try: raw = json.loads(raw)
        except Exception: raw = {}
    raw = raw or {}
    status = raw.get("status") or raw.get("statusCode") or (raw.get("metadata") or {}).get("status")
    body = raw.get("body", raw)
    if isinstance(body, str):
        try: body = json.loads(body)
        except Exception: body = {}
    sup = body.get("suppress") if isinstance(body, dict) else None
    try: status = int(status) if status is not None else None
    except Exception: status = None
    ok = (status in (None, 200)) and sup is False   # fail closed: anything but an explicit suppress=false is a suppress
    reasons = body.get("reasons") if isinstance(body, dict) else None
    return {"suppress": not ok, "sf_reasons": "; ".join(reasons) if isinstance(reasons, list) else ("http %s / no suppress=false" % status if not ok else "")}
```
Conditional "Not suppressed?" (`suppress == False`).

Verification: unauthenticated → 401, authenticated → 200. Our first live test hit a 404 because the route wasn't deployed yet. Fail-closed stopped every run, which is correct.

**Verification test.**

Decide-node tests: `{status:200, body:{suppress:false}}` → not suppressed; 200 + `suppress:true` → suppressed; 404 → suppressed; timeout/empty → suppressed.

**Acceptance criteria** (all must pass and be logged):
- [ ] Fail-closed in all non-explicit cases
- [ ] Secret set from a file, not visible in the logs

**Self-review (adversarial: what would break this?)**
- Does the HTTP node wrap the body differently (string vs object)? The decide code handles both.

**Rollback.** Restore the node from the previous file version (every node's code/prompt lives in a file; update nodes from files), or delete the node and re-wire the previous edges.

### Step 5.8: AI variables (own key)

**Goal.** Build and verify: AI variables (own key).

**Inputs.** Earlier Phase 5 steps, `ids.json`.

**Actions.**

- Agent node: model a small model on **your own OpenAI connection** (`gpt-5.4-nano` for us), internet search on.
- Structured output: `signal_line` (string), `peer_group` (string), and `industry_bucket` (enum: industrial_manufacturing, logistics_supply_chain, software_it_msp, financial_services, life_sciences_healthcare, business_services, consumer_retail, other).
- Inputs: company_name, company_domain, title, pages.

Full prompt:
```text
you fill 4 variables for a cold email. you do not write the email.

company: {{company_name}} ({{company_domain}})
contact title: {{title}}
topics they care about (never mention this to them): {{pages}}
research the company from its domain if you need to know what it sells and to whom.

the email they get reads:
"happy to send you a short list of {SIGNAL_LINE}, on us. each one comes with the right contact.
at apero advisors, we help {PEER_GROUP} get more pipeline without adding sales headcount.
here's how we built a list like that for a client: {CONTENT_URL}"
(CONTENT_URL is filled in later from a fixed list using your INDUSTRY_BUCKET. you do not pick a url.)

return three fields:
SIGNAL_LINE: a plural noun phrase naming THEIR buyers plus a live buying signal among those buyers, so it reads naturally after "a short list of". think: who buys from this company, and what event makes one of those buyers need them right now (opening or expanding a site, new funding, new leadership, launching a product line, hiring for a function, winning a contract, moving into a new region). 5 to 12 words.
PEER_GROUP: the industry or peer group of {{company_name}} ITSELF, described broadly, plural, so "we help {PEER_GROUP}" names companies like {{company_name}}. it must describe the kind of company they are, never their customers, never the audience they sell to, and never their specific product. test: would {{company_name}} say "we are one of the {PEER_GROUP}"? if not, rewrite. an hr and payroll provider is "hr and payroll software companies", never "small and midsized businesses". a packaging manufacturer is "packaging manufacturers", never "consumer goods companies". a software company that serves equipment dealers is "dealer software companies", never "heavy equipment dealers". 2 to 5 words. it must never share its main noun with SIGNAL_LINE.
INDUSTRY_BUCKET: exactly one of these values for {{company_name}} itself: industrial_manufacturing, logistics_supply_chain, software_it_msp, financial_services, life_sciences_healthcare, business_services, consumer_retail, other.

rules:
all lowercase. no dashes of any kind (no hyphen, en dash or em dash). no digits or numbers in SIGNAL_LINE or PEER_GROUP.
never mention or imply a website visit, browsing, or that we saw them.
no flattery, no questions, no exclamation marks, no quote marks, no trailing period.
SIGNAL_LINE describes their customers, not them. PEER_GROUP describes them, not their customers or their product.

example for a commercial hvac contractor, director of business development:
SIGNAL_LINE: property managers planning building retrofits this year
PEER_GROUP: commercial mechanical contractors
INDUSTRY_BUCKET: business_services

example for a b2b saas company selling to dental clinics, vp sales:
SIGNAL_LINE: dental groups adding new clinic locations
PEER_GROUP: dental software companies
INDUSTRY_BUCKET: software_it_msp

example for a freight brokerage, head of sales:
SIGNAL_LINE: shippers adding new distribution centers this quarter
PEER_GROUP: logistics and freight companies
INDUSTRY_BUCKET: logistics_supply_chain


never copy the examples above. write fresh values for this specific company and contact; outputs identical to an example are rejected.
return the three fields as structured output: signal_line, peer_group, industry_bucket.
```
Rules learned:
- The AI **never picks a URL**. Content is deterministic (§4.11). An AI-picked library link made no sense to the owner for one lead.
- `peer_group` must describe the **company itself** ("hr and payroll software companies" for a payroll provider, never "small and midsized businesses").
- Never use a real prospect as a prompt example. The model copies it, which happened to us once.

**Verification test.**

Agent node test on 5 real leads already in the system: each returns 3 fields, and `authAccountId` = own key.

**Acceptance criteria** (all must pass and be logged):
- [ ] All outputs parse
- [ ] `authAccountId` is not null
- [ ] peer_group names the company's own industry in 5/5 (self-judge with the prompt test question)

**Self-review (adversarial: what would break this?)**
- Does the model copy an example? Lint catches it, but the prompt must not use real prospects as examples.
- Does it put the customers in peer_group? Test it on a payroll-type company.

**Rollback.** Restore the node from the previous file version (every node's code/prompt lives in a file; update nodes from files), or delete the node and re-wire the previous edges.

### Step 5.9: Deterministic content map

**Goal.** Build and verify: Deterministic content map.

**Inputs.** Earlier Phase 5 steps, `ids.json`.

**Actions.**

The environmental services (ARC) case study is the **primary/default piece**.

| Bucket | Piece | URL | CONTENT_WHY |
|---|---|---|---|
| industrial_manufacturing, business_services, life_sciences_healthcare, consumer_retail, other / unknown | es_outbound (default) | https://aperoadvisors.com/case-studies/environmental-services-outbound | how we built the crm and outbound engine for a services company |
| logistics_supply_chain, software_it_msp | trackstar | https://aperoadvisors.com/case-studies/trackstar | how we scored a fragmented tam and scaled outbound for trackstar |
| financial_services | private_credit | https://aperoadvisors.com/case-studies/private-credit-origination | how we turned conference lists into deal flow for a credit fund |
| (alternate, unmapped) | pipeline_3m | https://aperoadvisors.com/media/generate-3m-pipeline | how one campaign created real pipeline for an industrial services company |

Keep the list to **client builds only** (case studies and campaign walkthroughs) and check that every URL returns 200 before launch. The ES case study page is unlisted/noindex, but it is live. Keep a JSON mirror of the map in the repo (ours: `clay-ai/content-map.json`).
```python
# Content map (deterministic lookup). Source of truth mirror: /workspace/web-visitor/clay-ai/content-map.json
PIECES = {
 "trackstar": ("https://aperoadvisors.com/case-studies/trackstar", "how we scored a fragmented tam and scaled outbound for trackstar"),
 "private_credit": ("https://aperoadvisors.com/case-studies/private-credit-origination", "how we turned conference lists into deal flow for a credit fund"),
 "pipeline_3m": ("https://aperoadvisors.com/media/generate-3m-pipeline", "how one campaign created real pipeline for an industrial services company"),
 "es_outbound": ("https://aperoadvisors.com/case-studies/environmental-services-outbound", "how we built the crm and outbound engine for a services company"),
}
BUCKET_TO_PIECE = {
 "industrial_manufacturing": "es_outbound", "business_services": "es_outbound",
 "life_sciences_healthcare": "es_outbound", "consumer_retail": "es_outbound", "other": "es_outbound",
 "logistics_supply_chain": "trackstar", "software_it_msp": "trackstar",
 "financial_services": "private_credit",
}
def handler(context):
    b = str(context.get_input("industry_bucket") or "").strip().lower().replace(" ", "_").replace("-", "_")
    key = BUCKET_TO_PIECE.get(b, "es_outbound")
    url, why = PIECES[key]
    return {"industry_bucket": b if b in BUCKET_TO_PIECE else "other", "content_key": key, "content_url": url, "content_why": why}
```

**Verification test.**

Node test for each of the 8 buckets plus 'weird value' → the expected piece. `curl -sI` each URL → 200.

**Acceptance criteria** (all must pass and be logged):
- [ ] 9/9 mappings correct
- [ ] All 4 URLs return 200
- [ ] JSON mirror matches the code

**Self-review (adversarial: what would break this?)**
- Bucket casing/hyphen variants ('Software-IT') must normalize.

**Rollback.** Restore the node from the previous file version (every node's code/prompt lives in a file; update nodes from files), or delete the node and re-wire the previous edges.

### Step 5.10: Lint gate

**Goal.** Build and verify: Lint gate.

**Inputs.** Earlier Phase 5 steps, `ids.json`.

**Actions.**

Hard rules on every AI value:
- lowercase, no dashes of any kind, no digits, no quotes, no `!` or `?`, no trailing period
- no visit wording (`visit|website|site|brows|saw you|noticed you|checking out|came across|stopped by|looked at|your interest`)
- word counts: signal_line 5-12, peer_group 2-5, content_why under 15
- content_url must be in the library
- reject exact copies of prompt examples
- **peer_group must not be generic** (an audience, not an industry)
- **peer_group must not share its main noun with the first two words of signal_line** (that would mean it describes their customers). Generic nouns like manufacturer, supplier and distributor are ignored, because they caused false positives.

```python
import re, json
LIB_URLS = set(["https://aperoadvisors.com/case-studies/environmental-services-outbound","https://aperoadvisors.com/case-studies/trackstar","https://aperoadvisors.com/case-studies/private-credit-origination","https://aperoadvisors.com/blog/ai-powered-abm-portfolio-company-ebitda", "https://aperoadvisors.com/blog/aperos-wbr-ritual", "https://aperoadvisors.com/blog/derisking-founder-led-sales-lower-middle-market-pe", "https://aperoadvisors.com/blog/gtm-is-still-very-much-human", "https://aperoadvisors.com/blog/how-a-non-traditional-career-built-gtm-models", "https://aperoadvisors.com/blog/how-to-build-a-crm-that-updates-itself", "https://aperoadvisors.com/blog/how-to-build-a-gtm-engineering-function", "https://aperoadvisors.com/blog/how-to-build-a-network-mining-tool", "https://aperoadvisors.com/blog/how-to-build-an-ai-responder-for-cold-outbound", "https://aperoadvisors.com/blog/how-to-not-hire-a-gtm-engineer", "https://aperoadvisors.com/blog/jungian-original-sin-b2b-sales", "https://aperoadvisors.com/blog/portco-gtm-readiness-scorecard", "https://aperoadvisors.com/blog/services-as-a-software", "https://aperoadvisors.com/blog/your-crm-should-be-a-serendipity-factory", "https://aperoadvisors.com/media/ai-for-prospecting-it-leaders", "https://aperoadvisors.com/media/ai-transformed-prospecting", "https://aperoadvisors.com/media/build-and-deploy-gtm-agent", "https://aperoadvisors.com/media/cold-email-alone-doesnt-work", "https://aperoadvisors.com/media/customers-before-automation", "https://aperoadvisors.com/media/full-stack-gtm-engineering", "https://aperoadvisors.com/media/future-of-gtm-engineering", "https://aperoadvisors.com/media/generate-3m-pipeline", "https://aperoadvisors.com/media/github-repo-for-revops", "https://aperoadvisors.com/media/gtm-campaign-in-60-minutes", "https://aperoadvisors.com/media/gtm-engineering-explained", "https://aperoadvisors.com/media/how-ai-agents-changing-gtm-engineering-jai-toor", "https://aperoadvisors.com/media/keeping-it-real-in-sales", "https://aperoadvisors.com/media/lead-gen-master", "https://aperoadvisors.com/media/nobody-wants-to-talk-to-an-sdr", "https://aperoadvisors.com/media/outbound-from-claude-code", "https://aperoadvisors.com/media/professionalizing-sales", "https://aperoadvisors.com/media/rizz-dev-ep-4", "https://aperoadvisors.com/media/saas-is-cooked", "https://aperoadvisors.com/media/segmenting-accounts", "https://aperoadvisors.com/media/service-pricing", "https://aperoadvisors.com/media/skip-zapier-go-to-code", "https://aperoadvisors.com/media/think-in-bets-for-gtm", "https://aperoadvisors.com/media/what-is-gtm-engineering", "https://aperoadvisors.com/media/why-ai-cant-fix-outbound"])
EXAMPLES = {"dental software companies","logistics and freight companies","commercial mechanical contractors","property managers planning building retrofits this year","commercial mechanical contractors","how a service business builds outbound without hiring more reps","dental groups adding new clinic locations","dental software companies","how a saas team ran outbound end to end with a small crew","shippers adding new distribution centers this quarter","logistics and freight companies","how a logistics sales team built steady outbound pipeline","manufacturers opening or expanding plants right now","packaging and industrial companies","this is how one campaign turned into real pipeline for a sales team like yours"}
VISIT = re.compile(r"\b(visit|visited|visiting|website|site|brows|saw you|noticed you|checking out|came across|stopped by|looked at|your interest)", re.I)
def handler(context):
    g=lambda k: (context.get_input(k) or "").strip()
    sl, pg, url, why = g("signal_line"), g("peer_group"), g("content_url"), g("content_why")
    pricing = str(context.get_input("is_pricing")).lower()=="true"
    errs=[]
    for name,v in [("signal_line",sl),("peer_group",pg),("content_why",why)]:
        if not v: errs.append(name+" empty"); continue
        if v != v.lower(): errs.append(name+" not lowercase")
        if re.search(r"[-\u2010-\u2015]", v): errs.append(name+" has dash")
        if re.search(r"\d", v): errs.append(name+" has digit")
        if re.search(r"[\"'\u2018\u2019\u201c\u201d\\\\!?]", v.replace("'s","s")): errs.append(name+" has quote/backslash/!/?")
        if v.endswith("."): errs.append(name+" trailing period")
        if v.lower() in EXAMPLES: errs.append(name+" copied a prompt example")
        if not pricing and VISIT.search(v): errs.append(name+" implies a visit")
    n=len(sl.split())
    if sl and not (5<=n<=12): errs.append("signal_line word count %d" % n)
    if pg and not (2<=len(pg.split())<=5): errs.append("peer_group word count")
    if why and len(why.split())>=15: errs.append("content_why too long")
    if url not in LIB_URLS: errs.append("content_url not in library")
    GENERIC = {"companies","company","teams","team","firms","firm","businesses","business","providers","provider","and","of","the","for","leaders","organizations","groups","brands","sized","small","midsized","mid","market","large","enterprise","enterprises"}
    pgw = set(w.strip("s") for w in pg.lower().split()) - set(w.strip("s") for w in GENERIC) - {"manufacturer","producer","supplier","distributor","maker"}
    slw = set(w.strip("s") for w in sl.lower().split()[:2]) - {"manufacturer","producer","supplier","distributor","maker"}
    if pg and (not pgw): errs.append("peer_group too generic (audience, not industry)")
    elif pgw and pgw & slw: errs.append("peer_group overlaps signal_line buyers (describes customers, not the company)")
    if sl.lower() in EXAMPLES and pg.lower() in EXAMPLES: errs.append("whole output equals prompt example")
    return {"lint_ok": not errs, "lint_errors": "; ".join(errs), "signal_line": sl, "peer_group": pg, "content_url": url, "content_why": why}
```
Conditional "Lint passed?" (`lint_ok == True`). A failing lead ends the run and is not pushed. Example of a correct rejection: the peer group "dealer and distributor companies" for a dealer-software firm whose signal line names dealers.

**Verification test.**

Node tests: visit word → fail; dash → fail; digit → fail; generic peer group ('small businesses') → fail; peer group overlapping the signal-line buyers → fail; prompt example copy → fail; a clean real output → pass.

**Acceptance criteria** (all must pass and be logged):
- [ ] 7/7 cases correct
- [ ] Lint pass rate on the 5 real leads from step 5.8 logged

**Self-review (adversarial: what would break this?)**
- False positives on words like 'manufacturer' (excluded nouns).
- Curly quotes and apostrophes ('s allowed).

**Rollback.** Restore the node from the previous file version (every node's code/prompt lives in a file; update nodes from files), or delete the node and re-wire the previous edges.

### Step 5.11: Required fields, dry-run fork, Slack, EmailBison push, stamping

**Goal.** Build and verify: Required fields, dry-run fork, Slack, EmailBison push, stamping.

**Inputs.** Earlier Phase 5 steps, `ids.json`.

**Actions.**

**Required fields**
Normalize, the email step and lint already enforce these. Re-check that `first_name`, full `last_name`, `linkedin_url` (`linkedin.com/in/`) and a `valid` email are all non-empty before the EmailBison nodes.

**Dry-run fork**
Conditional "Dry run?" with the rule `dry_run == True`:
- **True** goes to a "DRY RUN stop" code node that returns `would_push_campaign` and `would_push_email`. Nothing is sent and Pushed At is not written.
- **Default** goes to the live branch.

**Slack card**
HTTP POST `https://muthur-slack.vercel.app/webhooks/website-visitors` with `Authorization: Bearer <MUTHUR_WEBSITE_VISITORS_SECRET>`.
- Body: `{source, web_intent, enrich_company, contacts}` for company cards, or the person fields for Squid. Only `web_intent` is required.
- Muthur dedupes per domain + session and applies the domain blocklist.
- Cards show pages viewed, sessions, time on site, referrer, UTMs and contacts found. These cards are internal, so they can mention the visit. The emails never do.

**EmailBison push**
**Node: "EmailBison: create or update lead"** (action `emailbison-create-or-update-lead`, connection "<YOUR EMAILBISON CONNECTION>").

| EmailBison field | Source |
|---|---|
| email | final email step `$.final_email` |
| first_name | normalize `$.first_name_lower` (lowercase, matching the casual copy) |
| last_name | normalize `$.last_name` |
| title | normalize `$.title` |
| company | normalize `$.company_name` |
| custom_variables.signal_line | lint `$.signal_line` |
| custom_variables.peer_group | lint `$.peer_group` |
| custom_variables.content_url | lint `$.content_url` |
| custom_variables.content_why | lint `$.content_why` |
| custom_variables.linkedin_url | normalize `$.linkedin_url` (we forgot this at first, so 5 early leads lack it) |
| existing_lead_behavior | static `patch` (never `put`, which wipes other fields) |

**Node: "EmailBison: import lead to campaign"** (action `emailbison-import-lead-to-campaign`).

| Field | Source |
|---|---|
| campaign_id | normalize `$.campaign_id` |
| lead_id | coalesce(`$.result.data.id`, `$.result.id`) from the lead node |
| allow_parallel_sending | static **false**, so a lead already in sequence elsewhere is skipped |

"Already in sequence" errors are expected for re-runs. The variables still update.

**Stamping**
- **Person upsert:** lookup by `linkedin_url`. Write `WV Pushed At = now_iso` and `WV Campaign = campaign_id`.
- **Company upsert:** lookup by domain. Write `WV Last Pushed At = now_iso`.

Stamp only after a successful import. A failure leaves the stamp empty, so the person can be retried.

**Verification test.**

`graph validate`. Inspect the EmailBison node mapping table vs the spec (the mapping must include `linkedin_url` and `existing_lead_behavior=patch`, and `allow_parallel_sending=false`).

**Acceptance criteria** (all must pass and be logged):
- [ ] Dry-run fork default goes to the stop node when dry_run is true
- [ ] Mapping exactly matches the table
- [ ] Stamps only after a successful import

**Self-review (adversarial: what would break this?)**
- `put` instead of `patch` would wipe fields.
- Stamping before import would block retries on failure.

**Rollback.** Restore the node from the previous file version (every node's code/prompt lives in a file; update nodes from files), or delete the node and re-wire the previous edges.

### Step 5.12: Validate graph + full WF-B dry run on real leads

**Goal.** WF-B runs end to end on real existing visitors with dry_run=true and stops before Slack/EmailBison.

**Inputs.** Steps 5.1-5.11; 3-5 real person payloads from existing visitors.

**Actions.**

- Run `clay workflows graph validate <<CLAY_ID>>` and expect valid, 0 errors, 0 warnings.
- Export a mermaid diagram of each graph and keep it in the repo next to the node code (`wf/*.py`) and `ids.json`.
- Keep every prompt and code node as a file and update nodes from the files, so the files stay the source of truth.

- `clay workflows runs test <WF-B> --inputs -` with each real payload and `"dry_run": true`.

**Verification test.**

`clay workflows runs get <WF-B> <run> --wait --verbose` for each. Record the end node and reason. Check EmailBison `GET /api/campaigns/{id}/leads` for each campaign: lead counts unchanged. Audiences: no `WV Pushed At` written.

**Acceptance criteria** (all must pass and be logged):
- [ ] `graph validate` valid with 0 warnings
- [ ] Every run ends at a logged reason (filter/size/cooldown/no email/suppressed/lint) or at DRY RUN stop
- [ ] **0 new EmailBison leads**
- [ ] 0 stamps
- [ ] Credits for the batch logged and under cap

**Self-review (adversarial: what would break this?)**
- Did any run reach the EmailBison nodes? The lead count check proves it didn't.
- Are there leads that should have been filtered but weren't? Spot-check each against the rules.

**Rollback.** Nothing was pushed. Revert node changes from the files if needed.

> **PHASE 5 GATE.** Phase 6 must not start until every acceptance box in Phase 5 is checked in BUILD-LOG.md with evidence. Gate checks: WF-B validated; dry run produced 0 pushes. If any check fails, re-enter the failing step's BTER loop.

## Phase 6. WF-A: Web Intent company → buyers

### Step 6.1: Build WF-A (segment trigger, filters, Claygent pick, payloads, POST to WF-B)

**Goal.** Each qualifying Web Intent company produces 1-3 real buyer payloads posted to WF-B.

**Inputs.** Phase 5, the Audiences segment from step 1.2.

**Actions.**

**Trigger:** Audiences **segment** trigger on your Web Intent ACCOUNT segment. It provides `$.fields.Domain`, `$.fields["Company name"]`, `$.fields.Industry` and `$.fields["Employee count"]`.

Two gotchas:
- An Audiences *signal* trigger can't share a node with the segment trigger. It needs its own trigger node.
- `runs test --audience-segment --record-ids` failed with "not currently in this audience" for Web Intent records still in Draft/STAGED. Test with `--inputs` that mimic the record.

**Node 1: Company filters + blocklists (code).** This runs before any paid step. It drops:
- no domain, blocked domains, gov/edu,
- agency and vendor names,
- the excluded industries "staffing and recruiting", "marketing and advertising" and "government",
- **under 10 employees** and **over 1,000 employees**. Unknown headcount passes.

```python
import re
BLOCK_DOMAINS = {"<OWNER_PERSONAL_DOMAIN>", "aperoadvisors.com"}
# Lead-gen / GTM agencies and GTM software vendors / competitors (extend as needed)
BLOCK_COMPANY_DOMAINS = {"clay.com","apollo.io","zoominfo.com","outreach.io","salesloft.com","lemlist.com","instantly.ai","smartlead.ai","emailbison.com","hubspot.com","gong.io","6sense.com","demandbase.com","leadmagic.io","rb2b.com","warmly.ai","commonroom.io","unify.ai","unifygtm.com","fortunecreativeai.com","cleverly.co","belkins.io","martal.ca","callbox.com","growthx.ai"}
AGENCY_WORDS = ["lead generation","lead gen","outbound agency","appointment setting","sales development agency","growth agency","gtm agency","gtm engineering","demand generation agency","marketing agency","cold email","automation systems","revenue agency","sdr as a service","outsourced sdr","sales automation","sales engagement platform","b2b data provider","intent data"]
GOV_EDU = re.compile(r"(\.gov$|\.mil$|\.edu$|\.k12\.|\.gov\.[a-z]{2}$|\.edu\.[a-z]{2}$|\.ac\.[a-z]{2}$)")
ANGLES = {"homepage":83,"pricing":84,"blog":85,"media":86,"case studies":87,"contact no submit":88,"repeat":89}


def s(x): return "" if x is None else str(x).strip()
def handler(context):
    g=lambda k: context.get_input(k)
    dom=re.sub(r"^https?://","",s(g("domain")).lower()); dom=re.sub(r"^www\.","",dom).split("/")[0]
    name=s(g("company_name")); ind=s(g("industry")); emp=g("employees")
    try:
        _n=[int(float(x)) for x in re.findall(r"\d+(?:\.\d+)?", str(emp or "").replace(",",""))]
        emp=min(_n) if _n else 0
    except Exception: emp=0
    blob=(name+" "+ind).lower(); why=""
    if not dom: why="no domain"
    elif dom in BLOCK_DOMAINS or dom in BLOCK_COMPANY_DOMAINS: why="blocked domain"
    elif GOV_EDU.search(dom): why="gov/edu"
    elif any(w in blob for w in AGENCY_WORDS): why="agency/gtm vendor"
    elif any(w in ind.lower() for w in ["staffing and recruiting","marketing and advertising","government"]): why="excluded industry"
    elif emp and emp<10: why="under 10 employees"
    elif emp and emp>1000: why="over 1000 employees (%d)" % emp   # Oct 31: too hard above 1k; unknown (0) passes
    return {"pass": why=="", "skip_reason": why, "domain": dom, "company_name": name, "industry": ind, "employees": emp, "dry_run": True}
```
Then a conditional "Company passes?" with the rule `pass == True`.

**Node 2: Claygent "pick 2-3 likely buyers"** (agent, internet search on, **own OpenAI key**, structured output `people_json` string). Prompt:
```text
company: {{company_name}} ({{domain}}), industry: {{industry}}, employees: {{employees}}.
find the 2 to 3 people at this company most likely to buy outbound pipeline generation / gtm engineering services from a b2b agency.
priority: vp or head of sales, vp or head of marketing, demand generation or growth leaders, revops leaders. at small companies (under 200 employees) the founder or ceo is fine. at large companies (over 1000 employees) pick division or business unit sales, marketing and demand gen leaders, not the corporate c suite.
exclude: anyone who is not a current employee, interns, recruiters, hr, finance, legal, engineering, customer support, and anyone at a different company.
only return real people you found on linkedin with a linkedin.com/in/ profile url. never invent people or urls. if you are not sure, return fewer people.
return people_json as a json array string like [{"first_name":"","last_name":"","title":"","linkedin_url":"https://www.linkedin.com/in/..."}].
```
**Claygent UI gotcha:** Clay's JSON-schema output editor mangles input. Use Fields mode (`people_json`, or `linkedin_1..3` + `skip_reason`) or the Claygent builder. In the UI, the `/` menu works with keyboard arrows and Enter. Build long prompts and bodies append-only.

**Node 3: Require LinkedIn + first/last, cap 3, build payloads (code).**
```python
import json, re
def handler(context):
    raw=context.get_input("people_json"); dom=str(context.get_input("domain") or ""); co=str(context.get_input("company_name") or "")
    try: ppl=json.loads(raw) if isinstance(raw,str) else (raw or [])
    except Exception: ppl=[]
    if isinstance(ppl,dict): ppl=ppl.get("people",[])
    out=[]; seen=set(); dropped=[]
    for p in ppl if isinstance(ppl,list) else []:
        li=str(p.get("linkedin_url") or "").split("?")[0].rstrip("/")
        fn=str(p.get("first_name") or "").strip(); ln=str(p.get("last_name") or "").strip()
        if not re.search(r"linkedin\.com/in/[^/]+",li) or not fn or not ln: dropped.append(fn+" "+ln+" missing li/name"); continue
        if li.lower() in seen: continue
        seen.add(li.lower())
        out.append(json.dumps({"source":"clay_web_intent","first_name":fn,"last_name":ln,"full_name":fn+" "+ln,"title":str(p.get("title") or ""),"linkedin_url":li,"email":"","company_name":co,"company_domain":dom,"pages":"","page_angle":"","session_count":"","dry_run":True}))
        if len(out)>=3: break
    return {"bodies": out, "count": len(out), "dropped": "; ".join(dropped)}
```
Then a conditional "Any valid people?" (`count > 0`).

**Node 4: HTTP POST each person → WF-B webhook.** Run it in list mode over `bodies`, with POST `<WFB_WEBHOOK_URL>`, header `x-clay-webhook-auth: <WFB_WEBHOOK_TOKEN>`, and body = the item.
- Keep `dry_run: true` in the bodies until the Phase 8 launch gate passes.
- At launch (Phase 8, step 8.3), the agent changes `a_people.py` to emit `"dry_run": false`. Nobody else touches it.

There is no separate Enrich Person step; WF-B's LeadMagic does the email work.

**Verification test.**

- Node test on the company filter: own domain → fail; 7100 employees → fail; 855 → pass; blank → pass; 5 employees → fail; 'marketing and advertising' → fail.
- Claygent test on 1-2 real Web Intent companies (with `--inputs` mimicking the record): returns real `linkedin.com/in/` URLs. Open each URL to confirm the person exists and works at that company.
- `graph validate`.

**Acceptance criteria** (all must pass and be logged):
- [ ] Filter cases correct
- [ ] The Claygent returns only verifiable people (spot check 100%)
- [ ] Payloads carry `source=clay_web_intent` and `dry_run=true`
- [ ] Claygent `authAccountId` = own key
- [ ] Graph valid

**Self-review (adversarial: what would break this?)**
- Does the Claygent invent a LinkedIn URL? Spot-check every result, and drop the company if any is fake.
- Big company → corporate C-suite picks? The size gate now prevents most of this.
- Does the segment trigger fire for Draft/STAGED records? Test with `--inputs`.

**Rollback.** Unpublish/revert nodes from the files.

> **PHASE 6 GATE.** Phase 7 must not start until every acceptance box in Phase 6 is checked in BUILD-LOG.md with evidence. Gate checks: WF-A validated; buyer picks verified real. If any check fails, re-enter the failing step's BTER loop.

## Phase 7. Integrated testing: dry runs and draft pushes

All tests use **real visitors already in the system**. Never invent leads. Every test before launch runs with `dry_run: true`, or with `dry_run: false` only into **draft** campaigns. Draft campaigns never send, so this is safe without a human.

1. **Code nodes (free):** `clay workflows nodes test <wf> <node> --inputs -`, where inputs are keyed by the node's **input names** (for example `{"domain":..,"employees":..}`), not by the trigger shape. Then `clay workflows runs get <wf> <run> --wait --verbose` and read the outputs plus `actionCreditsUsed` and `dataCreditsUsed`. Cover:
   - Normalize: a blocked domain, gov/edu, an agency word, a "Lead Generation Manager" title (must pass), and a Squid pricing page (→ 84).
   - Size gate: 7100 (drop), 855 (pass), blank (pass, no flag), "1,001-5,000" (drop), "10,001+" (drop).
   - Cooldown: fresh (slot 1), full (wave full 3/3), wave started 30h ago (30-day cooldown), a token in our slot from another run (lost the race).
   - Content map: every bucket plus an unknown one (→ es_outbound).
   - Lint: visit wording, dash, digit, generic peer group, peer group overlapping buyers, prompt example copy.
2. **Agent nodes (a few credits each):** test the AI node on 5-7 real leads and the Claygent on 1-2 real companies. Check that `authAccountId` is your key.
3. **Multi-source node gotcha:** `nodes test --inputs` and `--source-run` **fail** for nodes whose inputs come from several upstream nodes ("no completed step found for source node"). Test those through a full `runs test --inputs -` with `dry_run: true`.
4. **Full dry run (WF-B):** `clay workflows runs test wf_... --inputs -` with a real person payload and `dry_run: true`. Expect "DRY RUN stop", with nothing in EmailBison and no Pushed At.
5. **Draft push (autonomous, draft campaigns only):** `dry_run: false` against the **draft** campaigns. Then verify in EmailBison that the lead exists, the custom variables are filled (including linkedin_url), the lead is in the right campaign, the preview of step 1 and step 2 renders, and the Audiences stamps are written.
6. **WF-A:** test with `--inputs` that mimic a real Web Intent record. The POST to WF-B only produces runs after WF-B is published.
7. **Seed send (Phase 8 gate):** sends only to the agent's own seed inboxes (pre-flight P5) to check line breaks, the subject, threading and the signature.
8. **Validate graphs** after every structural change.

#### Credit model
These are rough and from our runs. Check against your own plan.

| Step | Cost |
|---|---|
| Code nodes, conditionals, Audiences lookups/upserts, delay | 0 credits |
| LeadMagic find + validate (+ up to 3 guess validations) | about 1-5 data credits per person (own key: LeadMagic's own pricing) |
| AI variables on own OpenAI key | about 1 Clay action credit per run + fractions of a cent in OpenAI |
| Claygent pick on own key | about 1-3 action credits per company + OpenAI cost |
| HTTP (sf-check, Slack, WF-A → WF-B) | about 0-1 action credit each |
| EmailBison native actions | about 1 action credit each |

**Our measured costs:**
- First end-to-end build tests: about 21.5 credits.
- AI and lint retests on 7 leads: about 7 action credits.
- One session showed an unexplained drop of about 460 credits, most likely table auto-runs and Web Intent elsewhere in the workspace, not the workflow. **Turn off auto-run on legacy tables**, and check workspace usage in the UI after each session.

**Budget:** about 5-15 credits per person who reaches the push, and nearly 0 for those filtered out early. That's why the free filters and the size gate come first.

### Step 7.1: Connect sources (draft-safe)

**Goal.** Squid and WF-A point at WF-B, with everything still dry.

**Inputs.** WF-B webhook URL/token.

**Actions.**

- In Squid: Settings → Integrations → Clay → paste the WF-B webhook URL + token (UI step: drive the browser, screenshot it).
- Leave Squid payloads without `dry_run:false` (WF-B defaults to TRUE).

**Verification test.**

Squid's integration page shows connected. After WF-B is published in 8.3, the first real Squid visit creates a run (checked there).

**Acceptance criteria** (all must pass and be logged):
- [ ] Integration saved (screenshot)
- [ ] No `dry_run:false` anywhere yet (grep the WF-A code and the Squid config)

**Self-review (adversarial: what would break this?)**
- Could Squid send `dry_run:false` by default? Confirm with the payload preview.

**Rollback.** Remove the integration.

### Step 7.2: Draft pushes on real leads

**Goal.** Prove the live branch works into draft campaigns (which cannot send).

**Inputs.** 3-5 real leads that passed in step 5.12.

**Actions.**

Run WF-B `runs test --inputs -` with `dry_run:false` for those leads. Campaigns are still draft.

**Verification test.**

For each lead: `GET /api/leads?search=<email>` → exists with all 5 custom variables non-empty; the lead appears in the expected campaign; Audiences person has `WV Pushed At` + `WV Campaign`; company has a slot claimed; a Slack card was posted once.

**Acceptance criteria** (all must pass and be logged):
- [ ] 100% of pushed leads have first, last, LinkedIn and a valid email + all 5 vars
- [ ] Correct campaign by routing rule
- [ ] Stamps written
- [ ] Campaign status still draft

**Self-review (adversarial: what would break this?)**
- Is anything sending? `GET /api/scheduled-emails?status=sent` stays empty for these campaigns.
- 'Already in sequence' on re-run: the variables must update, with no duplicates.

**Rollback.** Remove the test leads from the draft campaigns via the API. Clear their stamps so they're eligible for the real launch.

> **PHASE 7 GATE.** Phase 8 must not start until every acceptance box in Phase 7 is checked in BUILD-LOG.md with evidence. Gate checks: all integrated tests PASS; 0 sends; credits under cap. If any check fails, re-enter the failing step's BTER loop.

## Phase 8. Autonomous launch gate and launch

Launch happens **only** when every launch-gate check below passes in the same session. There is no human approval; this gate replaces it.

### Step 8.1: Seed test to the agent's own inboxes

**Goal.** Prove the real rendered emails are correct before any prospect gets one.

**Inputs.** Seed inboxes P5.

**Actions.**

- Create a **separate** seed campaign per angle (copy the steps of 83, 84, 85 and 90 exactly), attach 1 inbox, set the schedule to now, and import only the seed inboxes as leads. Fill their custom variables with values from a real lint-passed lead.
- Launch the seed campaigns only. Wait for step 1. Force step 2 with wait 0 in the seed copy.
- Read each received email via the seed inbox API.

**Verification test.**

For every seed email, parse the subject, body, headers and threading.

**Acceptance criteria** (all must pass and be logged):
- [ ] Subject is 2-3 words, lowercase, spintax resolved, step 2 = single `Re: <same>` in the same thread
- [ ] No literal `{VAR}`, and all variables filled
- [ ] Paragraph breaks render (no literal `<br>`)
- [ ] Every link returns 200 and is the mapped content URL or the calendar
- [ ] No visit wording, no dashes, no opt-out line
- [ ] Landed in the inbox, not spam, on at least 2 of 3 providers

**Self-review (adversarial: what would break this?)**
- Are spintax variants mismatched between step 1 and step 2? Check the thread.
- Is the signature missing or doubled?
- Spam placement: if it's in spam on 2+ providers, FAIL and fix the domain/warmup first.

**Rollback.** Pause and delete the seed campaigns.

### Step 8.2: Final self-audit

**Goal.** Prove that every non-negotiable holds right before launch.

**Inputs.** The whole build.

**Actions.**

- Run the lint against a sample of **at least 20** real recent leads (or all available, if fewer) through the AI → map → lint nodes (dry).
- Re-run all code-node test suites.
- Re-read A.5 and check each item against the evidence.
- Confirm the credit spend.

**Verification test.**

Produce a table: non-negotiable → evidence → PASS/FAIL.

**Acceptance criteria** (all must pass and be logged):
- [ ] **Lint pass rate = 100%** on the sample (any failure is either a correct rejection, with the lead not pushable, or a prompt fix + rerun; the *pushable* set must be 100% clean)
- [ ] Dry run produced **0 pushes** (from step 5.12, rerun now)
- [ ] Seed test PASS (8.1)
- [ ] Credits spent < budget cap, with projected 30-day spend < cap
- [ ] All steps PASS, none BLOCKED
- [ ] Blocklists present in Clay and Muthur
- [ ] Every agent node is on the own key

**Self-review (adversarial: what would break this?)**
- What if traffic spikes 10x? Daily caps and the 3-per-company wave limit it, and the kill switches (Phase 9) stop it.
- What if the CRM endpoint dies after launch? Fail-closed stops pushes. Monitoring alerts on it.

**Rollback.** Do not launch. Fix and re-run 8.1-8.2.

### Step 8.3: Launch

**Goal.** Turn the evergreen system on.

**Inputs.** 8.1 and 8.2 PASS in this session.

**Actions.**

1. Set WF-A `a_people.py` payloads to `"dry_run": false`, update the node from the file, and validate.
2. Configure the Squid payload to send `"dry_run": false`.
3. Publish WF-B, then WF-A.
4. Launch campaigns 83-90 (status active).
5. Log the exact launch time (ET).

**Verification test.**

`GET /api/campaigns/{id}` → status active for all 8. Both workflows show published. The first real run after launch reaches a logged end reason.

**Acceptance criteria** (all must pass and be logged):
- [ ] 8 campaigns active
- [ ] Both workflows published
- [ ] First live run traced end to end

**Self-review (adversarial: what would break this?)**
- Did a stale test lead remain in a campaign? List the leads per campaign right after launch.

**Rollback.** Kill switch: pause all 8 campaigns and unpublish WF-A and WF-B (in that order).

> **PHASE 8 GATE.** Phase 9 must not start until every acceptance box in Phase 8 is checked in BUILD-LOG.md with evidence. Gate checks: launch verified. If any check fails, re-enter the failing step's BTER loop.

## Phase 9. Monitoring with automatic kill switches (first 24-48h, then ongoing)

Check every 30 min for the first 6h, then hourly to 48h, then daily. Each check is logged.

| Kill switch | Default threshold (override in P8) | Automatic action |
|---|---|---|
| Bounce rate | > 3% on any campaign (min 30 sends), or > 5 bounces/hour | Pause that campaign. If it hits 2+ campaigns, pause all and unpublish WF-B |
| Spam complaints | any complaint, or a provider block notice | Pause all campaigns, unpublish WF-A/WF-B |
| Lint failures | > 30% of AI runs failing lint in a rolling 2h, or any pushed lead found later failing the lint | Unpublish WF-B. A pushed bad lead is removed from its campaign |
| Credit burn | daily spend > 2x the projected rate, or cumulative > 90% of the cap | Unpublish WF-A, then WF-B |
| sf-check health | > 20% non-200 in 1h | Alert in the log only (fail closed already blocks pushes); investigate |
| Duplicate person | the same email in 2+ campaigns | Remove it from the later campaign; inspect the cooldown |
| Visit wording | any sent email containing banned words (`GET /api/scheduled-emails?status=sent` + grep) | Pause all, fix the copy |

After any kill switch fires: diagnose with BTER, fix, re-run the relevant Phase 8 checks, then relaunch automatically if they pass.

- **Daily:**
  - Clay run history for both workflows: counts by end reason (filtered, size, cooldown, no valid email, suppressed, lint failed, pushed).
  - EmailBison: bounces (pause a campaign if over 3%), replies, interested.
  - Slack visitor cards versus pushes.
- **Weekly:**
  - Credits used versus pushes.
  - A lint failure review (tune the prompt, never loosen the visit rule).
  - Content URLs still return 200.
  - Inbox health and warmup.
- **Alerts:** a sf-check non-200 spike means everything gets suppressed (fail closed). Investigate quickly, because leads silently stop.
- **Replies:** handled by the reply desk, with drafts for owner approval.

> **PHASE 9 GATE.** Phase 10 must not start until every acceptance box in Phase 9 is checked in BUILD-LOG.md with evidence. Gate checks: 48h passed with no unresolved kill switch. If any check fails, re-enter the failing step's BTER loop.

## Phase 10. Final end-to-end acceptance test and handoff

### Step 10.1: Final E2E acceptance
- **Company path:** pick a real Web Intent company that arrived after launch. Trace: segment trigger → WF-A filter → Claygent picks → WF-B runs → email validated → sf-check → AI → lint → push → stamp → Slack card → step 1 sent (`scheduled-emails?status=sent`).
- **Person path:** the same trace for one real Squid person.
- **Negative paths:** one company over 1,000 employees dropped at the gate; one blocked domain dropped; one cooldown-blocked person; one suppressed by the CRM.

**Acceptance:**
- [ ] Both positive traces complete with evidence at every hop.
- [ ] All 4 negative paths end at the right node, at 0 paid credits beyond the gate.
- [ ] 0 emails contain banned wording.
- [ ] Credits within the cap.

**Self-review:** "If I were trying to embarrass this system, which email would I make it send?" Look for it in the sent log.

### Step 10.2: Handoff report (write `HANDOFF.md`)
1. What is live: workflows, campaigns, routes and schedules.
2. The `ids.json` object registry (ids only, no secrets) and the secrets register (names only).
3. The final status table and the decisions log.
4. Metrics so far: runs, filtered by reason, pushed, sent, bounces, replies.
5. Credits and dollars spent vs the cap, and the projected monthly cost.
6. Kill-switch events and resolutions.
7. Anything BLOCKED, with evidence and the exact remaining action.
8. Pre-flight items that were defaulted (P9) and how to change them.
9. Runbook: how to pause everything (kill-switch order), how to update a prompt or code node from its file, and how to add a content piece.

---

## PART E. Known pitfalls (check against these in every self-review)
1. **Table approach failed:** the CLI couldn't edit tables, the AI columns were on Clay-managed AI, "Send table data" went to the wrong table (Squid), and pages crashed. **Fix:** move to Workflows with code nodes.
2. **sf-check 404:** the route wasn't deployed yet. Fail-closed stopped all runs, which is correct. **Fix:** deploy, and check 401/200.
3. **Agency false positive** from a job title ("Lead Generation Manager"). **Fix:** take the title out of the agency blob.
4. **linkedin_url missing in EmailBison** for early leads. **Fix:** add the custom var mapping. Backfill any leads missing it via the EmailBison API (PATCH).
5. **The AI copied the prompt example** (a real prospect used as the example peer group). **Fix:** use only non-prospect examples, and lint against example copies.
6. **The AI picked nonsense content links.** **Fix:** use a deterministic bucket → content map.
7. **The peer group described the customers** (a payroll provider → "small and midsized businesses"). **Fix:** a prompt rule plus an overlap lint, restricted to the first 2 words of signal_line minus generic nouns to avoid false positives.
8. **"Re: Re:" in the preview.** It's a preview artifact; real sends show a single Re:. Don't blank subjects without a seed test.
9. **WF-A → WF-B POSTs "succeed" but create no runs** while WF-B is unpublished.
10. **The segment trigger test fails** for Draft/STAGED Web Intent records. Test with `--inputs`.
11. **`nodes test` fails on multi-source nodes.** Use full dry runs.
12. **`nodes update` validation:** `outputSchema` must be passed as a flat properties map (`{field: {type, description}}`), and `incomingEdges` replaces all edges.
13. **Big companies are too hard.** Skip companies over 1,000 employees before any paid step. Unknown headcount just passes.
14. **Already in sequence:** re-imports error, but the variables still update. `allow_parallel_sending=false` is correct.
15. **CRLF secrets files:** strip `\r` when reading keys (`tr -d '\r'`). Never `source` or `echo` them.
16. **Unexplained credit burn:** legacy table auto-runs. Turn them off.
17. **Range versus count headcount** (one regional grocer: 855 on LinkedIn vs a 1,001-5,000 range). The low end of a range decides, and a count wins when both are present in the payload order.

---

## PART F. Former open items, now resolved by pre-flight or defaults
- Visit-time floor → P9 (default off).
- Squid payload field names (especially headcount) → step 1.3 documents them. The size gate falls back to Audiences.
- Clay and Squid plan names and prices → P1 approval and the step 1.1/1.3 screenshots.
- Seed-send verification of step 2 subjects and line breaks → step 8.1.
- linkedin_url backfill for leads pushed before the mapping existed → the API PATCH in step 7.2 rollback / pitfall 4.
- Lemlist LinkedIn touch → P9 (default off).
- Content map as a Clay table → optional; the code node is the source of truth.
- Extra inboxes for the page campaigns → P4 and the step 4.1 capacity check.
