@elitedcs/ghl-mcp 3.78.0 → 3.79.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -10713,6 +10713,15 @@ async function templateFacets(api, product = "funnels") {
10713
10713
  const names = (list2) => (list2 ?? []).map((x) => typeof x === "string" ? x : x.name ?? "").filter(Boolean);
10714
10714
  return { categories: names(out.categories), tags: names(out.tags), features: names(out.features) };
10715
10715
  }
10716
+ function installedFunnelId(loaded) {
10717
+ const r = loaded;
10718
+ if (!r || typeof r !== "object") return void 0;
10719
+ if (r.status !== "ok") return void 0;
10720
+ if (r.data?.status !== "completed") return void 0;
10721
+ const t = r.data?.target;
10722
+ if (!t || t.asset !== "funnels") return void 0;
10723
+ return typeof t.assetId === "string" && GHL_ID.test(t.assetId) ? t.assetId : void 0;
10724
+ }
10716
10725
  async function installTemplate(api, templateId) {
10717
10726
  if (!/^[A-Za-z0-9]{12,40}$/.test(templateId)) throw new Error(`Not a template id: ${JSON.stringify(templateId)}`);
10718
10727
  return call(api, "/templates/template/load", {
@@ -10737,12 +10746,13 @@ function rankForBrand(rows, brand) {
10737
10746
  return { ...t, why: parts.join("; ") };
10738
10747
  });
10739
10748
  }
10740
- var SVC;
10749
+ var SVC, GHL_ID;
10741
10750
  var init_ghl_templates = __esm({
10742
10751
  "src/command-os/design/ghl-templates.ts"() {
10743
10752
  "use strict";
10744
10753
  init_color();
10745
10754
  SVC = "https://services.leadconnectorhq.com";
10755
+ GHL_ID = /^[A-Za-z0-9]{15,40}$/;
10746
10756
  }
10747
10757
  });
10748
10758
 
@@ -13550,7 +13560,7 @@ function installedFunnelTracker(before) {
13550
13560
  };
13551
13561
  }
13552
13562
  function removalHoldBack(opts) {
13553
- if (opts.truncated) return `this sub-account has at least ${opts.funnelPageSize} funnels, so "which funnel is new" could not be established with certainty`;
13563
+ if (opts.truncated && !opts.identifiedFromResponse) return `this sub-account's funnels could not all be read back before the install, so "which funnel is new" could not be established with certainty`;
13554
13564
  if (opts.multiPageStepNames.length > 0) return `${opts.multiPageStepNames.length} step(s) carry more than one page (${opts.multiPageStepNames.join(", ")}) and only the first of each was rebuilt`;
13555
13565
  return null;
13556
13566
  }
@@ -13563,12 +13573,23 @@ function registerPageStudioTools(server2, builderClient) {
13563
13573
  locationId: () => client.locationId
13564
13574
  });
13565
13575
  const FUNNEL_PAGE = 100;
13576
+ const FUNNEL_PAGE_CAP = 20;
13566
13577
  async function funnelSummary() {
13567
13578
  const out = /* @__PURE__ */ new Map();
13568
- const r = await funnelRequest("GET", `/funnel/list?locationId=${client.locationId}&limit=${FUNNEL_PAGE}`);
13569
- const rows = r.funnels ?? [];
13570
- for (const f of rows) out.set(f._id, `${f.name} (${(f.steps ?? []).length} steps)`);
13571
- return { map: out, truncated: rows.length >= FUNNEL_PAGE };
13579
+ let truncated = false;
13580
+ for (let page = 0; page < FUNNEL_PAGE_CAP; page++) {
13581
+ const r = await funnelRequest("GET", `/funnel/list?locationId=${client.locationId}&limit=${FUNNEL_PAGE}&offset=${page * FUNNEL_PAGE}`);
13582
+ const rows = r.funnels ?? [];
13583
+ const fresh = rows.filter((f) => !out.has(f._id));
13584
+ if (page > 0 && fresh.length === 0 && rows.length > 0) {
13585
+ truncated = true;
13586
+ break;
13587
+ }
13588
+ for (const f of rows) out.set(f._id, `${f.name} (${(f.steps ?? []).length} steps)`);
13589
+ if (rows.length < FUNNEL_PAGE) return { map: out, truncated: false };
13590
+ if (page === FUNNEL_PAGE_CAP - 1) truncated = true;
13591
+ }
13592
+ return { map: out, truncated };
13572
13593
  }
