# External Funnel Lane (target: "external") — capability gate + site gen/deploy + verify

STATUS: v1, 2026-06-20. Owner: atlas (skill layer). Pairs with the canonical spec
`command-center/shared/blueprint-funnel-targets-spec.md` (§9 hosting=C power-user, §11 QA gate,
§12 verify_funnel) and the shipped bridge template `ghl-command-mcp/templates/external-funnel/`
(`cloudflare-worker.js` + README — the capability-gate source of truth).

This file is the full spec for the path a funnel takes when `target: "external"`. The GHL-native
path (`target: "ghl"`, default) is unchanged and never runs this lane.

> **The one-line product stance (say it plainly).** GHL Command does **not** build, host, or deploy
> the external site, and **never holds the user's keys or touches their accounts.** It generates the
> site, scaffolds the bridge config, hands over verified wiring values, and runs the QA gate. The
> **user** owns hosting, the secret, uptime, and DNS. This is a power-user, self-serve path.

---

## 0. When this lane runs

Any funnel in the approved plan with `target: "external"`. A plan can mix targets (one funnel in
GHL, one external). Run the lane once per external funnel, after plan approval. If no funnel is
external, this file is never used.

The decision is made during plan generation (SKILL STEP — per-funnel target question). The moment
the user picks `external` for a funnel, **run the capability gate (§1) immediately** — before the
plan is even finalized — so a non-technical user is steered to `target: "ghl"` before any external
design work is done.

---

## 1. Capability gate (P0) — run the instant a funnel is set to "external"

Present this verbatim-in-substance (source: template README "What you need"). Be honest, not
salesy. Then **stop and get an explicit yes** before continuing the external path.

> **Heads up — `external` is the power-user path.** Building this funnel outside GHL means YOU host
> the site and wire it back to your GHL sub-account. GHL Command generates everything and hands you
> the exact wiring values, but it never deploys for you or holds your keys. To do this you need:
>
> - **Your own Cloudflare account** (or Vercel) **and its CLI** (`wrangler` / `vercel`), logged in.
> - **A GHL Private Integration token** (Settings → Private Integrations) scoped to `contacts.write`
>   for this sub-account. You'll store it as a host secret — never in code, never shared with me.
> - **Comfort running a one-time command-line deploy** and editing one config file.
> - **(Optional) a custom domain** + DNS access, if you don't want the host's default subdomain.
>
> **If any of that isn't you, choose `ghl` instead** — Blueprint builds the funnel inside GoHighLevel
> with zero setup on your part, and everything downstream still works. There's no penalty for picking
> GHL-native; the external path is for people who specifically want their own site.

Then ask: **"Build this funnel external (you host it), or GHL-native (I build it in GHL)?"**

- If they hesitate, can't confirm the access list, or describe themselves as non-technical →
  **steer to `target: "ghl"`** and set it. Do not proceed external.
- Only continue this lane on a clear, informed yes. Never assume dev skills.

The approval view (§5A) renders the external funnel as the power-user path with the ownership facts
(see `references/approval-view.md`). The capability acknowledgment also appears as a manual step
("confirm you own a host account + can deploy") in List 2.

---

## 2. The build sequence (state machine)

```
approve plan
  └─► (a) stage the GHL side: apply_build_plan dry_run → confirm → execute
          └─► capture externalWiring bundle (verified fieldIds, bookingUrl, triggerTag, workflowId)
  └─► (b) generate the site (frontend-design) from page outlines + brand
  └─► (c) inject: form keyed by verified fieldIds  +  bookingUrl into the CTA  +  honeypot/Turnstile
  └─► (d) scaffold the bridge config (wrangler.toml vars) — user sets the SECRET themselves
  └─► (e) DEPLOY to a PREVIEW url (user runs the commands) ─► show it ─► explicit go
  └─► (f) PROMOTE to production domain (user runs it) — NEVER clobber a live deployment silently
  └─► (g) VERIFY on the real branded production URL (verify_funnel + browser submit + burner booking)
  └─► done ONLY when the gate (§7) is green. Until then it is NOT done.
```

Outward-facing + never-clobber: a Blueprint re-run creates a **new preview**; it never overwrites the
current production deployment without an explicit operator go. Deploy → preview → confirm → promote.

### 2a. Stage the GHL side and capture the wiring bundle

