@elitedcs/ghl-mcp 3.66.2 → 3.67.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 +38 -0
- package/README.md +4 -4
- package/dist/index.js +696 -50
- package/guide/guide.html +36 -3
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.67.0 — the offboarding kit
|
|
4
|
+
|
|
5
|
+
Every agency has an onboarding checklist. Almost none have an offboarding one, so
|
|
6
|
+
the day a client leaves turns into a week of hunting for logins, exports, and
|
|
7
|
+
"wait, who owns the pixel?"
|
|
8
|
+
|
|
9
|
+
Ask Claude to build the offboarding kit for an account and you get the whole exit
|
|
10
|
+
package in one go: every contact as a real export rather than the first hundred,
|
|
11
|
+
an inventory of what the account contains, a map of every person and connected
|
|
12
|
+
account that can still get in, and a handoff document written for whoever picks
|
|
13
|
+
the account up next.
|
|
14
|
+
|
|
15
|
+
Three things it deliberately will not do.
|
|
16
|
+
|
|
17
|
+
It will not print your keys. GoHighLevel does not hand key values to any tool,
|
|
18
|
+
including this one, so instead you get a list of every place a key lives and the
|
|
19
|
+
instruction to rotate it. That was the useful half anyway.
|
|
20
|
+
|
|
21
|
+
It will not guess who owns what. GoHighLevel stores nothing about ownership, so
|
|
22
|
+
the kit asks once and writes the answers into the client's own account as custom
|
|
23
|
+
values, where they outlive this tool and the client can read them without asking
|
|
24
|
+
you. Answer them the day you take the client on and every later run is instant.
|
|
25
|
+
Anything unanswered is listed as unanswered, never assumed.
|
|
26
|
+
|
|
27
|
+
It will not let a short export look like a complete one. The contact export
|
|
28
|
+
reports its own count and GoHighLevel's index count side by side rather than
|
|
29
|
+
claiming they agree, and if it stopped early the handoff document says INCOMPLETE
|
|
30
|
+
in its own text, not in a field somebody has to notice. A section that could not
|
|
31
|
+
be read says so and why, instead of showing zero.
|
|
32
|
+
|
|
33
|
+
The account you name is the account you get. Both new tools require the
|
|
34
|
+
sub-account explicitly and reach for that account's own key, and the parts that
|
|
35
|
+
depend on the workflow builder refuse outright when the builder is pointed
|
|
36
|
+
somewhere else. Handing one client's webhooks to another on their way out is the
|
|
37
|
+
one mistake an exit package must not be able to make.
|
|
38
|
+
|
|
39
|
+
**New: `build_offboarding_kit` and `record_asset_ownership`.** 241 tools.
|
|
40
|
+
|
|
3
41
|
## 3.66.2 — an export could describe the wrong client
|
|
4
42
|
|
|
5
43
|
If you asked for one client's account and the server had been started pointing at
|
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.** 241 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
|
|
|
@@ -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 (241 tools total with the optional Workflow Builder Firebase add-on).
|
|
139
139
|
|
|
140
140
|
### 4. Try it
|
|
141
141
|
|
|
@@ -175,7 +175,7 @@ https://app.gohighlevel.com/v2/location/YOUR_LOCATION_ID/dashboard
|
|
|
175
175
|
|
|
176
176
|
## Enable the Workflow Builder
|
|
177
177
|
|
|
178
|
-
The builder + cloner + validator tools (workflow builder, funnel builder, form builder, pipeline builder, workflow cloner, validate_workflow) use GHL's internal API and require Firebase credentials. Without them, the other
|
|
178
|
+
The builder + cloner + validator tools (workflow builder, funnel builder, form builder, pipeline builder, workflow cloner, validate_workflow) use GHL's internal API and require Firebase credentials. Without them, the other 186 tools work fine — you just won't have workflow/funnel/form/pipeline editing. This is the flagship feature: do this step.
|
|
179
179
|
|
|
180
180
|
**One-click capture (v3.49.0+, the supported path):**
|
|
181
181
|
|
|
@@ -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 241 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
|
@@ -147,10 +147,10 @@ var init_ghl_client = __esm({
|
|
|
147
147
|
throw new Error(`Request timeout (30s): ${method} ${path15}`);
|
|
148
148
|
}
|
|
149
149
|
if (!options.noRetry && attempt < MAX_RETRIES) {
|
|
150
|
-
const
|
|
151
|
-
process.stderr.write(`[ghl-mcp] Network error on ${method} ${path15}, retry ${attempt + 1}/${MAX_RETRIES} in ${
|
|
150
|
+
const delay5 = computeRetryDelay(null, attempt, BASE_DELAY_MS);
|
|
151
|
+
process.stderr.write(`[ghl-mcp] Network error on ${method} ${path15}, retry ${attempt + 1}/${MAX_RETRIES} in ${delay5}ms
|
|
152
152
|
`);
|
|
153
|
-
await new Promise((r) => setTimeout(r,
|
|
153
|
+
await new Promise((r) => setTimeout(r, delay5));
|
|
154
154
|
return this.request(method, path15, options, attempt + 1);
|
|
155
155
|
}
|
|
156
156
|
throw error;
|
|
@@ -158,10 +158,10 @@ var init_ghl_client = __esm({
|
|
|
158
158
|
clearTimeout(timeout);
|
|
159
159
|
}
|
|
160
160
|
if (!options.noRetry && (response.status === 429 || response.status >= 500) && attempt < MAX_RETRIES) {
|
|
161
|
-
const
|
|
162
|
-
process.stderr.write(`[ghl-mcp] ${response.status} on ${method} ${path15}, retry ${attempt + 1}/${MAX_RETRIES} in ${
|
|
161
|
+
const delay5 = computeRetryDelay(response.headers.get("Retry-After"), attempt, BASE_DELAY_MS);
|
|
162
|
+
process.stderr.write(`[ghl-mcp] ${response.status} on ${method} ${path15}, retry ${attempt + 1}/${MAX_RETRIES} in ${delay5}ms
|
|
163
163
|
`);
|
|
164
|
-
await new Promise((r) => setTimeout(r,
|
|
164
|
+
await new Promise((r) => setTimeout(r, delay5));
|
|
165
165
|
return this.request(method, path15, options, attempt + 1);
|
|
166
166
|
}
|
|
167
167
|
if (!response.ok) {
|
|
@@ -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 ? "111" : "
|
|
1651
|
+
const finishedCount = isFree ? "111" : "241";
|
|
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 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
|
|
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 186 to 241 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
|
|
@@ -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.67.0",
|
|
2858
2858
|
mcpName: "io.github.drjerryrelth/ghl-command",
|
|
2859
|
-
description: "GoHighLevel MCP Server for Claude.
|
|
2859
|
+
description: "GoHighLevel MCP Server for Claude. 241 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"
|
|
@@ -6187,10 +6187,10 @@ var WorkflowBuilderClient = class _WorkflowBuilderClient {
|
|
|
6187
6187
|
throw new Error(`Request timeout (30s): ${method} ${urlPath}`);
|
|
6188
6188
|
}
|
|
6189
6189
|
if (attempt < MAX_RETRIES2) {
|
|
6190
|
-
const
|
|
6191
|
-
process.stderr.write(`[ghl-mcp] Network error on ${method} ${urlPath}, retry ${attempt + 1}/${MAX_RETRIES2} in ${
|
|
6190
|
+
const delay5 = computeRetryDelay(null, attempt, BASE_DELAY_MS2);
|
|
6191
|
+
process.stderr.write(`[ghl-mcp] Network error on ${method} ${urlPath}, retry ${attempt + 1}/${MAX_RETRIES2} in ${delay5}ms
|
|
6192
6192
|
`);
|
|
6193
|
-
await new Promise((r) => setTimeout(r,
|
|
6193
|
+
await new Promise((r) => setTimeout(r, delay5));
|
|
6194
6194
|
return this.request(method, urlPath, body, attempt + 1);
|
|
6195
6195
|
}
|
|
6196
6196
|
throw error;
|
|
@@ -6198,10 +6198,10 @@ var WorkflowBuilderClient = class _WorkflowBuilderClient {
|
|
|
6198
6198
|
clearTimeout(timeout);
|
|
6199
6199
|
}
|
|
6200
6200
|
if ((response.status === 429 || response.status >= 500) && attempt < MAX_RETRIES2) {
|
|
6201
|
-
const
|
|
6202
|
-
process.stderr.write(`[ghl-mcp] ${response.status} on ${method} ${urlPath}, retry ${attempt + 1}/${MAX_RETRIES2} in ${
|
|
6201
|
+
const delay5 = computeRetryDelay(response.headers.get("Retry-After"), attempt, BASE_DELAY_MS2);
|
|
6202
|
+
process.stderr.write(`[ghl-mcp] ${response.status} on ${method} ${urlPath}, retry ${attempt + 1}/${MAX_RETRIES2} in ${delay5}ms
|
|
6203
6203
|
`);
|
|
6204
|
-
await new Promise((r) => setTimeout(r,
|
|
6204
|
+
await new Promise((r) => setTimeout(r, delay5));
|
|
6205
6205
|
return this.request(method, urlPath, body, attempt + 1);
|
|
6206
6206
|
}
|
|
6207
6207
|
if (!response.ok) {
|
|
@@ -10182,6 +10182,18 @@ function summarizeWebhookWorkflow(workflow) {
|
|
|
10182
10182
|
};
|
|
10183
10183
|
}
|
|
10184
10184
|
var WorkflowListSchema = import_zod34.z.object({ rows: import_zod34.z.array(import_zod34.z.object({ _id: import_zod34.z.string().optional(), id: import_zod34.z.string().optional() }).passthrough()).optional() }).passthrough();
|
|
10185
|
+
async function loadWebhooks(client, limit) {
|
|
10186
|
+
const listed = WorkflowListSchema.parse(await client.listWorkflows(limit, 0));
|
|
10187
|
+
const rows = listed.rows ?? [];
|
|
10188
|
+
const webhooks = [];
|
|
10189
|
+
for (const row of rows) {
|
|
10190
|
+
const id = row._id ?? row.id;
|
|
10191
|
+
if (!id) continue;
|
|
10192
|
+
const summary = summarizeWebhookWorkflow(await client.getWorkflow(id));
|
|
10193
|
+
if (summary) webhooks.push(summary);
|
|
10194
|
+
}
|
|
10195
|
+
return { webhooks, scanned: rows.length };
|
|
10196
|
+
}
|
|
10185
10197
|
function registerWebhookTools(server2, builderClient) {
|
|
10186
10198
|
const requireClient = () => {
|
|
10187
10199
|
if (!builderClient) {
|
|
@@ -10191,18 +10203,6 @@ function registerWebhookTools(server2, builderClient) {
|
|
|
10191
10203
|
}
|
|
10192
10204
|
return builderClient;
|
|
10193
10205
|
};
|
|
10194
|
-
const loadWebhooks = async (client, limit) => {
|
|
10195
|
-
const listed = WorkflowListSchema.parse(await client.listWorkflows(limit, 0));
|
|
10196
|
-
const rows = listed.rows ?? [];
|
|
10197
|
-
const webhooks = [];
|
|
10198
|
-
for (const row of rows) {
|
|
10199
|
-
const id = row._id ?? row.id;
|
|
10200
|
-
if (!id) continue;
|
|
10201
|
-
const summary = summarizeWebhookWorkflow(await client.getWorkflow(id));
|
|
10202
|
-
if (summary) webhooks.push(summary);
|
|
10203
|
-
}
|
|
10204
|
-
return { webhooks, scanned: rows.length };
|
|
10205
|
-
};
|
|
10206
10206
|
server2.tool(
|
|
10207
10207
|
"list_webhooks",
|
|
10208
10208
|
"List every webhook in the current sub-account. A webhook here is a workflow that fires a `webhook` action, so this also finds ones built by hand in the GHL UI. Returns the URL, HTTP method and triggering events for each. NOTE: GoHighLevel has no webhook REST API (it is Marketplace-OAuth only) \u2014 webhooks live on the workflow layer, which is why each one has a workflow ID and shows up in the client's Automation list. " + WEBHOOK_PAYLOAD_NOTE,
|
|
@@ -13688,9 +13688,391 @@ function registerBulkOperationTools(server2, client) {
|
|
|
13688
13688
|
|
|
13689
13689
|
// src/tools/account-export.ts
|
|
13690
13690
|
var import_zod46 = require("zod");
|
|
13691
|
+
|
|
13692
|
+
// src/contact-export.ts
|
|
13693
|
+
var CONTACT_PAGE_SIZE = 100;
|
|
13694
|
+
var DEFAULT_MAX_CONTACTS = 5e3;
|
|
13695
|
+
var MAX_PAGES = 1e3;
|
|
13696
|
+
function isComplete(reason) {
|
|
13697
|
+
return reason === "complete";
|
|
13698
|
+
}
|
|
13699
|
+
function stopReasonSentence(reason, fetched, cap) {
|
|
13700
|
+
switch (reason) {
|
|
13701
|
+
case "complete":
|
|
13702
|
+
return `All ${fetched} contacts were exported.`;
|
|
13703
|
+
case "cap-reached":
|
|
13704
|
+
return `INCOMPLETE: ${fetched} contacts were exported and the limit of ${cap} was reached. This account has more contacts than that. Run it again with a higher limit before treating this file as the full list.`;
|
|
13705
|
+
case "cursor-exhausted":
|
|
13706
|
+
return `INCOMPLETE: ${fetched} contacts were exported, then GoHighLevel stopped providing a way to ask for the next page. Treat this file as partial.`;
|
|
13707
|
+
case "cursor-stalled":
|
|
13708
|
+
return `INCOMPLETE: ${fetched} contacts were exported, then GoHighLevel began returning the same page repeatedly. The export stopped rather than loop. Treat this file as partial.`;
|
|
13709
|
+
case "page-ceiling":
|
|
13710
|
+
return `INCOMPLETE: ${fetched} contacts were exported and the internal page ceiling was reached. Treat this file as partial.`;
|
|
13711
|
+
}
|
|
13712
|
+
}
|
|
13691
13713
|
function delay2(ms) {
|
|
13692
13714
|
return new Promise((resolve8) => setTimeout(resolve8, ms));
|
|
13693
13715
|
}
|
|
13716
|
+
async function fetchAllContacts(client, locationId2, options = {}) {
|
|
13717
|
+
const cap = options.maxContacts ?? DEFAULT_MAX_CONTACTS;
|
|
13718
|
+
const pauseMs = options.pauseMs ?? 100;
|
|
13719
|
+
const rows = [];
|
|
13720
|
+
let startAfter;
|
|
13721
|
+
let startAfterId;
|
|
13722
|
+
let pages = 0;
|
|
13723
|
+
let indexTotal = null;
|
|
13724
|
+
let stopReason = "complete";
|
|
13725
|
+
while (true) {
|
|
13726
|
+
if (pages >= MAX_PAGES) {
|
|
13727
|
+
stopReason = "page-ceiling";
|
|
13728
|
+
break;
|
|
13729
|
+
}
|
|
13730
|
+
const raw = await client.get("/contacts/", {
|
|
13731
|
+
params: {
|
|
13732
|
+
locationId: locationId2,
|
|
13733
|
+
limit: CONTACT_PAGE_SIZE,
|
|
13734
|
+
// Both cursor halves or neither. Sending one alone re-serves page 1.
|
|
13735
|
+
startAfter,
|
|
13736
|
+
startAfterId
|
|
13737
|
+
}
|
|
13738
|
+
});
|
|
13739
|
+
const parsed = ContactSearchResponseSchema.parse(raw);
|
|
13740
|
+
pages += 1;
|
|
13741
|
+
if (pages === 1 && typeof parsed.meta?.total === "number") {
|
|
13742
|
+
indexTotal = parsed.meta.total;
|
|
13743
|
+
}
|
|
13744
|
+
if (parsed.contacts.length === 0) {
|
|
13745
|
+
stopReason = "complete";
|
|
13746
|
+
break;
|
|
13747
|
+
}
|
|
13748
|
+
for (const contact of parsed.contacts) {
|
|
13749
|
+
rows.push(contact);
|
|
13750
|
+
if (rows.length >= cap) break;
|
|
13751
|
+
}
|
|
13752
|
+
if (rows.length >= cap) {
|
|
13753
|
+
stopReason = "cap-reached";
|
|
13754
|
+
break;
|
|
13755
|
+
}
|
|
13756
|
+
const nextAfter = parsed.meta?.startAfter ?? void 0;
|
|
13757
|
+
const nextAfterId = parsed.meta?.startAfterId ?? void 0;
|
|
13758
|
+
if (nextAfter == null || !nextAfterId) {
|
|
13759
|
+
stopReason = "cursor-exhausted";
|
|
13760
|
+
break;
|
|
13761
|
+
}
|
|
13762
|
+
if (nextAfter === startAfter && nextAfterId === startAfterId) {
|
|
13763
|
+
stopReason = "cursor-stalled";
|
|
13764
|
+
break;
|
|
13765
|
+
}
|
|
13766
|
+
startAfter = nextAfter;
|
|
13767
|
+
startAfterId = nextAfterId;
|
|
13768
|
+
if (pauseMs > 0) await delay2(pauseMs);
|
|
13769
|
+
}
|
|
13770
|
+
return {
|
|
13771
|
+
rows,
|
|
13772
|
+
fetched: rows.length,
|
|
13773
|
+
pages,
|
|
13774
|
+
stopReason,
|
|
13775
|
+
complete: isComplete(stopReason),
|
|
13776
|
+
indexTotal
|
|
13777
|
+
};
|
|
13778
|
+
}
|
|
13779
|
+
var PREFERRED_COLUMNS = [
|
|
13780
|
+
"id",
|
|
13781
|
+
"firstName",
|
|
13782
|
+
"lastName",
|
|
13783
|
+
"name",
|
|
13784
|
+
"email",
|
|
13785
|
+
"phone",
|
|
13786
|
+
"companyName",
|
|
13787
|
+
"tags",
|
|
13788
|
+
"source",
|
|
13789
|
+
"type",
|
|
13790
|
+
"dateAdded",
|
|
13791
|
+
"dateUpdated",
|
|
13792
|
+
"address1",
|
|
13793
|
+
"city",
|
|
13794
|
+
"state",
|
|
13795
|
+
"postalCode",
|
|
13796
|
+
"country",
|
|
13797
|
+
"timezone",
|
|
13798
|
+
"dnd"
|
|
13799
|
+
];
|
|
13800
|
+
function neutralizeFormula(text) {
|
|
13801
|
+
return /^[=+\-@\t\r]/.test(text) ? `'${text}` : text;
|
|
13802
|
+
}
|
|
13803
|
+
function cell(value) {
|
|
13804
|
+
if (value === null || value === void 0) return "";
|
|
13805
|
+
const raw = typeof value === "string" ? value : typeof value === "number" || typeof value === "boolean" ? String(value) : JSON.stringify(value);
|
|
13806
|
+
const text = neutralizeFormula(raw);
|
|
13807
|
+
return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
|
13808
|
+
}
|
|
13809
|
+
function contactsToCsv(rows) {
|
|
13810
|
+
if (rows.length === 0) return "";
|
|
13811
|
+
const seen = /* @__PURE__ */ new Set();
|
|
13812
|
+
for (const row of rows) for (const key of Object.keys(row)) seen.add(key);
|
|
13813
|
+
const preferred = PREFERRED_COLUMNS.filter((c) => seen.has(c));
|
|
13814
|
+
const rest = [...seen].filter((c) => !preferred.includes(c)).sort();
|
|
13815
|
+
const columns = [...preferred, ...rest];
|
|
13816
|
+
const lines = [columns.join(",")];
|
|
13817
|
+
for (const row of rows) lines.push(columns.map((c) => cell(row[c])).join(","));
|
|
13818
|
+
return lines.join("\n");
|
|
13819
|
+
}
|
|
13820
|
+
|
|
13821
|
+
// src/asset-ownership.ts
|
|
13822
|
+
var OWNERSHIP_PREFIX = "Offboarding Owner: ";
|
|
13823
|
+
var OWNER_KINDS = ["client", "agency", "borrowed", "third-party"];
|
|
13824
|
+
var OWNER_MEANING = {
|
|
13825
|
+
client: "The client owns this. It stays with them and they keep paying for it.",
|
|
13826
|
+
agency: "The agency owns this. It leaves with the agency unless it is sold or transferred.",
|
|
13827
|
+
borrowed: "Borrowed for the engagement. It must be returned, disconnected, or replaced at exit.",
|
|
13828
|
+
"third-party": "Owned by an outside vendor. Transfer depends on that vendor's own process."
|
|
13829
|
+
};
|
|
13830
|
+
function readCustomValues(raw) {
|
|
13831
|
+
const box = raw && typeof raw === "object" ? raw : {};
|
|
13832
|
+
const list = Array.isArray(box.customValues) ? box.customValues : [];
|
|
13833
|
+
return list.flatMap((entry) => {
|
|
13834
|
+
if (!entry || typeof entry !== "object") return [];
|
|
13835
|
+
const row = entry;
|
|
13836
|
+
if (typeof row.id !== "string" || typeof row.name !== "string") return [];
|
|
13837
|
+
return [{ id: row.id, name: row.name, value: typeof row.value === "string" ? row.value : "" }];
|
|
13838
|
+
});
|
|
13839
|
+
}
|
|
13840
|
+
function isOwnerKind(value) {
|
|
13841
|
+
return OWNER_KINDS.includes(value);
|
|
13842
|
+
}
|
|
13843
|
+
function encodeOwnership(owner, note) {
|
|
13844
|
+
return note && note.trim() ? `${owner} | ${note.trim()}` : owner;
|
|
13845
|
+
}
|
|
13846
|
+
function decodeOwnership(value) {
|
|
13847
|
+
const [head, ...rest] = value.split("|");
|
|
13848
|
+
const owner = head.trim();
|
|
13849
|
+
const note = rest.join("|").trim();
|
|
13850
|
+
return { owner: isOwnerKind(owner) ? owner : null, note: note || void 0 };
|
|
13851
|
+
}
|
|
13852
|
+
async function readOwnership(client, locationId2) {
|
|
13853
|
+
const values = readCustomValues(await client.get(`/locations/${locationId2}/customValues`));
|
|
13854
|
+
const records = [];
|
|
13855
|
+
const unreadable = [];
|
|
13856
|
+
for (const value of values) {
|
|
13857
|
+
if (!value.name.startsWith(OWNERSHIP_PREFIX)) continue;
|
|
13858
|
+
const asset = value.name.slice(OWNERSHIP_PREFIX.length).trim();
|
|
13859
|
+
const { owner, note } = decodeOwnership(value.value);
|
|
13860
|
+
if (!owner) {
|
|
13861
|
+
unreadable.push(`${asset}: "${value.value}"`);
|
|
13862
|
+
continue;
|
|
13863
|
+
}
|
|
13864
|
+
records.push({ asset, owner, note, customValueId: value.id });
|
|
13865
|
+
}
|
|
13866
|
+
return { records, unreadable };
|
|
13867
|
+
}
|
|
13868
|
+
async function writeOwnership(client, locationId2, entries) {
|
|
13869
|
+
const existing = readCustomValues(await client.get(`/locations/${locationId2}/customValues`));
|
|
13870
|
+
const results = [];
|
|
13871
|
+
for (const entry of entries) {
|
|
13872
|
+
const asset = entry.asset.trim();
|
|
13873
|
+
const name = `${OWNERSHIP_PREFIX}${asset}`;
|
|
13874
|
+
const matches = existing.filter((value) => value.name === name);
|
|
13875
|
+
if (matches.length > 1) {
|
|
13876
|
+
results.push({
|
|
13877
|
+
asset,
|
|
13878
|
+
action: "halted",
|
|
13879
|
+
reason: `${matches.length} custom values are named "${name}". Updating by name cannot tell them apart, so nothing was changed. Delete the extras in GHL, then run this again.`
|
|
13880
|
+
});
|
|
13881
|
+
continue;
|
|
13882
|
+
}
|
|
13883
|
+
const body = { name, value: encodeOwnership(entry.owner, entry.note) };
|
|
13884
|
+
if (matches.length === 1) {
|
|
13885
|
+
if (!matches[0].id) {
|
|
13886
|
+
results.push({
|
|
13887
|
+
asset,
|
|
13888
|
+
action: "halted",
|
|
13889
|
+
reason: `"${name}" was created earlier in this request but GoHighLevel did not return its id, so it cannot be updated safely. Run this asset again on its own.`
|
|
13890
|
+
});
|
|
13891
|
+
continue;
|
|
13892
|
+
}
|
|
13893
|
+
await client.put(`/locations/${locationId2}/customValues/${matches[0].id}`, { body });
|
|
13894
|
+
results.push({ asset, action: "updated", customValueId: matches[0].id });
|
|
13895
|
+
continue;
|
|
13896
|
+
}
|
|
13897
|
+
const created = await client.post(`/locations/${locationId2}/customValues`, { body });
|
|
13898
|
+
const createdId = readCreatedId(created);
|
|
13899
|
+
existing.push({ id: createdId ?? "", name, value: body.value });
|
|
13900
|
+
results.push({ asset, action: "created", customValueId: createdId });
|
|
13901
|
+
}
|
|
13902
|
+
return results;
|
|
13903
|
+
}
|
|
13904
|
+
function readCreatedId(raw) {
|
|
13905
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
13906
|
+
const box = raw;
|
|
13907
|
+
if (typeof box.id === "string") return box.id;
|
|
13908
|
+
const nested = box.customValue;
|
|
13909
|
+
if (nested && typeof nested === "object") {
|
|
13910
|
+
const inner = nested.id;
|
|
13911
|
+
if (typeof inner === "string") return inner;
|
|
13912
|
+
}
|
|
13913
|
+
return void 0;
|
|
13914
|
+
}
|
|
13915
|
+
|
|
13916
|
+
// src/handoff-doc.ts
|
|
13917
|
+
function sectionItems(section2) {
|
|
13918
|
+
return Array.isArray(section2) ? section2 : [];
|
|
13919
|
+
}
|
|
13920
|
+
function bullet(lines) {
|
|
13921
|
+
return lines.length ? lines.map((l) => `- ${l}`).join("\n") : "- None found.";
|
|
13922
|
+
}
|
|
13923
|
+
function plainReason(reason) {
|
|
13924
|
+
const withoutBody = reason.split(/[{\n]/)[0];
|
|
13925
|
+
const withoutRoute = withoutBody.replace(/:?\s*(GET|POST|PUT|DELETE)\s+\/\S*/g, "");
|
|
13926
|
+
const cleaned = withoutRoute.replace(/\s+/g, " ").replace(/[:\s]+$/, "").trim();
|
|
13927
|
+
return cleaned || "the account did not return this section";
|
|
13928
|
+
}
|
|
13929
|
+
function inventoryTable(inventory) {
|
|
13930
|
+
const rows = Object.entries(inventory).map(([section2, data]) => {
|
|
13931
|
+
if (data.unavailable) return `| ${section2} | not read | ${plainReason(data.unavailable)} |`;
|
|
13932
|
+
const sample = data.names.slice(0, 8).join(", ");
|
|
13933
|
+
const more = data.names.length > 8 ? `, and ${data.names.length - 8} more` : "";
|
|
13934
|
+
return `| ${section2} | ${data.count ?? data.names.length} | ${sample || "none"}${more} |`;
|
|
13935
|
+
});
|
|
13936
|
+
return ["| What | How many | Which ones |", "| --- | --- | --- |", ...rows].join("\n");
|
|
13937
|
+
}
|
|
13938
|
+
function sectionList(section2, render) {
|
|
13939
|
+
if (!Array.isArray(section2)) {
|
|
13940
|
+
return `**Not read.** ${plainReason(section2.unavailable)}. This section is unknown, which is NOT the same as empty. Check it by hand before you rely on this document.`;
|
|
13941
|
+
}
|
|
13942
|
+
return bullet(section2.map(render));
|
|
13943
|
+
}
|
|
13944
|
+
function buildHandoffDoc(input) {
|
|
13945
|
+
const {
|
|
13946
|
+
accountName,
|
|
13947
|
+
locationId: locationId2,
|
|
13948
|
+
generatedAt,
|
|
13949
|
+
contacts,
|
|
13950
|
+
inventory,
|
|
13951
|
+
users,
|
|
13952
|
+
phoneNumbers,
|
|
13953
|
+
socialAccounts,
|
|
13954
|
+
webhooks,
|
|
13955
|
+
webhookScanPartial,
|
|
13956
|
+
ownership
|
|
13957
|
+
} = input;
|
|
13958
|
+
const ownedBy = (kind) => ownership.recorded.filter((r) => r.owner === kind);
|
|
13959
|
+
const ownershipSection = ownership.recorded.length ? ["client", "agency", "borrowed", "third-party"].map((kind) => {
|
|
13960
|
+
const items = ownedBy(kind);
|
|
13961
|
+
if (!items.length) return "";
|
|
13962
|
+
return `**${kind}** ${OWNER_MEANING[kind]}
|
|
13963
|
+
|
|
13964
|
+
${bullet(
|
|
13965
|
+
items.map((i) => i.note ? `${i.asset} (${i.note})` : i.asset)
|
|
13966
|
+
)}`;
|
|
13967
|
+
}).filter(Boolean).join("\n\n") : "Nothing has been recorded yet. Every asset below is unanswered.";
|
|
13968
|
+
const unknownSection = ownership.unknown.length ? `These exist in the account but nobody has said who owns them. Answer them once and they stay answered:
|
|
13969
|
+
|
|
13970
|
+
${bullet(ownership.unknown)}` : "Every asset found in this account has a recorded owner.";
|
|
13971
|
+
const webhookSection = sectionList(webhooks, (w) => `${w.name} sends to ${w.url}${w.events.length ? ` when: ${w.events.join(", ")}` : ""}`) + (webhookScanPartial ? `
|
|
13972
|
+
|
|
13973
|
+
**This list may be incomplete.** ${webhookScanPartial}` : "");
|
|
13974
|
+
return `# Account handoff: ${accountName}
|
|
13975
|
+
|
|
13976
|
+
Prepared ${generatedAt}. GoHighLevel account ID ${locationId2}.
|
|
13977
|
+
|
|
13978
|
+
This document describes what is inside this account, who can get into it, and
|
|
13979
|
+
what has to change hands. It was generated from the live account, not from
|
|
13980
|
+
memory.
|
|
13981
|
+
|
|
13982
|
+
## Read this first
|
|
13983
|
+
|
|
13984
|
+
${contacts.complete ? "" : '**The contact export in this package is incomplete. See "Contacts" below before you rely on it.**\n\n'}Three things this document deliberately does not contain:
|
|
13985
|
+
|
|
13986
|
+
1. **No passwords or API keys.** GoHighLevel does not hand out key values to any
|
|
13987
|
+
tool, including this one. What you get instead is a list of every place a key
|
|
13988
|
+
exists, so you can rotate them. That list is at the end.
|
|
13989
|
+
2. **No opinion about who owns what**, beyond what somebody actually recorded.
|
|
13990
|
+
Anything unanswered is listed as unanswered.
|
|
13991
|
+
3. **No guesses about what is missing.** If a section could not be read, it says
|
|
13992
|
+
so and why, rather than showing zero.
|
|
13993
|
+
|
|
13994
|
+
## Contacts
|
|
13995
|
+
|
|
13996
|
+
${contacts.sentence}${contacts.indexTotal !== null ? `
|
|
13997
|
+
|
|
13998
|
+
GoHighLevel's own index reports **${contacts.indexTotal}** contacts for this account. This export contains **${contacts.exported}**. These two numbers are counted differently and GoHighLevel's index updates on a delay, so a small difference is normal and does not by itself mean the export is short.` : ""}
|
|
13999
|
+
|
|
14000
|
+
## What is in the account
|
|
14001
|
+
|
|
14002
|
+
${inventoryTable(inventory)}
|
|
14003
|
+
|
|
14004
|
+
## Who can get in
|
|
14005
|
+
|
|
14006
|
+
### People with logins
|
|
14007
|
+
|
|
14008
|
+
${sectionList(users, (u) => `${u.name} (${u.email})${u.roles ? `, ${u.roles}` : ""}`)}
|
|
14009
|
+
|
|
14010
|
+
Every person above can log into this account right now. Remove the ones who
|
|
14011
|
+
should not be able to, on the day the relationship ends, not later.
|
|
14012
|
+
|
|
14013
|
+
### Phone numbers
|
|
14014
|
+
|
|
14015
|
+
${sectionList(phoneNumbers, (p) => p.label ? `${p.number} (${p.label})` : p.number)}
|
|
14016
|
+
|
|
14017
|
+
Numbers are rented from GoHighLevel and stop working if the account lapses. If
|
|
14018
|
+
the client needs to keep a number, it has to be ported out before then, and
|
|
14019
|
+
porting takes days to weeks.
|
|
14020
|
+
|
|
14021
|
+
### Connected accounts
|
|
14022
|
+
|
|
14023
|
+
${sectionList(socialAccounts, (s) => `${s.platform}: ${s.name}`)}
|
|
14024
|
+
|
|
14025
|
+
These connections were authorised by a person. If that person leaves, the
|
|
14026
|
+
connection can break even though nothing in GoHighLevel changed.
|
|
14027
|
+
|
|
14028
|
+
### Outbound webhooks
|
|
14029
|
+
|
|
14030
|
+
${webhookSection}
|
|
14031
|
+
|
|
14032
|
+
Each of these sends data out of this account to somewhere else. Confirm the
|
|
14033
|
+
receiving system should still be receiving it.
|
|
14034
|
+
|
|
14035
|
+
## Who owns what
|
|
14036
|
+
|
|
14037
|
+
${ownershipSection}
|
|
14038
|
+
|
|
14039
|
+
### Not yet answered
|
|
14040
|
+
|
|
14041
|
+
${unknownSection}
|
|
14042
|
+
${ownership.unreadable.length ? `
|
|
14043
|
+
### Recorded but unreadable
|
|
14044
|
+
|
|
14045
|
+
Somebody edited these by hand and the answer no longer parses. They need a human:
|
|
14046
|
+
|
|
14047
|
+
${bullet(ownership.unreadable)}
|
|
14048
|
+
` : ""}
|
|
14049
|
+
## Rotate these on the way out
|
|
14050
|
+
|
|
14051
|
+
Key values cannot be read by any tool, so this is a checklist of places to go
|
|
14052
|
+
and change something, not a list of secrets.
|
|
14053
|
+
|
|
14054
|
+
- The GoHighLevel Private Integration key for this account. Anyone holding the
|
|
14055
|
+
old one keeps full access until it is deleted.
|
|
14056
|
+
- Any Firebase or agency-level credential used to reach this account.
|
|
14057
|
+
- Every connected account listed above, if the departing party authorised it.
|
|
14058
|
+
- Every webhook destination above, if it authenticates with a shared secret.
|
|
14059
|
+
- Any payment processor, email sending domain, or advertising account attached
|
|
14060
|
+
to this business but living outside GoHighLevel.
|
|
14061
|
+
|
|
14062
|
+
## What to do next
|
|
14063
|
+
|
|
14064
|
+
1. Save the contact export alongside this document.
|
|
14065
|
+
2. Answer anything under "Not yet answered" and re-run the kit so the answers
|
|
14066
|
+
are stored in the account itself.
|
|
14067
|
+
3. Work down the rotation list above.
|
|
14068
|
+
4. Remove the logins that should no longer exist.
|
|
14069
|
+
`;
|
|
14070
|
+
}
|
|
14071
|
+
|
|
14072
|
+
// src/tools/account-export.ts
|
|
14073
|
+
function delay3(ms) {
|
|
14074
|
+
return new Promise((resolve8) => setTimeout(resolve8, ms));
|
|
14075
|
+
}
|
|
13694
14076
|
function builderSectionAvailability(builderLocationId, requestedLocationId) {
|
|
13695
14077
|
if (!builderLocationId) {
|
|
13696
14078
|
return {
|
|
@@ -13726,7 +14108,7 @@ function registerAccountExportTools(server2, client, registry2, builderClient) {
|
|
|
13726
14108
|
} catch (e) {
|
|
13727
14109
|
report.location = `Error fetching: ${errorMessage(e)}`;
|
|
13728
14110
|
}
|
|
13729
|
-
await
|
|
14111
|
+
await delay3(100);
|
|
13730
14112
|
try {
|
|
13731
14113
|
const contacts = await client.get("/contacts/", {
|
|
13732
14114
|
params: { locationId: locId, limit: includeContacts ? 100 : 1 }
|
|
@@ -13741,7 +14123,7 @@ function registerAccountExportTools(server2, client, registry2, builderClient) {
|
|
|
13741
14123
|
} catch (e) {
|
|
13742
14124
|
report.contacts = `Error fetching: ${errorMessage(e)}`;
|
|
13743
14125
|
}
|
|
13744
|
-
await
|
|
14126
|
+
await delay3(100);
|
|
13745
14127
|
try {
|
|
13746
14128
|
report.pipelines = await client.get("/opportunities/pipelines", {
|
|
13747
14129
|
params: { locationId: locId }
|
|
@@ -13749,7 +14131,7 @@ function registerAccountExportTools(server2, client, registry2, builderClient) {
|
|
|
13749
14131
|
} catch (e) {
|
|
13750
14132
|
report.pipelines = `Error fetching: ${errorMessage(e)}`;
|
|
13751
14133
|
}
|
|
13752
|
-
await
|
|
14134
|
+
await delay3(100);
|
|
13753
14135
|
try {
|
|
13754
14136
|
report.workflows = await client.get("/workflows/", {
|
|
13755
14137
|
params: { locationId: locId }
|
|
@@ -13757,7 +14139,7 @@ function registerAccountExportTools(server2, client, registry2, builderClient) {
|
|
|
13757
14139
|
} catch (e) {
|
|
13758
14140
|
report.workflows = `Error fetching: ${errorMessage(e)}`;
|
|
13759
14141
|
}
|
|
13760
|
-
await
|
|
14142
|
+
await delay3(100);
|
|
13761
14143
|
const builderUse = builderSectionAvailability(builderClient?.locationId, locId);
|
|
13762
14144
|
if (builderUse.usable && builderClient) {
|
|
13763
14145
|
try {
|
|
@@ -13765,7 +14147,7 @@ function registerAccountExportTools(server2, client, registry2, builderClient) {
|
|
|
13765
14147
|
} catch (e) {
|
|
13766
14148
|
report.workflowsFull = `Error fetching: ${errorMessage(e)}`;
|
|
13767
14149
|
}
|
|
13768
|
-
await
|
|
14150
|
+
await delay3(100);
|
|
13769
14151
|
} else {
|
|
13770
14152
|
report.workflowsFull = builderUse.reason;
|
|
13771
14153
|
}
|
|
@@ -13781,7 +14163,7 @@ function registerAccountExportTools(server2, client, registry2, builderClient) {
|
|
|
13781
14163
|
} catch (e) {
|
|
13782
14164
|
report.funnels = `Error fetching: ${errorMessage(e)}`;
|
|
13783
14165
|
}
|
|
13784
|
-
await
|
|
14166
|
+
await delay3(100);
|
|
13785
14167
|
} else {
|
|
13786
14168
|
try {
|
|
13787
14169
|
report.funnels = await client.get("/funnels/", {
|
|
@@ -13798,25 +14180,25 @@ function registerAccountExportTools(server2, client, registry2, builderClient) {
|
|
|
13798
14180
|
} catch (e) {
|
|
13799
14181
|
report.forms = `Error fetching: ${errorMessage(e)}`;
|
|
13800
14182
|
}
|
|
13801
|
-
await
|
|
14183
|
+
await delay3(100);
|
|
13802
14184
|
try {
|
|
13803
14185
|
report.customFields = await client.get("/locations/" + locId + "/customFields");
|
|
13804
14186
|
} catch (e) {
|
|
13805
14187
|
report.customFields = `Error fetching: ${errorMessage(e)}`;
|
|
13806
14188
|
}
|
|
13807
|
-
await
|
|
14189
|
+
await delay3(100);
|
|
13808
14190
|
try {
|
|
13809
14191
|
report.customValues = await client.get("/locations/" + locId + "/customValues");
|
|
13810
14192
|
} catch (e) {
|
|
13811
14193
|
report.customValues = `Error fetching: ${errorMessage(e)}`;
|
|
13812
14194
|
}
|
|
13813
|
-
await
|
|
14195
|
+
await delay3(100);
|
|
13814
14196
|
try {
|
|
13815
14197
|
report.tags = await client.get("/locations/" + locId + "/tags");
|
|
13816
14198
|
} catch (e) {
|
|
13817
14199
|
report.tags = `Error fetching: ${errorMessage(e)}`;
|
|
13818
14200
|
}
|
|
13819
|
-
await
|
|
14201
|
+
await delay3(100);
|
|
13820
14202
|
try {
|
|
13821
14203
|
report.calendars = await client.get("/calendars/", {
|
|
13822
14204
|
params: { locationId: locId }
|
|
@@ -13824,7 +14206,7 @@ function registerAccountExportTools(server2, client, registry2, builderClient) {
|
|
|
13824
14206
|
} catch (e) {
|
|
13825
14207
|
report.calendars = `Error fetching: ${errorMessage(e)}`;
|
|
13826
14208
|
}
|
|
13827
|
-
await
|
|
14209
|
+
await delay3(100);
|
|
13828
14210
|
try {
|
|
13829
14211
|
report.users = await client.get("/users/", {
|
|
13830
14212
|
params: { locationId: locId }
|
|
@@ -13870,13 +14252,13 @@ function registerAccountExportTools(server2, client, registry2, builderClient) {
|
|
|
13870
14252
|
const fetchData = async (locId) => {
|
|
13871
14253
|
const data = {};
|
|
13872
14254
|
data.pipelines = await read(() => client.get("/opportunities/pipelines", { params: { locationId: locId } }));
|
|
13873
|
-
await
|
|
14255
|
+
await delay3(100);
|
|
13874
14256
|
data.workflows = await read(() => client.get("/workflows/", { params: { locationId: locId } }));
|
|
13875
|
-
await
|
|
14257
|
+
await delay3(100);
|
|
13876
14258
|
data.customFields = await read(() => client.get(`/locations/${locId}/customFields`));
|
|
13877
|
-
await
|
|
14259
|
+
await delay3(100);
|
|
13878
14260
|
data.tags = await read(() => client.get(`/locations/${locId}/tags`));
|
|
13879
|
-
await
|
|
14261
|
+
await delay3(100);
|
|
13880
14262
|
data.forms = await read(() => client.get("/forms/", { params: { locationId: locId } }));
|
|
13881
14263
|
return data;
|
|
13882
14264
|
};
|
|
@@ -13909,6 +14291,270 @@ function registerAccountExportTools(server2, client, registry2, builderClient) {
|
|
|
13909
14291
|
}
|
|
13910
14292
|
}
|
|
13911
14293
|
);
|
|
14294
|
+
server2.tool(
|
|
14295
|
+
"build_offboarding_kit",
|
|
14296
|
+
"Assemble the complete exit package for a sub-account: a full contact export, an inventory of everything the account contains, an access map of every person and connected account that can reach it, the recorded owner of each asset, a key-rotation checklist, and a plain-language handoff document the next operator can follow. Read-only: it changes nothing. locationId is REQUIRED and never defaults, because exporting the wrong client's account is the one mistake this tool must not be able to make. Assets nobody has recorded an owner for come back under ownership.unanswered \u2014 answer those with record_asset_ownership and they stay answered. Key VALUES are never included: GoHighLevel does not expose them to any tool, so the kit lists where each key lives instead.",
|
|
14297
|
+
{
|
|
14298
|
+
locationId: import_zod46.z.string().describe("Sub-account to build the kit for. REQUIRED \u2014 this tool never falls back to the active account."),
|
|
14299
|
+
contacts: import_zod46.z.enum(["summary", "csv"]).optional().describe(
|
|
14300
|
+
"'summary' (default) returns contact counts only. 'csv' also returns the full contact export as CSV text, which can be large \u2014 ask for it when you are ready to save the file."
|
|
14301
|
+
),
|
|
14302
|
+
maxContacts: import_zod46.z.number().int().positive().optional().describe(`Ceiling on contacts read. Defaults to ${DEFAULT_MAX_CONTACTS}. If the ceiling is hit, the kit says so in the handoff document itself rather than quietly returning a short list.`)
|
|
14303
|
+
},
|
|
14304
|
+
async ({ locationId: locationId2, contacts, maxContacts }) => {
|
|
14305
|
+
const originalKey = client.getApiKey();
|
|
14306
|
+
const registered = registry2?.getToken(locationId2);
|
|
14307
|
+
const usedRegisteredKey = Boolean(registered?.apiKey && registered.apiKey !== originalKey);
|
|
14308
|
+
if (usedRegisteredKey && registered?.apiKey) client.setApiKey(registered.apiKey);
|
|
14309
|
+
try {
|
|
14310
|
+
const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
14311
|
+
let accountName = locationId2;
|
|
14312
|
+
let location = null;
|
|
14313
|
+
try {
|
|
14314
|
+
location = await client.get(`/locations/${locationId2}`);
|
|
14315
|
+
const box = location && typeof location === "object" ? location : {};
|
|
14316
|
+
const inner = box.location && typeof box.location === "object" ? box.location : box;
|
|
14317
|
+
if (typeof inner.name === "string" && inner.name) accountName = inner.name;
|
|
14318
|
+
} catch (e) {
|
|
14319
|
+
location = `Error fetching: ${errorMessage(e)}`;
|
|
14320
|
+
}
|
|
14321
|
+
await delay3(100);
|
|
14322
|
+
const inventory = {};
|
|
14323
|
+
const section2 = async (label, fn) => {
|
|
14324
|
+
try {
|
|
14325
|
+
const names = extractNames(await fn());
|
|
14326
|
+
inventory[label] = { count: names.length, names };
|
|
14327
|
+
} catch (e) {
|
|
14328
|
+
inventory[label] = { count: null, names: [], unavailable: errorMessage(e) };
|
|
14329
|
+
}
|
|
14330
|
+
await delay3(100);
|
|
14331
|
+
};
|
|
14332
|
+
await section2("pipelines", () => client.get("/opportunities/pipelines", { params: { locationId: locationId2 } }));
|
|
14333
|
+
await section2("workflows", () => client.get("/workflows/", { params: { locationId: locationId2 } }));
|
|
14334
|
+
await section2("funnels", () => client.get("/funnels/", { params: { locationId: locationId2 } }));
|
|
14335
|
+
await section2("forms", () => client.get("/forms/", { params: { locationId: locationId2 } }));
|
|
14336
|
+
await section2("calendars", () => client.get("/calendars/", { params: { locationId: locationId2 } }));
|
|
14337
|
+
await section2("customFields", () => client.get(`/locations/${locationId2}/customFields`));
|
|
14338
|
+
await section2("customValues", () => client.get(`/locations/${locationId2}/customValues`));
|
|
14339
|
+
await section2("tags", () => client.get(`/locations/${locationId2}/tags`));
|
|
14340
|
+
const users = await readUsers(client, locationId2);
|
|
14341
|
+
await delay3(100);
|
|
14342
|
+
const phoneNumbers = await readPhoneNumbers(client, locationId2);
|
|
14343
|
+
await delay3(100);
|
|
14344
|
+
const socialAccounts = await readSocialAccounts(client, locationId2);
|
|
14345
|
+
await delay3(100);
|
|
14346
|
+
const WEBHOOK_SCAN_LIMIT = 50;
|
|
14347
|
+
const builderUse = builderSectionAvailability(builderClient?.locationId, locationId2);
|
|
14348
|
+
let webhooks;
|
|
14349
|
+
let webhookScanPartial;
|
|
14350
|
+
if (builderUse.usable && builderClient) {
|
|
14351
|
+
try {
|
|
14352
|
+
const scan = await loadWebhooks(builderClient, WEBHOOK_SCAN_LIMIT);
|
|
14353
|
+
const stillOurs = builderSectionAvailability(builderClient.locationId, locationId2);
|
|
14354
|
+
if (!stillOurs.usable) {
|
|
14355
|
+
webhooks = { unavailable: `The active account changed while webhooks were being read, so the result was discarded. ${stillOurs.reason ?? ""}` };
|
|
14356
|
+
} else {
|
|
14357
|
+
webhooks = scan.webhooks.map((w) => ({
|
|
14358
|
+
name: w.name,
|
|
14359
|
+
url: w.actions[0]?.url ?? "",
|
|
14360
|
+
events: w.events
|
|
14361
|
+
}));
|
|
14362
|
+
if (scan.scanned >= WEBHOOK_SCAN_LIMIT) {
|
|
14363
|
+
webhookScanPartial = `Only the first ${WEBHOOK_SCAN_LIMIT} workflows were checked and this account has at least that many, so a webhook built into a later workflow would not appear here. Check the account's Automation list by hand.`;
|
|
14364
|
+
}
|
|
14365
|
+
}
|
|
14366
|
+
} catch (e) {
|
|
14367
|
+
webhooks = { unavailable: `Error reading webhooks: ${errorMessage(e)}` };
|
|
14368
|
+
}
|
|
14369
|
+
} else {
|
|
14370
|
+
webhooks = { unavailable: builderUse.reason ?? "Not read." };
|
|
14371
|
+
}
|
|
14372
|
+
let ownershipRecords = { records: [], unreadable: [] };
|
|
14373
|
+
let ownershipError;
|
|
14374
|
+
try {
|
|
14375
|
+
ownershipRecords = await readOwnership(client, locationId2);
|
|
14376
|
+
} catch (e) {
|
|
14377
|
+
ownershipError = errorMessage(e);
|
|
14378
|
+
}
|
|
14379
|
+
const discovered = discoverAssets({ phoneNumbers, socialAccounts, inventory });
|
|
14380
|
+
const answered = new Set(ownershipRecords.records.map((r) => r.asset.toLowerCase()));
|
|
14381
|
+
const unanswered = discovered.filter((asset) => !answered.has(asset.toLowerCase()));
|
|
14382
|
+
const cap = maxContacts ?? DEFAULT_MAX_CONTACTS;
|
|
14383
|
+
let contactResult;
|
|
14384
|
+
let contactSentence;
|
|
14385
|
+
try {
|
|
14386
|
+
contactResult = await fetchAllContacts(client, locationId2, { maxContacts: cap });
|
|
14387
|
+
contactSentence = stopReasonSentence(contactResult.stopReason, contactResult.fetched, cap);
|
|
14388
|
+
} catch (e) {
|
|
14389
|
+
contactResult = { rows: [], fetched: 0, pages: 0, stopReason: "cursor-exhausted", complete: false, indexTotal: null };
|
|
14390
|
+
contactSentence = `INCOMPLETE: the contact export failed and returned nothing. Reason: ${errorMessage(e)}`;
|
|
14391
|
+
}
|
|
14392
|
+
const handoffDoc = buildHandoffDoc({
|
|
14393
|
+
accountName,
|
|
14394
|
+
locationId: locationId2,
|
|
14395
|
+
generatedAt,
|
|
14396
|
+
contacts: {
|
|
14397
|
+
exported: contactResult.fetched,
|
|
14398
|
+
indexTotal: contactResult.indexTotal,
|
|
14399
|
+
complete: contactResult.complete,
|
|
14400
|
+
sentence: contactSentence
|
|
14401
|
+
},
|
|
14402
|
+
inventory,
|
|
14403
|
+
users,
|
|
14404
|
+
phoneNumbers,
|
|
14405
|
+
socialAccounts,
|
|
14406
|
+
webhooks,
|
|
14407
|
+
webhookScanPartial,
|
|
14408
|
+
ownership: {
|
|
14409
|
+
recorded: ownershipRecords.records,
|
|
14410
|
+
unknown: unanswered,
|
|
14411
|
+
unreadable: ownershipRecords.unreadable
|
|
14412
|
+
}
|
|
14413
|
+
});
|
|
14414
|
+
return jsonResponse({
|
|
14415
|
+
account: { locationId: locationId2, name: accountName, generatedAt, usedRegisteredKey },
|
|
14416
|
+
contacts: {
|
|
14417
|
+
exported: contactResult.fetched,
|
|
14418
|
+
ghlIndexTotal: contactResult.indexTotal,
|
|
14419
|
+
complete: contactResult.complete,
|
|
14420
|
+
stopReason: contactResult.stopReason,
|
|
14421
|
+
pagesRead: contactResult.pages,
|
|
14422
|
+
statement: contactSentence,
|
|
14423
|
+
...contacts === "csv" ? { csv: contactsToCsv(contactResult.rows) } : {
|
|
14424
|
+
csvNote: "Run again with contacts:'csv' to get the full export as CSV text to save."
|
|
14425
|
+
}
|
|
14426
|
+
},
|
|
14427
|
+
inventory,
|
|
14428
|
+
accessMap: {
|
|
14429
|
+
users,
|
|
14430
|
+
phoneNumbers,
|
|
14431
|
+
socialAccounts,
|
|
14432
|
+
webhooks,
|
|
14433
|
+
...webhookScanPartial ? { webhookScanPartial } : {}
|
|
14434
|
+
},
|
|
14435
|
+
ownership: {
|
|
14436
|
+
recorded: ownershipRecords.records,
|
|
14437
|
+
unanswered,
|
|
14438
|
+
unreadable: ownershipRecords.unreadable,
|
|
14439
|
+
...ownershipError ? { error: ownershipError } : {},
|
|
14440
|
+
howToAnswer: "Record these with record_asset_ownership so the next run needs no interview."
|
|
14441
|
+
},
|
|
14442
|
+
keyRotationNote: "Key VALUES are not retrievable from GoHighLevel by any tool. The handoff document lists where each key lives so they can be rotated.",
|
|
14443
|
+
handoffDoc
|
|
14444
|
+
});
|
|
14445
|
+
} catch (error) {
|
|
14446
|
+
return errorResponse(error);
|
|
14447
|
+
} finally {
|
|
14448
|
+
client.setApiKey(originalKey);
|
|
14449
|
+
}
|
|
14450
|
+
}
|
|
14451
|
+
);
|
|
14452
|
+
server2.tool(
|
|
14453
|
+
"record_asset_ownership",
|
|
14454
|
+
"Record who owns an asset attached to a sub-account: the client, the agency, something borrowed for the engagement, or an outside vendor. Stored as custom values inside the client's own account, so the record outlives this tool and the client can read it without asking anyone. Answer these once at setup and build_offboarding_kit needs no interview later. Re-recording the same asset updates it in place instead of adding a duplicate. locationId is REQUIRED: writing ownership into the wrong client's account is worth preventing outright.",
|
|
14455
|
+
{
|
|
14456
|
+
locationId: import_zod46.z.string().describe("Sub-account to record ownership in. REQUIRED \u2014 never falls back to the active account."),
|
|
14457
|
+
assets: import_zod46.z.array(
|
|
14458
|
+
import_zod46.z.object({
|
|
14459
|
+
asset: import_zod46.z.string().describe("What it is, in the words you would say out loud. e.g. 'Website domain' or 'Phone number +16025551234'."),
|
|
14460
|
+
owner: import_zod46.z.enum(OWNER_KINDS).describe("client = stays with them. agency = leaves with you. borrowed = must be returned or replaced at exit. third-party = an outside vendor controls it."),
|
|
14461
|
+
note: import_zod46.z.string().optional().describe("Anything the next person needs, e.g. 'registered on the client's own GoDaddy account'.")
|
|
14462
|
+
})
|
|
14463
|
+
).min(1).describe("One entry per asset. Send several at once.")
|
|
14464
|
+
},
|
|
14465
|
+
async ({ locationId: locationId2, assets }) => {
|
|
14466
|
+
const originalKey = client.getApiKey();
|
|
14467
|
+
const registered = registry2?.getToken(locationId2);
|
|
14468
|
+
if (registered?.apiKey && registered.apiKey !== originalKey) client.setApiKey(registered.apiKey);
|
|
14469
|
+
try {
|
|
14470
|
+
const results = await writeOwnership(client, locationId2, assets);
|
|
14471
|
+
const halted = results.filter((r) => r.action === "halted");
|
|
14472
|
+
return jsonResponse({
|
|
14473
|
+
locationId: locationId2,
|
|
14474
|
+
recorded: results.filter((r) => r.action !== "halted"),
|
|
14475
|
+
...halted.length ? { halted, warning: "Some assets were NOT recorded. See halted for which, and why." } : {}
|
|
14476
|
+
});
|
|
14477
|
+
} catch (error) {
|
|
14478
|
+
return errorResponse(error);
|
|
14479
|
+
} finally {
|
|
14480
|
+
client.setApiKey(originalKey);
|
|
14481
|
+
}
|
|
14482
|
+
}
|
|
14483
|
+
);
|
|
14484
|
+
}
|
|
14485
|
+
function extractNames(payload) {
|
|
14486
|
+
const list = Array.isArray(payload) ? payload : payload && typeof payload === "object" ? Object.values(payload).find(Array.isArray) : void 0;
|
|
14487
|
+
if (!Array.isArray(list)) return [];
|
|
14488
|
+
return list.map(
|
|
14489
|
+
(item) => item && typeof item === "object" ? String(item.name ?? item.id ?? "") : String(item)
|
|
14490
|
+
).filter(Boolean);
|
|
14491
|
+
}
|
|
14492
|
+
async function readSection(read) {
|
|
14493
|
+
try {
|
|
14494
|
+
return await read();
|
|
14495
|
+
} catch (e) {
|
|
14496
|
+
return { unavailable: errorMessage(e) };
|
|
14497
|
+
}
|
|
14498
|
+
}
|
|
14499
|
+
async function readUsers(client, locationId2) {
|
|
14500
|
+
return readSection(async () => {
|
|
14501
|
+
const raw = await client.get("/users/", { params: { locationId: locationId2 } });
|
|
14502
|
+
const box = raw && typeof raw === "object" ? raw : {};
|
|
14503
|
+
const list = Array.isArray(box.users) ? box.users : [];
|
|
14504
|
+
return list.flatMap((entry) => {
|
|
14505
|
+
if (!entry || typeof entry !== "object") return [];
|
|
14506
|
+
const u = entry;
|
|
14507
|
+
const roles = u.roles && typeof u.roles === "object" ? String(u.roles.role ?? "") : "";
|
|
14508
|
+
return [{
|
|
14509
|
+
name: String(u.name ?? `${u.firstName ?? ""} ${u.lastName ?? ""}`).trim() || "(unnamed)",
|
|
14510
|
+
email: String(u.email ?? ""),
|
|
14511
|
+
roles
|
|
14512
|
+
}];
|
|
14513
|
+
});
|
|
14514
|
+
});
|
|
14515
|
+
}
|
|
14516
|
+
async function readPhoneNumbers(client, locationId2) {
|
|
14517
|
+
return readSection(async () => {
|
|
14518
|
+
const raw = await client.get("/phone-system/numbers/", { params: { locationId: locationId2 } });
|
|
14519
|
+
const box = raw && typeof raw === "object" ? raw : {};
|
|
14520
|
+
const list = Array.isArray(box.phoneNumbers) ? box.phoneNumbers : [];
|
|
14521
|
+
return list.flatMap((entry) => {
|
|
14522
|
+
if (!entry || typeof entry !== "object") return [];
|
|
14523
|
+
const n = entry;
|
|
14524
|
+
const number = String(n.value ?? n.number ?? "");
|
|
14525
|
+
return number ? [{ number, label: n.title ? String(n.title) : void 0 }] : [];
|
|
14526
|
+
});
|
|
14527
|
+
});
|
|
14528
|
+
}
|
|
14529
|
+
async function readSocialAccounts(client, locationId2) {
|
|
14530
|
+
return readSection(async () => {
|
|
14531
|
+
const raw = await client.get(`/social-media-posting/${locationId2}/accounts`);
|
|
14532
|
+
const box = raw && typeof raw === "object" ? raw : {};
|
|
14533
|
+
const results = box.results && typeof box.results === "object" ? box.results : {};
|
|
14534
|
+
const list = Array.isArray(results.accounts) ? results.accounts : [];
|
|
14535
|
+
return list.flatMap((entry) => {
|
|
14536
|
+
if (!entry || typeof entry !== "object") return [];
|
|
14537
|
+
const a = entry;
|
|
14538
|
+
return [{ platform: String(a.platform ?? "unknown"), name: String(a.name ?? a.id ?? "") }];
|
|
14539
|
+
});
|
|
14540
|
+
});
|
|
14541
|
+
}
|
|
14542
|
+
var OFF_PLATFORM_ASSETS = [
|
|
14543
|
+
"Website domain",
|
|
14544
|
+
"Logo and brand files",
|
|
14545
|
+
"Meta pixel or ad account",
|
|
14546
|
+
"Google Analytics",
|
|
14547
|
+
"Payment processor account",
|
|
14548
|
+
"Email sending domain"
|
|
14549
|
+
];
|
|
14550
|
+
function discoverAssets(input) {
|
|
14551
|
+
const assets = [];
|
|
14552
|
+
for (const p of sectionItems(input.phoneNumbers)) assets.push(`Phone number ${p.number}`);
|
|
14553
|
+
for (const s of sectionItems(input.socialAccounts)) assets.push(`${s.platform} account ${s.name}`.trim());
|
|
14554
|
+
const funnels = input.inventory.funnels;
|
|
14555
|
+
if (funnels && !funnels.unavailable) for (const name of funnels.names) assets.push(`Funnel "${name}"`);
|
|
14556
|
+
assets.push(...OFF_PLATFORM_ASSETS);
|
|
14557
|
+
return assets;
|
|
13912
14558
|
}
|
|
13913
14559
|
function buildLocationComparison(dataA, dataB, sections) {
|
|
13914
14560
|
const unavailable = [];
|
|
@@ -14605,7 +15251,7 @@ function buildOfferPayload(o) {
|
|
|
14605
15251
|
var import_zod52 = require("zod");
|
|
14606
15252
|
var fs7 = __toESM(require("fs"));
|
|
14607
15253
|
var path6 = __toESM(require("path"));
|
|
14608
|
-
function
|
|
15254
|
+
function delay4(ms) {
|
|
14609
15255
|
return new Promise((resolve8) => setTimeout(resolve8, ms));
|
|
14610
15256
|
}
|
|
14611
15257
|
var TemplateSchema = import_zod52.z.object({
|
|
@@ -14790,7 +15436,7 @@ function registerTemplateDeployerTools(server2, client) {
|
|
|
14790
15436
|
errors.push(`Location update: ${errorMessage(e)}`);
|
|
14791
15437
|
log.push(" \u2717 Location update failed (may need agency-level key)");
|
|
14792
15438
|
}
|
|
14793
|
-
await
|
|
15439
|
+
await delay4(200);
|
|
14794
15440
|
}
|
|
14795
15441
|
}
|
|
14796
15442
|
if (template.tags) {
|
|
@@ -14809,7 +15455,7 @@ Tags: ${template.tags.length} to create`);
|
|
|
14809
15455
|
log.push(` \u2717 Tag failed: ${tag}`);
|
|
14810
15456
|
}
|
|
14811
15457
|
}
|
|
14812
|
-
await
|
|
15458
|
+
await delay4(100);
|
|
14813
15459
|
}
|
|
14814
15460
|
} else {
|
|
14815
15461
|
for (const tag of template.tags) {
|
|
@@ -14839,7 +15485,7 @@ Custom Fields: ${template.customFields.length} to create`);
|
|
|
14839
15485
|
log.push(` \u2717 Field failed: ${field.name}`);
|
|
14840
15486
|
}
|
|
14841
15487
|
}
|
|
14842
|
-
await
|
|
15488
|
+
await delay4(100);
|
|
14843
15489
|
}
|
|
14844
15490
|
} else {
|
|
14845
15491
|
for (const field of template.customFields) {
|
|
@@ -14885,7 +15531,7 @@ Pipelines: ${template.pipelines.length} to create`);
|
|
|
14885
15531
|
} catch (e) {
|
|
14886
15532
|
errors.push(`Pipeline "${resolved2.name}": ${errorMessage(e)}`);
|
|
14887
15533
|
}
|
|
14888
|
-
await
|
|
15534
|
+
await delay4(300);
|
|
14889
15535
|
}
|
|
14890
15536
|
}
|
|
14891
15537
|
}
|
|
@@ -14923,7 +15569,7 @@ Workflows: ${workflows.length} to create`);
|
|
|
14923
15569
|
errors.push(`Workflow "${resolved2.name}": ${errorMessage(e)}`);
|
|
14924
15570
|
log.push(` \u2717 Workflow failed: ${resolved2.name}`);
|
|
14925
15571
|
}
|
|
14926
|
-
await
|
|
15572
|
+
await delay4(500);
|
|
14927
15573
|
}
|
|
14928
15574
|
}
|
|
14929
15575
|
}
|
|
@@ -14948,7 +15594,7 @@ Calendars: ${template.calendars.length} to create`);
|
|
|
14948
15594
|
errors.push(`Calendar "${resolved2.name}": ${errorMessage(e)}`);
|
|
14949
15595
|
log.push(` \u2717 Calendar failed: ${resolved2.name}`);
|
|
14950
15596
|
}
|
|
14951
|
-
await
|
|
15597
|
+
await delay4(200);
|
|
14952
15598
|
}
|
|
14953
15599
|
}
|
|
14954
15600
|
}
|
package/guide/guide.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>GHL Command — User Guide</title>
|
|
7
|
-
<!-- guides-hash:
|
|
7
|
+
<!-- guides-hash: 3942652f2f21c41b -->
|
|
8
8
|
<style>
|
|
9
9
|
/* Deliberately light in every environment, including a dark-mode OS. This is a
|
|
10
10
|
reference document people read at length, print, and save to PDF, and a page
|
|
@@ -262,7 +262,7 @@ body.app.reading #indexview{display:none}
|
|
|
262
262
|
<div class="catsec" id="cat-forms-and-reviews"><h2>Forms and reviews</h2><div class="cards"><a class="card pub" href="#g-create-forms-and-read-submissions" data-k="form create intake survey fields submissions responses questions who filled in"><h3>Create forms and read submissions</h3><p>Intake forms built to spec, and submissions pulled into a clean summary.</p><div class="meta"><span class="pill full">Full license</span><span class="time">~10 minutes</span><span class="open">Open guide →</span></div></a>
|
|
263
263
|
<a class="card pub" href="#g-trigger-links" data-k="trigger links tracking click automations tracked link who clicked interest"><h3>Trigger links</h3><p>Create tracked links that fire automations when clicked.</p><div class="meta"><span class="pill full">Full license</span><span class="time">~2 minutes</span><span class="open">Open guide →</span></div></a>
|
|
264
264
|
<a class="card pub" href="#g-watch-your-reviews" data-k="reviews reputation ratings google stars new review negative feedback review link"><h3>Watch your reviews</h3><p>See new reviews across clients without logging into each account.</p><div class="meta"><span class="pill free">Free plan</span><span class="time">~30 seconds</span><span class="open">Open guide →</span></div></a></div></div>
|
|
265
|
-
<div class="catsec" id="cat-account-admin"><h2>Account admin</h2><div class="cards"><a class="card pub" href="#g-account-admin-basics" data-k="users phone numbers media export snapshot admin settings who has access compare"><h3>Account admin basics</h3><p>Users, phone numbers,
|
|
265
|
+
<div class="catsec" id="cat-account-admin"><h2>Account admin</h2><div class="cards"><a class="card pub" href="#g-account-admin-basics" data-k="users phone numbers media export snapshot admin settings who has access compare offboarding offboard a client client is leaving exit package handoff who owns the domain asset ownership transfer an account cancel a client rotate keys"><h3>Account admin basics</h3><p>Users, phone numbers, exports, and the whole exit package the day a client leaves.</p><div class="meta"><span class="pill full">Full license</span><span class="time">~1 minute</span><span class="open">Open guide →</span></div></a>
|
|
266
266
|
<a class="card pub" href="#g-custom-fields-and-objects" data-k="custom fields objects records associations create field dropdown date field niche"><h3>Custom fields and objects</h3><p>The fields and records your niche needs, created correctly the first time.</p><div class="meta"><span class="pill full">Full license</span><span class="time">~5 minutes</span><span class="open">Open guide →</span></div></a></div></div>
|
|
267
267
|
<div class="noresults" id="noresults"><p><b>No guides match that yet.</b> Try a different word, like "contacts" or "workflow".</p></div>
|
|
268
268
|
</main>
|
|
@@ -273,7 +273,7 @@ body.app.reading #indexview{display:none}
|
|
|
273
273
|
<div class="hero"><div class="hero-in">
|
|
274
274
|
<p class="eyebrow">GHL Command User Guide</p>
|
|
275
275
|
<h1>Account admin basics</h1>
|
|
276
|
-
<p class="sub">Users, phone numbers,
|
|
276
|
+
<p class="sub">Users, phone numbers, exports, and the whole exit package the day a client leaves.</p>
|
|
277
277
|
<div class="badges"><span class="badge on">Full license</span><span class="badge">~1 minute</span><span class="badge">Account admin</span></div>
|
|
278
278
|
</div></div>
|
|
279
279
|
<div class="shell"><main class="gview">
|
|
@@ -317,6 +317,39 @@ Do not change anything. This is a read.</p></div>
|
|
|
317
317
|
<div class="gsec"><h2>Where people go wrong</h2>
|
|
318
318
|
<div style="overflow-x:auto"><table class="wrongtbl"><tr><th>What happened</th><th>The fix</th></tr><tr><td>"Workflow notifications go to nobody."</td><td>They point at a user who has gone. List the users, then ask Claude to repoint the notification.</td></tr><tr><td>"Texts fail on a new client account."</td><td>The account has no number, or is not registered for texting yet. The number list shows you in seconds.</td></tr><tr><td>"The export is huge."</td><td>Ask for the part you care about: "just the workflows and pipelines by name".</td></tr><tr><td>"The comparison says the other account is empty."</td><td>Each account's key only works on that account, so the other one could not be read at all. Save its key once and compare again. Claude now says "unavailable" rather than showing zeros, so you can tell the two apart.</td></tr></table></div>
|
|
319
319
|
</div>
|
|
320
|
+
<div class="gsec"><h2>When a client leaves</h2>
|
|
321
|
+
<p class="gpara">Most agencies have an onboarding checklist and no offboarding one, so the day a</p>
|
|
322
|
+
<p class="gpara">client goes turns into a week of hunting for logins and exports. One ask</p>
|
|
323
|
+
<p class="gpara">assembles the whole exit package.</p>
|
|
324
|
+
<div class="pro"><div class="pro-head"><b>The exact ask we run on real client accounts</b><span class="pill full">Proven live</span><button class="copy" type="button">Copy</button></div><p class="pro-body">Build the offboarding kit for my [MCP Testing] account.
|
|
325
|
+
I want the full contact export, everything the account contains, every person and connected account that can still get into it, and a handoff document the next person can follow.
|
|
326
|
+
Tell me plainly if the contact export is short, and list anything nobody has recorded an owner for.
|
|
327
|
+
Do not change anything. This is a read.</p></div>
|
|
328
|
+
<p class="gpara">Everything in [brackets] is yours to change. Run it the day you take a client on,</p>
|
|
329
|
+
<p class="gpara">not the day they leave, and the answers are already there when you need them.</p>
|
|
330
|
+
<ul>
|
|
331
|
+
<li><b>The contact export is the part that matters.</b> It says how many it exported</li>
|
|
332
|
+
</ul>
|
|
333
|
+
<p class="gpara"> and what GoHighLevel's own index says, side by side. Those two numbers are</p>
|
|
334
|
+
<p class="gpara"> counted differently, so a small gap is normal. If the export stopped early it</p>
|
|
335
|
+
<p class="gpara"> says INCOMPLETE in the document itself, not in small print.</p>
|
|
336
|
+
<ul>
|
|
337
|
+
<li><b>Ownership is the one thing GoHighLevel cannot tell you.</b> It stores nothing</li>
|
|
338
|
+
</ul>
|
|
339
|
+
<p class="gpara"> about who paid for the domain or who keeps the logo. So the kit asks once, and</p>
|
|
340
|
+
<p class="gpara"> the answers are saved inside the client's own account where they survive you.</p>
|
|
341
|
+
<p class="gpara"> Answer them at setup and every later run is instant.</p>
|
|
342
|
+
<ul>
|
|
343
|
+
<li><b>Keys are never printed.</b> No tool can read them, including this one. You get</li>
|
|
344
|
+
</ul>
|
|
345
|
+
<p class="gpara"> a list of every place a key lives so you can rotate them, which is what you</p>
|
|
346
|
+
<p class="gpara"> actually needed.</p>
|
|
347
|
+
<ul>
|
|
348
|
+
<li><b>A section that could not be read says so.</b> It never shows an empty list for</li>
|
|
349
|
+
</ul>
|
|
350
|
+
<p class="gpara"> something it simply could not see.</p>
|
|
351
|
+
<div class="prompt"><q>Record who owns what for this account: the domain is the client's, the logo is mine.</q><button class="copy" type="button">Copy</button></div>
|
|
352
|
+
</div>
|
|
320
353
|
<div class="gsec"><h2>The rest of the account</h2>
|
|
321
354
|
<p class="gpara">One ask covers the things you only look at when something is wrong.</p>
|
|
322
355
|
<div class="prompt"><q>Who has access to this account?</q><button class="copy" type="button">Copy</button></div>
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elitedcs/ghl-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.67.0",
|
|
4
4
|
"mcpName": "io.github.drjerryrelth/ghl-command",
|
|
5
|
-
"description": "GoHighLevel MCP Server for Claude.
|
|
5
|
+
"description": "GoHighLevel MCP Server for Claude. 241 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"
|