13573
13594
  async function funnelRequest(method, path36, body) {
13574
13595
  const headers = await client.buildHeaders();
@@ -13723,23 +13744,34 @@ ${await response.text()}`);
13723
13744
  templateId: import_zod42.z.string().describe("From find_ghl_template."),
13724
13745
  brandSlug: import_zod42.z.string().optional().describe("A stored brand to repaint the template in. Omit to install it in the designer's own colours."),
13725
13746
  confirm: import_zod42.z.literal("INSTALL").describe("Type INSTALL. This writes into the live sub-account you are switched to."),
13726
- keepOriginals: import_zod42.z.boolean().optional().describe("With brandSlug: leave the template's original unbranded steps beside the branded ones. DEFAULT TRUE \u2014 nothing is deleted unless you pass false. Deleting means trusting that the funnel this installed into is the one the template made, and on a busy sub-account that cannot be established with certainty (see the tool's note).")
13747
+ keepOriginals: import_zod42.z.boolean().optional().describe("With brandSlug: leave the template's original unbranded steps beside the branded ones. Normally they are removed once every branded page has been read back and measured. They are KEPT automatically, whatever you pass, if GoHighLevel did not name the funnel it created in its install response and the identification had to fall back to comparing the account before and after.")
13727
13748
  },
13728
13749
  async ({ templateId, brandSlug, keepOriginals }) => {
13729
13750
  const brand = brandSlug ? houseLooks().find((x) => x.slug === brandSlug) ?? readBrand(slugify(brandSlug)) : null;
13730
13751
  const inventory = await funnelSummary();
13731
13752
  const before = inventory.map;
13732
- await installTemplate(templateApi(), templateId);
13753
+ const named = installedFunnelId(await installTemplate(templateApi(), templateId));
13754
+ const namedFunnelId = named && !before.has(named) ? named : void 0;
13755
+ const namedButNotNew = Boolean(named) && named !== namedFunnelId;
13733
13756
  let funnel;
13734
13757
  let ambiguous = [];
13735
13758
  const tracker = installedFunnelTracker(before);
13736
13759
  for (let tries = 0; tries < 8; tries++) {
13737
13760
  const list2 = await funnelRequest("GET", `/funnel/list?locationId=${client.locationId}&limit=${FUNNEL_PAGE}`);
13738
- const seen = tracker.observe(list2.funnels ?? []);
13739
- if (seen.settled) {
13740
- if ("funnel" in seen) funnel = seen.funnel;
13741
- else ambiguous = seen.ambiguous;
13742
- break;
13761
+ const rows = list2.funnels ?? [];
13762
+ if (namedFunnelId) {
13763
+ const mine = rows.find((f) => f._id === namedFunnelId);
13764
+ if (mine && (mine.steps ?? []).length > 0) {
13765
+ funnel = mine;
13766
+ break;
13767
+ }
13768
+ } else {
13769
+ const seen = tracker.observe(rows);
13770
+ if (seen.settled) {
13771
+ if ("funnel" in seen) funnel = seen.funnel;
13772
+ else ambiguous = seen.ambiguous;
13773
+ break;
13774
+ }
13743
13775
  }
13744
13776
  await new Promise((r) => setTimeout(r, 1500));
13745
13777
  }
@@ -13839,9 +13871,10 @@ ${await response.text()}`);
13839
13871
  }
13840
13872
  }
13841
13873
  let removed = 0;
13842
- const removeOriginals = keepOriginals === false;
13874
+ const certain = Boolean(namedFunnelId) && !inventory.truncated;
13875
+ const removeOriginals = certain ? keepOriginals !== true : keepOriginals === false;
13843
13876
  const complete = problems.length === 0 && done.length === originals.length && done.length > 0;
