@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 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.** 235 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.
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) — 109 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.
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 (235 tools total with the optional Workflow Builder Firebase add-on).
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 52 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. |
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 235 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`:
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 ? "109" : "235";
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 52 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 235 after the next Claude restart.",
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 235 tools load. Try: "List my workflows in full detail" or "Validate workflow <id>".',
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 235 tools load. Try: "List my workflows in full detail".',
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.64.0",
2857
+ version: "3.65.0",
2858
2858
  mcpName: "io.github.drjerryrelth/ghl-command",
2859
- description: "GoHighLevel MCP Server for Claude. 235 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.",
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
- const body = buildUpdateCalendarBody(args);
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
- if (limit !== void 0) params.limit = limit;
8628
- if (offset !== void 0) params.offset = offset;
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
- async ({ locationId: locationId2 }) => {
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
- async ({ locationId: locationId2 }) => {
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
- for (const line of text.split(/\r?\n/)) {
15960
- const t = line.trim();
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(/^(.*?)[\s]*[-—:|]\s*\$?\s*([\d,]+(?:\.\d+)?)/);
15963
- if (m) {
15964
- out.push({ name: m[1].trim(), price: m[2].replace(/,/g, "") });
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
- const priceOnly = t.match(/\$?\s*([\d,]+(?:\.\d+)?)\s*$/);
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: "==", field: "appointment.eventType", value: "normal", title: "Event Type", type: "select" },
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
- return native({ name: "Customer Replied", type: "customer_reply", conditions: [] });
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.64.0",
3
+ "version": "3.65.0",
4
4
  "mcpName": "io.github.drjerryrelth/ghl-command",
5
- "description": "GoHighLevel MCP Server for Claude. 235 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.",
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 2026-03-24",
3
+ "_version": "2.0 \u2014 2026-03-24",
4
4
  "_critical_rules": [
5
- "NEVER use 'body' for emails use 'html' with HTML markup",
6
- "NEVER use 'tagName' use 'tags' array",
7
- "NEVER use 'duration' for waits use startAfter object",
8
- "NEVER use singular min units real GHL uses 'minutes'",
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 it is only valid on condition-node, branch-yes, and branch-no nodes",
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. RECIPIENT: there is NO 'to' field — the email action ALWAYS sends to the enrolled contact (UI reads 'Send an email to the contact'). To copy a third party use attributes.cc (plain string, accepts merge fields e.g. {{contact.sales_rep_email}}); 'bcc' presumably mirrors it but is NOT live-verified. Never build a 'send to a third party' node expecting a To field — it silently mails the contact instead. (cc shape live-captured 2026-08-08, CLL.)"
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 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) 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."
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 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 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.",
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 this creates too many actions and can freeze GHL.",
382
- "exit_workflow": "Small 3-4 step workflow: remove_from_workflow note tag notify. Triggered by a tag being added to the contact.",
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
+ }