@elitedcs/ghl-mcp 3.47.0 → 3.48.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 +37 -0
- package/dist/index.js +320 -18
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.48.0 — Blueprint: complete workflow-building (opportunities, branching, appointment timers, opt-in publish)
|
|
4
|
+
|
|
5
|
+
Completes the `apply_build_plan` executor so a Blueprint build produces workflows that
|
|
6
|
+
create and move real opportunities, branch on whether a contact has one, fire on
|
|
7
|
+
appointment-relative timers, and (opt-in) go live instead of staging as drafts. Every
|
|
8
|
+
item below was proven live on a throwaway account and read back before shipping.
|
|
9
|
+
|
|
10
|
+
- **Fixed: `create_opportunity` now actually creates an opportunity.** The executor was
|
|
11
|
+
emitting GHL's *update* node (`internal_update_opportunity`), which only updates an
|
|
12
|
+
existing opp and silently no-ops when none exists. It now emits the real
|
|
13
|
+
`internal_create_opportunity` node (pipeline hoisted to the attribute level, stage + name
|
|
14
|
+
in the custom input fields), so a new-lead workflow lands a deal in the pipeline as
|
|
15
|
+
intended.
|
|
16
|
+
- **New: `wait_appointment` action** — appointment-relative reminder timers (e.g. "24 hours
|
|
17
|
+
before the appointment"), emitting GHL's appointment-relative wait node. The workflow
|
|
18
|
+
builder's action-chain validation accepts the appointment-relative `appointmentStartAfter`
|
|
19
|
+
shape alongside the standard time-based `startAfter`.
|
|
20
|
+
- **New: optional `monetaryValue`** on `create_opportunity` / `update_opportunity` so a deal
|
|
21
|
+
carries its value.
|
|
22
|
+
- **New: `find_opportunity` multi-path branching.** The first branching logical action: it
|
|
23
|
+
loads a contact's latest opportunity in a pipeline, then routes to a `found` / `not-found`
|
|
24
|
+
branch (each with its own actions, including waits and sends). This is what makes a
|
|
25
|
+
move-the-opp step inside a branch work — the find loads the opp the move then acts on.
|
|
26
|
+
- **Fixed: backward opportunity moves (win-back / reactivation) silently no-op'd.** GHL's
|
|
27
|
+
"Move opportunity" defaults `allowBackward:false`, which lets a forward stage move through
|
|
28
|
+
but silently refuses a move to an earlier stage — breaking every reactivation build (whose
|
|
29
|
+
whole point is a backward move). The executor now emits `allowBackward:true` so the target
|
|
30
|
+
stage is honored in either direction; forward moves are unaffected.
|
|
31
|
+
- **New: opt-in `publishWorkflows`.** Workflows still build as DRAFT by default (the safe
|
|
32
|
+
default). Passing `publishWorkflows:true` publishes ungated, newly-built workflows live
|
|
33
|
+
(re-syncing their triggers); a workflow gated DRAFT by an unmet handoff is never
|
|
34
|
+
auto-published, and a publish failure never halts or rolls back the build (it surfaces a
|
|
35
|
+
"publish it yourself" note and the build still succeeds).
|
|
36
|
+
- **New: funnel page content template** (`templates/funnel-page-content-template.md`) —
|
|
37
|
+
brand-themeable CSS plus always-on A2P/10DLC opt-in, SEO, and AI-search (GEO) structure
|
|
38
|
+
for the manual funnel-page-content step.
|
|
39
|
+
|
|
3
40
|
## 3.47.0 — Blueprint Cap-0: cross-workflow exit chaining + native trigger builders
|
|
4
41
|
|
|
5
42
|
Two foundational executor fixes so `apply_build_plan` builds workflows that actually
|
package/dist/index.js
CHANGED
|
@@ -31,7 +31,7 @@ var require_package = __commonJS({
|
|
|
31
31
|
"package.json"(exports2, module2) {
|
|
32
32
|
module2.exports = {
|
|
33
33
|
name: "@elitedcs/ghl-mcp",
|
|
34
|
-
version: "3.
|
|
34
|
+
version: "3.48.0",
|
|
35
35
|
mcpName: "io.github.drjerryrelth/ghl-command",
|
|
36
36
|
description: "GoHighLevel MCP Server for Claude. 220 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.",
|
|
37
37
|
main: "dist/index.js",
|
|
@@ -1237,7 +1237,9 @@ function validateActionChain(actions, existingIds) {
|
|
|
1237
1237
|
if (!Array.isArray(attr.tags) || !attr.tags.length) throw new Error(`Tag action "${action.name}" missing 'tags' array.`);
|
|
1238
1238
|
break;
|
|
1239
1239
|
case "wait":
|
|
1240
|
-
if (!attr.startAfter
|
|
1240
|
+
if (!attr.startAfter && !attr.appointmentStartAfter) {
|
|
1241
|
+
throw new Error(`Wait action "${action.name}" missing required 'startAfter' (or 'appointmentStartAfter' for an appointment-relative wait) in attributes.`);
|
|
1242
|
+
}
|
|
1241
1243
|
break;
|
|
1242
1244
|
case "internal_update_opportunity": {
|
|
1243
1245
|
const isRoundTripped = hasId(action) && (existingIds ? existingIds.has(action.id) : true);
|
|
@@ -1288,6 +1290,31 @@ function validateActionChain(actions, existingIds) {
|
|
|
1288
1290
|
if (!sibling || sibling.length !== 1) throw new Error(`If/else branch "${action.name}" must have exactly one sibling id.`);
|
|
1289
1291
|
}
|
|
1290
1292
|
}
|
|
1293
|
+
for (const action of actions) {
|
|
1294
|
+
if (action.type !== "find_opportunity") continue;
|
|
1295
|
+
if (existingIds && hasId(action) && existingIds.has(action.id)) continue;
|
|
1296
|
+
if (!action.id) throw new Error(`find_opportunity "${action.name}" missing id.`);
|
|
1297
|
+
const next = getStringArray(action.next);
|
|
1298
|
+
if (!next || next.length !== 2) {
|
|
1299
|
+
throw new Error(`find_opportunity "${action.name}" must have next as exactly [foundTransitionId, notFoundTransitionId].`);
|
|
1300
|
+
}
|
|
1301
|
+
for (const tid of next) {
|
|
1302
|
+
const t = byId.get(tid);
|
|
1303
|
+
if (!t || t.type !== "transition") {
|
|
1304
|
+
throw new Error(`find_opportunity "${action.name}" next must reference its two transition nodes.`);
|
|
1305
|
+
}
|
|
1306
|
+
if (t.parent !== action.id || t.parentKey !== action.id) {
|
|
1307
|
+
throw new Error(`Transition "${t.name}" of find_opportunity "${action.name}" must have parent and parentKey set to the find node id.`);
|
|
1308
|
+
}
|
|
1309
|
+
const firstChildId = typeof t.next === "string" ? t.next : void 0;
|
|
1310
|
+
if (firstChildId) {
|
|
1311
|
+
const c = byId.get(firstChildId);
|
|
1312
|
+
if (!c || c.parent !== tid || c.parentKey !== tid) {
|
|
1313
|
+
throw new Error(`First child of transition "${t.name}" must have parent and parentKey set to the transition id.`);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1291
1318
|
}
|
|
1292
1319
|
var WorkflowBuilderClient = class _WorkflowBuilderClient {
|
|
1293
1320
|
// Active Firebase auth — swapped when operating in another company's GHL.
|
|
@@ -1891,7 +1918,12 @@ ${errorBody}`
|
|
|
1891
1918
|
});
|
|
1892
1919
|
for (let i = 0; i < linked.length; i++) {
|
|
1893
1920
|
const action = linked[i];
|
|
1894
|
-
if (action.
|
|
1921
|
+
if (action.parent || action.nodeType) continue;
|
|
1922
|
+
if (action.cat === "multi-path") {
|
|
1923
|
+
if (i > 0 && !action.parentKey) action.parentKey = linked[i - 1].id;
|
|
1924
|
+
continue;
|
|
1925
|
+
}
|
|
1926
|
+
if (action.cat) continue;
|
|
1895
1927
|
if (i > 0 && !action.parentKey) {
|
|
1896
1928
|
action.parentKey = linked[i - 1].id;
|
|
1897
1929
|
}
|
|
@@ -11116,12 +11148,25 @@ var smsAssetSchema = import_zod53.z.object({
|
|
|
11116
11148
|
mergeTags: import_zod53.z.array(import_zod53.z.string()).optional()
|
|
11117
11149
|
});
|
|
11118
11150
|
var waitUnit = import_zod53.z.enum(["minutes", "hours", "days"]);
|
|
11119
|
-
var
|
|
11151
|
+
var branchActionOptions = [
|
|
11120
11152
|
import_zod53.z.object({ type: import_zod53.z.literal("add_contact_tag"), tagRef: nsRef("tag") }),
|
|
11121
11153
|
import_zod53.z.object({ type: import_zod53.z.literal("remove_contact_tag"), tagRef: nsRef("tag") }),
|
|
11122
11154
|
import_zod53.z.object({ type: import_zod53.z.literal("send_email"), emailRef: nsRef("email") }),
|
|
11123
11155
|
import_zod53.z.object({ type: import_zod53.z.literal("send_sms"), smsRef: nsRef("sms") }),
|
|
11124
11156
|
import_zod53.z.object({ type: import_zod53.z.literal("wait"), value: import_zod53.z.number().positive(), unit: waitUnit }),
|
|
11157
|
+
// Appointment-relative wait ("wait until N BEFORE the appointment").
|
|
11158
|
+
// Only works when the workflow has an appointment in context (i.e. an
|
|
11159
|
+
// `appointment` trigger) — enforced by validateBuildPlan. Expands to GHL's
|
|
11160
|
+
// appointment-wait node shape (attributes.type "appointment" +
|
|
11161
|
+
// appointmentStartAfter, captured live from Lux Bio). Integer units only
|
|
11162
|
+
// (GHL stores whole minutes). Only "before" is emitted today — that's the
|
|
11163
|
+
// shape we captured + proved; "after" (post-appointment follow-up) is
|
|
11164
|
+
// deferred until its shape is captured from a real workflow.
|
|
11165
|
+
import_zod53.z.object({
|
|
11166
|
+
type: import_zod53.z.literal("wait_appointment"),
|
|
11167
|
+
value: import_zod53.z.number().int().positive(),
|
|
11168
|
+
unit: waitUnit
|
|
11169
|
+
}),
|
|
11125
11170
|
import_zod53.z.object({
|
|
11126
11171
|
type: import_zod53.z.literal("internal_notification"),
|
|
11127
11172
|
to: import_zod53.z.string(),
|
|
@@ -11147,12 +11192,22 @@ var actionSchema = import_zod53.z.discriminatedUnion("type", [
|
|
|
11147
11192
|
type: import_zod53.z.literal("create_opportunity"),
|
|
11148
11193
|
pipelineRef: nsRef("pipeline"),
|
|
11149
11194
|
stageRef: nsRef("stage"),
|
|
11150
|
-
|
|
11195
|
+
// Opportunity name (merge fields allowed). Defaults to the contact's name.
|
|
11196
|
+
// Required by GHL's create node; without it the create silently no-ops.
|
|
11197
|
+
name: import_zod53.z.string().optional(),
|
|
11198
|
+
// Opportunity monetary value (the deal/sale dollar amount). A string so it
|
|
11199
|
+
// can be a literal ("2500") OR a merge field ("{{contact.package_value}}").
|
|
11200
|
+
// Optional — omitted → GHL leaves the value unset. Shape captured from Lux
|
|
11201
|
+
// Bio "14. Package Sale". Lux models the lifecycle by pipeline STAGE, not GHL
|
|
11202
|
+
// won/lost status, so a "won" opp = move to the closing stage WITH this value.
|
|
11203
|
+
value: import_zod53.z.string().optional()
|
|
11151
11204
|
}),
|
|
11152
11205
|
import_zod53.z.object({
|
|
11153
11206
|
type: import_zod53.z.literal("update_opportunity"),
|
|
11154
11207
|
pipelineRef: nsRef("pipeline"),
|
|
11155
|
-
stageRef: nsRef("stage")
|
|
11208
|
+
stageRef: nsRef("stage"),
|
|
11209
|
+
// Opportunity monetary value (see create_opportunity.value). Optional.
|
|
11210
|
+
value: import_zod53.z.string().optional()
|
|
11156
11211
|
}),
|
|
11157
11212
|
import_zod53.z.object({
|
|
11158
11213
|
type: import_zod53.z.literal("goal_event"),
|
|
@@ -11160,7 +11215,15 @@ var actionSchema = import_zod53.z.discriminatedUnion("type", [
|
|
|
11160
11215
|
// GHL's GoalAction enum (extracted 2026-05-18): continue | wait | exit.
|
|
11161
11216
|
action: import_zod53.z.enum(["exit", "continue", "wait"]).optional()
|
|
11162
11217
|
})
|
|
11163
|
-
]
|
|
11218
|
+
];
|
|
11219
|
+
var branchActionSchema = import_zod53.z.discriminatedUnion("type", branchActionOptions);
|
|
11220
|
+
var findOpportunitySchema = import_zod53.z.object({
|
|
11221
|
+
type: import_zod53.z.literal("find_opportunity"),
|
|
11222
|
+
pipelineRef: nsRef("pipeline"),
|
|
11223
|
+
found: import_zod53.z.array(branchActionSchema).default([]),
|
|
11224
|
+
notFound: import_zod53.z.array(branchActionSchema).default([])
|
|
11225
|
+
});
|
|
11226
|
+
var actionSchema = import_zod53.z.discriminatedUnion("type", [...branchActionOptions, findOpportunitySchema]);
|
|
11164
11227
|
var APPOINTMENT_STATUSES = ["new", "confirmed", "showed", "noshow", "cancelled", "invalid"];
|
|
11165
11228
|
var triggerSchema = import_zod53.z.object({
|
|
11166
11229
|
type: import_zod53.z.string(),
|
|
@@ -11293,8 +11356,7 @@ function checkRefIntegrity(plan, defined) {
|
|
|
11293
11356
|
check(t.pipelineRef, "pipeline", `workflows[${w.ref}].trigger.pipelineRef`);
|
|
11294
11357
|
check(t.stageRef, "stage", `workflows[${w.ref}].trigger.stageRef`);
|
|
11295
11358
|
}
|
|
11296
|
-
|
|
11297
|
-
const where = `workflows[${w.ref}].actions[${i}](${a.type})`;
|
|
11359
|
+
const checkActionRefs = (a, where) => {
|
|
11298
11360
|
if ("tagRef" in a) check(a.tagRef, "tag", where);
|
|
11299
11361
|
if ("emailRef" in a) check(a.emailRef, "email", where);
|
|
11300
11362
|
if ("smsRef" in a) check(a.smsRef, "sms", where);
|
|
@@ -11302,6 +11364,17 @@ function checkRefIntegrity(plan, defined) {
|
|
|
11302
11364
|
if ("pipelineRef" in a) check(a.pipelineRef, "pipeline", where);
|
|
11303
11365
|
if ("stageRef" in a) check(a.stageRef, "stage", where);
|
|
11304
11366
|
if ("workflowRef" in a) check(a.workflowRef, "workflow", where);
|
|
11367
|
+
};
|
|
11368
|
+
w.actions.forEach((a, i) => {
|
|
11369
|
+
const where = `workflows[${w.ref}].actions[${i}](${a.type})`;
|
|
11370
|
+
checkActionRefs(a, where);
|
|
11371
|
+
if (a.type === "find_opportunity") {
|
|
11372
|
+
if (i !== w.actions.length - 1) {
|
|
11373
|
+
errors.push(`${where}: find_opportunity must be the last action in the workflow (its Found/Not-Found branches do not rejoin a linear tail).`);
|
|
11374
|
+
}
|
|
11375
|
+
a.found.forEach((c, ci) => checkActionRefs(c, `${where}.found[${ci}](${c.type})`));
|
|
11376
|
+
a.notFound.forEach((c, ci) => checkActionRefs(c, `${where}.notFound[${ci}](${c.type})`));
|
|
11377
|
+
}
|
|
11305
11378
|
});
|
|
11306
11379
|
}
|
|
11307
11380
|
for (const h of plan.handoffs ?? []) {
|
|
@@ -11433,6 +11506,32 @@ function validateBuildPlan(input) {
|
|
|
11433
11506
|
);
|
|
11434
11507
|
}
|
|
11435
11508
|
}
|
|
11509
|
+
for (const wf of plan.workflows ?? []) {
|
|
11510
|
+
const hasApptWait = wf.actions.some((a) => a.type === "wait_appointment");
|
|
11511
|
+
if (hasApptWait && wf.trigger?.type !== "appointment") {
|
|
11512
|
+
warnings.push(
|
|
11513
|
+
`workflow "${wf.ref}" uses a wait_appointment ("before appointment" timer) but its trigger is ${wf.trigger?.type ? `"${wf.trigger.type}"` : "not set to an appointment trigger"} \u2014 that timer needs an appointment in context (an "appointment" trigger), or GHL silently mis-times it`
|
|
11514
|
+
);
|
|
11515
|
+
}
|
|
11516
|
+
}
|
|
11517
|
+
const MAX_NODES = 40;
|
|
11518
|
+
for (const wf of plan.workflows ?? []) {
|
|
11519
|
+
let nodeCount = 0;
|
|
11520
|
+
let hasBranch = false;
|
|
11521
|
+
for (const a of wf.actions) {
|
|
11522
|
+
if (a.type === "find_opportunity") {
|
|
11523
|
+
hasBranch = true;
|
|
11524
|
+
nodeCount += 3 + a.found.length + a.notFound.length;
|
|
11525
|
+
} else {
|
|
11526
|
+
nodeCount += 1;
|
|
11527
|
+
}
|
|
11528
|
+
}
|
|
11529
|
+
if (hasBranch && nodeCount > MAX_NODES) {
|
|
11530
|
+
allErrors.push(
|
|
11531
|
+
`workflow "${wf.ref}" expands to ${nodeCount} nodes (> ${MAX_NODES}); a workflow with a find_opportunity branch cannot be auto-split \u2014 move actions into separate workflows.`
|
|
11532
|
+
);
|
|
11533
|
+
}
|
|
11534
|
+
}
|
|
11436
11535
|
return {
|
|
11437
11536
|
valid: allErrors.length === 0,
|
|
11438
11537
|
errors: allErrors,
|
|
@@ -11526,6 +11625,15 @@ function htmlWrap(text) {
|
|
|
11526
11625
|
if (/^\s*<[a-z]/i.test(t)) return t;
|
|
11527
11626
|
return `<p style="margin:0px;">${t}</p>`;
|
|
11528
11627
|
}
|
|
11628
|
+
function monetaryValueField(value) {
|
|
11629
|
+
return {
|
|
11630
|
+
__customInputs__: { value: "numerical" },
|
|
11631
|
+
filterField: "monetaryValue",
|
|
11632
|
+
valueFieldType: "custom-input",
|
|
11633
|
+
value,
|
|
11634
|
+
dataType: "NUMERICAL"
|
|
11635
|
+
};
|
|
11636
|
+
}
|
|
11529
11637
|
function resolveId(ref, idMap) {
|
|
11530
11638
|
const real = idMap.get(ref);
|
|
11531
11639
|
return real ?? PENDING(ref);
|
|
@@ -11608,6 +11716,41 @@ function expandAction(action, idx, idMap) {
|
|
|
11608
11716
|
}
|
|
11609
11717
|
};
|
|
11610
11718
|
}
|
|
11719
|
+
case "wait_appointment": {
|
|
11720
|
+
const totalMinutes = action.value * (action.unit === "days" ? 1440 : action.unit === "hours" ? 60 : 1);
|
|
11721
|
+
const distributed = {
|
|
11722
|
+
months: 0,
|
|
11723
|
+
days: Math.floor(totalMinutes / 1440),
|
|
11724
|
+
hours: Math.floor(totalMinutes % 1440 / 60),
|
|
11725
|
+
minutes: totalMinutes % 60
|
|
11726
|
+
};
|
|
11727
|
+
const label = `Wait until ${action.value} ${action.unit} before appointment`;
|
|
11728
|
+
return {
|
|
11729
|
+
kind: "expanded",
|
|
11730
|
+
pendingRefs: [],
|
|
11731
|
+
native: {
|
|
11732
|
+
type: "wait",
|
|
11733
|
+
name: label,
|
|
11734
|
+
attributes: {
|
|
11735
|
+
type: "appointment",
|
|
11736
|
+
name: label,
|
|
11737
|
+
cat: "",
|
|
11738
|
+
isHybridAction: true,
|
|
11739
|
+
hybridActionType: "wait",
|
|
11740
|
+
convertToMultipath: false,
|
|
11741
|
+
transitions: [],
|
|
11742
|
+
appointmentStartAfter: {
|
|
11743
|
+
// Only "before" is emitted — the captured/proven shape. See plan.ts.
|
|
11744
|
+
when: "before",
|
|
11745
|
+
type: "minutes",
|
|
11746
|
+
value: totalMinutes,
|
|
11747
|
+
distributed
|
|
11748
|
+
},
|
|
11749
|
+
appointmentCondition: "skip"
|
|
11750
|
+
}
|
|
11751
|
+
}
|
|
11752
|
+
};
|
|
11753
|
+
}
|
|
11611
11754
|
case "internal_notification": {
|
|
11612
11755
|
const looksLikeUserId = /^[A-Za-z0-9]{17,}$/.test(action.to);
|
|
11613
11756
|
return {
|
|
@@ -11698,7 +11841,40 @@ function expandAction(action, idx, idMap) {
|
|
|
11698
11841
|
}
|
|
11699
11842
|
};
|
|
11700
11843
|
}
|
|
11701
|
-
case "create_opportunity":
|
|
11844
|
+
case "create_opportunity": {
|
|
11845
|
+
const pipelineId = resolveId(action.pipelineRef, idMap);
|
|
11846
|
+
const stageId = resolveId(action.stageRef, idMap);
|
|
11847
|
+
const pendingRefs = [];
|
|
11848
|
+
if (isPending(pipelineId)) pendingRefs.push(action.pipelineRef);
|
|
11849
|
+
if (isPending(stageId)) pendingRefs.push(action.stageRef);
|
|
11850
|
+
const pName = idx.pipelineName.get(action.pipelineRef) ?? action.pipelineRef;
|
|
11851
|
+
const sName = idx.stageName.get(action.stageRef) ?? action.stageRef;
|
|
11852
|
+
const oppName = action.name ?? "{{contact.first_name}} {{contact.last_name}}";
|
|
11853
|
+
return {
|
|
11854
|
+
kind: "expanded",
|
|
11855
|
+
pendingRefs,
|
|
11856
|
+
native: {
|
|
11857
|
+
type: "internal_create_opportunity",
|
|
11858
|
+
name: `Create opportunity: ${pName} / ${sName}`,
|
|
11859
|
+
workflowsActionType: "INTERNAL",
|
|
11860
|
+
attributes: {
|
|
11861
|
+
type: "internal_create_opportunity",
|
|
11862
|
+
pipelineId,
|
|
11863
|
+
// The save-time normalizer only scaffolds internal_update_opportunity, so this
|
|
11864
|
+
// create node carries GHL's full shape itself (attribute-level + per-field
|
|
11865
|
+
// __customInputs__), matching the captured live node exactly.
|
|
11866
|
+
__customInputs__: {},
|
|
11867
|
+
__customInputFields__: [
|
|
11868
|
+
{ __customInputs__: {}, filterField: "pipelineStageId", value: stageId, valueFieldType: "select", dataType: "SINGLE_OPTIONS" },
|
|
11869
|
+
{ __customInputs__: {}, filterField: "name", value: oppName, valueFieldType: "string", dataType: "TEXT" },
|
|
11870
|
+
// Optional opportunity dollar value (Lux "14. Package Sale" shape). Only
|
|
11871
|
+
// emitted when the plan supplies a value; omitted → GHL leaves it unset.
|
|
11872
|
+
...action.value ? [monetaryValueField(action.value)] : []
|
|
11873
|
+
]
|
|
11874
|
+
}
|
|
11875
|
+
}
|
|
11876
|
+
};
|
|
11877
|
+
}
|
|
11702
11878
|
case "update_opportunity": {
|
|
11703
11879
|
const pipelineId = resolveId(action.pipelineRef, idMap);
|
|
11704
11880
|
const stageId = resolveId(action.stageRef, idMap);
|
|
@@ -11707,26 +11883,119 @@ function expandAction(action, idx, idMap) {
|
|
|
11707
11883
|
if (isPending(stageId)) pendingRefs.push(action.stageRef);
|
|
11708
11884
|
const pName = idx.pipelineName.get(action.pipelineRef) ?? action.pipelineRef;
|
|
11709
11885
|
const sName = idx.stageName.get(action.stageRef) ?? action.stageRef;
|
|
11710
|
-
const verb = action.type === "create_opportunity" ? "Create opportunity" : "Move opportunity";
|
|
11711
11886
|
return {
|
|
11712
11887
|
kind: "expanded",
|
|
11713
11888
|
pendingRefs,
|
|
11714
11889
|
native: {
|
|
11715
11890
|
type: "internal_update_opportunity",
|
|
11716
|
-
name:
|
|
11717
|
-
// NODE-level discriminator (the normalizer also forces this; included so
|
|
11718
|
-
// the expanded action is correct even outside the save path).
|
|
11891
|
+
name: `Move opportunity: ${pName} / ${sName}`,
|
|
11719
11892
|
workflowsActionType: "INTERNAL",
|
|
11720
11893
|
attributes: {
|
|
11721
11894
|
type: "internal_update_opportunity",
|
|
11895
|
+
// Honor the operator's explicit target stage in EITHER direction. GHL's
|
|
11896
|
+
// "Move opportunity" action defaults allowBackward:false, which SILENTLY
|
|
11897
|
+
// refuses to move an opp to an EARLIER pipeline stage (lower position) —
|
|
11898
|
+
// breaking win-back / reactivation moves whose whole point is to move a
|
|
11899
|
+
// deal BACKWARD (e.g. Cancelled → Active). The Blueprint intent of an
|
|
11900
|
+
// update_opportunity action is "put this opp in this stage" regardless of
|
|
11901
|
+
// direction, so allow backward moves. (Live-caught on Cap-3 wf7 2026-06-24:
|
|
11902
|
+
// the identical node moved a deal FORWARD fine in wf6 but silently no-op'd
|
|
11903
|
+
// moving it BACKWARD here.) The save-time normalizer preserves an explicit
|
|
11904
|
+
// boolean, so this is honored end-to-end.
|
|
11905
|
+
allowBackward: true,
|
|
11722
11906
|
__customInputFields__: [
|
|
11723
11907
|
{ filterField: "pipelineId", value: pipelineId, valueFieldType: "select", dataType: "SINGLE_OPTIONS" },
|
|
11724
|
-
{ filterField: "pipelineStageId", value: stageId, valueFieldType: "select", dataType: "SINGLE_OPTIONS" }
|
|
11908
|
+
{ filterField: "pipelineStageId", value: stageId, valueFieldType: "select", dataType: "SINGLE_OPTIONS" },
|
|
11909
|
+
// Optional opportunity dollar value (Lux "14. Package Sale" shape). Only
|
|
11910
|
+
// emitted when the plan supplies a value; omitted → GHL leaves it unchanged.
|
|
11911
|
+
...action.value ? [monetaryValueField(action.value)] : []
|
|
11725
11912
|
]
|
|
11726
11913
|
}
|
|
11727
11914
|
}
|
|
11728
11915
|
};
|
|
11729
11916
|
}
|
|
11917
|
+
case "find_opportunity": {
|
|
11918
|
+
const pipelineId = resolveId(action.pipelineRef, idMap);
|
|
11919
|
+
const pendingRefs = [];
|
|
11920
|
+
if (isPending(pipelineId)) pendingRefs.push(action.pipelineRef);
|
|
11921
|
+
for (const child of [...action.found, ...action.notFound]) {
|
|
11922
|
+
const probe = expandAction(child, idx, idMap);
|
|
11923
|
+
if (probe.kind === "needs_content")
|
|
11924
|
+
return { kind: "needs_content", logicalType: `find_opportunity \u2192 ${child.type}`, reason: probe.reason };
|
|
11925
|
+
if (probe.kind === "manual")
|
|
11926
|
+
return { kind: "manual", logicalType: `find_opportunity \u2192 ${child.type}`, reason: probe.reason };
|
|
11927
|
+
}
|
|
11928
|
+
const findId = crypto.randomUUID();
|
|
11929
|
+
const foundTId = crypto.randomUUID();
|
|
11930
|
+
const notFoundTId = crypto.randomUUID();
|
|
11931
|
+
const wireBranch = (children, transitionId) => {
|
|
11932
|
+
const out = [];
|
|
11933
|
+
children.forEach((child, ci) => {
|
|
11934
|
+
const exp = expandAction(child, idx, idMap);
|
|
11935
|
+
if (exp.kind !== "expanded") return;
|
|
11936
|
+
exp.pendingRefs.forEach((r) => pendingRefs.push(r));
|
|
11937
|
+
const node = exp.native;
|
|
11938
|
+
node.id = crypto.randomUUID();
|
|
11939
|
+
node.parent = transitionId;
|
|
11940
|
+
node.parentKey = ci === 0 ? transitionId : out[ci - 1].id;
|
|
11941
|
+
node.order = ci;
|
|
11942
|
+
delete node.next;
|
|
11943
|
+
out.push(node);
|
|
11944
|
+
});
|
|
11945
|
+
for (let k = 0; k < out.length - 1; k++) out[k].next = out[k + 1].id;
|
|
11946
|
+
return out;
|
|
11947
|
+
};
|
|
11948
|
+
const foundNodes = wireBranch(action.found, foundTId);
|
|
11949
|
+
const notFoundNodes = wireBranch(action.notFound, notFoundTId);
|
|
11950
|
+
const makeTransition = (id, name, firstChildId) => ({
|
|
11951
|
+
id,
|
|
11952
|
+
type: "transition",
|
|
11953
|
+
name,
|
|
11954
|
+
cat: "transition",
|
|
11955
|
+
parent: findId,
|
|
11956
|
+
parentKey: findId,
|
|
11957
|
+
attributes: {},
|
|
11958
|
+
...firstChildId ? { next: firstChildId } : {}
|
|
11959
|
+
});
|
|
11960
|
+
const pName = idx.pipelineName.get(action.pipelineRef) ?? action.pipelineRef;
|
|
11961
|
+
const findNode = {
|
|
11962
|
+
id: findId,
|
|
11963
|
+
type: "find_opportunity",
|
|
11964
|
+
name: `Find opportunity: ${pName}`,
|
|
11965
|
+
cat: "multi-path",
|
|
11966
|
+
workflowsActionType: "INTERNAL",
|
|
11967
|
+
next: [foundTId, notFoundTId],
|
|
11968
|
+
attributes: {
|
|
11969
|
+
sorting: "latest",
|
|
11970
|
+
type: "find_opportunity",
|
|
11971
|
+
// Filter shape is DISTINCT from the update/create node: the field name is
|
|
11972
|
+
// `pipeline_id` (underscore), the operator lives in `value`, the id in
|
|
11973
|
+
// `secondValue` (captured from Lux "01"/"02").
|
|
11974
|
+
__customInputFields__: [
|
|
11975
|
+
{ __customInputs__: {}, filterField: "pipeline_id", value: "eq", secondValue: pipelineId }
|
|
11976
|
+
],
|
|
11977
|
+
__customInputs__: {},
|
|
11978
|
+
cat: "multi-path",
|
|
11979
|
+
convertToMultipath: true,
|
|
11980
|
+
transitions: [
|
|
11981
|
+
{ id: foundTId, name: "Opportunity Found", fields: [], meta: { __branchKey__: "predefined_Opportunity Found" }, conditionType: "pre-defined" },
|
|
11982
|
+
{ id: notFoundTId, name: "Opportunity Not Found", fields: [], meta: { __branchKey__: "predefined_Opportunity Not Found" }, conditionType: "pre-defined" }
|
|
11983
|
+
],
|
|
11984
|
+
__name__: "Find Opportunity"
|
|
11985
|
+
}
|
|
11986
|
+
};
|
|
11987
|
+
return {
|
|
11988
|
+
kind: "expanded",
|
|
11989
|
+
pendingRefs,
|
|
11990
|
+
native: findNode,
|
|
11991
|
+
extraNodes: [
|
|
11992
|
+
makeTransition(foundTId, "Opportunity Found", foundNodes[0]?.id),
|
|
11993
|
+
...foundNodes,
|
|
11994
|
+
makeTransition(notFoundTId, "Opportunity Not Found", notFoundNodes[0]?.id),
|
|
11995
|
+
...notFoundNodes
|
|
11996
|
+
]
|
|
11997
|
+
};
|
|
11998
|
+
}
|
|
11730
11999
|
case "goal_event": {
|
|
11731
12000
|
return {
|
|
11732
12001
|
kind: "expanded",
|
|
@@ -11838,16 +12107,30 @@ function expandWorkflow(workflow, idx, idMap, gatedBy) {
|
|
|
11838
12107
|
const manual = [];
|
|
11839
12108
|
const needsContent = [];
|
|
11840
12109
|
const pendingRefs = /* @__PURE__ */ new Set();
|
|
12110
|
+
let branchEmitted = false;
|
|
11841
12111
|
workflow.actions.forEach((a, i) => {
|
|
12112
|
+
if (branchEmitted) {
|
|
12113
|
+
manual.push({ index: i, logicalType: a.type, reason: "action follows a find_opportunity branch (which must be the last action) \u2014 not emitted to avoid an orphaned node" });
|
|
12114
|
+
return;
|
|
12115
|
+
}
|
|
11842
12116
|
const exp = expandAction(a, idx, idMap);
|
|
11843
12117
|
if (exp.kind === "expanded") {
|
|
11844
12118
|
nativeActions.push(exp.native);
|
|
11845
12119
|
exp.pendingRefs.forEach((r) => pendingRefs.add(r));
|
|
12120
|
+
if (exp.extraNodes && exp.extraNodes.length) {
|
|
12121
|
+
const baseOrder = nativeActions.length - 1;
|
|
12122
|
+
exp.native.order = baseOrder;
|
|
12123
|
+
for (const n of exp.extraNodes) {
|
|
12124
|
+
if (n.cat === "transition") n.order = baseOrder + 1;
|
|
12125
|
+
nativeActions.push(n);
|
|
12126
|
+
}
|
|
12127
|
+
}
|
|
11846
12128
|
} else if (exp.kind === "manual") {
|
|
11847
12129
|
manual.push({ index: i, logicalType: exp.logicalType, reason: exp.reason });
|
|
11848
12130
|
} else {
|
|
11849
12131
|
needsContent.push({ index: i, logicalType: exp.logicalType, reason: exp.reason });
|
|
11850
12132
|
}
|
|
12133
|
+
if (a.type === "find_opportunity") branchEmitted = true;
|
|
11851
12134
|
});
|
|
11852
12135
|
const splitInto = Math.max(1, Math.ceil(nativeActions.length / MAX_ACTIONS_PER_WORKFLOW));
|
|
11853
12136
|
let nativeTrigger;
|
|
@@ -12137,11 +12420,13 @@ async function executeBackbone(plan, deps, opts = {}) {
|
|
|
12137
12420
|
const idMap = {};
|
|
12138
12421
|
const built = [];
|
|
12139
12422
|
const manual = [];
|
|
12423
|
+
const published = [];
|
|
12140
12424
|
const halt = (atRef, type, reason) => ({
|
|
12141
12425
|
ok: false,
|
|
12142
12426
|
idMap,
|
|
12143
12427
|
built,
|
|
12144
12428
|
manual,
|
|
12429
|
+
published,
|
|
12145
12430
|
halted: { atRef, type, reason },
|
|
12146
12431
|
deferred: deferredSections(plan)
|
|
12147
12432
|
});
|
|
@@ -12522,11 +12807,19 @@ async function executeBackbone(plan, deps, opts = {}) {
|
|
|
12522
12807
|
}
|
|
12523
12808
|
savedWfIds.add(wfId);
|
|
12524
12809
|
built.push({ ref: wf.ref, type: "workflow", name: wf.name, status: "created", realId: wfId });
|
|
12810
|
+
if (opts.publishWorkflows && !opts.gatedWorkflowRefs?.has(wf.ref)) {
|
|
12811
|
+
try {
|
|
12812
|
+
await deps.publishWorkflow(wfId);
|
|
12813
|
+
published.push(wf.ref);
|
|
12814
|
+
} catch (e) {
|
|
12815
|
+
manual.push({ ref: wf.ref, type: "workflow-publish", name: wf.name, reason: `[${wf.name}] built successfully but auto-publish failed (${msg(e)}) \u2014 publish it manually in GHL.` });
|
|
12816
|
+
}
|
|
12817
|
+
}
|
|
12525
12818
|
if (expansion.triggerManual) manual.push({ ref: wf.ref, type: "workflow-trigger", name: wf.name, reason: `[${wf.name}] ${expansion.triggerManual}` });
|
|
12526
12819
|
for (const nc of expansion.needsContent) manual.push({ ref: wf.ref, type: "workflow-action", name: wf.name, reason: `[${wf.name}] ${nc.reason}` });
|
|
12527
12820
|
for (const m of expansion.manual) manual.push({ ref: wf.ref, type: "workflow-action", name: wf.name, reason: `[${wf.name}] ${m.reason}` });
|
|
12528
12821
|
}
|
|
12529
|
-
return { ok: true, idMap, built, manual, deferred: deferredSections(plan) };
|
|
12822
|
+
return { ok: true, idMap, built, manual, published, deferred: deferredSections(plan) };
|
|
12530
12823
|
}
|
|
12531
12824
|
async function buildSimple(objects, type, list, create, nameOf, pollForNew, idMap, built) {
|
|
12532
12825
|
if (objects.length === 0) return null;
|
|
@@ -12940,6 +13233,9 @@ ${text2.slice(0, 300)}`);
|
|
|
12940
13233
|
},
|
|
12941
13234
|
deleteWorkflow: async (workflowId) => {
|
|
12942
13235
|
await builderClient.deleteWorkflow(workflowId);
|
|
13236
|
+
},
|
|
13237
|
+
publishWorkflow: async (workflowId) => {
|
|
13238
|
+
await builderClient.publishWorkflow(workflowId);
|
|
12943
13239
|
}
|
|
12944
13240
|
};
|
|
12945
13241
|
}
|
|
@@ -13072,7 +13368,11 @@ function registerIntakeToBuildTools(server2, client, builderClient) {
|
|
|
13072
13368
|
}
|
|
13073
13369
|
}
|
|
13074
13370
|
const deps = makeExecuteDeps(client, builderClient, activeLocation);
|
|
13075
|
-
const
|
|
13371
|
+
const gatedWorkflowRefs = new Set(result.workflows.filter((w) => w.gatedBy.length > 0).map((w) => w.ref));
|
|
13372
|
+
const exec = await executeBackbone(typedPlan, deps, {
|
|
13373
|
+
publishWorkflows: publishWorkflows ?? false,
|
|
13374
|
+
gatedWorkflowRefs
|
|
13375
|
+
});
|
|
13076
13376
|
const externalWiring = buildExternalWiring(typedPlan, exec.idMap, activeLocation);
|
|
13077
13377
|
const execManualLines = exec.manual.map((m) => `[${m.type}] ${m.reason}`);
|
|
13078
13378
|
const manualLines = result.workflows.flatMap((w) => [
|
|
@@ -13091,12 +13391,14 @@ function registerIntakeToBuildTools(server2, client, builderClient) {
|
|
|
13091
13391
|
built: exec.built,
|
|
13092
13392
|
manual: exec.manual,
|
|
13093
13393
|
idMap: exec.idMap,
|
|
13394
|
+
published: exec.published,
|
|
13395
|
+
publishNote: exec.published.length ? `Auto-published ${exec.published.length} workflow(s) live (you opted in). Any gated workflow stays DRAFT until its handoff is met.` : exec.built.some((b) => b.type === "workflow" && b.status === "created") ? "Workflows were built as DRAFT (the safe default). Review them in GHL and publish, or re-run with publishWorkflows:true to auto-publish ungated ones." : "No workflows were built in this run (none in the plan, or all already existed and were left untouched).",
|
|
13094
13396
|
externalWiring,
|
|
13095
13397
|
externalWiringNote: externalWiring ? "For each target:\"external\" funnel: plug these into your self-hosted site form + lead bridge (templates/external-funnel/). custom formFields carry the VERIFIED GHL field id your form's `custom` object must send (never name-guess keys); add a triggerTag to start the speed-to-lead workflow; bookingUrl is the GHL calendar widget for the CTA. Anything in `unresolved` wasn't built yet \u2014 re-run after it is." : void 0,
|
|
13096
13398
|
deferred: exec.deferred,
|
|
13097
13399
|
deferredNote: 'execute builds the WHOLE plan live: CRM backbone (pipelines, custom fields, tags, custom values), calendars, forms, funnels (GHL-built = funnel + named steps), and workflows (DRAFT, with all steps incl. opportunity create/move). Remaining manual steps are surfaced per item: staff-requiring calendars in multi-user accounts, GHL funnel page content/design, EXTERNAL funnels (target:"external" \u2014 the user builds + hosts the site themselves and wires it back; not built here), workflow triggers other than contact_tag, and publishing the DRAFT workflows.',
|
|
13098
13400
|
nextManualSteps: [...execManualLines, ...manualLines, ...handoffLines],
|
|
13099
|
-
summary: exec.ok ? `Built ${exec.built.filter((b) => b.status === "created").length} new object(s), bound ${exec.built.filter((b) => b.status === "existing").length} existing.${exec.manual.length ? ` ${exec.manual.length} item(s) need a manual step (see nextManualSteps).` : ""}${exec.deferred.length ? " Deferred: " + exec.deferred.map((d) => `${d.count} ${d.section}`).join(", ") + " (manual)." : ""}` : `HALTED at ${exec.halted?.atRef} (${exec.halted?.reason}). ${exec.built.length} object(s) were created before the halt \u2014 see idMap to resume or clean up. NEVER-CLOBBER means a re-run will bind those, not duplicate them.`
|
|
13401
|
+
summary: exec.ok ? `Built ${exec.built.filter((b) => b.status === "created").length} new object(s), bound ${exec.built.filter((b) => b.status === "existing").length} existing.${exec.published.length ? ` Published ${exec.published.length} workflow(s) live.` : exec.built.some((b) => b.type === "workflow" && b.status === "created") ? " Workflows are DRAFT (opt in with publishWorkflows to auto-publish)." : ""}${exec.manual.length ? ` ${exec.manual.length} item(s) need a manual step (see nextManualSteps).` : ""}${exec.deferred.length ? " Deferred: " + exec.deferred.map((d) => `${d.count} ${d.section}`).join(", ") + " (manual)." : ""}` : `HALTED at ${exec.halted?.atRef} (${exec.halted?.reason}). ${exec.built.length} object(s) were created before the halt \u2014 see idMap to resume or clean up. NEVER-CLOBBER means a re-run will bind those, not duplicate them.`
|
|
13100
13402
|
});
|
|
13101
13403
|
}
|
|
13102
13404
|
const collisions = result.items.filter((i) => i.status === "existing");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elitedcs/ghl-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.48.0",
|
|
4
4
|
"mcpName": "io.github.drjerryrelth/ghl-command",
|
|
5
5
|
"description": "GoHighLevel MCP Server for Claude. 220 tools — 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.",
|
|
6
6
|
"main": "dist/index.js",
|