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 theghl_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.
getCampaignAutomationserves 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.intentKeyislocationId + 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.classifyMcpCampaignResultrequires a verified campaign id; unverified, duplicate-name-ambiguous, or missing results are failures. - Completion is claim-fenced. A reclaimed lease increments
attemptsand a monotonicclaimGeneration; 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(seeCampaignRequestFailureCode) and branch-specific operator copy, plus a redactedfailureDetailwhen 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.
CampaignSetupOnboardingis 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: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.

