---
name: gohighlevel-ops
description: Operate Rob's GoHighLevel (GHL) subaccount via API — auth setup, token/scope pitfalls, read-only funnels API, and the AI Studio site editing workflow. Use for ANY task touching the Real Results Ready GHL account (funnels, pages, contacts, forms, calendars, campaigns) or editing his GHL AI Studio website.
---

# GoHighLevel Subaccount Operations

Rob's subaccount: **Real Results Ready LLC** — locationId `4yTvHUHrmVuP8CgCt1Q9` (companyId `e4QT67gW4Bz6yO6r7Teu`). Agency tier $297/mo.

The marketplace app lineage: "Hermes1" (2026-07-29) → renamed "Hermes App V2" (freemium-locked) → **"Hermes App V3" (LIVE since 2026-08-03): pricing=FREE, Sub-account target, Agency-only install, Standard listing, ~168 scopes, Client ID `6a70395a17e1fb08c0025342-mscw8lp0`**. VPS creds (`ghl-oauth.env`) = V3; V2 backup at `ghl-oauth.env.v2-backup`. V3 installed at agency level with both sub-accounts attached. **The V3 agency token has full read+write access to sub-account data EXCEPT voice-ai and funnels (both 401 authClass per-endpoint — voice-ai verified 2026-08-07) — use it for everything else.** Token store holds: V3 agency token (primary) + RRR location token (voice-ai, minted 2026-08-07) + dead V2 sandbox Location token (historical). **V3 client secret was pasted in chat during setup — rotate when convenient.** V2 can be deleted once its sandbox token is no longer needed (deleting V2 kills V2-issued tokens). Marketplace's "Active on N sub-accounts" count is NOT proof a usable token exists per location — see pitfall #13.

Social Planner (LinkedIn etc. — UI + API workflow, verified 2026-08-03): `references/social-planner.md`. The app's scope set already includes all `socialplanner/*` scopes.

Funnel landing pages (build workflow — Claude-spec → native page-builder rebuild, the "Missed Call Diagnostic" pattern; AI Studio ≠ funnel builder; native Countdown element for timers; multi-state urgency is a manual page swap): `references/ghl-funnel-pages.md`.

**Funnel page-builder AI patch limits (verified live 2026-08-10, Premium Line checkout build):** the builder's AI assistant can patch order-form ELEMENT settings (coupon toggle, button text, colors, hide-shipping toggle) but CANNOT (a) attach a product to the order form, (b) add custom fields like a Town dropdown, or (c) set success/expire redirects. Those live outside element settings: product attach = funnel STEP level Products sub-tab (see "Product attach route" below); custom fields = **Settings → Custom Fields** in the main GHL sidebar — but note the native order form CANNOT take them at all (see "Custom fields + native order forms" below), so creating them only pays off for the two-step Form→order-form upgrade path. When the AI says "not exposed via patchable settings," that's a true tooling limit, not a refusal — switch to the manual route immediately instead of re-prompting. Rob's division of labor on checkout forms: let the AI do element-level copy/styling, hand-wire product + fields + redirects manually.

**Custom fields + native order forms (RESOLVED 2026-08-10):** the native order-form element CANNOT take custom fields at all — confirmed by both the settings panel (no add-field control anywhere: Contact Options holds only behavior toggles — Sticky Contact / New Contact on every purchase / Validate Disposable email) and the builder AI's own statement. Only the fixed built-in set is available: Full Name, Email, Phone, and Toggle Company Name (with a mandatory checkbox + editable label/placeholder). **The workaround Rob chose (launch-fast path): Toggle Company Name ON + mandatory + relabel to "Agency Name"; collect Town post-checkout via automation instead of on the form.** Custom fields (created live 2026-08-10: "Town" Single Select with the 7-town list + "Agency Name" Single Line; Object = Contact, Folder = General Details — GHL's default, don't create new folders) remain useful for the future upgrade: GHL native FORMS do support custom fields, so the planned Option 3 flow (form page with Town dropdown → order form page pre-filled) works without rebuilding fields. Also note: "Full name validation" toggle ≠ required-field control — it only enforces first+last format.

**UI-walkthrough direction style (user correction 2026-08-10): "you need to be better giving direction... section, field, etc."** When walking Rob through the GHL UI, every instruction must name the EXACT screen location path: which sidebar item, which tab, which settings-panel section, which field. "Look for a product selector" is not a direction — "funnel step view → Products sub-tab → + Add Product" is. Rules burned this session: (1) tell him WHICH screen he's leaving and which he's going to ("exit the page editor via ← Back top-left → funnel step view → far-left dark sidebar → gear ⚙️ Settings at the bottom"), because the page editor's left panel is NOT the main GHL sidebar and he conflates them; (2) when he pastes a settings-panel contents list, point at HIS list by name ("it's under 'Main Product Options' in the list you just pasted"); (3) one screen-location change per message — don't stack "close the AI panel, then click the form, then look at the right panel" into one instruction; (4) when he's mid-form filling it in and asks about one field ("Object and folder name"), answer ONLY that field — don't re-send the whole form spec.

**Guiding button-copy decisions (user pushed back 2026-08-10):** when Rob questions a copy choice ("Lock in your town button?"), give the conversion rationale + the consistency pattern (page copy = "your", button = "my" — standard CTA pattern), state the recommendation, and move on. One short answer, not an essay.

**Product attach route (verified 2026-08-10):** the funnel page builder's top toolbar has NO Products tab — it's a per-element toolbar (add/AI/layers/pages/code/styles/text/images/sections/blog/video/colors/preview). Product attach lives at the **funnel STEP level**: save the page, back out to the funnel overview (Steps / Stats / Sales / Security / Events / Settings tabs), click the step, then the step-level **Products** sub-tab (Overview / Products / Publishing) → "+ Add Product". Once attached there, the order form stops showing the "Dynamic Item $99" placeholder and renders the real product + trial terms (e.g. "The Premium Line — 22 day trial and $497 per month after"). The EDITOR canvas may keep showing the stale placeholder after attach — the truth check requires publishing the step (Publishing tab) and viewing the live URL; the editor preview 404s until the step is published (page never published = no route exists). Publishing an unknown URL is safe for verification; unpublish anytime.