The site needs **verified** GHL field IDs, so the GHL objects must exist first. Confirm the active
sub-account (`get_current_location`), run `apply_build_plan` `mode:"dry_run"` and show the report,
then on the operator's go run `mode:"execute"`. For an external funnel, execute builds the receiver
(custom fields, trigger tag, speed-to-lead `contact_tag` workflow) and returns the **`externalWiring`**
bundle. The funnel itself is NOT built or hosted by execute — that's this lane's job.

`externalWiring` shape (template from this — never name-guess keys):

```json
{
  "locationId": "<sub-account id>",
  "funnels": [{
    "ref": "funnel.site", "name": "...", "host": "cloudflare|vercel", "domain": "go.example.com",
    "formFields": [
      { "kind": "standard", "key": "email", "label": "email", "required": true },
      { "kind": "custom", "fieldId": "<VERIFIED GHL field id>", "label": "Primary Goal",
        "dataType": "SINGLE_OPTIONS", "required": true }
    ],
    "bookingUrl": "https://api.leadconnectorhq.com/widget/booking/<calId>",
    "triggerTags": [{ "ref": "tag.lead", "name": "new-lead", "id": "<tag id>" }],
    "unresolved": []
  }]
}
```

- **`unresolved` non-empty = stop.** Something wasn't built. Re-run execute first. Never inject a form
  that points at an unresolved field — that's the silent-drop class the whole lane exists to prevent.
- Keep the bundle for §3 (injection) and §7 (verify). Also note the speed-to-lead **workflow id**
  (from the execute result) for `verify_funnel.workflowId` (it flags a DRAFT workflow).

### 2b–2c. Generate the site and inject the wiring — see §3.
### 2d–2f. Deploy — see §4.
### 2g. Verify — see §7.

---

## 3. Site generation + wiring injection

### 3.1 Generate the site
Use the `frontend-design` skill. Brief it from each funnel page's `outline` + `role` + the brand
context files (voice, colors, positioning — same brand inputs the rest of the plan uses). One page
per `funnel.pages[]` entry. The optin page (`role: "optin"`) hosts the lead form; the confirmation
page (`role: "confirmation"`) hosts the booking CTA. Brand-aware, production HTML/CSS/JS — vertical-
first if it's a mobile-led funnel, per Jerry's default.

### 3.2 Inject the lead form — keyed by VERIFIED field IDs
The optin page's form is the only piece that touches GHL. It POSTs JSON to the bridge Worker. Build
the form and its submit handler from the wiring bundle's `formFields[]`:

- **Standard fields** (`kind: "standard"`): top-level keys — `first_name`, `last_name`, `email`,
  `phone`. Use exactly the `key`.
- **Custom fields** (`kind: "custom"`): collected under a `custom` object **keyed by `fieldId`**
  (never the label, never a guessed key). `{ "custom": { "<fieldId>": value } }`.
- **Value shape by `dataType`:** for `SINGLE_OPTIONS` / `MULTIPLE_OPTIONS`, the input must submit the
  **exact option value** (render `<select>`/radios whose values are the option values, not display
  labels — a label/value mismatch saves empty and `verify_funnel` will fail field-value fidelity).
  `PHONE` → E.164; `DATE` → the format GHL expects; `CHECKBOX` → the boolean/value GHL stores.
- **`required`** mirrors the bundle. Don't require a field the bundle marks optional.

POST body contract (matches `cloudflare-worker.js`):
```jsonc
{
  "first_name": "...", "last_name": "...", "email": "...", "phone": "...",
  "custom": { "<fieldId>": "Botox", "<consentFieldId>": "yes" },
  "_hp": "",                       // honeypot — hidden input, humans leave empty, bots fill it
  "cf_turnstile_token": "..."      // present only if Turnstile is enabled
}
```

- **Honeypot:** add a visually-hidden `_hp` input (off-screen, `tabindex=-1`, `autocomplete=off`).
- **Turnstile (strongly recommend for production):** add the Cloudflare Turnstile widget and send its
  token as `cf_turnstile_token`. Tell the user the honeypot only stops naive bots; Turnstile is the
  real abuse gate on a public, unauthenticated endpoint.
- **Submit handler:** POST to the Worker URL (filled at deploy time), `Content-Type: application/json`.
  On a non-2xx, show an error and let the user retry — **fail closed, never a silent success.** Do not
  show the thank-you page unless the bridge returned 2xx (a thank-you page is not proof of capture).
