> ## Documentation Index
> Fetch the complete documentation index at: https://docs.myfundingmachine.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Partner campaign automation

# Funding Partners campaign automation (worker handoff)

The Partners page can now tell an operator whether their **Apply for Funding**
funnel serves from a domain that referral links can actually use, and — when it
does and no Funding Partners campaign exists — request the campaign for them.

## Why there is a worker at all

The CRM's public Affiliate Manager API is **read-only for campaigns**. The only
verified create path is the `ghl_create_affiliate_campaign` tool on the local
stdio MCP server, which neither Vercel nor Convex can reach. So the operator
button does not create anything. It queues one location-scoped intent, and an
off-platform worker drives the tool and reports a verified result back.

```
Partners page → /api/affiliate-partners (session-gated)
              → convex prospecting/partnerCampaignAutomation (location-gated)
              → partnerCampaignRequests (one row per location+campaign intent)

worker machine → POST /api/partner-campaign-worker  (bearer token)
              → claim  → ghl_create_affiliate_campaign (stdio MCP)
              → complete (raw tool result; the SERVER classifies it)
```

## Invariants worth preserving

* **The domain gate is server-side.** The gate that decides whether a campaign
  may be queued is recomputed live, from the location's own funnels, inside
  `requestCampaignCreate`; the client cannot assert a domain it does not have.
  Vendor-owned funnel hosts do not count as connected.
* **Rendering never triggers the live check.** `getCampaignAutomation` serves
  readiness from the recorded state only (see below). A render that cannot find a
  usable recording says so — it never presents "not checked" as "not ready".
* **One intent per location.** `partnerCampaignRequests.intentKey` is
  `locationId + campaign name`, so repeated clicks cannot fan out into duplicate
  campaigns — and the API offers no campaign delete to clean them up.
* **The worker cannot declare success.** It forwards the arguments the server
  composed, including the exact gated page id as `stepId`, and the raw tool
  result. `classifyMcpCampaignResult` requires a verified campaign id;
  unverified, duplicate-name-ambiguous, or missing results are failures.
* **Completion is claim-fenced.** A reclaimed lease increments `attempts` and a
  monotonic `claimGeneration`; completion must echo both. The generation does
  not reset with an operator retry, so a late worker cannot pass an ABA attempt
  collision and overwrite the newer run.
* **Campaigns are created as drafts** (`publish: false`). Publishing stays a
  human decision in the CRM.
* **Operator failures are non-technical but distinguishable.** Each failure
  branch persists a stable `failureCode` (see `CampaignRequestFailureCode`) and
  branch-specific operator copy, plus a redacted `failureDetail` when the tool
  reported one — but tokens, URLs, file paths, vendor product names, and raw
  tool errors never reach the request row or operator UI.
* **The manual walkthrough is no longer a path.** `CampaignSetupOnboarding` is
  not rendered anywhere: setup is the two steps in "Setup & health", and this
  automation creates the campaign once step 1's domain is confirmed. The
  component file is still in the tree, but nothing mounts it.

## Where domain readiness comes from (FM-2216)

Readiness is established off every render path and replayed on render.

| Path                                              | What it does                                                                  |
| ------------------------------------------------- | ----------------------------------------------------------------------------- |
| `getCampaignAutomation` (card render)             | Reads the recording. No CRM calls.                                            |
| `refreshCampaignAutomation` (**Re-check domain**) | Live check, then records the result.                                          |
| `refreshFunnelReadinessBatch` (cron, every 6h)    | Live check for recordings older than 6h, oldest first, ≤50 locations per run. |
| `requestCampaignCreate` (**Create campaign**)     | Live check as the gate, then records the result.                              |

The recording is two tables, one row per location either way:
`partnerFunnelReadinessCache` holds a confirmed **ready** state (funnel, page,
connected domain), and `partnerFunnelReadinessClears` holds the latest **not
ready** classification plus the observation window that orders concurrent
writers. A recording older than `MAX_READINESS_CACHE_AGE_MS` (7 days) is not
displayed at all — the card falls back to "not confirmed" rather than reassuring
an operator from an answer nothing has re-confirmed.

