@elitedcs/ghl-mcp 3.64.0 → 3.65.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 +62 -0
- package/README.md +5 -5
- package/dist/index.js +324 -37
- package/package.json +2 -2
- package/templates/action-schemas.json +27 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,67 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.65.0 — nine calls that reported success and did the wrong thing
|
|
4
|
+
|
|
5
|
+
Found while building a 139-requirement home-care front office end to end. Every
|
|
6
|
+
fix below has the same shape: the call succeeds, the response reads correctly,
|
|
7
|
+
and something quietly did not happen. None raised an error. Four were findable
|
|
8
|
+
only by firing the system and watching, not by reading settings back.
|
|
9
|
+
|
|
10
|
+
**Three of these were damaging live accounts.**
|
|
11
|
+
|
|
12
|
+
- **Fix: saving a calendar wiped its own settings.** Updating one field on a
|
|
13
|
+
calendar sent only that field, and GoHighLevel treated the omissions as
|
|
14
|
+
deletions — slot duration, slot interval, buffers and the entire open-hours
|
|
15
|
+
grid were erased. The call returned 200 and the calendar looked fine in the
|
|
16
|
+
list. Calendar updates now read the existing record first and merge; if that
|
|
17
|
+
read fails the update refuses rather than falling back to a write that would
|
|
18
|
+
clobber.
|
|
19
|
+
- **Fix: saving a workflow silently deleted its quiet hours.** The update never
|
|
20
|
+
sent the sending-window field, so any workflow with contact-hour restrictions
|
|
21
|
+
lost them on the next save, with no warning. For anyone using quiet hours for
|
|
22
|
+
TCPA compliance, the protection disappeared the first time the workflow was
|
|
23
|
+
touched.
|
|
24
|
+
- **Fix: every smart-list tool pointed at the wrong service.** All five called an
|
|
25
|
+
endpoint that does not serve contact smart lists, so they never worked.
|
|
26
|
+
|
|
27
|
+
Silent no-ops:
|
|
28
|
+
|
|
29
|
+
- **Fix: appointment triggers ignored the calendar you chose** and fired for every
|
|
30
|
+
calendar in the account. A caregiver interview booking would start the family
|
|
31
|
+
consultation sequence.
|
|
32
|
+
- **Fix: reply triggers fired on every inbound message**, because the conditions
|
|
33
|
+
were emitted empty.
|
|
34
|
+
- **Fix: moving a deal to a new stage did nothing** unless the workflow had first
|
|
35
|
+
looked the opportunity up. The step reported success at every save. Now caught
|
|
36
|
+
before the build runs.
|
|
37
|
+
|
|
38
|
+
New:
|
|
39
|
+
|
|
40
|
+
- **`create_contact_smart_list`, `get_contact_smart_list`, `delete_contact_smart_list`.**
|
|
41
|
+
238 tools total, 110 free.
|
|
42
|
+
- **Calendar per-day limits and minimum booking notice** are now reachable
|
|
43
|
+
(`appointmentPerDay`, `allowBookingAfter`, `allowBookingAfterUnit`).
|
|
44
|
+
- **Workflow quiet hours** can now be set through the tool — a sending window
|
|
45
|
+
with days, applied per workflow.
|
|
46
|
+
- **Four trigger builders**: calendar-scoped appointments, reply-intent routing,
|
|
47
|
+
call status, and phone-number validation failures.
|
|
48
|
+
|
|
49
|
+
## 3.64.1 — three more calls that failed quietly
|
|
50
|
+
|
|
51
|
+
- **Fix: client pricing was mangled on the way into a build plan.** The Blueprint
|
|
52
|
+
intake parsed price answers by splitting on line breaks only, and read the
|
|
53
|
+
hyphen in a price range as the separator between a product and its price. An
|
|
54
|
+
answer typed as a sentence — "Contouring packages 1200-3500. Injectables
|
|
55
|
+
400-900." — became a single entry named "Contouring packages 1200" priced at
|
|
56
|
+
"3500", with the second offering discarded entirely, and the brief still
|
|
57
|
+
reported as valid. Prices now survive as ranges, sentences and semicolons
|
|
58
|
+
separate entries, and nothing is silently dropped.
|
|
59
|
+
- **Fix: listing invoices always failed.** GoHighLevel requires both a limit and
|
|
60
|
+
an offset on that endpoint and rejects the call without them, so the ordinary
|
|
61
|
+
"show me my invoices" request never worked. Both are now always sent.
|
|
62
|
+
- **Fix: blog authors and categories always failed**, for the same reason and
|
|
63
|
+
with the same fix. The paths were never wrong — only the request was.
|
|
64
|
+
|
|
3
65
|
## 3.64.0 — four silent failures fixed, four more guides open
|
|
4
66
|
|
|
5
67
|
Every fix here is the same shape: a call that succeeded, returned
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# GHL Command — GoHighLevel MCP Server
|
|
2
2
|
|
|
3
|
-
**Full GoHighLevel API access for Claude.**
|
|
3
|
+
**Full GoHighLevel API access for Claude.** 238 tools across 50 modules — manage contacts, conversations, pipelines, calendars, funnels, workflows, invoices, custom objects, webhooks, and more. **Includes full workflow builder, funnel/page editor, form builder, pipeline builder, bulk operations, account export, and workflow cloning** — capabilities no other GHL tool offers. **Multi-tenant:** one install can run the workflow builder across multiple clients' GHL accounts.
|
|
4
4
|
|
|
5
5
|
**Distributed via npm as [`@elitedcs/ghl-mcp`](https://www.npmjs.com/package/@elitedcs/ghl-mcp).** Buyers install with one config block — no git, no Node.js setup, no terminal commands. Updates flow automatically (`npx @latest` re-resolves on every Claude restart).
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ Works with **both the Claude Desktop App and Claude Code terminal** — your cho
|
|
|
8
8
|
|
|
9
9
|
**License required.** Get it at [ghlcommand.com](https://ghlcommand.com?utm_source=npm&utm_medium=readme) — $97/mo, covering UNLIMITED GoHighLevel sub-accounts on up to 3 machines (never billed per account). **30-day time-back guarantee**: save 5+ hours on one client build or your first month back.
|
|
10
10
|
|
|
11
|
-
**Try it FREE (read-only tier).** Grab an instant free key at [ghlcommand.com/free](https://ghlcommand.com/free?utm_source=npm&utm_medium=readme) —
|
|
11
|
+
**Try it FREE (read-only tier).** Grab an instant free key at [ghlcommand.com/free](https://ghlcommand.com/free?utm_source=npm&utm_medium=readme) — 110 read-only tools on your own GHL account once the two-step install is finished (the same install as the paid tier — upgrading later is just a license swap): the account-wide workflow audit (`audit_workflows` finds the silently broken references GHL never warns you about), account health, and every read across contacts, pipelines, funnels, and workflows. Write tools stay visible and answer with upgrade info. Buying later with the same email upgrades your key in place — 1 machine on free, 3 when you upgrade.
|
|
12
12
|
|
|
13
13
|
**How this differs from HighLevel's official MCP.** HighLevel ships an official Anthropic MCP that reads and writes your CRM (contacts, conversations, calendars, opportunities, payments, social) over the public API — it's free and it's good; use it for that. What it *cannot* do, because the public API doesn't expose it: **build or edit workflows, build funnels/landing pages, or build forms.** That's exactly what GHL Command does — the programmatic Workflow Builder, funnel/page builder, form builder, deep workflow cloning, the silent-failure `audit_workflows`, and Blueprint (build a whole client account from one intake — ships as a guided skill that **installs itself automatically** with this package; nothing to download), all on GHL's internal APIs. **Official MCP = read/write your CRM. GHL Command = build and fix it.** They stack fine together.
|
|
14
14
|
|
|
@@ -135,7 +135,7 @@ Run setup_ghl_mcp to activate GHL Command:
|
|
|
135
135
|
ghl_location_id: YOUR_LOCATION_ID
|
|
136
136
|
```
|
|
137
137
|
|
|
138
|
-
Approve the tool call. Server validates your license, verifies your GHL credentials, writes them to a per-user config file. **Quit Claude one more time and reopen** — the full core toolset is now unlocked (
|
|
138
|
+
Approve the tool call. Server validates your license, verifies your GHL credentials, writes them to a per-user config file. **Quit Claude one more time and reopen** — the full core toolset is now unlocked (238 tools total with the optional Workflow Builder Firebase add-on).
|
|
139
139
|
|
|
140
140
|
### 4. Try it
|
|
141
141
|
|
|
@@ -518,7 +518,7 @@ Highlights (full guide: [`docs/HEADLESS.md`](docs/HEADLESS.md)):
|
|
|
518
518
|
|---|---|
|
|
519
519
|
| `get_mcp_version` | Check installed version against the latest published to npm. Confirms an upgrade landed after restarting Claude. Available even before GHL credentials are configured. |
|
|
520
520
|
| `health_check` | Run a full health check: npm registry + version status, GHL API key validity, default location reachability, Firebase auth status, token registry presence. Use when something feels broken. |
|
|
521
|
-
| `enable_workflow_builder` | Add Firebase credentials to an existing install to unlock
|
|
521
|
+
| `enable_workflow_builder` | Add Firebase credentials to an existing install to unlock 55 additional Firebase-gated tools (workflow builder, funnel builder, form builder, pipeline builder, workflow cloner, smart lists, reputation, email campaigns, email templates, memberships, validate_workflow). No need to re-enter license / API key / location ID. Run this any time after the basic setup, on the buyer's schedule. |
|
|
522
522
|
|
|
523
523
|
### Other Modules
|
|
524
524
|
|
|
@@ -798,7 +798,7 @@ Source repo is private. Contributors need an invitation from `drjerryrelth`. The
|
|
|
798
798
|
|
|
799
799
|
### Reducing context / token usage
|
|
800
800
|
|
|
801
|
-
Every registered MCP tool's schema is shipped to the model on every message. With
|
|
801
|
+
Every registered MCP tool's schema is shipped to the model on every message. With 238 tools that's a meaningful per-message context cost even in chats that never touch GHL. If you only use a slice of GHL Command, restrict the tool surface with `GHL_ENABLED_MODULES` and/or `GHL_ENABLED_TOOLS`:
|
|
802
802
|
|
|
803
803
|
```jsonc
|
|
804
804
|
// Claude Desktop config — enable whole modules
|
package/dist/index.js
CHANGED
|
@@ -1648,7 +1648,7 @@ Note: Firebase credentials rejected (${fb.error}).`;
|
|
|
1648
1648
|
const telemetryLine = telemetryDisabled(process.env) ? "" : `
|
|
1649
1649
|
|
|
1650
1650
|
${TELEMETRY_DISCLOSURE}`;
|
|
1651
|
-
const finishedCount = isFree ? "
|
|
1651
|
+
const finishedCount = isFree ? "110" : "238";
|
|
1652
1652
|
const freeTip = isFree ? `
|
|
1653
1653
|
|
|
1654
1654
|
Free tier: read-only. Write tools stay visible but answer with upgrade info instead of acting. Full version ($97/mo founding rate) upgrades in place \u2014 same install, you only swap the license key: https://ghlcommand.com` : "";
|
|
@@ -1693,7 +1693,7 @@ Free tier: read-only. Write tools stay visible but answer with upgrade info inst
|
|
|
1693
1693
|
function registerEnableWorkflowBuilderTool(server2) {
|
|
1694
1694
|
server2.tool(
|
|
1695
1695
|
"enable_workflow_builder",
|
|
1696
|
-
"Add Firebase credentials to an existing GHL Command install to unlock
|
|
1696
|
+
"Add Firebase credentials to an existing GHL Command install to unlock 55 additional tools across the internal-API modules: workflow builder (create/edit/clone/delete/publish/validate workflows, build_if_else_branch, build_goal_event, get_trigger_registry), funnel + page builder, form builder, pipeline builder, workflow cloner, smart lists, reputation, email campaigns, email templates, and memberships, plus the pre-deploy validator. On the FREE tier this same login unlocks the read-only auditor suite (audit_workflows, validate_workflow, full-detail workflow/funnel/pipeline reads). Requires you've already run setup_ghl_mcp. EASIEST PATH: run `capture_firebase_interactive` instead \u2014 a Chrome window opens, you log into GHL, zero pasting. Use THIS tool when you have JSON from `auto_capture_firebase_script` (console-paste path) to put in `firebase_paste`, or the three manual DevTools fields. Tool count goes from 183 to 238 after the next Claude restart.",
|
|
1697
1697
|
{
|
|
1698
1698
|
// v3.25.0: one-paste path. Tool runs `auto_capture_firebase_script` to
|
|
1699
1699
|
// get the console script; the script returns a JSON object that pastes
|
|
@@ -1774,7 +1774,7 @@ DevTools steps: https://elitedcs.com/ghl-mcp-firebase`
|
|
|
1774
1774
|
"",
|
|
1775
1775
|
"**You MUST restart Claude before using any workflow-builder tool.** Quit Claude completely (Cmd+Q on Mac, full exit on Windows) and reopen. Without a restart, the workflow builder tools will keep using the OLD Firebase auth from before this call and fail with 401 errors \u2014 even though this tool reported success.",
|
|
1776
1776
|
"",
|
|
1777
|
-
'After restart, all
|
|
1777
|
+
'After restart, all 238 tools load. Try: "List my workflows in full detail" or "Validate workflow <id>".',
|
|
1778
1778
|
"",
|
|
1779
1779
|
"Note: Firebase refresh tokens rotate every few weeks. If workflow tools stop working in a few weeks (run `health_check` to confirm Firebase auth: FAIL), run `auto_capture_firebase_script` for fresh values and re-run this tool with the new firebase_paste."
|
|
1780
1780
|
].join("\n")
|
|
@@ -1870,7 +1870,7 @@ The login in the capture window may belong to the wrong GHL account, or the sess
|
|
|
1870
1870
|
"",
|
|
1871
1871
|
"**You MUST restart Claude before using any workflow-builder tool.** Quit Claude completely (Cmd+Q on Mac, full exit on Windows) and reopen.",
|
|
1872
1872
|
"",
|
|
1873
|
-
'After restart, all
|
|
1873
|
+
'After restart, all 238 tools load. Try: "List my workflows in full detail".',
|
|
1874
1874
|
"",
|
|
1875
1875
|
"Future token rotations re-capture silently \u2014 if workflow tools ever 401, just run capture_firebase_interactive again; no window should appear."
|
|
1876
1876
|
].join("\n")
|
|
@@ -2854,9 +2854,9 @@ var require_package = __commonJS({
|
|
|
2854
2854
|
"package.json"(exports2, module2) {
|
|
2855
2855
|
module2.exports = {
|
|
2856
2856
|
name: "@elitedcs/ghl-mcp",
|
|
2857
|
-
version: "3.
|
|
2857
|
+
version: "3.65.0",
|
|
2858
2858
|
mcpName: "io.github.drjerryrelth/ghl-command",
|
|
2859
|
-
description: "GoHighLevel MCP Server for Claude.
|
|
2859
|
+
description: "GoHighLevel MCP Server for Claude. 238 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.",
|
|
2860
2860
|
main: "dist/index.js",
|
|
2861
2861
|
bin: {
|
|
2862
2862
|
"ghl-mcp": "dist/index.js"
|
|
@@ -6271,6 +6271,9 @@ ${errorBody}`
|
|
|
6271
6271
|
allowMultipleOpportunity: updates.allowMultipleOpportunity ?? current.allowMultipleOpportunity ?? false,
|
|
6272
6272
|
autoMarkAsRead: updates.autoMarkAsRead ?? current.autoMarkAsRead ?? false,
|
|
6273
6273
|
removeContactFromLastStep: updates.removeContactFromLastStep ?? current.removeContactFromLastStep ?? true,
|
|
6274
|
+
// Preserve unless explicitly set (including an explicit null to clear).
|
|
6275
|
+
// `?? ` would be wrong here — null is a meaningful value.
|
|
6276
|
+
window: updates.window !== void 0 ? updates.window : current.window ?? null,
|
|
6274
6277
|
workflowData: { templates: linkedActions },
|
|
6275
6278
|
updatedBy: this.userId,
|
|
6276
6279
|
// Triggers live in Firestore and are managed out-of-band; the workflow
|
|
@@ -7593,6 +7596,9 @@ function applyCalendarFields(body, args) {
|
|
|
7593
7596
|
if (args.slotBuffer !== void 0) body.slotBuffer = args.slotBuffer;
|
|
7594
7597
|
if (args.slotInterval !== void 0) body.slotInterval = args.slotInterval;
|
|
7595
7598
|
if (args.appointmentPerSlot !== void 0) body.appointmentPerSlot = args.appointmentPerSlot;
|
|
7599
|
+
if (args.appointmentPerDay !== void 0) body.appointmentPerDay = args.appointmentPerDay;
|
|
7600
|
+
if (args.allowBookingAfter !== void 0) body.allowBookingAfter = args.allowBookingAfter;
|
|
7601
|
+
if (args.allowBookingAfterUnit !== void 0) body.allowBookingAfterUnit = args.allowBookingAfterUnit;
|
|
7596
7602
|
if (args.openHours !== void 0) {
|
|
7597
7603
|
body.openHours = expandOpenHours(args.openHours);
|
|
7598
7604
|
body.availabilityType = args.availabilityType ?? 0;
|
|
@@ -7614,6 +7620,30 @@ function buildUpdateCalendarBody(args) {
|
|
|
7614
7620
|
applyCalendarFields(body, args);
|
|
7615
7621
|
return body;
|
|
7616
7622
|
}
|
|
7623
|
+
var MERGE_PRESERVED_FIELDS = [
|
|
7624
|
+
"slotDuration",
|
|
7625
|
+
"slotInterval",
|
|
7626
|
+
"slotBuffer",
|
|
7627
|
+
"appointmentPerSlot",
|
|
7628
|
+
"appointmentPerDay",
|
|
7629
|
+
"allowBookingAfter",
|
|
7630
|
+
"allowBookingAfterUnit",
|
|
7631
|
+
"availabilityType"
|
|
7632
|
+
];
|
|
7633
|
+
function mergeCalendarUpdate(existing, args) {
|
|
7634
|
+
if (!existing) return args;
|
|
7635
|
+
const merged = { ...args };
|
|
7636
|
+
for (const key of MERGE_PRESERVED_FIELDS) {
|
|
7637
|
+
if (merged[key] === void 0 && existing[key] !== void 0 && existing[key] !== "") {
|
|
7638
|
+
merged[key] = existing[key];
|
|
7639
|
+
}
|
|
7640
|
+
}
|
|
7641
|
+
if (merged.openHours === void 0) {
|
|
7642
|
+
const hours = existing.openHours;
|
|
7643
|
+
if (Array.isArray(hours) && hours.length > 0) merged.openHours = hours;
|
|
7644
|
+
}
|
|
7645
|
+
return merged;
|
|
7646
|
+
}
|
|
7617
7647
|
function tzOffsetMillis(utcMillis, timeZone) {
|
|
7618
7648
|
const dtf = new Intl.DateTimeFormat("en-US", {
|
|
7619
7649
|
timeZone,
|
|
@@ -7724,6 +7754,9 @@ function registerCalendarTools(server2, client) {
|
|
|
7724
7754
|
slotBuffer: import_zod12.z.number().optional().describe("Buffer time between slots in minutes"),
|
|
7725
7755
|
slotInterval: import_zod12.z.number().optional().describe("Slot interval in minutes"),
|
|
7726
7756
|
appointmentPerSlot: import_zod12.z.number().optional().describe("Max appointments per slot"),
|
|
7757
|
+
appointmentPerDay: import_zod12.z.number().optional().describe("Max appointments bookable per DAY on this calendar (the daily ceiling, distinct from appointmentPerSlot). Without it the day's capacity is whatever the open hours allow."),
|
|
7758
|
+
allowBookingAfter: import_zod12.z.number().optional().describe("Minimum advance notice before a slot can be booked, in allowBookingAfterUnit. E.g. 24 with unit 'hours' blocks same-day and short-notice bookings."),
|
|
7759
|
+
allowBookingAfterUnit: import_zod12.z.string().optional().describe("Unit for allowBookingAfter: 'minutes' | 'hours' | 'days'. Send alongside allowBookingAfter."),
|
|
7727
7760
|
openHours: import_zod12.z.array(OpenHoursBlockSchema).optional().describe(
|
|
7728
7761
|
"Weekly business hours that make slots bookable (the 'assign availability hours' step). Each entry maps weekdays (0=Sun \u2026 6=Sat) to one or more open windows on a 24-hour clock; multi-day entries are auto-expanded to GHL's required one-block-per-day form. Example Mon-Fri 9-6 + Sat 10-1: [{daysOfTheWeek:[1,2,3,4,5],hours:[{openHour:9,openMinute:0,closeHour:18,closeMinute:0}]},{daysOfTheWeek:[6],hours:[{openHour:10,openMinute:0,closeHour:13,closeMinute:0}]}]. Behavior (verified live): on event/simple calendars these hours are the sole source of availability. On round_robin calendars the bookable slots are the INTERSECTION of these hours and each assigned user's working hours, so a day only opens if a team member is also available then (e.g. a Saturday window needs a user with Saturday availability). Omit to use GHL's defaults."
|
|
7729
7762
|
),
|
|
@@ -7753,13 +7786,26 @@ function registerCalendarTools(server2, client) {
|
|
|
7753
7786
|
slotBuffer: import_zod12.z.number().optional().describe("Buffer time between slots in minutes"),
|
|
7754
7787
|
slotInterval: import_zod12.z.number().optional().describe("Slot interval in minutes"),
|
|
7755
7788
|
appointmentPerSlot: import_zod12.z.number().optional().describe("Max appointments per slot"),
|
|
7789
|
+
appointmentPerDay: import_zod12.z.number().optional().describe("Max appointments bookable per DAY on this calendar (the daily ceiling, distinct from appointmentPerSlot)."),
|
|
7790
|
+
allowBookingAfter: import_zod12.z.number().optional().describe("Minimum advance notice before a slot can be booked, in allowBookingAfterUnit. E.g. 24 with unit 'hours' blocks same-day and short-notice bookings."),
|
|
7791
|
+
allowBookingAfterUnit: import_zod12.z.string().optional().describe("Unit for allowBookingAfter: 'minutes' | 'hours' | 'days'. Send alongside allowBookingAfter."),
|
|
7756
7792
|
openHours: import_zod12.z.array(OpenHoursBlockSchema).optional().describe(
|
|
7757
7793
|
"Weekly business hours that make slots bookable. Each entry maps weekdays (0=Sun \u2026 6=Sat) to one or more open windows on a 24-hour clock; multi-day entries are auto-expanded to GHL's required one-block-per-day form (e.g. [{daysOfTheWeek:[1,2,3,4,5],hours:[{openHour:9,openMinute:0,closeHour:18,closeMinute:0}]}] = Mon-Fri 9am-6pm). Replaces the calendar's existing hours. Behavior (verified live): event/simple calendars use these hours directly; round_robin calendars intersect them with each assigned user's working hours (a day only opens if a team member is available then)."
|
|
7758
7794
|
),
|
|
7759
7795
|
availabilityType: import_zod12.z.number().int().optional().describe("Availability mode. 0 = standard weekly hours (from openHours); auto-set when you pass openHours. Leave unset otherwise.")
|
|
7760
7796
|
},
|
|
7761
7797
|
async (args) => {
|
|
7762
|
-
|
|
7798
|
+
let existing;
|
|
7799
|
+
try {
|
|
7800
|
+
const current = await client.get(`/calendars/${args.calendarId}`);
|
|
7801
|
+
const cal = current?.calendar ?? current;
|
|
7802
|
+
if (cal && typeof cal === "object") existing = cal;
|
|
7803
|
+
} catch (e) {
|
|
7804
|
+
throw new Error(
|
|
7805
|
+
`update_calendar: could not read calendar ${args.calendarId} before updating it (${e instanceof Error ? e.message : String(e)}). Refusing to write: GHL's calendar update does not merge, so a partial write here would silently reset this calendar's slot length, buffer and availability hours.`
|
|
7806
|
+
);
|
|
7807
|
+
}
|
|
7808
|
+
const body = buildUpdateCalendarBody(mergeCalendarUpdate(existing, args));
|
|
7763
7809
|
return await client.put(`/calendars/${args.calendarId}`, { body });
|
|
7764
7810
|
}
|
|
7765
7811
|
);
|
|
@@ -8606,11 +8652,11 @@ function registerInvoiceTools(server2, client) {
|
|
|
8606
8652
|
safeTool(
|
|
8607
8653
|
server2,
|
|
8608
8654
|
"list_invoices",
|
|
8609
|
-
"List invoices for a location. Offset-based pagination via limit/offset.",
|
|
8655
|
+
"List invoices for a location. Offset-based pagination via limit/offset; both are always sent because GHL requires them.",
|
|
8610
8656
|
{
|
|
8611
8657
|
locationId: import_zod20.z.string().optional().describe("GHL Location ID (optional if GHL_LOCATION_ID is set)"),
|
|
8612
|
-
limit: import_zod20.z.number().optional().describe("Max number of invoices to return"),
|
|
8613
|
-
offset: import_zod20.z.number().optional().describe("Offset for pagination"),
|
|
8658
|
+
limit: import_zod20.z.number().min(1).optional().describe("Max number of invoices to return (must be at least 1). Defaults to 10."),
|
|
8659
|
+
offset: import_zod20.z.number().min(0).optional().describe("Offset for pagination. Defaults to 0."),
|
|
8614
8660
|
status: import_zod20.z.enum(["draft", "sent", "paid", "void", "partially_paid"]).optional().describe("Filter by invoice status"),
|
|
8615
8661
|
contactId: import_zod20.z.string().optional().describe("Filter by contact ID"),
|
|
8616
8662
|
startAt: import_zod20.z.string().optional().describe("Start date filter (ISO 8601)"),
|
|
@@ -8624,8 +8670,8 @@ function registerInvoiceTools(server2, client) {
|
|
|
8624
8670
|
altId: resolvedLocationId,
|
|
8625
8671
|
altType: "location"
|
|
8626
8672
|
};
|
|
8627
|
-
|
|
8628
|
-
|
|
8673
|
+
params.limit = limit ?? 10;
|
|
8674
|
+
params.offset = offset ?? 0;
|
|
8629
8675
|
if (status !== void 0) params.status = status;
|
|
8630
8676
|
if (contactId !== void 0) params.contactId = contactId;
|
|
8631
8677
|
if (startAt !== void 0) params.startAt = startAt;
|
|
@@ -9214,28 +9260,36 @@ function registerBlogTools(server2, client) {
|
|
|
9214
9260
|
safeTool(
|
|
9215
9261
|
server2,
|
|
9216
9262
|
"get_blog_authors",
|
|
9217
|
-
"List blog authors for a location",
|
|
9263
|
+
"List blog authors for a location.",
|
|
9218
9264
|
{
|
|
9219
|
-
locationId: import_zod27.z.string().optional().describe("GHL Location ID (optional if GHL_LOCATION_ID is set)")
|
|
9265
|
+
locationId: import_zod27.z.string().optional().describe("GHL Location ID (optional if GHL_LOCATION_ID is set)"),
|
|
9266
|
+
limit: import_zod27.z.number().min(1).max(50).optional().describe("How many authors to return (1-50). Defaults to 50."),
|
|
9267
|
+
offset: import_zod27.z.number().min(0).optional().describe("How many to skip. Defaults to 0.")
|
|
9220
9268
|
},
|
|
9221
|
-
|
|
9269
|
+
// limit and offset are REQUIRED by GHL even though they read as optional
|
|
9270
|
+
// paging: omitting them returns 422 "limit should not be empty", which
|
|
9271
|
+
// surfaced as "blog authors are broken" (live-verified 2026-08-08).
|
|
9272
|
+
async ({ locationId: locationId2, limit, offset }) => {
|
|
9222
9273
|
const resolvedLocationId = client.resolveLocationId(locationId2);
|
|
9223
9274
|
return client.get("/blogs/authors", {
|
|
9224
|
-
params: { locationId: resolvedLocationId }
|
|
9275
|
+
params: { locationId: resolvedLocationId, limit: limit ?? 50, offset: offset ?? 0 }
|
|
9225
9276
|
});
|
|
9226
9277
|
}
|
|
9227
9278
|
);
|
|
9228
9279
|
safeTool(
|
|
9229
9280
|
server2,
|
|
9230
9281
|
"get_blog_categories",
|
|
9231
|
-
"List blog categories for a location",
|
|
9282
|
+
"List blog categories for a location.",
|
|
9232
9283
|
{
|
|
9233
|
-
locationId: import_zod27.z.string().optional().describe("GHL Location ID (optional if GHL_LOCATION_ID is set)")
|
|
9284
|
+
locationId: import_zod27.z.string().optional().describe("GHL Location ID (optional if GHL_LOCATION_ID is set)"),
|
|
9285
|
+
limit: import_zod27.z.number().min(1).max(50).optional().describe("How many categories to return (1-50). Defaults to 50."),
|
|
9286
|
+
offset: import_zod27.z.number().min(0).optional().describe("How many to skip. Defaults to 0.")
|
|
9234
9287
|
},
|
|
9235
|
-
|
|
9288
|
+
// Same required-paging contract as /blogs/authors — see the note there.
|
|
9289
|
+
async ({ locationId: locationId2, limit, offset }) => {
|
|
9236
9290
|
const resolvedLocationId = client.resolveLocationId(locationId2);
|
|
9237
9291
|
return client.get("/blogs/categories", {
|
|
9238
|
-
params: { locationId: resolvedLocationId }
|
|
9292
|
+
params: { locationId: resolvedLocationId, limit: limit ?? 50, offset: offset ?? 0 }
|
|
9239
9293
|
});
|
|
9240
9294
|
}
|
|
9241
9295
|
);
|
|
@@ -10372,9 +10426,17 @@ function registerWorkflowBuilderTools(server2, client) {
|
|
|
10372
10426
|
stopOnResponse: import_zod36.z.boolean().optional().describe("Stop the workflow when the contact replies. If omitted, the current value is preserved."),
|
|
10373
10427
|
autoMarkAsRead: import_zod36.z.boolean().optional().describe("Auto-mark conversations as read. If omitted, the current value is preserved."),
|
|
10374
10428
|
removeContactFromLastStep: import_zod36.z.boolean().optional().describe("Remove the contact when they reach the last step. If omitted, the current value is preserved."),
|
|
10375
|
-
allowMultipleOpportunity: import_zod36.z.boolean().optional().describe("Allow creating multiple opportunities. If omitted, the current value is preserved.")
|
|
10429
|
+
allowMultipleOpportunity: import_zod36.z.boolean().optional().describe("Allow creating multiple opportunities. If omitted, the current value is preserved."),
|
|
10430
|
+
window: import_zod36.z.object({
|
|
10431
|
+
condition: import_zod36.z.string().describe('Usually "when" \u2014 run only DURING the window.'),
|
|
10432
|
+
start: import_zod36.z.string().describe('Start of the window, 24-hour "HH:MM" (e.g. "08:00").'),
|
|
10433
|
+
end: import_zod36.z.string().describe('End of the window, 24-hour "HH:MM" (e.g. "21:00").'),
|
|
10434
|
+
days: import_zod36.z.array(import_zod36.z.number().int().min(0).max(6)).describe("Days the window applies to. 0=Sunday \u2026 6=Saturday.")
|
|
10435
|
+
}).nullable().optional().describe(
|
|
10436
|
+
`EXECUTION WINDOW \u2014 the hours this workflow is allowed to run. This is where quiet hours / TCPA contact-hour restrictions are enforced: there is NO time-of-day setting inside the Voice AI or Conversation AI builders, so a "never text before 8am or after 9pm" rule has to live here, on every workflow that sends SMS. Example: {condition:"when", start:"08:00", end:"21:00", days:[0,1,2,3,4,5,6]}. Pass null to clear. OMIT to preserve the workflow's existing window (it was previously dropped from the payload entirely, so saving a workflow silently wiped its window).`
|
|
10437
|
+
)
|
|
10376
10438
|
},
|
|
10377
|
-
async ({ workflowId, name, status, actions, triggers, allowMultiple, stopOnResponse, autoMarkAsRead, removeContactFromLastStep, allowMultipleOpportunity }) => {
|
|
10439
|
+
async ({ workflowId, name, status, actions, triggers, allowMultiple, stopOnResponse, autoMarkAsRead, removeContactFromLastStep, allowMultipleOpportunity, window }) => {
|
|
10378
10440
|
try {
|
|
10379
10441
|
const result = await client.updateWorkflow(workflowId, {
|
|
10380
10442
|
name,
|
|
@@ -10385,7 +10447,8 @@ function registerWorkflowBuilderTools(server2, client) {
|
|
|
10385
10447
|
stopOnResponse,
|
|
10386
10448
|
autoMarkAsRead,
|
|
10387
10449
|
removeContactFromLastStep,
|
|
10388
|
-
allowMultipleOpportunity
|
|
10450
|
+
allowMultipleOpportunity,
|
|
10451
|
+
window
|
|
10389
10452
|
});
|
|
10390
10453
|
return jsonResponse(result);
|
|
10391
10454
|
} catch (error) {
|
|
@@ -13673,6 +13736,33 @@ function registerWorkflowClonerTools(server2, builderClient) {
|
|
|
13673
13736
|
var import_zod48 = require("zod");
|
|
13674
13737
|
var SMARTLIST_BASE = "https://backend.leadconnectorhq.com/lists/dynamic";
|
|
13675
13738
|
var OBJECT_KEYS = ["contacts", "opportunity"];
|
|
13739
|
+
var CONTACT_SMARTLIST_BASE = "https://services.leadconnectorhq.com/contacts/smartlist";
|
|
13740
|
+
var CONTACT_SMARTLIST_DELETE = "https://api.leadconnectorhq.com/smartlist/delete";
|
|
13741
|
+
var DEFAULT_CONTACT_SMARTLIST_COLUMNS = [
|
|
13742
|
+
{ key: "name", value: "name", order: 0 },
|
|
13743
|
+
{ key: "phone", value: "phone", order: 1 },
|
|
13744
|
+
{ key: "email", value: "email", order: 2 },
|
|
13745
|
+
{ key: "companyName", value: "companyName", order: 3 },
|
|
13746
|
+
{ key: "dateAdded", value: "dateAdded", order: 4 },
|
|
13747
|
+
{ key: "lastActivity", value: "lastActivity", order: 5 },
|
|
13748
|
+
{ key: "tags", value: "tags", order: 6 }
|
|
13749
|
+
];
|
|
13750
|
+
function buildContactTagFilter(tagNames) {
|
|
13751
|
+
return { field: "tags", operator: "eq", value: tagNames, options: { minimumMatch: "all" } };
|
|
13752
|
+
}
|
|
13753
|
+
function buildContactSmartListBody(args) {
|
|
13754
|
+
return {
|
|
13755
|
+
columns: args.columns ?? DEFAULT_CONTACT_SMARTLIST_COLUMNS,
|
|
13756
|
+
filterSpecs: {
|
|
13757
|
+
filters: [{ group: "OR", filters: [{ group: "AND", filters: args.filters }] }],
|
|
13758
|
+
page: 1,
|
|
13759
|
+
limit: args.limit ?? 20
|
|
13760
|
+
},
|
|
13761
|
+
listName: args.listName,
|
|
13762
|
+
sortSpecs: [],
|
|
13763
|
+
locationId: args.locationId
|
|
13764
|
+
};
|
|
13765
|
+
}
|
|
13676
13766
|
function registerSmartListTools(server2, builderClient) {
|
|
13677
13767
|
const client = builderClient;
|
|
13678
13768
|
if (!client) return;
|
|
@@ -13706,6 +13796,19 @@ ${text2}`);
|
|
|
13706
13796
|
async ({ objectKey, query, limit, startAfter, locationId: locationId2 }) => {
|
|
13707
13797
|
try {
|
|
13708
13798
|
const loc = locationId2 ?? client.locationId;
|
|
13799
|
+
if (objectKey === "contacts") {
|
|
13800
|
+
const headers = await client.buildHeaders();
|
|
13801
|
+
const p = new URLSearchParams({
|
|
13802
|
+
locationId: loc,
|
|
13803
|
+
userId: client.getUserId(),
|
|
13804
|
+
globals: "true",
|
|
13805
|
+
transform: "true"
|
|
13806
|
+
});
|
|
13807
|
+
const res = await fetch(`${CONTACT_SMARTLIST_BASE}/search?${p.toString()}`, { headers });
|
|
13808
|
+
if (!res.ok) throw new Error(`Smart Lists API Error ${res.status}: GET /contacts/smartlist/search
|
|
13809
|
+
${await res.text()}`);
|
|
13810
|
+
return jsonResponse(JSON.parse(await res.text() || "{}"));
|
|
13811
|
+
}
|
|
13709
13812
|
const params = new URLSearchParams({ objectKey });
|
|
13710
13813
|
if (query) params.set("query", query);
|
|
13711
13814
|
if (limit !== void 0) params.set("limit", String(limit));
|
|
@@ -13717,6 +13820,74 @@ ${text2}`);
|
|
|
13717
13820
|
}
|
|
13718
13821
|
}
|
|
13719
13822
|
);
|
|
13823
|
+
server2.tool(
|
|
13824
|
+
"create_contact_smart_list",
|
|
13825
|
+
"Create a CONTACT smart list (saved filter over contacts) with working filters \u2014 the owner-exception views a dashboard needs. Uses GHL's contacts/smartlist service and its captured OR-of-AND filter envelope. `tagFilters` is the common case: each entry is a list of tag names the contact must ALL carry. For anything else pass `rawFilters` (leaf objects: {field, operator, value, options}). NOTE: create_smart_list (the older tool) targets the opportunity-list service and cannot express contact filters.",
|
|
13826
|
+
{
|
|
13827
|
+
name: import_zod48.z.string().describe("Display name for the smart list."),
|
|
13828
|
+
tagFilters: import_zod48.z.array(import_zod48.z.array(import_zod48.z.string())).optional().describe('Tag conditions. Each inner array is one filter requiring ALL those tags, e.g. [["escalated"]] for "has the escalated tag".'),
|
|
13829
|
+
rawFilters: import_zod48.z.array(import_zod48.z.record(import_zod48.z.unknown())).optional().describe("Advanced: raw filter leaves {field, operator, value, options}. Combined with tagFilters into one AND group."),
|
|
13830
|
+
locationId: import_zod48.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
|
|
13831
|
+
},
|
|
13832
|
+
async ({ name, tagFilters, rawFilters, locationId: locationId2 }) => {
|
|
13833
|
+
try {
|
|
13834
|
+
const loc = locationId2 ?? client.locationId;
|
|
13835
|
+
const filters = [
|
|
13836
|
+
...(tagFilters ?? []).map((tags) => buildContactTagFilter(tags)),
|
|
13837
|
+
...rawFilters ?? []
|
|
13838
|
+
];
|
|
13839
|
+
const body = buildContactSmartListBody({ listName: name, locationId: loc, filters });
|
|
13840
|
+
const headers = await client.buildHeaders();
|
|
13841
|
+
const res = await fetch(CONTACT_SMARTLIST_BASE, { method: "POST", headers, body: JSON.stringify(body) });
|
|
13842
|
+
if (!res.ok) throw new Error(`Smart Lists API Error ${res.status}: POST /contacts/smartlist
|
|
13843
|
+
${await res.text()}`);
|
|
13844
|
+
return jsonResponse(JSON.parse(await res.text() || "{}"));
|
|
13845
|
+
} catch (error) {
|
|
13846
|
+
return errorResponse(error);
|
|
13847
|
+
}
|
|
13848
|
+
}
|
|
13849
|
+
);
|
|
13850
|
+
server2.tool(
|
|
13851
|
+
"get_contact_smart_list",
|
|
13852
|
+
"Get a CONTACT smart list by id INCLUDING its filterSpecs. Use this to verify a contact smart list's filters after creating it \u2014 get_smart_list targets the opportunity-list service and returns the record without any filters, which reads as 'no filters set'.",
|
|
13853
|
+
{
|
|
13854
|
+
listId: import_zod48.z.string().describe("The contact smart list id.")
|
|
13855
|
+
},
|
|
13856
|
+
async ({ listId }) => {
|
|
13857
|
+
try {
|
|
13858
|
+
const headers = await client.buildHeaders();
|
|
13859
|
+
const res = await fetch(`${CONTACT_SMARTLIST_BASE}/${listId}?transform=true`, { headers });
|
|
13860
|
+
if (!res.ok) throw new Error(`Smart Lists API Error ${res.status}: GET /contacts/smartlist/${listId}
|
|
13861
|
+
${await res.text()}`);
|
|
13862
|
+
return jsonResponse(JSON.parse(await res.text() || "{}"));
|
|
13863
|
+
} catch (error) {
|
|
13864
|
+
return errorResponse(error);
|
|
13865
|
+
}
|
|
13866
|
+
}
|
|
13867
|
+
);
|
|
13868
|
+
server2.tool(
|
|
13869
|
+
"delete_contact_smart_list",
|
|
13870
|
+
"Delete a CONTACT smart list by id.",
|
|
13871
|
+
{
|
|
13872
|
+
listId: import_zod48.z.string().describe("The contact smart list id."),
|
|
13873
|
+
confirm: import_zod48.z.literal("DELETE").describe('Type "DELETE" to confirm.')
|
|
13874
|
+
},
|
|
13875
|
+
async ({ listId }) => {
|
|
13876
|
+
try {
|
|
13877
|
+
const headers = await client.buildHeaders();
|
|
13878
|
+
const res = await fetch(CONTACT_SMARTLIST_DELETE, {
|
|
13879
|
+
method: "POST",
|
|
13880
|
+
headers,
|
|
13881
|
+
body: JSON.stringify({ smartlist_id: listId })
|
|
13882
|
+
});
|
|
13883
|
+
if (!res.ok) throw new Error(`Smart Lists API Error ${res.status}: POST /smartlist/delete
|
|
13884
|
+
${await res.text()}`);
|
|
13885
|
+
return jsonResponse(JSON.parse(await res.text() || "{}"));
|
|
13886
|
+
} catch (error) {
|
|
13887
|
+
return errorResponse(error);
|
|
13888
|
+
}
|
|
13889
|
+
}
|
|
13890
|
+
);
|
|
13720
13891
|
server2.tool(
|
|
13721
13892
|
"get_smart_list",
|
|
13722
13893
|
"Get a single smart list by ID with its full configuration: filters, columns, permissions, and metadata. The filters array is what defines who/what is in the list.",
|
|
@@ -15956,18 +16127,20 @@ function splitList(text) {
|
|
|
15956
16127
|
}
|
|
15957
16128
|
function parsePricePoints(text) {
|
|
15958
16129
|
const out = [];
|
|
15959
|
-
|
|
15960
|
-
|
|
16130
|
+
const segments = text.split(/\r?\n|;|\.\s+/);
|
|
16131
|
+
const ENTRY = /^(.*?)\s*[-–—:|]?\s*\$?\s*([\d,]+(?:\.\d+)?)\s*(?:[-–—]\s*\$?\s*([\d,]+(?:\.\d+)?))?\s*$/;
|
|
16132
|
+
const clean = (s) => s.replace(/[\s.,:;|–—-]+$/, "").trim();
|
|
16133
|
+
const num = (s) => s.replace(/,/g, "");
|
|
16134
|
+
for (const segment of segments) {
|
|
16135
|
+
const t = segment.trim().replace(/\.$/, "");
|
|
15961
16136
|
if (!t) continue;
|
|
15962
|
-
const m = t.match(
|
|
15963
|
-
if (m) {
|
|
15964
|
-
|
|
16137
|
+
const m = t.match(ENTRY);
|
|
16138
|
+
if (m && m[2]) {
|
|
16139
|
+
const name = clean(m[1]);
|
|
16140
|
+
const price = m[3] ? `${num(m[2])}-${num(m[3])}` : num(m[2]);
|
|
16141
|
+
out.push({ name: name || t, price });
|
|
15965
16142
|
} else {
|
|
15966
|
-
|
|
15967
|
-
out.push({
|
|
15968
|
-
name: priceOnly ? t.slice(0, priceOnly.index).trim() || t : t,
|
|
15969
|
-
price: priceOnly ? priceOnly[1].replace(/,/g, "") : ""
|
|
15970
|
-
});
|
|
16143
|
+
out.push({ name: t, price: "" });
|
|
15971
16144
|
}
|
|
15972
16145
|
}
|
|
15973
16146
|
return out;
|
|
@@ -16310,6 +16483,8 @@ var findOpportunitySchema = import_zod58.z.object({
|
|
|
16310
16483
|
});
|
|
16311
16484
|
var actionSchema = import_zod58.z.discriminatedUnion("type", [...branchActionOptions, findOpportunitySchema]);
|
|
16312
16485
|
var APPOINTMENT_STATUSES = ["new", "confirmed", "showed", "noshow", "cancelled", "invalid"];
|
|
16486
|
+
var CALL_STATUSES = ["busy", "canceled", "voicemail", "no-answer", "completed"];
|
|
16487
|
+
var NUMBER_VALIDATION_STATES = ["not_valid", "sms_incapable"];
|
|
16313
16488
|
var triggerSchema = import_zod58.z.object({
|
|
16314
16489
|
type: import_zod58.z.string(),
|
|
16315
16490
|
formRef: nsRef("form").optional(),
|
|
@@ -16318,7 +16493,34 @@ var triggerSchema = import_zod58.z.object({
|
|
|
16318
16493
|
pipelineRef: nsRef("pipeline").optional(),
|
|
16319
16494
|
stageRef: nsRef("stage").optional(),
|
|
16320
16495
|
// Required for a native `appointment` trigger (the status it fires on).
|
|
16321
|
-
appointmentStatus: import_zod58.z.enum(APPOINTMENT_STATUSES).optional()
|
|
16496
|
+
appointmentStatus: import_zod58.z.enum(APPOINTMENT_STATUSES).optional(),
|
|
16497
|
+
// ── customer_reply scoping ──────────────────────────────────────
|
|
16498
|
+
// A bare customer_reply trigger fires on EVERY inbound reply from EVERY
|
|
16499
|
+
// contact. That is almost never what a plan means: "alert the owner when a
|
|
16500
|
+
// referral partner replies" must not fire for a family's reply. GHL's
|
|
16501
|
+
// ContactReplyFilter supports both of these (see templates/trigger-schemas.json):
|
|
16502
|
+
// hasTagRef → contact.tags / index-of-true (only replies from contacts
|
|
16503
|
+
// carrying this tag)
|
|
16504
|
+
// replyIntent → message.body / matches_intent (GHL classifies the reply as
|
|
16505
|
+
// a positive or negative response; the option values are
|
|
16506
|
+
// "schedule-yes" and "schedule-no")
|
|
16507
|
+
// Both optional and additive — omitting them keeps the long-standing
|
|
16508
|
+
// fires-on-any-reply baseline.
|
|
16509
|
+
hasTagRef: nsRef("tag").optional(),
|
|
16510
|
+
replyIntent: import_zod58.z.enum(["positive", "negative"]).optional(),
|
|
16511
|
+
// ── call_status scoping (missed-call text-back) ─────────────────
|
|
16512
|
+
// GHL's call_status trigger fires on a completed call attempt. The states it
|
|
16513
|
+
// exposes are exactly what a "missed call" means operationally. Required for
|
|
16514
|
+
// a call_status trigger — without them the trigger would fire on EVERY call
|
|
16515
|
+
// including answered ones, so the executor refuses rather than guess.
|
|
16516
|
+
callStatuses: import_zod58.z.array(import_zod58.z.enum(CALL_STATUSES)).optional(),
|
|
16517
|
+
callDirection: import_zod58.z.enum(["inbound", "outbound"]).optional(),
|
|
16518
|
+
// ── validation_error (GHL UI: "Number validation") ──────────────
|
|
16519
|
+
// Fires after a phone number passes or fails a validation check. The UI shows
|
|
16520
|
+
// "Not valid" / "SMS incapable"; the WIRE values are snake_case. Required for
|
|
16521
|
+
// a validation_error trigger — without them the executor refuses rather than
|
|
16522
|
+
// guess which failure states to fire on.
|
|
16523
|
+
numberValidation: import_zod58.z.array(import_zod58.z.enum(NUMBER_VALIDATION_STATES)).optional()
|
|
16322
16524
|
});
|
|
16323
16525
|
var workflowSchema = import_zod58.z.object({
|
|
16324
16526
|
ref: nsRef("workflow"),
|
|
@@ -16437,6 +16639,7 @@ function checkRefIntegrity(plan, defined) {
|
|
|
16437
16639
|
const t = w.trigger;
|
|
16438
16640
|
check(t.formRef, "form", `workflows[${w.ref}].trigger.formRef`);
|
|
16439
16641
|
check(t.tagRef, "tag", `workflows[${w.ref}].trigger.tagRef`);
|
|
16642
|
+
check(t.hasTagRef, "tag", `workflows[${w.ref}].trigger.hasTagRef`);
|
|
16440
16643
|
check(t.calendarRef, "calendar", `workflows[${w.ref}].trigger.calendarRef`);
|
|
16441
16644
|
check(t.pipelineRef, "pipeline", `workflows[${w.ref}].trigger.pipelineRef`);
|
|
16442
16645
|
check(t.stageRef, "stage", `workflows[${w.ref}].trigger.stageRef`);
|
|
@@ -16599,6 +16802,21 @@ function validateBuildPlan(input) {
|
|
|
16599
16802
|
);
|
|
16600
16803
|
}
|
|
16601
16804
|
}
|
|
16805
|
+
for (const wf of plan.workflows ?? []) {
|
|
16806
|
+
let seenFind = false;
|
|
16807
|
+
for (const a of wf.actions) {
|
|
16808
|
+
if (a.type === "find_opportunity") {
|
|
16809
|
+
seenFind = true;
|
|
16810
|
+
continue;
|
|
16811
|
+
}
|
|
16812
|
+
if (a.type === "update_opportunity" && !seenFind) {
|
|
16813
|
+
warnings.push(
|
|
16814
|
+
`workflow "${wf.ref}" has an update_opportunity ("move to stage") with no find_opportunity before it \u2014 GHL updates the opportunity currently in workflow context, so with nothing loaded this action SILENTLY DOES NOTHING and the workflow carries on as if it worked. Put the move inside a find_opportunity "found" branch.`
|
|
16815
|
+
);
|
|
16816
|
+
break;
|
|
16817
|
+
}
|
|
16818
|
+
}
|
|
16819
|
+
}
|
|
16602
16820
|
const MAX_NODES = 40;
|
|
16603
16821
|
for (const wf of plan.workflows ?? []) {
|
|
16604
16822
|
let nodeCount = 0;
|
|
@@ -17148,18 +17366,87 @@ function expandTrigger(trigger, idx, idMap) {
|
|
|
17148
17366
|
case "appointment": {
|
|
17149
17367
|
const status = trigger.appointmentStatus;
|
|
17150
17368
|
if (!status) return manual(`Set this workflow's appointment trigger in the GHL UI: ${triggerPlainEnglish(trigger, idx)} (the plan trigger has no appointmentStatus to fire on, e.g. confirmed / noshow).`);
|
|
17369
|
+
const conditions = [
|
|
17370
|
+
{ operator: "==", field: "appointment.eventType", value: "normal", title: "Event Type", type: "select" },
|
|
17371
|
+
{ operator: "==", field: "appointment.status", value: status, title: "Appointment status is", type: "select" }
|
|
17372
|
+
];
|
|
17373
|
+
const pending = [];
|
|
17374
|
+
if (trigger.calendarRef) {
|
|
17375
|
+
const calendarId = resolveId(trigger.calendarRef, idMap);
|
|
17376
|
+
if (isPending(calendarId)) pending.push(trigger.calendarRef);
|
|
17377
|
+
conditions.unshift({
|
|
17378
|
+
operator: "==",
|
|
17379
|
+
field: "calendar.id",
|
|
17380
|
+
value: calendarId,
|
|
17381
|
+
title: "In calendar",
|
|
17382
|
+
type: "select"
|
|
17383
|
+
});
|
|
17384
|
+
}
|
|
17151
17385
|
return native({
|
|
17152
17386
|
name: `Appointment Status \u2014 ${status}`,
|
|
17153
17387
|
type: "appointment",
|
|
17388
|
+
conditions
|
|
17389
|
+
}, pending);
|
|
17390
|
+
}
|
|
17391
|
+
case "call_status": {
|
|
17392
|
+
const statuses = trigger.callStatuses;
|
|
17393
|
+
if (!statuses || statuses.length === 0) {
|
|
17394
|
+
return manual(
|
|
17395
|
+
`Set this workflow's call trigger in the GHL UI and choose which call outcomes it fires on (e.g. no-answer, busy, voicemail for a missed call). Blueprint will not build it without them: an unscoped call trigger fires on EVERY call, answered ones included.`
|
|
17396
|
+
);
|
|
17397
|
+
}
|
|
17398
|
+
const callConditions = [
|
|
17399
|
+
{ operator: "contains-any", field: "call_status", value: statuses, title: "Call Status" }
|
|
17400
|
+
];
|
|
17401
|
+
if (trigger.callDirection) {
|
|
17402
|
+
callConditions.push({
|
|
17403
|
+
operator: "==",
|
|
17404
|
+
field: "message.direction",
|
|
17405
|
+
value: trigger.callDirection,
|
|
17406
|
+
title: "Call Direction",
|
|
17407
|
+
type: "select"
|
|
17408
|
+
});
|
|
17409
|
+
}
|
|
17410
|
+
return native({ name: "Call Status", type: "call_status", conditions: callConditions });
|
|
17411
|
+
}
|
|
17412
|
+
case "validation_error": {
|
|
17413
|
+
const states = trigger.numberValidation;
|
|
17414
|
+
if (!states || states.length === 0) {
|
|
17415
|
+
return manual(
|
|
17416
|
+
`Set this workflow's "Number validation" trigger in the GHL UI and choose which states it fires on (Not valid / SMS incapable). Blueprint will not build it without them rather than guess which failures matter.`
|
|
17417
|
+
);
|
|
17418
|
+
}
|
|
17419
|
+
return native({
|
|
17420
|
+
name: "Number Validation",
|
|
17421
|
+
type: "validation_error",
|
|
17154
17422
|
conditions: [
|
|
17155
|
-
{ operator: "
|
|
17156
|
-
{ operator: "==", field: "appointment.status", value: status, title: "Appointment status is", type: "select" }
|
|
17423
|
+
{ operator: "contains-any", field: "contact.phoneInfo", value: states, title: "Number is", type: "multiselect" }
|
|
17157
17424
|
]
|
|
17158
17425
|
});
|
|
17159
17426
|
}
|
|
17160
17427
|
case "customer_reply":
|
|
17161
17428
|
case "contact_replied": {
|
|
17162
|
-
|
|
17429
|
+
const replyConditions = [];
|
|
17430
|
+
if (trigger.hasTagRef) {
|
|
17431
|
+
const tagName = idx.tagName.get(trigger.hasTagRef) ?? trigger.hasTagRef;
|
|
17432
|
+
replyConditions.push({
|
|
17433
|
+
operator: "index-of-true",
|
|
17434
|
+
field: "contact.tags",
|
|
17435
|
+
value: tagName,
|
|
17436
|
+
title: "Has Tag",
|
|
17437
|
+
type: "select"
|
|
17438
|
+
});
|
|
17439
|
+
}
|
|
17440
|
+
if (trigger.replyIntent) {
|
|
17441
|
+
replyConditions.push({
|
|
17442
|
+
operator: "matches_intent",
|
|
17443
|
+
field: "message.body",
|
|
17444
|
+
value: trigger.replyIntent === "positive" ? "schedule-yes" : "schedule-no",
|
|
17445
|
+
title: "Intent type",
|
|
17446
|
+
type: "select"
|
|
17447
|
+
});
|
|
17448
|
+
}
|
|
17449
|
+
return native({ name: "Customer Replied", type: "customer_reply", conditions: replyConditions });
|
|
17163
17450
|
}
|
|
17164
17451
|
case "pipeline_stage_updated": {
|
|
17165
17452
|
if (!trigger.pipelineRef || !trigger.stageRef) return manual("Set this workflow's pipeline-stage trigger in the GHL UI (the plan trigger needs both a pipeline and a stage).");
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elitedcs/ghl-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.65.0",
|
|
4
4
|
"mcpName": "io.github.drjerryrelth/ghl-command",
|
|
5
|
-
"description": "GoHighLevel MCP Server for Claude.
|
|
5
|
+
"description": "GoHighLevel MCP Server for Claude. 238 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.",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"bin": {
|
|
8
8
|
"ghl-mcp": "dist/index.js"
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_description": "AUTHORITATIVE REFERENCE: Correct GHL workflow action attribute schemas. Derived from real working workflows built in GHL UI. ALL sessions MUST use these exact formats.",
|
|
3
|
-
"_version": "2.0
|
|
3
|
+
"_version": "2.0 \u2014 2026-03-24",
|
|
4
4
|
"_critical_rules": [
|
|
5
|
-
"NEVER use 'body' for emails
|
|
6
|
-
"NEVER use 'tagName'
|
|
7
|
-
"NEVER use 'duration' for waits
|
|
8
|
-
"NEVER use singular min units
|
|
5
|
+
"NEVER use 'body' for emails \u2014 use 'html' with HTML markup",
|
|
6
|
+
"NEVER use 'tagName' \u2014 use 'tags' array",
|
|
7
|
+
"NEVER use 'duration' for waits \u2014 use startAfter object",
|
|
8
|
+
"NEVER use singular min units \u2014 real GHL uses 'minutes'",
|
|
9
9
|
"ALWAYS include 'attachments: []' on SMS actions",
|
|
10
10
|
"ALWAYS include 'trackingOptions' on email actions",
|
|
11
11
|
"ALWAYS include all 5 fields on wait actions (type, startAfter, isHybridAction, hybridActionType, transitions)",
|
|
12
12
|
"ALWAYS use 'parent' AND 'parentKey' on branch nodes",
|
|
13
13
|
"ALWAYS use 'sibling' arrays on branch-yes and branch-no nodes",
|
|
14
|
-
"NEVER use if_else as a single flat action
|
|
14
|
+
"NEVER use if_else as a single flat action \u2014 it is only valid on condition-node, branch-yes, and branch-no nodes",
|
|
15
15
|
"ALWAYS include workflow_id ARRAY in remove_from_workflow (not just workflowId string)",
|
|
16
16
|
"For linear workflows, the buildActionChain needs BOTH next (forward) and parentKey (backward) on every action",
|
|
17
17
|
"Keep workflows under 40 actions to avoid GHL UI renderer freezes",
|
|
@@ -48,10 +48,9 @@
|
|
|
48
48
|
"hasTrackingLinks": false,
|
|
49
49
|
"hasUtmTracking": false,
|
|
50
50
|
"hasTags": false
|
|
51
|
-
}
|
|
52
|
-
"cc": "{{contact.sales_rep_email}}"
|
|
51
|
+
}
|
|
53
52
|
},
|
|
54
|
-
"notes": "Use 'html' NOT 'body'. Content MUST be HTML with <p> tags. Subject supports merge fields. trackingOptions is REQUIRED.
|
|
53
|
+
"notes": "Use 'html' NOT 'body'. Content MUST be HTML with <p> tags. Subject supports merge fields. trackingOptions is REQUIRED."
|
|
55
54
|
},
|
|
56
55
|
"wait": {
|
|
57
56
|
"example_1hour": {
|
|
@@ -107,7 +106,20 @@
|
|
|
107
106
|
"selectedUser": ""
|
|
108
107
|
}
|
|
109
108
|
},
|
|
110
|
-
"notes": "Nested 'notification' object REQUIRED. selectedUser empty string for all users, or specific user ID."
|
|
109
|
+
"notes": "Nested 'notification' object REQUIRED. selectedUser empty string for all users, or specific user ID. EMAIL CHANNEL (verified live 2026-07-20, PWDJ workflow e91f28da): attributes.type is 'email' (NOT 'notification'), nested key is 'email' (NOT 'notification'), body field is 'html' (NOT 'body'), selectedUser is an ARRAY of user IDs, include attachments:[] and isCloned:false. A 'send_email' discriminator inside a 'notification' object saves but silently never sends.",
|
|
110
|
+
"emailChannelExample": {
|
|
111
|
+
"type": "email",
|
|
112
|
+
"email": {
|
|
113
|
+
"isCloned": false,
|
|
114
|
+
"html": "<p style=\"margin:0px;\">We received a new Collector story.</p>",
|
|
115
|
+
"subject": "New Painting Collector Story Received",
|
|
116
|
+
"selectedUser": [
|
|
117
|
+
"<userId>"
|
|
118
|
+
],
|
|
119
|
+
"userType": "user",
|
|
120
|
+
"attachments": []
|
|
121
|
+
}
|
|
122
|
+
}
|
|
111
123
|
},
|
|
112
124
|
"update_contact_field": {
|
|
113
125
|
"example": {
|
|
@@ -173,7 +185,7 @@
|
|
|
173
185
|
"workflowsActionType": "INTERNAL",
|
|
174
186
|
"type": "internal_update_opportunity"
|
|
175
187
|
},
|
|
176
|
-
"notes": "CREATABLE from scratch (re-enabled v3.41.0). The discriminator workflowsActionType:'INTERNAL' MUST sit at the NODE level, never nested in attributes
|
|
188
|
+
"notes": "CREATABLE from scratch (re-enabled v3.41.0). The discriminator workflowsActionType:'INTERNAL' MUST sit at the NODE level, never nested in attributes \u2014 a nested copy makes GHL reject the node as 'action has a corrupted type' and silently fail the whole save. update_workflow_actions normalizes this for you (hoists workflowsActionType to the node level, scaffolds allowBackward + __customInputs__, gives each __customInputFields__ entry an __customInputs__). The shape below (workflowsActionType at the node level alongside type/name/attributes) is correct for both creating and round-tripping. Use pipeline and stage IDs (not names) \u2014 get_pipelines / list_pipelines_full to find them FIRST. CRITICAL: if the pipelineId or pipelineStageId don't exist in the target sub-account, GHL silently fails this action AND can kill subsequent actions. A synthesized node needs BOTH a pipelineId and a pipelineStageId entry; a node round-tripped via get_workflow_full keeps its id and passes through unchanged."
|
|
177
189
|
},
|
|
178
190
|
"_if_else_branching": {
|
|
179
191
|
"notes": "if_else is a node type discriminator only. Do not send a single flat if_else action.",
|
|
@@ -297,7 +309,7 @@
|
|
|
297
309
|
"action": "exit"
|
|
298
310
|
}
|
|
299
311
|
},
|
|
300
|
-
"notes": "Goal-event nodes sit inline in the action chain
|
|
312
|
+
"notes": "Goal-event nodes sit inline in the action chain \u2014 when the goal condition fires during workflow execution, the configured action runs (default: 'exit' terminates the workflow path). The previous action's `next` should point to the goal node's id; the goal node itself does NOT have a `next` field. Use build_goal_event to construct the node \u2014 it handles UUID generation and the nested op/segments/conditions shape. Verified goal_condition value: 'review_request_clicked' (with extras { reviewTypes: ['sms', 'email'], reviewLinkId: '' }). Other goal_condition strings from GHL's UI pass through verbatim; the full catalogue of goal conditions has not been captured yet.",
|
|
301
313
|
"build_helper": "build_goal_event(goal_condition, extras?, action='exit', target_node_id?, name='Goal', op='or', inner_op='or')"
|
|
302
314
|
},
|
|
303
315
|
"_trigger_patterns": {
|
|
@@ -378,8 +390,8 @@
|
|
|
378
390
|
"notes": "Workflow GET requests must include ?includeTriggers=true or triggers silently disappear. Each trigger type has type-specific condition fields."
|
|
379
391
|
},
|
|
380
392
|
"_workflow_design_patterns": {
|
|
381
|
-
"nurture_sequence": "Use linear workflow with stopOnResponse:true. Create separate 'exit workflows' triggered by tags (appointment-booked, do-not-contact) that use remove_from_workflow to pull contacts out. Do NOT use inline if/else gates at every step
|
|
382
|
-
"exit_workflow": "Small 3-4 step workflow: remove_from_workflow
|
|
393
|
+
"nurture_sequence": "Use linear workflow with stopOnResponse:true. Create separate 'exit workflows' triggered by tags (appointment-booked, do-not-contact) that use remove_from_workflow to pull contacts out. Do NOT use inline if/else gates at every step \u2014 this creates too many actions and can freeze GHL.",
|
|
394
|
+
"exit_workflow": "Small 3-4 step workflow: remove_from_workflow \u2192 note \u2192 tag \u2192 notify. Triggered by a tag being added to the contact.",
|
|
383
395
|
"max_actions": "Keep workflows under 40 actions. GHL's UI renderer struggles with larger workflows. Split into multiple connected workflows if needed."
|
|
384
396
|
}
|
|
385
|
-
}
|
|
397
|
+
}
|