- The Worker URL is unknown until §4 (deploy), so inject a clear placeholder
  (`__LEAD_BRIDGE_URL__`) and replace it once the Worker is deployed.

### 3.3 Inject the booking CTA
The confirmation page's "book" CTA links straight to the bundle's `bookingUrl` (the GHL calendar's
public widget). No bridge, no extra wiring.

### 3.4 Consent (if the workflow messages)
If the speed-to-lead workflow sends SMS/email, the plan MUST include a consent custom field and the
form MUST render an explicit consent checkbox + copy. Send its value in `custom` keyed by that field's
id, like any other custom field. The user owns CAN-SPAM/TCPA for an externally-captured lead — say so.

---

## 4. Deploy orchestration (the user runs every command; the product runs none)

GHL Command **never deploys and never handles the token.** Walk the user through it; they run each
command in their own shell (suggest the `! <command>` prompt prefix so the output lands in-session).

### 4.1 Scaffold the bridge config
Drop the shipped `cloudflare-worker.js` (from `ghl-command-mcp/templates/external-funnel/`) into the
user's project, and generate a `wrangler.toml` filled from the wiring bundle — **vars only, never the
secret:**
```toml
name = "lead-bridge-<funnel-slug>"
main = "cloudflare-worker.js"
compatibility_date = "2024-01-01"

[vars]
GHL_LOCATION_ID = "<bundle.locationId>"
TRIGGER_TAG     = "<bundle.triggerTags[0].name>"
ALLOWED_ORIGIN  = "https://<your production funnel domain>"   # CORS lock — no wildcard
RE_ENROLL       = "false"                                     # see re-enrollment caveat
```
(Vercel variant: the same logic as `api/lead.js`, config from `process.env`.)

### 4.2 Deploy the Worker FIRST, then set the secret
Order matters (e2e-verified): `wrangler secret put` requires the Worker to already exist, so the
first `wrangler deploy` comes before the secret. Have the user run, in their own account — **do not
ask for, read, or store the token:**
```
npm i -g wrangler && wrangler login
wrangler deploy                      # FIRST deploy — creates the Worker (no extra setup step needed)
wrangler secret put GHL_PIT          # now the Worker exists: paste the PIT, secret-only
wrangler secret put TURNSTILE_SECRET # only if Turnstile is enabled
```
The first `wrangler deploy` prints the **Worker URL**. Replace `__LEAD_BRIDGE_URL__` in the site
form with it, confirm `ALLOWED_ORIGIN` is the site's production domain, then `wrangler deploy` once
more so the secret + final origin are live.

### 4.3 Deploy the site → preview → confirm → promote
Cloudflare Pages (e2e-verified): `wrangler pages deploy` does NOT auto-create the Pages project in
wrangler v4 — it errors `Project not found`. Create the project first, then deploy:
```
wrangler pages project create <project-name> --production-branch main
wrangler pages deploy <site-dir> --project-name <project-name> --branch <branch>
```
1. **PREVIEW first:** deploy the generated site to a **preview branch** (e.g. `--branch preview`) →
   a preview URL. Show it to the operator. This is outward-facing — get an **explicit go** before live.
2. **PROMOTE:** on the go, deploy to the production branch (`--branch main`) and point DNS if using a
   custom domain. **Never clobber a live deployment** without explicit approval — a re-run makes a new
   preview; promotion is always a deliberate step.
3. Record the deployment URL(s) so a later re-run can detect the existing live deployment.
(Vercel: `vercel` for a preview deploy, `vercel --prod` to promote.)
4. Record the deployment URL(s) so a later re-run can detect the existing live deployment.

Booking needs no deploy — it's just the `bookingUrl` link on the confirmation page.

---

## 5. Honesty / ownership facts to surface (say these, don't bury them)
- Where the funnel lives: **the user's host + domain, NOT GHL.** It is not in the GHL funnels list.
- **Hosting owner / domain owner / secret owner = the user.** Uptime, DNS, rotation, the registry of
  what's deployed — all theirs, in their environment.
- **Re-run behavior:** a re-run never silently overwrites the live site; it creates a new preview.
- **Who responds if the public site or bridge fails: the user.** Support stance = power-user docs,
  not managed ops.