13844
- const holdBack = removalHoldBack({ truncated: inventory.truncated, funnelPageSize: FUNNEL_PAGE, multiPageStepNames: multiPage.map((m) => m.name) });
13877
+ const holdBack = removalHoldBack({ truncated: inventory.truncated, funnelPageSize: FUNNEL_PAGE, multiPageStepNames: multiPage.map((m) => m.name), identifiedFromResponse: certain });
13845
13878
  if (complete && removeOriginals && holdBack === null) {
13846
13879
  for (const d of done) {
13847
13880
  try {
@@ -13860,6 +13893,7 @@ ${await response.text()}`);
13860
13893
  pages: done,
13861
13894
  originalsRemoved: complete && removeOriginals ? removed : 0,
13862
13895
  ...problems.length > 0 && { problems, kept: "Every original step was LEFT IN PLACE because something failed. Nothing was deleted." },
13896
+ ...namedButNotNew && { identification: "GoHighLevel named a funnel that this account ALREADY had before the install. That name was discarded and the account was compared instead, so the funnel above was identified the cautious way and originals are only removed if you asked for it explicitly. Worth reporting: it means the install response no longer means what it did." },
13863
13897
  ...holdBack !== null && { kept: `Every original step was LEFT IN PLACE on purpose: ${holdBack}. The branded pages are there beside them \u2014 remove the originals by hand once you have looked.` },
13864
13898
  warnings: rethemeEnvelope({}, brand, palette).warnings,
13865
13899
  next: done.length ? `Grade one before the client sees it: verify_design on ${String(done[0].preview)} with medium "site" and placement "ghl". GoHighLevel's own templates are designed but frequently fail contrast and tap-target rules.` : "Nothing was branded.",
@@ -31555,12 +31589,23 @@ RESULT: {"ok":true,"summary":"<one sentence: who the client is and what they wan
31555
31589
  "mcp__ghl__get_pipelines",
31556
31590
  "mcp__ghl__list_workflows_full",
31557
31591
  // Finding 17: the recipient of a staff notification comes from THIS account's user list or stays empty.
31558
- "mcp__ghl__get_users"
31592
+ "mcp__ghl__get_users",
31593
+ // Finding 22 (2026-09-03 proof run): an intake filled in the cockpit lands as "Intake:" custom
31594
+ // fields on a contact tagged blueprint-intake, NOT as a form submission — GHL's submissions
31595
+ // endpoint returns nothing for API-created forms (client-intake.ts:175). Stage 1 already checks
31596
+ // both paths; stage 3 could not, because it lacked these three reads, so the cockpit's own
31597
+ // intake panel produced a valid Brief and then a dead stop here.
31598
+ "mcp__ghl__search_contacts",
31599
+ "mcp__ghl__get_contact",
31600
+ "mcp__ghl__get_custom_fields"
31559
31601
  ],
31560
31602
  prompt: (locationId2, locationName, ctx) => ctx?.savedPlan ? buildStageReusePrompt(locationId2, locationName, ctx.savedPlan) : `You are running ONE unattended step of a GHL Command Blueprint build. Use ONLY the ghl MCP tools.
31561
31603
  THE PLAN IS COMPOSED IN CODE, NOT BY YOU (plan and build were split 2026-08-26 after a run spent 18 of its 25 minutes writing 108 workflow actions by hand). You never author workflows, actions, emails or texts unless step 6's fallback fires.
31562
31604
  1. switch_location to ${locationId2} (${locationName}); verify with get_current_location and stop if it is not that location.
31563
- 2. Rebuild the Brief from the intake submission (get_forms \u2192 get_form_submissions_full \u2192 normalize_submission_to_brief).
31605
+ 2. Rebuild the Brief. The answers arrive one of TWO ways and you must check BOTH:
31606
+ (a) a real form submission \u2014 get_forms then get_form_submissions_full, then normalize_submission_to_brief; or
31607
+ (b) an intake completed in the cockpit \u2014 search_contacts for the tag "blueprint-intake", read that contact with get_contact, and assemble the Brief directly from its "Intake: <question>" custom fields (get_custom_fields maps ids to names). apply_build_plan accepts a Brief assembled this way.
31608
+ Only if BOTH come up empty, stop and report that there are no intake answers yet \u2014 never tell the operator to fill in the form when path (b) was never checked.
31564
31609
  3. Call apply_build_plan with fromPreset:true, preset:"${presetForIndustry(ctx?.industry)}", brief:<the Brief from step 2>, mode:"dry_run". The server expands the industry preset with the client's staff, calendars (appointment lengths included), pipeline stages, offer and notification rules in under a second, validates it, and reports what would be built.
31565
31610
  4. Read the dry-run report. Its composed.copySlots list the message templates; their copy is already filled from the brief and send-ready. ONLY where the brief gives client-specific material the preset copy misses (the client's signature line, a named offer or price), pass copy:[{ref:"email_template.<slug>", subject, html}] / [{ref:"sms_template.<slug>", body}] for JUST those templates on the next call. Never restructure; never rewrite every message.
31566
31611
  5. Call apply_build_plan again with the SAME fromPreset:true, preset and brief (plus your copy overrides, if any) and mode:"execute". Never delete or recreate anything that already exists.
@@ -33906,6 +33951,240 @@ var init_publish_report = __esm({
33906
33951
  }
33907
33952
  });
33908
33953
 
33954
+ // src/client-intake.ts
33955
+ function publicFormUrl(formId) {
33956
+ return `https://api.leadconnectorhq.com/widget/form/${formId}`;
33957
+ }
33958
+ function urlCannotCarry(value) {
33959
+ if (Array.isArray(value)) return value.length > 1 ? "several answers" : value[0] && /\+/.test(value[0]) ? "contains a plus sign" : null;
33960
+ return /\+/.test(value) ? "contains a plus sign" : null;
33961
+ }
33962
+ function prefillFormUrl(formUrl, queryKeys, answers, contact = {}, altQueryKeys = {}) {
33963
+ const p = new URLSearchParams();
33964
+ if (contact.firstName) p.set("first_name", contact.firstName);
33965
+ if (contact.lastName) p.set("last_name", contact.lastName);
33966
+ if (contact.email) p.set("email", contact.email);
33967
+ if (contact.phone) p.set("phone", contact.phone);
33968
+ const leftovers = [];
33969
+ for (const [label, raw] of Object.entries(answers)) {
33970
+ const values = (Array.isArray(raw) ? raw : [raw]).map((v) => String(v ?? "").trim()).filter(Boolean);
33971
+ if (!values.length) continue;
33972
+ const key = queryKeys[label];
33973
+ if (!key) {
33974
+ leftovers.push({ label, values, reason: "no field for this question" });
33975
+ continue;
33976
+ }
33977
+ const reason = urlCannotCarry(Array.isArray(raw) ? values : values[0]);
33978
+ if (reason) {
33979
+ leftovers.push({ label, values, reason });
33980
+ continue;
33981
+ }
33982
+ p.set(key, values[0]);
33983
+ const alt = altQueryKeys[label];
33984
+ if (alt && alt !== key) p.set(alt, values[0]);
33985
+ }
33986
+ const qs = p.toString();
33987
+ return { url: qs ? `${formUrl}?${qs}` : formUrl, leftovers };
33988
+ }
33989
+ async function readIntakeSnapshot(client, locationId2, sentMarker) {
33990
+ const empty = { status: "not_installed", fields: {}, queryKeys: {}, submissions: 0 };
33991
+ try {
33992
+ const forms = await client.get("/forms/", { params: { locationId: locationId2, limit: 100 } });
33993
+ const form = (forms.forms ?? []).find((f) => f.name === INTAKE_FORM_NAME2);
33994
+ if (!form) return empty;
33995
+ const cf = await client.get("/locations/" + locationId2 + "/customFields");
33996
+ const fields = {};
33997
+ const queryKeys = {};
33998
+ const altQueryKeys = {};
33999
+ const multiFields = {};
34000
+ for (const f of cf.customFields ?? []) {
34001
+ if (!f.name?.startsWith(INTAKE_FIELD_NAME_PREFIX)) continue;
34002
+ const label = f.name.slice(INTAKE_FIELD_NAME_PREFIX.length);
34003
+ fields[label] = f.id;
34004
+ queryKeys[label] = formQueryKey(f.name);
34005
+ if (f.dataType === "MULTIPLE_OPTIONS") multiFields[label] = true;
34006
+ if (f.fieldKey) {
34007
+ const alt = f.fieldKey.replace(/^contact\./, "");
34008
+ if (alt !== queryKeys[label]) altQueryKeys[label] = alt;
34009
+ }
34010
+ }
34011
+ let submissions = 0;
34012
+ let answeredAt;
34013
+ let channel;
34014
+ let contactId;
34015
+ try {
34016
+ const subs = await client.get("/forms/submissions", { params: { locationId: locationId2, formId: form.id, limit: 5 } });
34017
+ if ((subs.submissions ?? []).length) {
34018
+ submissions = subs.submissions.length;
34019
+ answeredAt = subs.submissions[0]?.createdAt;
34020
+ channel = "form";
34021
+ }
34022
+ } catch {
34023
+ }
34024
+ if (!submissions) {
34025
+ try {
34026
+ const seen = /* @__PURE__ */ new Map();
34027
+ for (const q2 of [INTAKE_CONTACT_TAG, INTAKE_FORM_NAME2]) {
34028
+ const found = await client.get("/contacts/", { params: { locationId: locationId2, query: q2, limit: 20 } });
34029
+ for (const c of found.contacts ?? []) seen.set(c.id, c);
34030
+ }
34031
+ const isFormSubmission = (c) => c.source === INTAKE_FORM_NAME2 || (c.attributions ?? []).some((a) => a.mediumId === form.id || a.medium === "form");
34032
+ const all = [...seen.values()];
34033
+ const hit = all.find(isFormSubmission) ?? all.find((c) => (c.tags ?? []).includes(INTAKE_CONTACT_TAG));
34034
+ if (hit) {
34035
+ submissions = 1;
34036
+ answeredAt = hit.dateUpdated;
34037
+ contactId = hit.id;
34038
+ channel = isFormSubmission(hit) ? "form" : "cockpit";
34039
+ }
34040
+ } catch {
34041
+ }
34042
+ }
34043
+ const status = submissions > 0 ? "received" : sentMarker ? "sent" : "installed";
34044
+ return { status, formId: form.id, formUrl: publicFormUrl(form.id), fields, queryKeys, altQueryKeys, multiFields, submissions, answeredAt, channel, contactId };
34045
+ } catch {
34046
+ return empty;
34047
+ }
34048
+ }
34049
+ async function submitIntakeAnswers(client, locationId2, snapshot, answers, contact) {
34050
+ const customFields = [];
34051
+ const unmapped = [];
34052
+ for (const [label, raw] of Object.entries(answers)) {
34053
+ const values = (Array.isArray(raw) ? raw : [raw]).map((v) => String(v ?? "").trim()).filter(Boolean);
34054
+ if (!values.length) continue;
34055
+ const id = snapshot.fields[label];
34056
+ if (id) customFields.push({ id, value: snapshot.multiFields?.[label] ? values : values[0] });
34057
+ else unmapped.push(label);
34058
+ }
34059
+ try {
34060
+ const body = {
34061
+ locationId: locationId2,
34062
+ firstName: contact.firstName || "Intake",
34063
+ lastName: contact.lastName || "Submission",
34064
+ ...contact.email && { email: contact.email },
34065
+ ...contact.phone && { phone: contact.phone },
34066
+ tags: [INTAKE_CONTACT_TAG],
34067
+ customFields
34068
+ };
34069
+ const res = await client.post("/contacts/upsert", { body });
34070
+ return { ok: true, contactId: res.contact?.id, unmapped };
34071
+ } catch (e) {
34072
+ return { ok: false, unmapped, error: e instanceof Error ? e.message : String(e) };
34073
+ }
34074
+ }
34075
+ async function emailIntakeLink(client, locationId2, to, formUrl, agencyName) {
34076
+ try {
34077
+ const found = await client.get("/contacts/", { params: { locationId: locationId2, query: to.email, limit: 5 } });
34078
+ let contactId = (found.contacts ?? []).find((c) => c.email?.toLowerCase() === to.email.toLowerCase())?.id;
34079
+ if (!contactId) {
34080
+ const made = await client.post("/contacts/", {
34081
+ body: { locationId: locationId2, email: to.email, ...to.firstName && { firstName: to.firstName }, tags: ["blueprint-intake-invited"] }
34082
+ });
34083
+ contactId = made.contact?.id;
34084
+ }
34085
+ if (!contactId) throw new Error("could not create a contact to email");
34086
+ const html2 = [
34087
+ `<p>Hi ${to.firstName || "there"},</p>`,
34088
+ `<p>Before we build out your account, we need the details only you can give us. It takes about ten minutes, and you can stop and come back to it.</p>`,
34089
+ `<p><a href="${formUrl}"><strong>Open your onboarding questionnaire</strong></a></p>`,
34090
+ `<p>Answer what you know. Anything you're unsure about, leave it and we'll cover it on our next call.</p>`,
34091
+ `<p>${agencyName}</p>`
34092
+ ].join("");
34093
+ await client.post("/conversations/messages", {
34094
+ body: { type: "Email", contactId, emailTo: to.email, subject: "Your onboarding questionnaire", html: html2 }
34095
+ });
34096
+ return { ok: true };
34097
+ } catch (e) {
34098
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
34099
+ }
34100
+ }
34101
+ function recorderAllowPatterns(mcpListOutput) {
34102
+ const out = [];
34103
+ for (const line3 of mcpListOutput.split("\n")) {
34104
+ const m = /^\s*(.+?):\s+https?:\/\//.exec(line3);
34105
+ if (!m) continue;
34106
+ const name = m[1].trim();
34107
+ if (!/✔|Connected/i.test(line3)) continue;
34108
+ if (!RECORDER_HINTS.some((h) => name.toLowerCase().includes(h))) continue;
34109
+ out.push(`mcp__${name.replace(/[^A-Za-z0-9]+/g, "_")}__*`);
34110
+ }
34111
+ return [...new Set(out)];
34112
+ }
34113
+ function prefillTools(source, recorderPatterns = []) {
34114
+ const deny = "Bash,Write,Edit,MultiEdit,NotebookEdit,Task";
34115
+ if (source.kind === "text") return { allow: "", deny: deny + ",WebFetch,WebSearch,Read,Glob,Grep" };
34116
+ if (source.kind === "url") return { allow: "WebFetch", deny: deny + ",WebSearch,Read,Glob,Grep" };
34117
+ return { allow: recorderPatterns.join(","), deny: deny + ",Read,Glob,Grep" };
34118
+ }
34119
+ function sourcePreamble(source) {
34120
+ if (source.kind === "url") {
34121
+ return `Fetch this and use it as the material: ${source.url}
34122
+ If it needs a login or returns nothing usable, say so \u2014 do not guess from the URL alone.`;
34123
+ }
34124
+ if (source.kind === "recorder") {
34125
+ return `Find the recording/transcript the operator means using their connected recorder tools, then use its transcript as the material.
34126
+ Operator's request: "${source.instruction}"
34127
+ If you cannot find exactly one clear match, stop and say which recordings you did find instead of guessing.`;
34128
+ }
34129
+ return "";
34130
+ }
34131
+ function prefillPrompt(labels, transcript) {
34132
+ return [
34133
+ "You are filling in a client-onboarding questionnaire from a discovery call.",
34134
+ "Answer ONLY from the material provided. Never invent a fact, a number, or a name.",
34135
+ 'If the material does not clearly answer a question, leave it out and list it under "unknown".',
34136
+ "Keep answers short and concrete \u2014 this feeds an automated build, not a report.",
34137
+ "",
34138
+ "QUESTIONS (use these labels verbatim as keys):",
34139
+ ...labels.map((l) => `- ${l}`),
34140
+ "",
34141
+ "MATERIAL:",
34142
+ transcript.slice(0, 6e4),
34143
+ "",
34144
+ "Reply with ONE line of JSON and nothing else:",
34145
+ 'RESULT: {"answers":{"<label>":"<answer>"},"unknown":["<label>"]}'
34146
+ ].join("\n");
34147
+ }
34148
+ function prefillPromptForSource(labels, source) {
34149
+ const pre = sourcePreamble(source);
34150
+ const body = prefillPrompt(labels, source.kind === "text" ? source.text : "(see the material you fetched above)");
34151
+ return pre ? `${pre}
34152
+
34153
+ ${body}` : body;
34154
+ }
34155
+ async function prefillFromSource(spawnClaude, labels, source, recorderPatterns = []) {
34156
+ const empty = source.kind === "text" && !source.text.trim() || source.kind === "url" && !source.url.trim() || source.kind === "recorder" && !source.instruction.trim();
34157
+ if (empty) return { ok: false, answers: {}, unknown: labels, error: "nothing to read" };
34158
+ return runPrefill(() => spawnClaude(prefillPromptForSource(labels, source), prefillTools(source, recorderPatterns)), labels);
34159
+ }
34160
+ async function runPrefill(call2, labels) {
34161
+ try {
34162
+ const out = await call2();
34163
+ const m = /RESULT:\s*(\{[\s\S]*\})/.exec(out);
34164
+ if (!m) return { ok: false, answers: {}, unknown: labels, error: "the model did not return a result line" };
34165
+ const parsed = JSON.parse(m[1]);
34166
+ const answers = {};
34167
+ for (const [k, v] of Object.entries(parsed.answers ?? {})) {
34168
+ if (labels.includes(k) && typeof v === "string" && v.trim()) answers[k] = v.trim();
34169
+ }
34170
+ const unknown = labels.filter((l) => !(l in answers));
34171
+ return { ok: true, answers, unknown };
34172
+ } catch (e) {
34173
+ return { ok: false, answers: {}, unknown: labels, error: e instanceof Error ? e.message : String(e) };
34174
+ }
34175
+ }
34176
+ var INTAKE_FORM_NAME2, INTAKE_CONTACT_TAG, RECORDER_HINTS;
34177
+ var init_client_intake = __esm({
34178
+ "src/client-intake.ts"() {
34179
+ "use strict";
34180
+ init_question_set();
34181
+ init_form_template();
34182
+ INTAKE_FORM_NAME2 = "GHL Command Blueprint \u2014 Client Intake";
34183
+ INTAKE_CONTACT_TAG = "blueprint-intake";
34184
+ RECORDER_HINTS = ["plaud", "otter", "fireflies", "grain", "fathom", "tldv", "granola", "zoom", "read.ai", "readai", "supernormal", "avoma"];
34185
+ }
34186
+ });
34187
+
33909
34188
  // src/intake-to-build/customization.ts
33910
34189
  function industryPack(slug3) {
33911
34190
  if (!slug3) return void 0;
@@ -37884,7 +38163,7 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
37884
38163
  }
37885
38164
  server2.tool(
37886
38165
  "install_intake_form",
37887
- `Install the Intake-to-Build client-intake form into the CURRENT GHL location (whatever get_current_location returns). Account-agnostic, zero hardcoded IDs. Creates any missing intake custom fields (idempotent \u2014 reused on re-run), then builds the form with the proven GHL form-builder field shapes and verifies it. Pass industry (e.g. "clinic", "med-spa") to install the TAILORED set \u2014 base questions + that industry's pack + the agency's own overlay (added / removed questions), the same set the intake interview asks \u2014 so no cockpit answer is left without a form field; omit it for the base set. Returns {formId, fieldMap} \u2014 keep fieldMap; normalize_submission_to_brief uses it. Pass dryRun:true to preview what would be created without writing. Pass formId to update an existing intake form in place (e.g. to add an industry's questions to a form installed without one) instead of creating a new one.`,
38166
+ `Install the Intake-to-Build client-intake form into the CURRENT GHL location (whatever get_current_location returns). Account-agnostic, zero hardcoded IDs. Creates any missing intake custom fields (idempotent \u2014 reused on re-run), then builds the form with the proven GHL form-builder field shapes and verifies it. Pass industry (e.g. "clinic", "med-spa") to install the TAILORED set \u2014 base questions + that industry's pack + the agency's own overlay (added / removed questions), the same set the intake interview asks \u2014 so no cockpit answer is left without a form field; omit it for the base set. Returns {formId, formUrl, fieldMap} \u2014 formUrl is the public link to send the client; keep fieldMap. NOTE: the answers come back on a CONTACT tagged "blueprint-intake", not as a form submission (GoHighLevel reports no submissions for forms created through the API), so read the contact and assemble the Brief from its fields. Pass dryRun:true to preview what would be created without writing. Pass formId to update an existing intake form in place (e.g. to add an industry's questions to a form installed without one) instead of creating a new one.`,
37888
38167
  {
37889
38168
  dryRun: import_zod76.z.boolean().optional().describe("Preview the fields/form that would be created without writing anything."),
37890
38169
  formId: import_zod76.z.string().optional().describe("Update this existing form in place instead of creating a new one."),
@@ -37981,7 +38260,14 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
37981
38260
  fieldsPersisted: persistedCount,
37982
38261
  fieldsExpectedApprox: expectedCount,
37983
38262
  fieldMap,
37984
- next: "Drive traffic to the form, then run normalize_submission_to_brief with this formId (and fieldMap) once a submission lands."
38263
+ // The link the operator actually sends the client. It was missing for
38264
+ // six releases: the guide promised "gives you a link to send them",
38265
+ // the code already knew how to build one (client-intake.publicFormUrl), and
38266
+ // this response never handed it over — so the operator had to go and
38267
+ // find the form in the GoHighLevel UI, which is the one thing this
38268
+ // product exists to avoid. Found by running the guide (2026-09-04).
38269
+ formUrl: publicFormUrl(resolvedFormId),
38270
+ next: `Send the client ${publicFormUrl(resolvedFormId)}. When the answers are in, read them from the CONTACT tagged "${INTAKE_CONTACT_TAG}" (search_contacts for that tag, get_contact for its fields, get_custom_fields to map ids to question names) and pass the assembled Brief to apply_build_plan. Do NOT reach for normalize_submission_to_brief here: GoHighLevel's submissions endpoint returns nothing for a form created through the API, so it will report no submissions even after the client has filled this one in.`
37985
38271
  });
37986
38272
  } catch (error) {
37987
38273
  return errorResponse(error);
@@ -37990,7 +38276,7 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
37990
38276
  );
37991
38277
  server2.tool(
37992
38278
  "normalize_submission_to_brief",
37993
- 'Read an intake form submission and normalize it into a \xA74 Brief (briefSource:"intake_form"). Pass the formId; by default the most recent submission is used (or pass submissionId). The intakeKey->customFieldId map is taken from fieldMap if provided (the install_intake_form output, most robust), otherwise reconstructed from the live form. Returns {brief, validation, submissionId} \u2014 validation flags any missing required fields (e.g. an incomplete submission).',
38279
+ `Read an intake form submission and normalize it into a \xA74 Brief (briefSource:"intake_form"). ONLY works for a form whose submissions GoHighLevel actually reports, which in practice means a form built in GHL's own editor \u2014 for an intake form installed by install_intake_form it will answer "No submissions found" even after the client has filled it in, and the answers should be read from the contact tagged "blueprint-intake" instead. Pass the formId; by default the most recent submission is used (or pass submissionId). The intakeKey->customFieldId map is taken from fieldMap if provided (the install_intake_form output, most robust), otherwise reconstructed from the live form. Returns {brief, validation, submissionId} \u2014 validation flags any missing required fields (e.g. an incomplete submission).`,
37994
38280
  {
37995
38281
  formId: import_zod76.z.string().describe("The intake form ID (from install_intake_form)."),
37996
38282
  submissionId: import_zod76.z.string().optional().describe("Specific submission to normalize. Defaults to the most recent."),
@@ -38057,6 +38343,7 @@ var init_intake_to_build = __esm({
38057
38343
  init_user_provisioning();
38058
38344
  init_plan_form();
38059
38345
  init_publish_report();
38346
+ init_client_intake();
38060
38347
  init_question_set();
38061
38348
  init_customization();
38062
38349
  init_intake_overlay();
@@ -38775,7 +39062,7 @@ var require_package = __commonJS({
38775
39062
  "package.json"(exports2, module2) {
38776
39063
  module2.exports = {
38777
39064
  name: "@elitedcs/ghl-mcp",
38778
- version: "3.78.0",
39065
+ version: "3.79.0",
38779
39066
  mcpName: "io.github.drjerryrelth/ghl-command",
38780
39067
  description: "GoHighLevel MCP Server for Claude. 250 tools \u2014 full CRM, automation, marketing control, account-wide workflow audit, live funnel-capture verification, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
38781
39068
  main: "dist/index.js",
@@ -39086,240 +39373,6 @@ var init_intake_contact = __esm({
39086
39373
  }
39087
39374
  });
39088
39375
 
39089
- // src/client-intake.ts
39090
- function publicFormUrl(formId) {
39091
- return `https://api.leadconnectorhq.com/widget/form/${formId}`;
39092
- }
39093
- function urlCannotCarry(value) {
39094
- if (Array.isArray(value)) return value.length > 1 ? "several answers" : value[0] && /\+/.test(value[0]) ? "contains a plus sign" : null;
39095
- return /\+/.test(value) ? "contains a plus sign" : null;
39096
- }
39097
- function prefillFormUrl(formUrl, queryKeys, answers, contact = {}, altQueryKeys = {}) {
39098
- const p = new URLSearchParams();
39099
- if (contact.firstName) p.set("first_name", contact.firstName);
39100
- if (contact.lastName) p.set("last_name", contact.lastName);
39101
- if (contact.email) p.set("email", contact.email);
39102
- if (contact.phone) p.set("phone", contact.phone);
39103
- const leftovers = [];
39104
- for (const [label, raw] of Object.entries(answers)) {
39105
- const values = (Array.isArray(raw) ? raw : [raw]).map((v) => String(v ?? "").trim()).filter(Boolean);
39106
- if (!values.length) continue;
39107
- const key = queryKeys[label];
39108
- if (!key) {
39109
- leftovers.push({ label, values, reason: "no field for this question" });
39110
- continue;
39111
- }
39112
- const reason = urlCannotCarry(Array.isArray(raw) ? values : values[0]);
39113
- if (reason) {
39114
- leftovers.push({ label, values, reason });
39115
- continue;
39116
- }
39117
- p.set(key, values[0]);
39118
- const alt = altQueryKeys[label];
39119
- if (alt && alt !== key) p.set(alt, values[0]);
39120
- }
39121
- const qs = p.toString();
39122
- return { url: qs ? `${formUrl}?${qs}` : formUrl, leftovers };
39123
- }
39124
- async function readIntakeSnapshot(client, locationId2, sentMarker) {
39125
- const empty = { status: "not_installed", fields: {}, queryKeys: {}, submissions: 0 };
39126
- try {
39127
- const forms = await client.get("/forms/", { params: { locationId: locationId2, limit: 100 } });
39128
- const form = (forms.forms ?? []).find((f) => f.name === INTAKE_FORM_NAME2);
39129
- if (!form) return empty;
39130
- const cf = await client.get("/locations/" + locationId2 + "/customFields");
39131
- const fields = {};
39132
- const queryKeys = {};
39133
- const altQueryKeys = {};
39134
- const multiFields = {};
39135
- for (const f of cf.customFields ?? []) {
39136
- if (!f.name?.startsWith(INTAKE_FIELD_NAME_PREFIX)) continue;
39137
- const label = f.name.slice(INTAKE_FIELD_NAME_PREFIX.length);
39138
- fields[label] = f.id;
39139
- queryKeys[label] = formQueryKey(f.name);
39140
- if (f.dataType === "MULTIPLE_OPTIONS") multiFields[label] = true;
39141
- if (f.fieldKey) {
39142
- const alt = f.fieldKey.replace(/^contact\./, "");
39143
- if (alt !== queryKeys[label]) altQueryKeys[label] = alt;
39144
- }
39145
- }
39146
- let submissions = 0;
39147
- let answeredAt;
39148
- let channel;
39149
- let contactId;
39150
- try {
39151
- const subs = await client.get("/forms/submissions", { params: { locationId: locationId2, formId: form.id, limit: 5 } });
39152
- if ((subs.submissions ?? []).length) {
39153
- submissions = subs.submissions.length;
39154
- answeredAt = subs.submissions[0]?.createdAt;
39155
- channel = "form";
39156
- }
39157
- } catch {
39158
- }
39159
- if (!submissions) {
39160
- try {
39161
- const seen = /* @__PURE__ */ new Map();
39162
- for (const q2 of [INTAKE_CONTACT_TAG, INTAKE_FORM_NAME2]) {
39163
- const found = await client.get("/contacts/", { params: { locationId: locationId2, query: q2, limit: 20 } });
39164
- for (const c of found.contacts ?? []) seen.set(c.id, c);
39165
- }
39166
- const isFormSubmission = (c) => c.source === INTAKE_FORM_NAME2 || (c.attributions ?? []).some((a) => a.mediumId === form.id || a.medium === "form");
39167
- const all = [...seen.values()];
39168
- const hit = all.find(isFormSubmission) ?? all.find((c) => (c.tags ?? []).includes(INTAKE_CONTACT_TAG));
39169
- if (hit) {
39170
- submissions = 1;
39171
- answeredAt = hit.dateUpdated;
39172
- contactId = hit.id;
39173
- channel = isFormSubmission(hit) ? "form" : "cockpit";
39174
- }
39175
- } catch {
39176
- }
39177
- }
39178
- const status = submissions > 0 ? "received" : sentMarker ? "sent" : "installed";
39179
- return { status, formId: form.id, formUrl: publicFormUrl(form.id), fields, queryKeys, altQueryKeys, multiFields, submissions, answeredAt, channel, contactId };
39180
- } catch {
39181
- return empty;
39182
- }
39183
- }
39184
- async function submitIntakeAnswers(client, locationId2, snapshot, answers, contact) {
39185
- const customFields = [];
39186
- const unmapped = [];
39187
- for (const [label, raw] of Object.entries(answers)) {
39188
- const values = (Array.isArray(raw) ? raw : [raw]).map((v) => String(v ?? "").trim()).filter(Boolean);
39189
- if (!values.length) continue;
39190
- const id = snapshot.fields[label];
39191
- if (id) customFields.push({ id, value: snapshot.multiFields?.[label] ? values : values[0] });
39192
- else unmapped.push(label);
39193
- }
39194
- try {
39195
- const body = {
39196
- locationId: locationId2,
39197
- firstName: contact.firstName || "Intake",
39198
- lastName: contact.lastName || "Submission",
39199
- ...contact.email && { email: contact.email },
39200
- ...contact.phone && { phone: contact.phone },
39201
- tags: [INTAKE_CONTACT_TAG],
39202
- customFields
39203
- };
39204
- const res = await client.post("/contacts/upsert", { body });
39205
- return { ok: true, contactId: res.contact?.id, unmapped };
39206
- } catch (e) {
39207
- return { ok: false, unmapped, error: e instanceof Error ? e.message : String(e) };
39208
- }
39209
- }
39210
- async function emailIntakeLink(client, locationId2, to, formUrl, agencyName) {
39211
- try {
39212
- const found = await client.get("/contacts/", { params: { locationId: locationId2, query: to.email, limit: 5 } });
39213
- let contactId = (found.contacts ?? []).find((c) => c.email?.toLowerCase() === to.email.toLowerCase())?.id;
39214
- if (!contactId) {
39215
- const made = await client.post("/contacts/", {
39216
- body: { locationId: locationId2, email: to.email, ...to.firstName && { firstName: to.firstName }, tags: ["blueprint-intake-invited"] }
39217
- });
39218
- contactId = made.contact?.id;
39219
- }
39220
- if (!contactId) throw new Error("could not create a contact to email");
39221
- const html2 = [
39222
- `<p>Hi ${to.firstName || "there"},</p>`,
39223
- `<p>Before we build out your account, we need the details only you can give us. It takes about ten minutes, and you can stop and come back to it.</p>`,
39224
- `<p><a href="${formUrl}"><strong>Open your onboarding questionnaire</strong></a></p>`,
39225
- `<p>Answer what you know. Anything you're unsure about, leave it and we'll cover it on our next call.</p>`,
39226
- `<p>${agencyName}</p>`
39227
- ].join("");
39228
- await client.post("/conversations/messages", {
39229
- body: { type: "Email", contactId, emailTo: to.email, subject: "Your onboarding questionnaire", html: html2 }
39230
- });
39231
- return { ok: true };
39232
- } catch (e) {
39233
- return { ok: false, error: e instanceof Error ? e.message : String(e) };
39234
- }
39235
- }
39236
- function recorderAllowPatterns(mcpListOutput) {
39237
- const out = [];
39238
- for (const line3 of mcpListOutput.split("\n")) {
39239
- const m = /^\s*(.+?):\s+https?:\/\//.exec(line3);
39240
- if (!m) continue;
39241
- const name = m[1].trim();
39242
- if (!/✔|Connected/i.test(line3)) continue;
39243
- if (!RECORDER_HINTS.some((h) => name.toLowerCase().includes(h))) continue;
39244
- out.push(`mcp__${name.replace(/[^A-Za-z0-9]+/g, "_")}__*`);
39245
- }
39246
- return [...new Set(out)];
39247
- }
39248
- function prefillTools(source, recorderPatterns = []) {
39249
- const deny = "Bash,Write,Edit,MultiEdit,NotebookEdit,Task";
39250
- if (source.kind === "text") return { allow: "", deny: deny + ",WebFetch,WebSearch,Read,Glob,Grep" };
39251
- if (source.kind === "url") return { allow: "WebFetch", deny: deny + ",WebSearch,Read,Glob,Grep" };
39252
- return { allow: recorderPatterns.join(","), deny: deny + ",Read,Glob,Grep" };
39253
- }
39254
- function sourcePreamble(source) {
39255
- if (source.kind === "url") {
39256
- return `Fetch this and use it as the material: ${source.url}
39257
- If it needs a login or returns nothing usable, say so \u2014 do not guess from the URL alone.`;
39258
- }
39259
- if (source.kind === "recorder") {
39260
- return `Find the recording/transcript the operator means using their connected recorder tools, then use its transcript as the material.
39261
- Operator's request: "${source.instruction}"
39262
- If you cannot find exactly one clear match, stop and say which recordings you did find instead of guessing.`;
39263
- }
39264
- return "";
39265
- }
39266
- function prefillPrompt(labels, transcript) {
39267
- return [
39268
- "You are filling in a client-onboarding questionnaire from a discovery call.",
39269
- "Answer ONLY from the material provided. Never invent a fact, a number, or a name.",
39270
- 'If the material does not clearly answer a question, leave it out and list it under "unknown".',
39271
- "Keep answers short and concrete \u2014 this feeds an automated build, not a report.",
39272
- "",
39273
- "QUESTIONS (use these labels verbatim as keys):",
39274
- ...labels.map((l) => `- ${l}`),
39275
- "",
39276
- "MATERIAL:",
39277
- transcript.slice(0, 6e4),
39278
- "",
39279
- "Reply with ONE line of JSON and nothing else:",
39280
- 'RESULT: {"answers":{"<label>":"<answer>"},"unknown":["<label>"]}'
39281
- ].join("\n");
39282
- }
39283
- function prefillPromptForSource(labels, source) {
39284
- const pre = sourcePreamble(source);
39285
- const body = prefillPrompt(labels, source.kind === "text" ? source.text : "(see the material you fetched above)");
39286
- return pre ? `${pre}
39287
-
39288
- ${body}` : body;
39289
- }
39290
- async function prefillFromSource(spawnClaude, labels, source, recorderPatterns = []) {
39291
- const empty = source.kind === "text" && !source.text.trim() || source.kind === "url" && !source.url.trim() || source.kind === "recorder" && !source.instruction.trim();
39292
- if (empty) return { ok: false, answers: {}, unknown: labels, error: "nothing to read" };
39293
- return runPrefill(() => spawnClaude(prefillPromptForSource(labels, source), prefillTools(source, recorderPatterns)), labels);
39294
- }
39295
- async function runPrefill(call2, labels) {
39296
- try {
39297
- const out = await call2();
39298
- const m = /RESULT:\s*(\{[\s\S]*\})/.exec(out);
39299
- if (!m) return { ok: false, answers: {}, unknown: labels, error: "the model did not return a result line" };
39300
- const parsed = JSON.parse(m[1]);
39301
- const answers = {};
39302
- for (const [k, v] of Object.entries(parsed.answers ?? {})) {
39303
- if (labels.includes(k) && typeof v === "string" && v.trim()) answers[k] = v.trim();
39304
- }
39305
- const unknown = labels.filter((l) => !(l in answers));
39306
- return { ok: true, answers, unknown };
39307
- } catch (e) {
39308
- return { ok: false, answers: {}, unknown: labels, error: e instanceof Error ? e.message : String(e) };
39309
- }
39310
- }
39311
- var INTAKE_FORM_NAME2, INTAKE_CONTACT_TAG, RECORDER_HINTS;
39312
- var init_client_intake = __esm({
39313
- "src/client-intake.ts"() {
39314
- "use strict";
39315
- init_question_set();
39316
- init_form_template();
39317
- INTAKE_FORM_NAME2 = "GHL Command Blueprint \u2014 Client Intake";
39318
- INTAKE_CONTACT_TAG = "blueprint-intake";
39319
- RECORDER_HINTS = ["plaud", "otter", "fireflies", "grain", "fathom", "tldv", "granola", "zoom", "read.ai", "readai", "supernormal", "avoma"];
39320
- }
39321
- });
39322
-
39323
39376
  // src/agency-profile-page.ts
39324
39377
  function agencyProfilePageHtml(p, clients = [], seatToken = "") {
39325
39378
  const offers = (p.offers ?? []).map((o) => `${o.name}${o.priceFloor ? ` \u2014 ${o.priceFloor}${o.cadence === "monthly" ? "/mo" : ""}` : ""}`).join("\n");