@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.
Files changed (3) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/index.js +320 -18
  3. 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.47.0",
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) throw new Error(`Wait action "${action.name}" missing required 'startAfter' in attributes.`);
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.cat || action.nodeType || action.parent) continue;
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 actionSchema = import_zod53.z.discriminatedUnion("type", [
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
- status: import_zod53.z.string().optional()
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
- w.actions.forEach((a, i) => {
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: `${verb}: ${pName} / ${sName}`,
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 exec = await executeBackbone(typedPlan, deps);
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.47.0",
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",