**GHL trial products: day-count ONLY, no calendar billing anchor (verified 2026-08-10).** The product trial field takes a plain number of days — there is no "bill on the 1st of the month" anchor option. Checkout copy must therefore promise a day count ("your first $497 bills in 22 days"), never a calendar date ("bills on the 1st of next month"), because the calendar promise can't be honored for every signup date. When copy and product capability conflict, change the COPY to match the product (Option B) — don't contort the product.

**Scarcity/countdown landing page build facts (locked 2026-08-09):**
- **GHL native builder cannot run a self-ticking custom JS countdown from a prompt.** Use the **native Countdown element set to a fixed date**. A "clock stops when X happens" behavior is NOT achievable by halting the native timer — achieve it via **page swap/redirect**: the countdown lives only on the UNCLAIMED sales page; on the trigger event the buyer is sent to a CLAIMED page with NO countdown. (Visitor-visible effect is identical; it's the only native way.)
- **Purchase-triggered flip needs a real checkout + a RECURRING-subscription product.** Verify the product type before wiring: a "Physical"-typed product or a "CALENDAR"-tagged booking product is NOT a chargeable order form. For "$0 now, $X/mo later" use a recurring product with a trial — and check the price matches the copy (caught live: "Growth" product was $479/Physical, the copy said $497 — create a fresh $497/mo subscription, don't reuse a mismatched product). Requires a connected payment gateway (Stripe); test-mode keys let you build + test-card the full flow without charging.
- **Products/payments endpoints are NOT covered by the V3 agency token** (`/products` 401s "not authorized for this scope" under it). Can't audit a user's product list agent-side without a products-scope token — ask the user for a UI screenshot instead (works well with vision_analyze). **Building a subscription product is therefore a guided UI walkthrough, not an API call — full field-by-field map of the product-create form (edit-screen trap, trial day-count quirk, label/online-store red herrings, number-of-payments/setup-fee traps): `references/ghl-product-setup.md`.**
- **Match the funnel page to the LIVE SITE's design system, not a separate campaign identity.** A landing page reached through the main site must read as the same company. Pull real tokens from the site's CSS bundle (find the `<link ...css>` on the homepage, fetch it, extract `--custom-properties`, hex frequency, `font-family`, gradient decls). RRR's live system: Inter font, royal blue #2563EB CTA, light white→#EFF6FB hero, `--gold` amber accent. Don't reuse a print-collateral palette (the bail "navy+amber night" flyer look) on the web funnel page.

Known locations under the agency (as of 2026-08-03):
- **Real Results Ready LLC** — `4yTvHUHrmVuP8CgCt1Q9` (production)
- **Test Sandbox** — `VLPUVcvUDOaXhgFPKiGl` (created via API 2026-07-30 for safe testing; use this for any destructive/experimental call)
- **Pipeline Layer** — `k6PTFz0gXAdNJLDT1aji` (created via API 2026-08-03; website cloned in from RRR via GHL UI snapshot — snapshots/funnel cloning are UI-only, no API exists, don't promise agent-side clones)

## Connecting / Auth

**Two auth paths exist — prefer OAuth (live since 2026-07-30).** Token types, the single-token overwrite trap, the location-install flow, and the endpoint test matrix: `references/oauth-token-lifecycle.md`.

**Do not propose PIT-based workarounds for location-level access** (e.g. "use the sub-account's own PIT instead") — ruled out by the user 2026-07-30 (PIT scope set is too limited). The supported path is the V3 agency token (full scope set, covers all sub-accounts — verified 2026-08-03).

### OAuth (preferred — auto-refreshing agency token)

Token file: `/root/.hermes/secrets/ghl-oauth.json` (chmod 600). Written on install callback; background thread refreshes when <1h remains. Health check: `curl -s https://robblake.cloud/ghl/health`.

To use:
```bash
TOKEN=$(python3 -c "import json; print(json.load(open('/root/.hermes/secrets/ghl-oauth.json'))['access_token'])")
curl -s -H "Authorization: Bearer $TOKEN" -H "Version: 2021-07-28" -H "Accept: application/json" "https://services.leadconnectorhq.com/locations/search?companyId=e4QT67gW4Bz6yO6r7Teu"
```

Agency-level token (`userType: Company`, `locationId: null`) with broader scopes than the PIT — including `agent-studio.write`, `adPublishing.*`, full contact/opportunity/calendar/conversation scopes.

### PIT (legacy — manual rotation, still works)

Token lives at `/root/.hermes/secrets/ghl.env` (chmod 600 — root FS is read-only outside `/root/.hermes`, never try `/root/.config`). Source it: `source /root/.hermes/secrets/ghl.env` → `$GHL_TOKEN`, `$GHL_API_BASE`, `$GHL_API_VERSION`.

Every request needs THREE headers: `Authorization: Bearer $GHL_TOKEN`, `Version: 2021-07-28` (REQUIRED — GHL rejects without it), `Accept: application/json`.

Base URL: `https://services.leadconnectorhq.com`.

First move on any GHL task: run `scripts/ghl-probe.sh` to confirm the token is alive and see which scopes are granted before promising anything.

Key endpoints + error dictionary: `references/ghl-api.md`.

## OAuth app + callback handler (added 2026-07-29)

Rob published a marketplace app (agency-level scope, decided 2026-07-29) so Hermes can hold an auto-refreshing OAuth token instead of only the PIT. Registered redirect URL: `https://robblake.cloud/ghl/oauth/callback` — must match the app registration EXACTLY (no trailing slash).

- Handler: `/root/.hermes/ghl-oauth/ghl_oauth_server.py` (stdlib-only python, listens 127.0.0.1:9120)
- systemd: `ghl-oauth.service` (enabled). Logs: `journalctl -u ghl-oauth.service`
- nginx: `include /etc/nginx/snippets/ghl-oauth.conf;` inside the 443 block of `/etc/nginx/sites-enabled/robblake.cloud`, proxies `/ghl/` → 9120. NOTE: the 443 server block lives in `robblake.cloud` (server_name `2.25.172.164 robblake.cloud www.robblake.cloud`), NOT in `sites-enabled/default` (that's a stock static site on :80 only). Any `location` added must go in the 443 block or the hermes dashboard SPA catch-all swallows it (returns 200 HTML — looks like success, isn't).
- App creds: `/root/.hermes/secrets/ghl-oauth.env` (GHL_CLIENT_ID / GHL_CLIENT_SECRET / GHL_REDIRECT_URI). After editing creds: `systemctl restart ghl-oauth.service`.
- Tokens: `/root/.hermes/secrets/ghl-oauth.json` (chmod 600) — written on install callback; background thread refreshes when <1h remains. Health: `curl -k https://robblake.cloud/ghl/health` shows `token_file` + `expires_in_s`.
- **SSL cert:** as of 2026-07-30 the site uses a REAL Let's Encrypt cert (certbot --nginx), not self-signed. Auto-renews via `certbot.timer`. If the cert ever breaks, re-run: `certbot --nginx -d robblake.cloud -d www.robblake.cloud --non-interactive --agree-tos --email rkblake@gmail.com --redirect`.
- App credentials: marketplace dashboard (https://marketplace.gohighlevel.com → My Apps → the app → Auth): Client ID + Client Secret + redirect URIs. The default Client Secret may be UNREVEALABLE — hit Regenerate (creates a new named secret; Rob's is "Hermes1"), copy it IMMEDIATELY (it hides again). Then update `/root/.hermes/secrets/ghl-oauth.env` and `systemctl restart ghl-oauth.service`.
- Install links: the app gives installation / standard / whitelabel variants. For installing into Rob's OWN sub-account use the STANDARD link in a browser already logged into GHL → choose the RRR location → lands on the callback ✅ page. Whitelabel is only for reselling under your own brand — not needed for self-install.
- Install flow: GHL marketplace → app → Install → authorize location(s) → browser hits callback → "App installed" page. Verify with the health endpoint + a real API call using `access_token` from the JSON.
- GHL token exchange endpoint: POST `https://services.leadconnectorhq.com/oauth/token`, form-encoded, grant_types `authorization_code` / `refresh_token`.

### Install-time pitfalls (hit live 2026-07-29)

1. **Client secret is hidden by default** in marketplace app → Auth settings (eye icon may not reveal). Hit **Regenerate/Rotate** to get a visible secret, copy IMMEDIATELY (it re-hides). Client ID is always visible. The app currently configured on the VPS is **"Hermes App V2"** (originally installed 2026-07-29 as "Hermes1", renamed later — same app, Client ID `6a3156368a9daa764c5da477-ms6r4isq`); it is being replaced by **V3** (free pricing) — see header note. Do NOT treat version-name changes as new apps without checking the Client ID against `/root/.hermes/secrets/ghl-oauth.env` first.
2. **Use the STANDARD installation link** for Rob's own account. Whitelabel link is only for resale branding.
3. **Install level depends on the job — agency covers MOST things, voice-ai/funnels need a location token (updated 2026-08-07).** An agency-level install with the full scope set reads AND writes sub-account data on most endpoint families. `/voice-ai/*` and `/funnels/*` 401 `authClass` under it regardless of scopes — those need a per-location install (full-scope chooselocation link, pitfall #15). RRR holds both token types since 2026-08-07; Test Sandbox's V2 Location token is dead (creds rotated, "Invalid client credentials").
4. **Never paste real creds in chat blocks** — happened once (works, but lives in session history); advise one regenerate + direct file edit afterward.
5. **Hostinger web terminal visually wraps long pasted commands**, splitting them into broken fragments (sed "no input files", "Permission denied" symptoms). Paste blocks for it must use SHORT lines, one command per line — no long &&-chained one-liners.
6. **Cloudflare error 1010 on token exchange — a browser User-Agent is NOT enough (proven 2026-07-30).** `services.leadconnectorhq.com` sits behind Cloudflare bot detection. Python `urllib` gets HTTP 403 `error_code 1010 browser_signature_banned` EVEN WITH a Chrome User-Agent — Cloudflare fingerprints the TLS stack, not just headers. `curl` passes fine (verified: same POST via curl returned a normal GHL 401 JSON error instead of the Cloudflare block page). Fix applied: `post_form()` in `ghl_oauth_server.py` shells out to `curl` via `subprocess.run` instead of urllib. Rule for ANY outbound call to leadconnectorhq.com from this VPS: use curl (shell or subprocess), never Python urllib/requests. If a Python call 403s with `cloudflare_error: true`, that's TLS fingerprinting, NOT bad creds — do not rotate the secret.
7. **Auth codes are single-use and die silently.** If the user's browser can't reach the callback (DNS, wrong network), the code GHL minted is burned and nothing reaches the server — the only trace is an ABSENCE of new lines in `journalctl -u ghl-oauth.service` and no `ghl-oauth.json`. Diagnosis pattern: check `journalctl` for a "received authorization code" line; if the install "worked" browser-side but no such log line exists, the request never arrived and the install link must be re-run from scratch (each run needs a fresh trip through the GHL authorize screen — old codes can't be replayed).
8. **`robblake.cloud` may NXDOMAIN on Rob's Windows machines.** Symptom: callback URL shows `DNS_PROBE_FINISHED_NXDOMAIN` even though the VPS is fine. Fix on Windows (admin PS): `ipconfig /flushdns`, retry in an INCOGNITO window; fallback: hosts entry `2.25.172.164 robblake.cloud`. Before blaming the server, verify reachability from the VPS itself: `curl -s --resolve robblake.cloud:443:127.0.0.1 -k https://robblake.cloud/ghl/health` — JSON back = service fine, problem is client-side DNS.

9. **GHL caches install state — a "successful" reinstall may issue NO code.** If a prior install attempt reached the ✅ page (even if the exchange failed server-side), GHL considers the app installed. Re-running the install link then skips the authorize screen, goes straight to ✅, and the callback NEVER fires. Fix: uninstall the app at the AGENCY level (agency view → Settings → Apps → uninstall), then re-run the install link in an incognito window — the authorize screen MUST appear, or no fresh code will be issued.
10. **Self-signed cert on `robblake.cloud` triggered `ERR_CERT_COMMON_NAME_INVALID` in Chrome.** The old cert was for `2.25.172.164`, not the domain. Fixed 2026-07-30 with `certbot --nginx` — now uses Let's Encrypt. If this regresses (e.g. after server rebuild), re-run certbot and verify `openssl s_client -connect 127.0.0.1:443 -servername robblake.cloud </dev/null 2>/dev/null | openssl x509 -noout -subject` shows `CN = robblake.cloud`.
11. **"Freemium" pricing model blocks direct OAuth install links (hit live 2026-07-30; HARD-blocked again 2026-08-03 even AFTER uninstalling the target location).** Hitting the chooselocation URL directly returned GHL's error page: "Paid apps can only be installed within the platform. To install the app, log in to your account and visit the App Marketplace." GHL treats freemium as paid. The 7/30 workaround (uninstall from target location first, then the link works — that's how the sandbox token landed) is NO LONGER RELIABLE: on 2026-08-03 the scopeless short link hard-blocked with the same error after RRR was uninstalled. UI installs always work BUT default to agency-level grants (see #14). GHL LOCKS the pricing field after app creation (Rob tried freemium→free on Hermes1 and the field was not editable). **Decided fix (2026-08-03): create "Hermes App V3" with pricing=free from the start + select-all scopes + same redirect URI — free apps aren't link-blocked. RRR has never held a usable location token; V3's RRR install is the pending action.** Do NOT flip pricing to freemium while troubleshooting unrelated install errors — it unsticks nothing and creates this blocker.
12. **Sub-accounts have NO app-management UI (2026-07-30).** There is no Settings → Apps and no private-app install path inside a sub-account; agency-level installs can also make the app appear "already installed" there without yielding a location token. Installs happen ONLY at agency level (App Marketplace UI) or via the direct chooselocation link — log in at AGENCY, then pick the specific sub-account in the location picker that appears AFTER login. Uninstalling inside a sub-account does not make a sub-account-level install path appear — it just removes the inherited install.
13. **Marketplace "Active on N sub-accounts" ≠ token in hand (hit live 2026-08-03).** GHL's installed-state flag does not mint tokens — only a completed trip through the AUTHORIZE screen (with the location picker) followed by a successful callback does. A location can show the app as installed while `ghl-oauth.json` holds a DIFFERENT location's token or no token for it at all. Diagnosis: `journalctl -u ghl-oauth.service` — the last "tokens saved:" lines are the ONLY source of truth; the marketplace count is cosmetic. To get a token for a location that "already has the app": agency Settings → Apps → uninstall from THAT location → re-install in an incognito window → the authorize screen MUST appear (straight-to-✅ means no code was issued). **Corollary: never accept the user's "it worked" at face value** — Rob saw ✅ pages twice while the server log showed zero new callbacks. Verify every install via `/ghl/health` (the new key must appear in `tokens`) before proceeding. The ✅ page echoes the storage key: `4yTvHUHrmVuP8CgCt1Q9` = RRR success; `agency:e4QT...` = wrong grant again.
14. **"All eligible sub-accounts" at chooselocation = agency token (burned 3 install attempts 2026-08-03).** Ticking everything — or the agency-wide option — mints ONE `userType: Company` token with `locationId: null`, which 401s on all location-data writes (`authClass type is not allowed`). It feels like the broadest grant; it's the ONE choice that can't do location work. At the picker the user must tick EXACTLY ONE sub-account. Warn about this explicitly BEFORE the user clicks — Rob hit it twice in a row because the instruction "pick RRR" didn't preempt the select-all option.
15. **Chooselocation link length: scopeless short form works for AGENCY installs; LOCATION installs need an explicit `scope=` list (burned 2026-08-07).** The ~700-char full link got visually chopped in chat on 2026-08-03, which made the scopeless short form the default. But on 2026-08-07, the scopeless link against a freshly-uninstalled sub-account returned "Error: Scopes can't be empty" at the authorize screen — GHL's agency flow falls back to the app's registered scopes, the location flow does NOT. Working pattern: agency install → scopeless short link; location install → full link with a CURATED scope list (only what the task needs — voice-ai/contacts/conversations/calendars/locations/users/phonenumbers covered the bail-bonds build). Keep the scope list short enough that the URL stays paste-safe; ~10–15 scopes is fine. URL-encode the redirect_uri, `+` between scopes, `%2F` for slashes inside scope names (e.g. `conversations%2Fmessage.readonly`). Related: don't hand Rob VPS-local file paths as a workaround for mangled chat content — he works from Windows (Connie); a file at /root/.hermes/... is not openable from his laptop. Keep shareable artifacts in-chat and as SHORT as the level allows.
15b. **voice-ai PATCH/DELETE want ids in the QUERY STRING, not the body (2026-08-07).** `PATCH /voice-ai/agents/{id}?locationId=<loc>` — body-only locationId returns 403 "LocationId is required". `DELETE /voice-ai/actions/{actionId}?locationId=<loc>&agentId=<agentId>` — omitting agentId returns 403 "AgentId is required" (204 on success).
16. **Two voice-ai agents on the same number causes greeting-cutoff / call drop (2026-08-14).** If two agents both have `inboundNumbers` pointing to the same phone number, GHL routes the call but audio drops partway through the greeting — caller hears a few words then silence. Symptom: "starts greeting then stops." Diagnosis: `GET /voice-ai/agents?locationId=<loc>` → check `inboundNumbers` on every agent → look for duplicates. Fix: PATCH one agent to remove the number (set `inboundNumber: ""` and `inboundNumbers: []`). This happened when PL's copy agent and the bail-bonds template agent both held +187****3948 (877-LAN-EXIT).
17. **"Error: scopes cannot be empty" on chooselocation link = use full link with explicit scope= list for location-level installs (2026-08-14).** The scopeless short link works for agency-level installs but FAILS for location-level installs with "Scopes can't be empty." GHL's agency flow falls back to the app's registered scopes; the location flow does NOT. Fix: use the FULL link with `&scope=` spelled out. Keep the scope list short enough to survive chat paste (~10–15 scopes). URL-encode the redirect_uri, use `+` between scopes, `%2F` for slashes inside scope names.
16. **"Error: scopes cannot be empty" at install time = the app has NO scopes saved (hit live 2026-08-03 on V3's first install attempt).** Publishing a marketplace app without saving the Permissions section leaves the scope set empty; the OAuth flow refuses to start. Fix: app → Permissions/Scopes → tick scopes → Save → re-run the install link. There is NO "select all" button in the scope picker (~160 individual checkboxes for a full grant — Rob chose to tick everything on V3 anyway; if doing it again, the ~35-scope working set covering contacts/opportunities/calendars/conversations/locations/forms/workflows/users/oauth/funnels/medias/links/campaigns/socialplanner is enough for every task seen to date, and scopes can be added later + reinstall to refresh the token).
17. **Marketplace "Client keys" screen: one default key is enough (2026-08-03).** Multiple key pairs exist to distinguish install CHANNELS (marketplace UI vs direct link) — irrelevant for self-use. Generate ONE client key; it becomes the default and serves both the UI Install button and the direct chooselocation link. The "Shared Secret Key" on the same screen is OPTIONAL — only used for decrypting signed SSO/user-context payloads in embedded apps; the OAuth token exchange doesn't use it. Generate one anyway if webhooks/embedded features are on the roadmap (store as `GHL_SHARED_SECRET` in ghl-oauth.env — the handler ignores unknown lines).
18. **Agency-level install covers MOST work — but voice-ai and funnels are location-token-only (updated 2026-08-07).** The V3 agency token (168 scopes) reads+writes RRR sub-account data on Social Planner/contacts/calendars/conversations-class endpoints (verified 2026-08-03). It does NOT cover `/voice-ai/*` (401 authClass, hit 2026-08-07) or `/funnels/*` — those need a per-location install via the full-scope chooselocation link (pitfall #15). Architecture that works: agency install as the default + ONE location install per sub-account that needs voice-ai or funnels. The multi-token handler stores both without clobbering. Verify any install via journalctl "tokens saved:" + /ghl/health before believing the ✅ page.

**CRITICAL 2026-08-15: agency token may not have access to a sub-account AT ALL.** If a sub-account (e.g. Pipeline Layer) wasn't selected during the original agency install, ALL endpoints against it return `403 — The token does not have access to this location` — not just voice-ai, but contacts, conversations, phone-system numbers too. Diagnosis: if `/voice-ai/agents?locationId=<id>` AND `/contacts/?locationId=<id>&limit=1` both 403 with "does not have access to this location," the sub-account isn't in the install scope. Fix: uninstall V3 at agency level → reinstall → at chooselocation picker, tick BOTH sub-accounts (NOT "all eligible"). Or use "Install under all locations" + auto-install to future locations. This burned a full troubleshooting session on 2026-08-15.

## OAuth token usage (post-install)

Once `ghl-oauth.json` exists, the access token can be used for GHL API calls. The token type depends on how the app was installed:

- **Agency-level** (`userType: Company`, `locationId: null`): with a complete scope set, reads AND writes sub-account data (verified 2026-08-03 — the old "cannot write" claim was a V2 scope gap). Also lists sub-accounts, SaaS sub-account creation.
- **Location-level** (`userType: Location`, `locationId: <id>`): full read/write access to that specific sub-account. (No longer required for location data — see "Agency tokens CAN read+write" correction.)

To use:
```bash
TOKEN=$(python3 -c "import json; print(json.load(open('/root/.hermes/secrets/ghl-oauth.json'))['access_token'])")
curl -s -H "Authorization: Bearer $TOKEN" -H "Version: 2021-07-28" -H "Accept: application/json" "https://services.leadconnectorhq.com/locations/search?companyId=e4QT67gW4Bz6yO6r7Teu"
```

The background thread in `ghl_oauth_server.py` refreshes the token automatically when <1h remains. Check `expires_in_s` at `/ghl/health`.

**Multi-token since 2026-08-03:** `ghl-oauth.json` holds a `tokens` dict keyed by locationId (agency tokens under `agency:<companyId>`); the top level mirrors the most recently saved token for legacy readers. See "Multi-token handler" below. Prefer per-location reads via `GET http://127.0.0.1:9120/ghl/token?locationId=<id>` over parsing the file; always check `/ghl/health` (`tokens` map) before assuming which tokens you hold.

### Sandbox sub-account (added 2026-07-30)

**Test Sandbox** — locationId `VLPUVcvUDOaXhgFPKiGl`. Created via SaaS mode API (`POST /locations/`) for safe testing. Location-level OAuth token installed via direct link after uninstalling agency-level app from sub-account. Verified working: created contact `VwU4Qytvs6dZXmTluSnN`.

**Key learnings:**
- SaaS mode is enabled — sub-accounts can be created via API.
- **CORRECTED 2026-08-03: agency tokens CAN write to sub-accounts with a complete scope set** — the 7/30 `authClass type is not allowed` 401 was a V2 scope gap, not a rule. See the correction section.
- Getting a location-level token REQUIRES the authorize screen with a single sub-account ticked — never "all eligible sub-accounts" (that's an agency grant). Full sequence in pitfalls #11/#13/#14/#15. (Location tokens optional since 2026-08-03 — see pitfall #18.)
- "Freemium" pricing blocks direct oauth installs with "Paid apps can only be installed within the platform" — as of 2026-08-03 this block is UNCONDITIONAL (the 7/30 uninstall-first workaround no longer holds). The durable fix is a free-priced app (V3, live since 2026-08-03 — see header note).
- Sub-accounts do NOT have app management UI — installs must be initiated from agency level or via direct oauth link.

### API write gotchas (verified in sandbox 2026-07-30)

1. **Calendar creation requires a real `userId`** in `teamMembers` — a fresh sub-account has ZERO users, so `POST /calendars/` fails with `400 "No team member found."` until a user is added. No API "copy user between locations" endpoint exists; add the user via GHL UI (sub-account → Settings → Team → Add) so they get a real login invite, then grab the id from `GET /users/?locationId=...`.
2. **Pipeline stages require explicit `position` integers** — `POST /opportunities/pipelines` with stages missing `position` fails 422 listing every stage. Include `"position": 0, 1, 2...` per stage.
3. **Calendar slugs are GLOBALLY unique across all of GHL**, not per-location. Generic slugs like `simple-calendar` are already taken — always use a unique suffix (e.g. `sandbox-booking-7x2q`).
4. **Pipelines endpoint path:** `GET/POST /opportunities/pipelines?locationId=...` (NOT `/pipelines/` — that 404s).
5. **The oauth handler is multi-token since 2026-08-03** (see "Multi-token handler" section below) — reinstalling at a different level no longer clobbers other tokens. Historical note: pre-patch, the sandbox location install (2026-07-30) clobbered the agency token, and RRR's original 7/29 token was clobbered by the sandbox install — which is why RRR needed a fresh install.

**Endpoint authClass restrictions are PER-ENDPOINT, not per-token (nuance added 2026-08-03).** The V3 agency token (168 scopes) reads+writes `/social-media-posting/*` on any sub-account, but `GET /funnels/funnel/list?locationId=<subaccount>` still 401s `authClass type is not allowed`. So "agency token covers everything" is true for Social Planner/contacts-class endpoints but NOT funnels — funnels remain location-token-only territory even on V3. Test the specific endpoint family before promising coverage; the agency token is the default, with per-endpoint verification.

### Agency tokens CAN read+write sub-account data when the scope set is complete (CORRECTED 2026-08-03)

The 7/30 claim "agency tokens cannot write to sub-accounts (401 authClass type is not allowed)" was **a scope-gap symptom on V2's then-current token, not a platform rule.** Verified live 2026-08-03 against V3's agency token (168 scopes, `userType: Company`, `locationId: null`): `GET /social-media-posting/4yTvHUHrmVuP8CgCt1Q9/accounts` → **200** (returned RRR's connected LinkedIn profile), and `POST .../posts` → **422 validation error (NOT 401)** — write permission confirmed, payload shape was just wrong. Working architecture, user-sourced and now proven: **agency-level install + "Install under all locations" + auto-install-to-future-locations = one token covering all sub-accounts.** Per-location tokens are no longer required for location-data work — keep the sandbox's V2 Location token as a spare, but default to the V3 agency token for everything. If an endpoint 401s `authClass type is not allowed` under the V3 agency token, treat it as a missing scope on the APP (add scope → reinstall) rather than proof a location token is needed.

**Endpoint authClass restrictions are PER-ENDPOINT, not per-token (nuance added 2026-08-03).** The V3 agency token (168 scopes) reads+writes `/social-media-posting/*` on any sub-account, but `GET /funnels/funnel/list?locationId=<subaccount>` still 401s `authClass type is not allowed`. So "agency token covers everything" is true for Social Planner/contacts-class endpoints but NOT funnels — funnels remain location-token-only territory even on V3. Test the specific endpoint family before promising coverage; the agency token is the default, with per-endpoint verification.

**Social Planner post payload gotchas (burned 2026-08-03, 5 posts scheduled):** `scheduleDate` not `scheduledAt`; `type: post|story|reel` (not `text`); `media` must be an array of objects with `url` + `type` (MIME type like `image/png`, not `image`); `userId` required (use the account's `oauthId`); `platform` is NOT a valid field in the create payload — the response shows `platform: google` as a display artifact, not the actual destination. Edit posts via `PUT /posts/{id}` requires the FULL payload (accountIds, summary, type, status, scheduleDate, userId, media) — a partial edit payload 422s with "accountIds must be an array."

POST `https://services.leadconnectorhq.com/locations/` with the agency token creates a sub-account (201). Minimal body: `{"name":"...","companyId":"e4QT67gW4Bz6yO6r7Teu","email":"..."}`. This is how Test Sandbox was made. Use it to spin up throwaway locations for testing instead of touching RRR production data.

### Multi-token handler (patched 2026-08-03)

The one-token limitation is FIXED. `ghl_oauth_server.py` v2.0 stores tokens keyed by locationId (agency tokens under `agency:<companyId>`) — installs no longer clobber each other. Layout: `{"tokens": {"<locationId>": {...}}, "active": "<last key>", ...mirror of active token top-level for legacy readers...}`. The refresh loop walks ALL stored tokens. New endpoint: `GET http://127.0.0.1:9120/ghl/token?locationId=<id>` returns the raw payload for that location (omit param = active token). Health (`/ghl/health`) now lists every token + expiry. Backups from the patch: `ghl_oauth_server.py.bak-20260803` + `ghl-oauth.json.bak-20260803`. **RRR (`4yTvHUHrmVuP8CgCt1Q9`) location token LANDED 2026-08-07** (voice-ai work forced it — voice-ai is agency-token-blocked per pitfall #15/authClass rules). Token store now holds: V3 agency token (primary, everything except voice-ai/funnels) + RRR location token (voice-ai, narrower scope list) + dead V2 sandbox token (`Invalid client credentials` — V2 app creds rotated out from under it; treat as historical).

### Webhook receiver (handler v2.1, 2026-08-03)

`POST /ghl/webhook` (public: `https://robblake.cloud/ghl/webhook`) logs GHL marketplace webhook events to `/root/.hermes/ghl-oauth/webhooks.jsonl` (rotates at 5MB), returns 200 immediately. Registered as the Default webhook URL on the V3 app. NO signature verification yet — GHL signs outbound webhooks with Ed25519 (`x-ghl-signature` header); add verification before any flow that ACTS on events. **V3's client secret was pasted in chat during setup (2026-08-03) — regenerate it in the marketplace dashboard once the RRR install is confirmed and update ghl-oauth.env directly in the terminal.**

## Voice AI agents (added 2026-08-07 — verified working end-to-end)

Full reference: `references/voice-ai-agents.md` (endpoints, schema, inventory). Workflow triggers + UI traps for call-end automations (premium "Transcript generated" vs standard "Call details" — use "Call details": status=completed + Direction=incoming + In phone number=<agent's number>; SMS recipient must be hardcoded, NOT {{contact.phone}}): `references/voice-ai-agents-api.md`. **Debugging misbehaving agents (greeting cuts off, call drops, routing conflicts): `references/voice-ai-troubleshooting.md`** — covers duplicate-number conflicts and 403 diagnosis. Rob's demo-notification cell: 303-828-7753 (distinct from his Google Voice +1303***7628, which is Connie-RRR's call-transfer target). Summary:

- **Location-token-only surface.** The V3 agency token (168 scopes incl. `voice-ai-agents.*`) 401s `authClass type is not allowed` on every `/voice-ai/*` endpoint — same restriction class as funnels. Voice agent work REQUIRES a location-level install; get one via the full-scope chooselocation link (pitfall #15). RRR's location token landed 2026-08-07 (covers voice-ai + contacts + conversations + calendars + locations + users + phonenumbers — NO customFields/workflows, which were out of scope for that install; re-install with a wider scope= list to add them).
- Working calls (all with the location token, `Version: 2021-07-28` header): `GET/POST /voice-ai/agents?locationId=<loc>`, `GET/PATCH/DELETE /voice-ai/agents/{id}?locationId=<loc>`, `POST /voice-ai/actions?locationId=<loc>`, `DELETE /voice-ai/actions/{id}?locationId=<loc>&agentId=<agentId>`.
- **SMS action texts THE CALLER, not a third party** — the action schema has no destination-number field. "Text Rob the handoff after each call" requires a workflow (UI-only; Connie-RRR has 3 wired via `callEndWorkflowIds`) or `sendPostCallNotificationTo` (email/users only, no arbitrary phone).
- Copy voice/settings from an existing production agent (Connie-RRR: voiceId `g6xIsTj2HwM6VR4iXFCw`, en-US, responsiveness 0.7, maxCallDuration 600, America/Denver) rather than guessing voice IDs — no voice-list endpoint verified.
- **Existing RRR agents as of 2026-08-07:** "Connie - RRR" (production receptionist, on +172****6868), "Connie - Pipeline Layer" + copy (PL prompts stranded in RRR subaccount), "18 Local Biz Live Demo" + copy, "AI Sells Itself" (third-party template), **"Missed Call Only Agent (Bail Bonds)" id `6a75d6414ee3e02404bb0127`** — the bail-bonds demo pilot built 2026-08-07, attached to 877-LAN-EXIT, prompt spec at `/root/.hermes/work/ghl/voice-agent-bail-bonds.json`. Call-end SMS-to-Rob workflow pending (Rob building in UI). Always LIST existing agents before creating a new one — don't clobber or duplicate.

## Social Planner (LinkedIn scheduling) — verified 2026-08-03

The app has the full `socialplanner/*` scope set (post/oauth/csv/account/tag/category/statistics read+write). Verified working: `GET /social-media-posting/{locationId}/accounts` → 200 against sandbox token.

Key endpoints (all under base, `Version: 2021-07-28` header required):
- `GET /social-media-posting/{locationId}/accounts` — list connected social accounts
- `GET /social-media-posting/oauth/linkedin/start` — begin LinkedIn connect (browser OAuth — must be clicked through by Rob with his LinkedIn login; not scriptable)
- `POST /social-media-posting/{locationId}/posts` — create/schedule post (supports `linkedinPostDetails`)
- `PUT|DELETE|PATCH /social-media-posting/{locationId}/posts/{id}` — edit/delete
- `POST /social-media-posting/{locationId}/csv` — bulk CSV scheduling
- `POST /social-media-posting/statistics` — analytics

Workflow for LinkedIn posting: (1) the V3 agency token already covers `socialplanner/*` on every sub-account — no per-location token needed, (2) Rob connects the social account once via Social Planner UI (Marketing → Social Planner → Settings → Connect) — a browser OAuth click-through with HIS social login, not scriptable, (3) then post scheduling is fully scriptable via the posts endpoint. Payload gotchas (`scheduleDate` not `scheduledAt`, `type: post|story|reel`, required `userId`/`media` array, and the account ids already on file for RRR's LinkedIn): `references/social-planner-api.md`. UI path for manual work: Marketing → Social Planner → New Post (post now / schedule / recurring / evergreen queue / approval flow; bulk CSV upload also in UI).

**Edit post requires FULL payload (burned 2026-08-03):** `PUT /social-media-posting/{locationId}/posts/{id}` 422s on partial edits. You must resend the complete payload — `accountIds`, `summary`, `type`, `status`, `scheduleDate`, `userId`, `media` — with only the field you want changed modified. To remove media from a scheduled post, send `"media": []` alongside all other fields.

**LinkedIn image ops (burned 2026-08-11):** published LinkedIn posts allow text edits but the IMAGE is locked — retrofitting new cards onto published posts is impossible. When media-updating several posts, map posts by summary CONTENT, never by date label or list order (an ID→date map cross-attached Tue/Thu images). The `platform: "google"` label on LinkedIn-bound posts is a display quirk, not a misroute. Full detail + editorial card render-system constraints (headline width limit, vision_analyze overlap false-positives): `references/linkedin-image-ops-2026-08-11.md`.

## Pitfalls (all hit live 2026-07-18)

**Image generation backend lesson (2026-08-03):** The Abacus AI RouteLLM plugin was installed but the API key returned 403 "not authorized" on `/v1/chat/completions` while working on `/v1/models`. The key was created from ChatLLM Teams (web UI) but RouteLLM API generation may require a different permission level or plan tier. **Lesson: verify API access BEFORE installing a plugin or spending money.** Test the key with a simple curl to the actual endpoint you need, not just `/v1/models`. If generation endpoints 403 while list endpoints work, it's a plan/permission issue, not a config issue. Switch to a working backend (OpenAI gpt-image-2, DeepInfra, or FAL flux-2-pro) rather than debugging a plan limitation.

**Brand separation is non-negotiable (user correction 2026-08-03).** Pipeline Layer (B2B SaaS RevOps) and RRR (local service trades) are separate brands with separate audiences. Do NOT cross-post content between them. When scheduling social posts, confirm which sub-account + which platform + which brand voice before writing. Rob flagged "those LinkedIn posts are not for RRR" when I conflated the two — a first-class signal to keep brands isolated.

1. **Scopes are locked at token creation.** Adding scopes to an existing Private Integration and hitting Save does NOT apply them to the already-issued token. Signature: every endpoint 401s with "The token is not authorized for this scope" while `/locations/search` still returns 200. Fix: create a FRESH integration (select scopes during creation) or rotate the token — the newly minted token carries the current scope set.
2. **Error messages distinguish bad token vs bad scope:** "Invalid Private Integration token" = wrong/dead string (check transcription). "Not authorized for this scope" = token is VALID but scope missing. Always read which one.
3. **Token rotation:** GHL shows "new" + "old" cards; both work during the ~7-day grace period, then old expires. The token displayed in the rotation UI may be a DIFFERENT generation than the one you hold — if auth is weird, ask the user which token they're looking at before debugging further.
4. **Never transcribe tokens from screenshots.** UUIDs OCR badly (session failure: `1bb7` read as `11b7` — cost a full round-trip). Always ask the user to click Copy and paste the token as TEXT.
5. **Funnels/Websites API is READ-ONLY for Private Integrations.** "Select all" scopes is the maximum and contains NO `funnels/funnel.write` or page-write scopes. Do not promise API-driven page edits — they don't exist. Reads (list funnels, list pages, page counts, redirects) work fine.

## Editing Rob's AI Studio site (the "vibe coding" builder)

AI Studio generates a REAL React project (vite/lovable-style: `.tsx` files, `@/components/` import alias, shadcn/tailwind). It is NOT reachable via any API. Editing workflow:

1. User pastes a file's contents from AI Studio's code editor.
2. Edit it here; return the COMPLETE updated file (user pastes it back over the old one — partial diffs are error-prone for this user).
3. Start from `Index.tsx`/page skeletons to map the section tree, then request the specific component(s) the change touches.
4. Check for dead imports (imported but unrendered components) — they flag sections that were removed or never wired; ask before assuming.
5. No bulk export exists in AI Studio (zip/GitHub export is a requested-but-unshipped GHL feature) — all transfer is copy-paste, so keep round-trips small: one file at a time.
6. **Asset uploads: hand the agent URLs, not files (burned 2026-08-03).** The AI Studio chat agent mangles direct image uploads — Rob burned "about a billion attempts" on a footer PNG that kept "timing out before it got set." Working pattern: upload the asset to the builder's **Media Library** yourself (or any stable host), then paste the agent the image URL. Route around the middleman when it keeps fumbling the handoff — same principle as nginx-staging files for Composio's `UPLOAD_FROM_URL`.
7. **The builder's own specs can contradict themselves (2026-08-03).** Its logo spec demanded BOTH "zero padding, edge-to-edge" AND an example "~5:1" aspect ratio — impossible together for a 7.8:1 wordmark. When spec clauses conflict, the builder's example ratio is the one that drives on-screen size (its `object-contain` scales the whole file to display height); "zero padding" means no padding beyond the planned ratio. Reconcile before exporting, or the logo displays wildly oversized and Rob burns an evening on it. Full method: `brand-asset-generation` skill, "Exact-spec logo exports."

**Brand separation is non-negotiable (user correction 2026-08-03).** Pipeline Layer (B2B SaaS RevOps) and RRR (local service trades) are separate brands with separate audiences. Do NOT cross-post content between them. When scheduling social posts, confirm which sub-account + which platform + which brand voice before writing. Rob flagged "those LinkedIn posts are not for RRR" when I conflated the two — a first-class signal to keep brands isolated.

## Phone numbers across sub-accounts (added 2026-08-03)

Phone numbers and their SMS registrations are **welded to the sub-account** that bought them — there is no move/transfer API. Rob hit this when PL's booking agent + vanity number ended up living in the RRR sub-account.

- **List a sub-account's numbers:** `GET /phone-system/numbers?locationId=<id>` works with the V3 agency token. **The API masks middle digits** (`+197****5752`) and there is no unmask endpoint — full digits only visible in GHL UI → Settings → Phone Numbers. Titles (`"Rob's Toll Free"`) are useful hints for which is which.
- **RRR sub-account inventory (2026-08-03):** `+172****6868` (720, Denver metro), `+197****0326`, **`+187****3948` = +1 877-526-3948 = "877-LAN-EXIT" — PL's vanity number, bought 2026-06-02 for Pipeline Layer (contact rob@pipelinelayer.ai), misfiled in RRR**, `+197****5752` = 970-598-5752 (970 = northern CO / Brighton — right area code for RRR's local-trade brand).
- **Vanity decode method:** keypad-map digits (2=ABC…9=WXYZ) and brute-force against a wordlist (`itertools.product` over per-digit letter sets × a dictionary — the VPS has no `/usr/share/dict/words`; fetch dwyl/english-words `words_alpha.txt` from GitHub). Check the last 7 digits as one word, then brand fragments (PL, LYR, RRR, EXIT…) at any offset. 526-3948 → LAN-EXIT.
- **When a number is in the wrong sub-account, options in order:** (1) **GHL support ticket asking them to move it** between sub-accounts under the same agency — the common agency ask, avoids the release/rebuy gamble; (2) release + rebuy (number returns to pool, small snipe risk — lower for toll-free than local) + re-register SMS; (3) leave it and buy fresh (right answer when the number fits the brand it's stuck in — e.g. a 970 local stranded in RRR is fine; RRR needs a local number anyway). A vanity number bought for the OTHER brand (877-LAN-EXIT in RRR) is case (1)/(2), not (3).
- **Toll-free SMS ≠ 10DLC A2P:** toll-free numbers use **Toll-Free Verification**, a different and typically faster/less bureaucratic path than A2P 10DLC campaign registration for local numbers. Don't conflate the two when estimating the re-approval pain of a move.

## Business context worth knowing

Real Results Ready = AI booking/revenue-intelligence for local service trades (HVAC/plumbing/electrical/roofing), Brighton CO pilot. Positioning (as of 2026-07): revenue-data detective ("which marketing dollar booked the job"), NOT commodity AI-receptionist. Pricing: $497 Missed Call Diagnostic (entry, credited to first month) → Capture ~$497/mo → Convert ~$2,497/mo → Compound ~$4,997/mo. Funnel strategy and 14 open deliverables live in the user's session-handoff docs, not in this skill — ask for the latest handoff rather than relying on memory of it.

## Go-to-market: warm network first, case study is the sizzle (locked 2026-07-20)

Rob has 15+ years living in Brighton CO ("virtually everyone in town knows me") + 27 years enterprise digital marketing. The enterprise background was pushing him toward cold-outreach-first ("eventually you'll have to convince strangers — might as well start now"). **Corrected: that sequencing is backwards for this business.** The warm network IS the first go-to-market motion — one text message buys what 6 weeks of cold-group engagement buys a stranger. Strangers buy a case study ("$X recovered in 60 days for a shop like yours"), not a pitch; the warm network is the factory that produces the case study. Do not help him write cold-outreach copy before he has 1-3 local pilot clients. His framing: fear-shaped pitches ("Don't Miss Another Call", "Lost Revenue Audit") repel him — the sell that fits is opportunity-shaped: present the owner's own numbers with the fee as a fraction of the recovered gap (Zane Cole "software tailor" playbook: discovery call pulls THEIR declined/missed-job numbers, close call presents their math). First assignment pending: name 3 trade-owner contacts for 20-min validation conversations. RRR client delivery rides on GHL hosted rails (booking/automations = GHL's uptime problem), NOT on the self-hosted Hermes stack — decided after ~6.5 weeks of infrastructure fires; Hermes is Rob's internal leverage only.