Locations join the cron rotation by having a recording at all, which they get
from an operator's first re-check or create attempt. Nothing enumerates installs:
there is no registry of which locations use the Partner Module, and sweeping all
of them would spend two internal calls apiece on locations that never open the
page. Set `MFM_READINESS_REFRESH_ENABLED=false` on the deployment to stop the
sweep without a deploy.

Three display states, kept distinct because they need different responses:

* **ready** — the funnel serves from a connected domain.
* **not ready with a named cause** (`domain-missing`, `domain-vendor`,
  `funnel-missing`, `page-ambiguous`, …) — the operator can fix this in their
  CRM, and the copy says which thing to fix.
* **could not be confirmed** — either nothing has checked yet, or the check
  itself failed (expired agency credential, upstream outage). This is our
  problem, not theirs: the copy never tells them to go connect a domain, and it
  never names a credential, token, path, or vendor.

## Running the worker

On a machine that can start the MCP server:

```sh theme={null}
export PARTNER_CAMPAIGN_WORKER_TOKEN=…            # same value as the deployment
export PARTNER_CAMPAIGN_WORKER_APP_URL=https://app.example.com
export PARTNER_CAMPAIGN_MCP_COMMAND=node
export PARTNER_CAMPAIGN_MCP_ARGS="/path/to/mcp/dist/server.js"
export PARTNER_CAMPAIGN_MCP_ENV_ALLOWLIST="MCP_API_TOKEN,GHL_LOCATION_ID"
export PARTNER_CAMPAIGN_MCP_REQUIRED_ENV="MCP_API_TOKEN"   # must-be-set subset
bun run scripts/partner-campaign-worker.ts          # poll (SIGINT to stop)
bun run scripts/partner-campaign-worker.ts --once   # single pass
```

At startup the worker runs a preflight before claiming anything: every
variable in `PARTNER_CAMPAIGN_MCP_REQUIRED_ENV` must be set and non-blank, the
MCP server must start, and it must offer the campaign-create tool. A worker
that fails any of these exits non-zero and names the problem (variables by
name only — values are never printed), instead of claiming requests and
failing each one with a generic message.

Generate the token with `openssl rand -hex 32`. Anything shorter than 32
characters is treated as unconfigured, and the route then rejects every request
— including a correct-looking one. Set the same value on the app deployment
(`PARTNER_CAMPAIGN_WORKER_TOKEN`); the worker-only variables belong on the worker
machine, never in the app's environment. The token is sent only as an
`Authorization` header and is never logged.

The MCP child inherits only the SDK's safe process variables plus names listed
in `PARTNER_CAMPAIGN_MCP_ENV_ALLOWLIST`. Add only variables the MCP server
actually needs. Worker-control variables, including the queue bearer, are
rejected and never reach the child process.

## Operating notes

* A claimed request holds a lease. If the worker dies mid-run, the lease lapses
  and the request is reclaimed on a later pass, bounded by an attempt ceiling; a
  max-attempt request becomes failed/retryable, and a deliberate operator retry
  resets that count.
* The worker claims exactly one request per pass. MCP initialization, stdio
  framing, request timeouts, and shutdown are handled by
  `@modelcontextprotocol/sdk`.
* After a successful create, the Partners page still reads cached campaign rows,
  so the operator uses the existing **Refresh from CRM** sync (under their own
  session) to pull the new campaign in. The worker never syncs on their behalf.
* If the funnel has no usable domain, nothing is queued: the card explains that a
  domain must be connected in the CRM first, and offers a re-check.
* If a card reads "we couldn't confirm the domain check", check the agency
  credential and the sweep's logs before touching the location — the operator has
  nothing to fix. An operator re-check is the fastest way to reconfirm once the
  underlying problem is resolved.
