Skip to main content

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.

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. 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:
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.