## 6. Re-enrollment caveat (carry the product fact)
GHL's Contact-Tag trigger fires on a tag **transition** (absent → present). Re-adding a tag a contact
already has won't re-fire the workflow — usually correct for speed-to-lead (don't re-spam someone
already in the pipeline). If returning submitters must re-enroll, set `RE_ENROLL=true` (the Worker
removes then re-adds the tag) **and** enable re-enrollment on the workflow in GHL. Confirm on the
account before relying on it.

---

## 7. The verify gate — `verify_funnel` on the REAL branded production URL

A funnel is **NOT done** until all three layers pass on the live, branded production URL — never a
local build, never the Worker in isolation, never a thank-you page. This is the §11/§12 standard.

### 7.1 Automated backend truth — `verify_funnel` against the bridge
Run `verify_funnel` (it POSTs a sentinel lead and reads GHL back). Build the call from the wiring
bundle:

```jsonc
verify_funnel({
  funnelUrl:   "<the deployed Worker bridge URL>",   // verify_funnel POSTs JSON here
  locationId:  "<bundle.locationId>",
  triggerTag:  "<bundle.triggerTags[0].name>",
  workflowId:  "<speed-to-lead workflow id from execute>",  // flags a DRAFT workflow
  expectCustom: [ { fieldId: "<id>", value: "<sent value>", label: "<label>" }, ... ], // from formFields
  consentFieldId: "<consent field id, if the workflow messages>",
  // testPhone: "<a test-safe number YOU control>",  // only if asserting SMS-path; real SMS may bill
  cleanup: true                                       // removes the blueprint-qa sentinel after
})
```

This proves, from the backend (not the UI): (1) the contact is **actually created** (with submit→appear
latency); (2) each `expectCustom` value **persisted** (field-value fidelity — catches a wrong field id);
(3) the **trigger tag landed**; (4) SMS/A2P pre-check; (5) **workflow is not DRAFT** (a draft never fires
on real leads); (6) the **outreach actually fired** (an outbound message was logged — proves enroll +
send, not just a green log); (7) **consent recorded** when messaging fires; (8) **duplicate-contact
retest** (same email twice → one deduped contact, not a duplicate or false failure). Read the report;
every assertion must pass. A failure here = the funnel is a paper shredder; fix and re-verify.

> Note: `verify_funnel` POSTs JSON **directly to the Worker**, so it proves bridge → GHL. It does NOT
> drive the rendered site form, so it can't prove the **site → bridge** wiring (that the generated HTML
> sends the right field ids). That's what 7.2 closes.

### 7.2 Real branded-URL submission (closes the site → bridge gap)
Have the user submit the **live branded site form in a browser** once with a real/marked email, then
confirm via `search_contacts` that the contact landed AND open it to confirm each custom field saved
with the value typed. This is the only step that proves the generated form's field-id keying is correct
end to end on the real production URL. (The honeypot/Turnstile and `ALLOWED_ORIGIN` are also exercised
here, since this is a genuine browser submit from the production origin.)

### 7.3 Burner booking (manual — can't be faked server-side)
If booking is wired, the user books **one real "burner" appointment** through the production booking
CTA and confirms it appears in GHL. A reachable widget URL is not proof.

### 7.4 Done definition
The funnel is done only when **7.1 passes clean**, **7.2's branded-URL contact landed with correct
field values**, and **7.3's booking is confirmed** (when booking is wired). Otherwise it is not done —
say what failed and stop. Don't call a funnel that thanks people while leads evaporate "done."

---

## 8. Quick checklist (run before calling any external funnel complete)
- [ ] Capability gate shown; informed yes (non-technical users steered to GHL-native).
- [ ] GHL side executed; `externalWiring.unresolved` is empty.
- [ ] Form keyed by **verified `fieldId`s**; option values match GHL option values; consent field sent if messaging.
- [ ] Honeypot present; Turnstile enabled for production.
- [ ] Worker deployed by the user; `GHL_PIT` set as a secret by the user (never handled by the product).
- [ ] `ALLOWED_ORIGIN` = production domain; fail-closed on non-2xx.
- [ ] Site deployed preview → confirmed → promoted; live deployment never silently clobbered.
- [ ] `verify_funnel` green (contact + fidelity + tag + non-draft + outreach + consent + dedup).
- [ ] Real branded-URL browser submit landed with correct field values.
- [ ] Burner booking confirmed in GHL (if booking wired).
- [ ] Ownership/support facts stated to the operator.
</content>
</invoke>
