@elitedcs/ghl-mcp 3.78.1 → 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/CHANGELOG.md +47 -0
- package/README.md +1 -1
- package/dist/index.js +259 -240
- package/guide/guide.html +664 -13
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.79.0 — the automations agencies actually sell, written down
|
|
4
|
+
|
|
5
|
+
The guide library grew by seven, and every one of them is a thing you can sell on a
|
|
6
|
+
call: answer a new lead in seconds, text back every missed call, win back a cold
|
|
7
|
+
list, rescue a no-show, remind people about their appointment, confirm a booking and
|
|
8
|
+
move the deal, and take over an account somebody else built.
|
|
9
|
+
|
|
10
|
+
These are not new capabilities. GHL Command already built every one of them inside
|
|
11
|
+
the Blueprint, and the library only taught one. So an owner who was not running a
|
|
12
|
+
whole Blueprint had no way to find the missed-call text-back or the speed-to-lead,
|
|
13
|
+
which are the two automations most agencies get paid for. Now each is its own guide,
|
|
14
|
+
with a prompt you paste and change.
|
|
15
|
+
|
|
16
|
+
Each was proven by running its own prompt against a real GoHighLevel account and
|
|
17
|
+
deleting the result in the same run. The proofs are deliberately narrow about what
|
|
18
|
+
they show: the workflow saved in the shape asked for, and every pipeline, stage,
|
|
19
|
+
calendar and user id inside it points at something real. That is the failure
|
|
20
|
+
GoHighLevel never warns you about, where a workflow saves cleanly and then quietly
|
|
21
|
+
does nothing. It is not a claim that the sequence was watched running against a real
|
|
22
|
+
person, and the guides say so.
|
|
23
|
+
|
|
24
|
+
The booking guide's proof is the interesting one, because it was allowed to fail. The
|
|
25
|
+
deal-move step was built the wrong way round on purpose, and the validator answered
|
|
26
|
+
with the warning that whole guide exists to prevent.
|
|
27
|
+
|
|
28
|
+
**Also in this release**
|
|
29
|
+
|
|
30
|
+
Two guides came off "Coming in an update" and are now open: **The Blueprint: build a
|
|
31
|
+
client account** and **Page Studio: pages and sites**. Both were rewritten where
|
|
32
|
+
running them proved the old text wrong. The Blueprint guide had been telling you your
|
|
33
|
+
client's intake answers arrive as a form submission; GoHighLevel does not report
|
|
34
|
+
submissions for any form created through the API, so that path never worked. The
|
|
35
|
+
answers are on a contact, and the guide now says so. `install_intake_form` also hands
|
|
36
|
+
back the link to send your client instead of leaving you to find it in the
|
|
37
|
+
GoHighLevel interface.
|
|
38
|
+
|
|
39
|
+
Page Studio's guide had promised a social-proof section that cannot be composed and a
|
|
40
|
+
live URL that needs publishing and a domain first. It now describes what the tool
|
|
41
|
+
actually does and shows you the preview instead.
|
|
42
|
+
|
|
43
|
+
The blog guide has been retired rather than left sitting as a card. GoHighLevel's
|
|
44
|
+
blog feature is not used anywhere we can reach, so it could never be proven, and a
|
|
45
|
+
card promising a guide that is never coming is worse than no card.
|
|
46
|
+
|
|
47
|
+
Library: 41 open guides across 10 categories. Counts unchanged: 250 tools, 51
|
|
48
|
+
modules, free tier 112.
|
|
49
|
+
|
|
3
50
|
## 3.78.1 — the template installer stops guessing which funnel it just made
|
|
4
51
|
|
|
5
52
|
Yesterday's release shipped `install_ghl_template` with the originals kept by default,
|
package/README.md
CHANGED
package/dist/index.js
CHANGED
|
@@ -31589,12 +31589,23 @@ RESULT: {"ok":true,"summary":"<one sentence: who the client is and what they wan
|
|
|
31589
31589
|
"mcp__ghl__get_pipelines",
|
|
31590
31590
|
"mcp__ghl__list_workflows_full",
|
|
31591
31591
|
// Finding 17: the recipient of a staff notification comes from THIS account's user list or stays empty.
|
|
31592
|
-
"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"
|
|
31593
31601
|
],
|
|
31594
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.
|
|
31595
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.
|
|
31596
31604
|
1. switch_location to ${locationId2} (${locationName}); verify with get_current_location and stop if it is not that location.
|
|
31597
|
-
2. Rebuild the 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.
|
|
31598
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.
|
|
31599
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.
|
|
31600
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.
|
|
@@ -33940,6 +33951,240 @@ var init_publish_report = __esm({
|
|
|
33940
33951
|
}
|
|
33941
33952
|
});
|
|
33942
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
|
+
|
|
33943
34188
|
// src/intake-to-build/customization.ts
|
|
33944
34189
|
function industryPack(slug3) {
|
|
33945
34190
|
if (!slug3) return void 0;
|
|
@@ -37918,7 +38163,7 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
|
|
|
37918
38163
|
}
|
|
37919
38164
|
server2.tool(
|
|
37920
38165
|
"install_intake_form",
|
|
37921
|
-
`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
|
|
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.`,
|
|
37922
38167
|
{
|
|
37923
38168
|
dryRun: import_zod76.z.boolean().optional().describe("Preview the fields/form that would be created without writing anything."),
|
|
37924
38169
|
formId: import_zod76.z.string().optional().describe("Update this existing form in place instead of creating a new one."),
|
|
@@ -38015,7 +38260,14 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
|
|
|
38015
38260
|
fieldsPersisted: persistedCount,
|
|
38016
38261
|
fieldsExpectedApprox: expectedCount,
|
|
38017
38262
|
fieldMap,
|
|
38018
|
-
|
|
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.`
|
|
38019
38271
|
});
|
|
38020
38272
|
} catch (error) {
|
|
38021
38273
|
return errorResponse(error);
|
|
@@ -38024,7 +38276,7 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
|
|
|
38024
38276
|
);
|
|
38025
38277
|
server2.tool(
|
|
38026
38278
|
"normalize_submission_to_brief",
|
|
38027
|
-
|
|
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).`,
|
|
38028
38280
|
{
|
|
38029
38281
|
formId: import_zod76.z.string().describe("The intake form ID (from install_intake_form)."),
|
|
38030
38282
|
submissionId: import_zod76.z.string().optional().describe("Specific submission to normalize. Defaults to the most recent."),
|
|
@@ -38091,6 +38343,7 @@ var init_intake_to_build = __esm({
|
|
|
38091
38343
|
init_user_provisioning();
|
|
38092
38344
|
init_plan_form();
|
|
38093
38345
|
init_publish_report();
|
|
38346
|
+
init_client_intake();
|
|
38094
38347
|
init_question_set();
|
|
38095
38348
|
init_customization();
|
|
38096
38349
|
init_intake_overlay();
|
|
@@ -38809,7 +39062,7 @@ var require_package = __commonJS({
|
|
|
38809
39062
|
"package.json"(exports2, module2) {
|
|
38810
39063
|
module2.exports = {
|
|
38811
39064
|
name: "@elitedcs/ghl-mcp",
|
|
38812
|
-
version: "3.
|
|
39065
|
+
version: "3.79.0",
|
|
38813
39066
|
mcpName: "io.github.drjerryrelth/ghl-command",
|
|
38814
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.",
|
|
38815
39068
|
main: "dist/index.js",
|
|
@@ -39120,240 +39373,6 @@ var init_intake_contact = __esm({
|
|
|
39120
39373
|
}
|
|
39121
39374
|
});
|
|
39122
39375
|
|
|
39123
|
-
// src/client-intake.ts
|
|
39124
|
-
function publicFormUrl(formId) {
|
|
39125
|
-
return `https://api.leadconnectorhq.com/widget/form/${formId}`;
|
|
39126
|
-
}
|
|
39127
|
-
function urlCannotCarry(value) {
|
|
39128
|
-
if (Array.isArray(value)) return value.length > 1 ? "several answers" : value[0] && /\+/.test(value[0]) ? "contains a plus sign" : null;
|
|
39129
|
-
return /\+/.test(value) ? "contains a plus sign" : null;
|
|
39130
|
-
}
|
|
39131
|
-
function prefillFormUrl(formUrl, queryKeys, answers, contact = {}, altQueryKeys = {}) {
|
|
39132
|
-
const p = new URLSearchParams();
|
|
39133
|
-
if (contact.firstName) p.set("first_name", contact.firstName);
|
|
39134
|
-
if (contact.lastName) p.set("last_name", contact.lastName);
|
|
39135
|
-
if (contact.email) p.set("email", contact.email);
|
|
39136
|
-
if (contact.phone) p.set("phone", contact.phone);
|
|
39137
|
-
const leftovers = [];
|
|
39138
|
-
for (const [label, raw] of Object.entries(answers)) {
|
|
39139
|
-
const values = (Array.isArray(raw) ? raw : [raw]).map((v) => String(v ?? "").trim()).filter(Boolean);
|
|
39140
|
-
if (!values.length) continue;
|
|
39141
|
-
const key = queryKeys[label];
|
|
39142
|
-
if (!key) {
|
|
39143
|
-
leftovers.push({ label, values, reason: "no field for this question" });
|
|
39144
|
-
continue;
|
|
39145
|
-
}
|
|
39146
|
-
const reason = urlCannotCarry(Array.isArray(raw) ? values : values[0]);
|
|
39147
|
-
if (reason) {
|
|
39148
|
-
leftovers.push({ label, values, reason });
|
|
39149
|
-
continue;
|
|
39150
|
-
}
|
|
39151
|
-
p.set(key, values[0]);
|
|
39152
|
-
const alt = altQueryKeys[label];
|
|
39153
|
-
if (alt && alt !== key) p.set(alt, values[0]);
|
|
39154
|
-
}
|
|
39155
|
-
const qs = p.toString();
|
|
39156
|
-
return { url: qs ? `${formUrl}?${qs}` : formUrl, leftovers };
|
|
39157
|
-
}
|
|
39158
|
-
async function readIntakeSnapshot(client, locationId2, sentMarker) {
|
|
39159
|
-
const empty = { status: "not_installed", fields: {}, queryKeys: {}, submissions: 0 };
|
|
39160
|
-
try {
|
|
39161
|
-
const forms = await client.get("/forms/", { params: { locationId: locationId2, limit: 100 } });
|
|
39162
|
-
const form = (forms.forms ?? []).find((f) => f.name === INTAKE_FORM_NAME2);
|
|
39163
|
-
if (!form) return empty;
|
|
39164
|
-
const cf = await client.get("/locations/" + locationId2 + "/customFields");
|
|
39165
|
-
const fields = {};
|
|
39166
|
-
const queryKeys = {};
|
|
39167
|
-
const altQueryKeys = {};
|
|
39168
|
-
const multiFields = {};
|
|
39169
|
-
for (const f of cf.customFields ?? []) {
|
|
39170
|
-
if (!f.name?.startsWith(INTAKE_FIELD_NAME_PREFIX)) continue;
|
|
39171
|
-
const label = f.name.slice(INTAKE_FIELD_NAME_PREFIX.length);
|
|
39172
|
-
fields[label] = f.id;
|
|
39173
|
-
queryKeys[label] = formQueryKey(f.name);
|
|
39174
|
-
if (f.dataType === "MULTIPLE_OPTIONS") multiFields[label] = true;
|
|
39175
|
-
if (f.fieldKey) {
|
|
39176
|
-
const alt = f.fieldKey.replace(/^contact\./, "");
|
|
39177
|
-
if (alt !== queryKeys[label]) altQueryKeys[label] = alt;
|
|
39178
|
-
}
|
|
39179
|
-
}
|
|
39180
|
-
let submissions = 0;
|
|
39181
|
-
let answeredAt;
|
|
39182
|
-
let channel;
|
|
39183
|
-
let contactId;
|
|
39184
|
-
try {
|
|
39185
|
-
const subs = await client.get("/forms/submissions", { params: { locationId: locationId2, formId: form.id, limit: 5 } });
|
|
39186
|
-
if ((subs.submissions ?? []).length) {
|
|
39187
|
-
submissions = subs.submissions.length;
|
|
39188
|
-
answeredAt = subs.submissions[0]?.createdAt;
|
|
39189
|
-
channel = "form";
|
|
39190
|
-
}
|
|
39191
|
-
} catch {
|
|
39192
|
-
}
|
|
39193
|
-
if (!submissions) {
|
|
39194
|
-
try {
|
|
39195
|
-
const seen = /* @__PURE__ */ new Map();
|
|
39196
|
-
for (const q2 of [INTAKE_CONTACT_TAG, INTAKE_FORM_NAME2]) {
|
|
39197
|
-
const found = await client.get("/contacts/", { params: { locationId: locationId2, query: q2, limit: 20 } });
|
|
39198
|
-
for (const c of found.contacts ?? []) seen.set(c.id, c);
|
|
39199
|
-
}
|
|
39200
|
-
const isFormSubmission = (c) => c.source === INTAKE_FORM_NAME2 || (c.attributions ?? []).some((a) => a.mediumId === form.id || a.medium === "form");
|
|
39201
|
-
const all = [...seen.values()];
|
|
39202
|
-
const hit = all.find(isFormSubmission) ?? all.find((c) => (c.tags ?? []).includes(INTAKE_CONTACT_TAG));
|
|
39203
|
-
if (hit) {
|
|
39204
|
-
submissions = 1;
|
|
39205
|
-
answeredAt = hit.dateUpdated;
|
|
39206
|
-
contactId = hit.id;
|
|
39207
|
-
channel = isFormSubmission(hit) ? "form" : "cockpit";
|
|
39208
|
-
}
|
|
39209
|
-
} catch {
|
|
39210
|
-
}
|
|
39211
|
-
}
|
|
39212
|
-
const status = submissions > 0 ? "received" : sentMarker ? "sent" : "installed";
|
|
39213
|
-
return { status, formId: form.id, formUrl: publicFormUrl(form.id), fields, queryKeys, altQueryKeys, multiFields, submissions, answeredAt, channel, contactId };
|
|
39214
|
-
} catch {
|
|
39215
|
-
return empty;
|
|
39216
|
-
}
|
|
39217
|
-
}
|
|
39218
|
-
async function submitIntakeAnswers(client, locationId2, snapshot, answers, contact) {
|
|
39219
|
-
const customFields = [];
|
|
39220
|
-
const unmapped = [];
|
|
39221
|
-
for (const [label, raw] of Object.entries(answers)) {
|
|
39222
|
-
const values = (Array.isArray(raw) ? raw : [raw]).map((v) => String(v ?? "").trim()).filter(Boolean);
|
|
39223
|
-
if (!values.length) continue;
|
|
39224
|
-
const id = snapshot.fields[label];
|
|
39225
|
-
if (id) customFields.push({ id, value: snapshot.multiFields?.[label] ? values : values[0] });
|
|
39226
|
-
else unmapped.push(label);
|
|
39227
|
-
}
|
|
39228
|
-
try {
|
|
39229
|
-
const body = {
|
|
39230
|
-
locationId: locationId2,
|
|
39231
|
-
firstName: contact.firstName || "Intake",
|
|
39232
|
-
lastName: contact.lastName || "Submission",
|
|
39233
|
-
...contact.email && { email: contact.email },
|
|
39234
|
-
...contact.phone && { phone: contact.phone },
|
|
39235
|
-
tags: [INTAKE_CONTACT_TAG],
|
|
39236
|
-
customFields
|
|
39237
|
-
};
|
|
39238
|
-
const res = await client.post("/contacts/upsert", { body });
|
|
39239
|
-
return { ok: true, contactId: res.contact?.id, unmapped };
|
|
39240
|
-
} catch (e) {
|
|
39241
|
-
return { ok: false, unmapped, error: e instanceof Error ? e.message : String(e) };
|
|
39242
|
-
}
|
|
39243
|
-
}
|
|
39244
|
-
async function emailIntakeLink(client, locationId2, to, formUrl, agencyName) {
|
|
39245
|
-
try {
|
|
39246
|
-
const found = await client.get("/contacts/", { params: { locationId: locationId2, query: to.email, limit: 5 } });
|
|
39247
|
-
let contactId = (found.contacts ?? []).find((c) => c.email?.toLowerCase() === to.email.toLowerCase())?.id;
|
|
39248
|
-
if (!contactId) {
|
|
39249
|
-
const made = await client.post("/contacts/", {
|
|
39250
|
-
body: { locationId: locationId2, email: to.email, ...to.firstName && { firstName: to.firstName }, tags: ["blueprint-intake-invited"] }
|
|
39251
|
-
});
|
|
39252
|
-
contactId = made.contact?.id;
|
|
39253
|
-
}
|
|
39254
|
-
if (!contactId) throw new Error("could not create a contact to email");
|
|
39255
|
-
const html2 = [
|
|
39256
|
-
`<p>Hi ${to.firstName || "there"},</p>`,
|
|
39257
|
-
`<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>`,
|
|
39258
|
-
`<p><a href="${formUrl}"><strong>Open your onboarding questionnaire</strong></a></p>`,
|
|
39259
|
-
`<p>Answer what you know. Anything you're unsure about, leave it and we'll cover it on our next call.</p>`,
|
|
39260
|
-
`<p>${agencyName}</p>`
|
|
39261
|
-
].join("");
|
|
39262
|
-
await client.post("/conversations/messages", {
|
|
39263
|
-
body: { type: "Email", contactId, emailTo: to.email, subject: "Your onboarding questionnaire", html: html2 }
|
|
39264
|
-
});
|
|
39265
|
-
return { ok: true };
|
|
39266
|
-
} catch (e) {
|
|
39267
|
-
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
39268
|
-
}
|
|
39269
|
-
}
|
|
39270
|
-
function recorderAllowPatterns(mcpListOutput) {
|
|
39271
|
-
const out = [];
|
|
39272
|
-
for (const line3 of mcpListOutput.split("\n")) {
|
|
39273
|
-
const m = /^\s*(.+?):\s+https?:\/\//.exec(line3);
|
|
39274
|
-
if (!m) continue;
|
|
39275
|
-
const name = m[1].trim();
|
|
39276
|
-
if (!/✔|Connected/i.test(line3)) continue;
|
|
39277
|
-
if (!RECORDER_HINTS.some((h) => name.toLowerCase().includes(h))) continue;
|
|
39278
|
-
out.push(`mcp__${name.replace(/[^A-Za-z0-9]+/g, "_")}__*`);
|
|
39279
|
-
}
|
|
39280
|
-
return [...new Set(out)];
|
|
39281
|
-
}
|
|
39282
|
-
function prefillTools(source, recorderPatterns = []) {
|
|
39283
|
-
const deny = "Bash,Write,Edit,MultiEdit,NotebookEdit,Task";
|
|
39284
|
-
if (source.kind === "text") return { allow: "", deny: deny + ",WebFetch,WebSearch,Read,Glob,Grep" };
|
|
39285
|
-
if (source.kind === "url") return { allow: "WebFetch", deny: deny + ",WebSearch,Read,Glob,Grep" };
|
|
39286
|
-
return { allow: recorderPatterns.join(","), deny: deny + ",Read,Glob,Grep" };
|
|
39287
|
-
}
|
|
39288
|
-
function sourcePreamble(source) {
|
|
39289
|
-
if (source.kind === "url") {
|
|
39290
|
-
return `Fetch this and use it as the material: ${source.url}
|
|
39291
|
-
If it needs a login or returns nothing usable, say so \u2014 do not guess from the URL alone.`;
|
|
39292
|
-
}
|
|
39293
|
-
if (source.kind === "recorder") {
|
|
39294
|
-
return `Find the recording/transcript the operator means using their connected recorder tools, then use its transcript as the material.
|
|
39295
|
-
Operator's request: "${source.instruction}"
|
|
39296
|
-
If you cannot find exactly one clear match, stop and say which recordings you did find instead of guessing.`;
|
|
39297
|
-
}
|
|
39298
|
-
return "";
|
|
39299
|
-
}
|
|
39300
|
-
function prefillPrompt(labels, transcript) {
|
|
39301
|
-
return [
|
|
39302
|
-
"You are filling in a client-onboarding questionnaire from a discovery call.",
|
|
39303
|
-
"Answer ONLY from the material provided. Never invent a fact, a number, or a name.",
|
|
39304
|
-
'If the material does not clearly answer a question, leave it out and list it under "unknown".',
|
|
39305
|
-
"Keep answers short and concrete \u2014 this feeds an automated build, not a report.",
|
|
39306
|
-
"",
|
|
39307
|
-
"QUESTIONS (use these labels verbatim as keys):",
|
|
39308
|
-
...labels.map((l) => `- ${l}`),
|
|
39309
|
-
"",
|
|
39310
|
-
"MATERIAL:",
|
|
39311
|
-
transcript.slice(0, 6e4),
|
|
39312
|
-
"",
|
|
39313
|
-
"Reply with ONE line of JSON and nothing else:",
|
|
39314
|
-
'RESULT: {"answers":{"<label>":"<answer>"},"unknown":["<label>"]}'
|
|
39315
|
-
].join("\n");
|
|
39316
|
-
}
|
|
39317
|
-
function prefillPromptForSource(labels, source) {
|
|
39318
|
-
const pre = sourcePreamble(source);
|
|
39319
|
-
const body = prefillPrompt(labels, source.kind === "text" ? source.text : "(see the material you fetched above)");
|
|
39320
|
-
return pre ? `${pre}
|
|
39321
|
-
|
|
39322
|
-
${body}` : body;
|
|
39323
|
-
}
|
|
39324
|
-
async function prefillFromSource(spawnClaude, labels, source, recorderPatterns = []) {
|
|
39325
|
-
const empty = source.kind === "text" && !source.text.trim() || source.kind === "url" && !source.url.trim() || source.kind === "recorder" && !source.instruction.trim();
|
|
39326
|
-
if (empty) return { ok: false, answers: {}, unknown: labels, error: "nothing to read" };
|
|
39327
|
-
return runPrefill(() => spawnClaude(prefillPromptForSource(labels, source), prefillTools(source, recorderPatterns)), labels);
|
|
39328
|
-
}
|
|
39329
|
-
async function runPrefill(call2, labels) {
|
|
39330
|
-
try {
|
|
39331
|
-
const out = await call2();
|
|
39332
|
-
const m = /RESULT:\s*(\{[\s\S]*\})/.exec(out);
|
|
39333
|
-
if (!m) return { ok: false, answers: {}, unknown: labels, error: "the model did not return a result line" };
|
|
39334
|
-
const parsed = JSON.parse(m[1]);
|
|
39335
|
-
const answers = {};
|
|
39336
|
-
for (const [k, v] of Object.entries(parsed.answers ?? {})) {
|
|
39337
|
-
if (labels.includes(k) && typeof v === "string" && v.trim()) answers[k] = v.trim();
|
|
39338
|
-
}
|
|
39339
|
-
const unknown = labels.filter((l) => !(l in answers));
|
|
39340
|
-
return { ok: true, answers, unknown };
|
|
39341
|
-
} catch (e) {
|
|
39342
|
-
return { ok: false, answers: {}, unknown: labels, error: e instanceof Error ? e.message : String(e) };
|
|
39343
|
-
}
|
|
39344
|
-
}
|
|
39345
|
-
var INTAKE_FORM_NAME2, INTAKE_CONTACT_TAG, RECORDER_HINTS;
|
|
39346
|
-
var init_client_intake = __esm({
|
|
39347
|
-
"src/client-intake.ts"() {
|
|
39348
|
-
"use strict";
|
|
39349
|
-
init_question_set();
|
|
39350
|
-
init_form_template();
|
|
39351
|
-
INTAKE_FORM_NAME2 = "GHL Command Blueprint \u2014 Client Intake";
|
|
39352
|
-
INTAKE_CONTACT_TAG = "blueprint-intake";
|
|
39353
|
-
RECORDER_HINTS = ["plaud", "otter", "fireflies", "grain", "fathom", "tldv", "granola", "zoom", "read.ai", "readai", "supernormal", "avoma"];
|
|
39354
|
-
}
|
|
39355
|
-
});
|
|
39356
|
-
|
|
39357
39376
|
// src/agency-profile-page.ts
|
|
39358
39377
|
function agencyProfilePageHtml(p, clients = [], seatToken = "") {
|
|
39359
39378
|
const offers = (p.offers ?? []).map((o) => `${o.name}${o.priceFloor ? ` \u2014 ${o.priceFloor}${o.cadence === "monthly" ? "/mo" : ""}` : ""}`).join("\n");
|