# Changelog

## Unreleased

## 3.84.3 — a branded template now looks branded

**The colours a visitor sees are the brand's, on every page of an installed template.** GoHighLevel
keeps a design's colours at the funnel level as named site colours, and the shared header, footer
and FAQ sections are served last on every page and win. The repaint changed the page's own data,
reported "repainted", and the page still rendered in the template's colours. It now repaints the
funnel's shared sections through GoHighLevel's own save, reads them back, and judges every page
from what GoHighLevel actually serves, not from what it stored: brand, still the template, mixed,
nothing to judge, or unreadable. A page that is not the brand's is a problem and blocks any removal
of the originals. The site-colour names come from the design's own colour registry, so both
spellings GoHighLevel uses are judged. One side effect is stated in the result: shared sections are
funnel-wide, so the template's original pages render in the brand's colours too.

**Installing a template keeps the originals unless removal is asked for**, and removal reports only
the pages it confirmed gone after a re-read. **Branded pages get real addresses** on fresh steps
instead of blank slugs. **The template's existing form widget is bound to the member's form**, and
the result says so per page. **Navigation on a branded page points at its replacement pages**, read
back as a flag, and an unreadable original still stops the install. **A template whose accent
already matches the brand is not a failed save.** **A checkup pointed at another account refuses**
instead of reporting no breakage with the wrong key.

## 3.84.2 — done means read back, not echoed

Every tool that reports a thing as done, verified, ready or published was checked against a second,
independent read of what GoHighLevel actually holds. Thirteen places were claiming an outcome from
the write's own response, a counter, or the wrong field. Each now derives the claim from a read taken
after the write, or says plainly what could not be confirmed.

**A build report no longer says "Published N workflows live" for a workflow GoHighLevel kept as a
draft.** The publish step already read the workflow back and said NOT LIVE when it was not; the
build threw that answer away and counted it anyway. It is counted now, and a workflow that published
with every trigger still inactive is named as such rather than called running. The publish tool's
step and trigger counts come from the read after the write, with the number of active triggers
beside them.

**The workflow audit no longer says "ok" while dropping four kinds of warning.** Steps GoHighLevel
will not accept on save, waits in the unit it no longer takes, an alert set to notify a specific
person with nobody chosen, and steps needing a social account that is not connected were computed
for every workflow and then left out of the roll-up. They are in it, and in the client-facing report,
in plain words.

**"Trained." means the pages are trained.** Training a knowledge base settled on a status shared
with discovery, so it said Trained the moment discovery finished, for a failed run, and for pages
queued behind an earlier batch. It now settles only when every requested page is in the trained
bucket, and a crawl GoHighLevel stopped says so instead of "Discovery finished".

**The morning brief no longer calls a morning quiet when your own calendar could not be read.**
A connected Google Calendar that failed to open produced the same "Nothing needs you this morning"
as an empty one. The headline now says the calendar could not be read.

**Readiness, calendars, smart lists, keys and pages tell the truth.** A phone-agent readiness check
no longer says READY on a number pool whose numbers it cannot read. Renaming a calendar carries the
assigned staff back in the shape GoHighLevel validates and reports the stored team-member count from
a read after the update. A contact smart list is refused on the opportunity service instead of being
created where the Contacts screen never shows it. "Removed" for a sub-account key is said only when
the key really left this computer's file. A page save whose baseline could not be read is reported
as unverified rather than visible, and a page build is verified on the pointer GoHighLevel serves
the page from. Switching accounts says the workflow-builder credentials were found, not tested.

**Also in this release:** the eleven small findings deferred from the 3.84.1 reviews. Among them:
a survey trigger is checked against the account instead of refused as a dead end; three more trigger
slots are read back; a plan handed back by hand is held to the same confirmed selection as a saved
one; a half-applied older build is told to keep its undo and plan only the rest; a number an agent
held only in the old non-routing shape is named when a bind starts routing it; and the brand repaint
leaves a tint or tone as it was only when that makes the text readable, saying so honestly, and
repaints every spelling of a colour, not only the six-digit hex.

## 3.84.1 — the phone agent answers, the repaint tells the truth, and the build waits to be told

**A phone agent you connect now actually answers.** Connecting a number to a voice agent wrote a
setting GoHighLevel stores but does not route calls by: the tool said the number was connected and
the readiness check said READY, while the number itself rang out to voicemail. The number is now
written the way GoHighLevel's own screen writes it, proven by a real call, and "connected" is only
claimed when the agent reads back that way. An agent still carrying the old setting is named, with
the two ways to repair it. Disconnecting clears the number for real; it used to leave it behind.

**Branding a GoHighLevel design says what changed, in what a visitor sees.** The report used to
count colour substitutions, which said "a full repaint" on a page where only the button changed.
It now compares the page GoHighLevel stored before and after, resolved through the design's own
colour settings, and says plainly when the visible palette barely moved. It also picks the colours
the page really paints with: the big bands a design is made of, rather than colours that only touch
a border. Headline and body faces no longer get swapped the wrong way round. Any text that was
readable and no longer meets the 4.5:1 standard is named, colour by colour, on the page result and
at the top of the run.

**The client document only offers what the build leaves out.** Ticking every feature used to clear
the record, and the document then offered features the build includes as "available next", priced.
A feature that was bought and then unticked disappeared from the document altogether. Both are
fixed, and ticking everything is now kept as a confirmation rather than as no answer at all.

**Nothing is built until you confirm what to build.** Saving what a client bought used to create
the build selection too. It now records the purchase only; the review screen is ticked from it, and
the build stops and asks until you confirm. Re-running a build from a saved plan follows the
selection as it stands now, instead of building what you removed.

**A plan aimed at something that does not exist is refused before it runs.** Every id a plan
carries is read back from the account first, as the kind the step says it is, including the ids
inside workflow steps, triggers, if/else branches and contact fields. A list the account cannot
page through is never treated as proof that an id is missing. A plan checked against one
sub-account cannot be approved or run against another. An id this cannot check is refused by name,
with the node it sits in and what to do about it.

## 3.84.0 — the brief reaches past GoHighLevel, Slack arrives, the build follows what the client bought

**The morning brief now asks your own Gmail, Google Calendar, Drive and Slack.** It ends with each
client's email addresses and website, read from the client's own account record, and tells the
assistant you already use what to ask: which client mail is waiting on a reply, what your own day
holds, and what is waiting unanswered in Slack. Nothing is stored on our side and no Google
sign-up is needed: the assistant's own connectors answer. Only clients are listed, only addresses
that look like addresses, and a source that is not connected is said out loud rather than skipped.

**Slack is connected.** Connect it once with a bot token from a Slack app you own; the token is
tried before it is saved and never shown again. Post the brief, or anything, to a channel. Read
what is waiting unanswered in the channels the bot has been invited to.

**Build what the client bought.** The build now follows one record: the features the member
confirms in the cockpit, prefilled from what the Assessment recorded as bought. An empty
selection builds nothing and asks. Clearing the selection never erases the purchase record. The
client document, the Review screen and the build tell one story.

**Every build is on file before it happens.** A build killed at any point is already in the
journal, so the undo list is complete. A staff member found only after a failed create is marked
"Check", never "Reused".

**Quote follow-up and review requests** are now preset workflows in the clinic, med-spa,
local-service and coach presets, held draft behind the same handoffs as every other text-bearing
workflow. Review requests never fire after a consultation or estimate, only after delivered
service.

**Leads by source.** New leads in the brief and the client week are broken down by where they
came from, from GoHighLevel's own attribution, with a read that refuses to report a partial count
as a whole one.

**Your own automations are checked at the door.** The planner now reads the account and refuses
any id it cannot see, including ids hidden inside a step's arguments.

**The Assessment is attached to the client.** It fills the intake, the Review quotes it, and the
client document prices what they did not buy in their own numbers.

**Google Calendar, read-only.** Your own day, from your own calendar, beside the clients'
appointments and never merged with them.

Also: eight rows in the defect ledger that had already shipped are now recorded against their
releases; a client record can hold the addresses of the people who write to you.

## 3.83.9 — the first outside beta reports: keys stay private, a damaged download is named, Windows finds Claude Code

Six fixes. Three come from the first external beta tester, one closes a
security edge left open in 3.83.7, one prepares the Windows beta, and one is
housekeeping for what the cockpit prints.

- **Your GoHighLevel key is never shown, only named.** A health check, "which
  account am I in", switching accounts and registering an agency key used to
  print the first 12 characters of the live key. They now say "key ending
  …ab12": enough to tell two keys apart, useless to anyone who reads it. A test
  fails if any of those screens, or the command line, ever shows more than four
  characters of a key again.
- **"Skills: 43 installed" was counting files.** The startup line and the
  install_skills reply now count skills and name them: "Skills: 3 installed
  (blueprint, clone-site, ghl-reports)".
- **A damaged download is named instead of "Connection closed".** When an
  earlier npx download of GHL Command on your computer loses its package.json,
  Claude cannot start it and shows only "Connection closed", which looks like a
  dead licence. GHL Command cannot report this itself, because it never starts.
  The installer command, `npx -y @elitedcs/ghl-mcp@latest cli install`, now
  checks for that exact damage and prints one line naming the folder to delete.
  It deletes nothing. When the damaged download is the very one that command
  runs from, npm stops first with its own error, so the install guide's new
  "Connection closed" entry gives the fix the Members Hub gives: quit Claude,
  drag the npx download folder to the Trash in Finder, reopen. Every install instruction now names
  @latest, the form the installer and the cockpit's launcher already used.
- **The cockpit refuses a request that sends its Host twice, or Sec-Fetch-Site
  twice.** Two request shapes a program on your own computer could send, and a
  web page cannot, passed the 3.83.7 address checks: a second Host after the
  allowed one, and two Sec-Fetch-Site values that Node joined into one. Host,
  Origin and Sec-Fetch-Site are now read exactly as they arrived on the wire; a
  repeated one is refused, and Sec-Fetch-Site is accepted only as same-origin
  or none, or absent.
- **Addresses the cockpit prints carry no http://.** The cockpit's start-up
  line and its "already running" line now name the address as
  localhost:7300, and the reply after creating a client says "Open
  localhost:7300/new…". Printed addresses get pasted into support threads and
  emails, where click tracking rewrites any http address, so one plain form is
  used everywhere. The cost: a bare address is not a clickable link in
  Terminal or in Claude Desktop, so you type it rather than click it.
- **Windows: the cockpit finds Claude Code.** On Windows, Claude Code is
  claude.exe (Anthropic's installer, WinGet) or a claude.cmd shim (npm). The
  cockpit looked for a file named exactly "claude", so a Windows member reached
  the first powered stage and read "Claude Code CLI not found". The lookup now
  follows Windows' own rules (PATHEXT, and %USERPROFILE%\.local\bin\claude.exe
  where Anthropic's installer puts it), and an npm shim is started the way Node
  requires on Windows, with prompts passed to Claude untouched. **This has not
  yet run on a Windows machine.** The tests simulate Windows; the first real run
  is our Windows beta tester's, after this release.

## 3.83.8 — plain sentences where a few screens showed raw data

Four places in the cockpit could show raw technical data where a sentence
belonged. Each now says what happened in plain words.

- **Saving an Assessment to GoHighLevel.** A client detail that was too long came
  back as a block of validation data. The client fields now stop at the length the
  save accepts, and a refusal names the field: "Client name is too long, 120
  characters at most."
- **Design Studio's "Make it".** The form accepted slide and point counts that the
  design run itself refuses, so the run failed with an error in its log. The form
  and the cockpit now use the design run's own limits and say so before anything
  starts: "Choose a whole number of slides from 3 to 24." A run that would be
  refused never starts, so it never uses your Claude.
- **Errors from GoHighLevel.** Saving an Assessment, attaching one on a client card,
  and preparing or sending the handoff email could put GoHighLevel's own error text
  on the page. Those screens now say what failed and what to check, and keep the
  technical detail in the cockpit's own log. Messages the cockpit itself gives you,
  like a step that still needs approving, reach you unchanged.
- **"Save answers" in the Assessment.** Every save opened a box full of the answers
  file's contents, even when the download worked. The download is unchanged; the
  contents now appear only if you choose "Show the file's contents", for when a
  browser blocks the download.

## 3.83.7 — the cockpit answers only to its own address, and keeps its key to itself

**Security fix. Update to this version.**

The cockpit runs on your own computer and only accepts connections from it. Two
things it should have refused, it did not:

- **A design run's details were readable without the cockpit's key.** Anything
  able to reach the cockpit on your computer could list the Design Studio runs it
  had started: the brand, headline, points and audience you typed, the run's log,
  and the folder it saved to, which includes your computer's user name.
- **The cockpit handed its key to a request that used a different address for
  it.** A web page that tricks your browser into reaching the cockpit under
  another name could have picked up that key, and with it read what the cockpit
  reads for your clients: intake answers, uploaded intake documents, what each
  account already has set up, and your snapshot list. Nothing could be changed
  that way; every change the cockpit makes kept refusing such requests. No
  GoHighLevel key was exposed by either path.

Now the cockpit refuses any request that is not addressed to `localhost` or
`127.0.0.1` on its own port, before it does anything else. It gives its key only
to its own pages opened on your computer, never to anything else. Design Studio's
run list and progress need the key, like the cockpit's other private reads. And a
request that spells a page's address in an unusual way is refused rather than
guessed at.

Nothing changes in how you open it: the address the cockpit opens for you is one of
the two it accepts.

## 3.83.6 — what a design run actually costs you, measured

3.83.5 gave a running job a clock. This gives it an honest number on it.

How long a run takes was written by hand in three places and every one was wrong.
The brand page said a deck takes "roughly ten minutes". The run API said "twenty
minutes or more". The clock added in 3.83.5 said "ten to twenty". Nobody had ever
timed one.

Two runs have now been measured. A six-slide deck took 12 minutes 39 seconds and
$1.74 for a single pass. A conversion page on a real brand took 26 minutes and
$3.75 across three. So a pass is 9 to 13 minutes and $1.25 to $1.75, and a run may
use three of them.

That sentence now lives in one file and every screen reads it, so the three cannot
drift apart again.

**The cost is now stated before you press the button, not discovered afterwards.**
These runs use your own Claude, never ours. On a Max plan that is usage rather than
a bill, which is exactly why the figure belongs on the screen: on pay as you go it
is real money, and you should know roughly what a button costs before you press it.

## 3.83.5 — the brand page's buttons work, and a run tells you it is working

If you use Design Studio, every button on a brand's own page has been dead since
3.78.0. "Make it" did nothing but load a blank page. So did "Save changes", which
means any accent, font, name or logo you edited there was thrown away without
saying so. Nine days, five releases. One piece of broken punctuation in the page's
script switched all of it off at once, and because a browser discards a script like
that in silence, nothing on screen ever said so.

Both are fixed, and a new check now reads every page the cockpit serves and refuses
to ship one whose script will not run.

A design run also used to tell you nothing at all while it worked. Claude writes the
whole file in a single answer, so the log stayed empty for ten or twenty minutes,
and there was no way to tell a working run from a dead one. The sensible thing to do
was stop it, which was the wrong thing to do. The run now shows a clock, counts the
passes, and says plainly that nothing will appear until a pass finishes.

While reading what gets sent to Claude on those runs, we found the brief was asking
for two tools that are deliberately switched off for them, and then telling Claude
in the next line that it had no tools. It no longer does that.

**A correction to 3.83.4.** That release said a form field written without its id,
key and data type "is stored and shows as raw data instead of a rendered field."
That is wrong, and we tested it after we shipped it rather than before. A field
without those still renders normally in the form editor. What they actually do is
tie the answer to the contact record, which is reason enough to set them, and
`update_form` now says only that.

## 3.83.4 — one door for every write, so "protected" cannot quietly stop meaning protected

3.83.3 fixed the holes. This closes the shape that kept making them.

Marking a client account protected is checked in one place for the public API. But
this product also writes to GoHighLevel through its own builder connection, and
eleven tool modules had each grown their own copy of that request — their own
headers, their own timeout, their own fetch. Every copy was a separate door into
your account, and a check added to one of them told you nothing about the other
ten. That is why the last release had to find fourteen of them.

They now share one door. It builds the headers, applies the thirty-second timeout
and runs the protected-account check once, for all of them. A tool written next
year gets the check without its author having to remember, and a test refuses any
new code that tries to go around it.

Nothing about what the tools do has changed. The same requests go to the same
places with the same bodies and the same headers, including the two different
builder origins GoHighLevel requires for page saves and for everything else. If
you cannot tell the difference from the outside, that is the intended result.

Also in this release: `update_form` now tells you what a field needs in order to
RENDER rather than only how to send one. A custom question needs the real custom
field's id, key and data type from your account; a field written without them is
stored and shows as raw data instead of a rendered field.

> **Corrected 2026-09-12, in 3.83.5.** The last sentence is wrong. It was tested
> after this release shipped, not before: a field without those keys still renders
> normally in the form editor. They tie the answer to the contact record. That is
> what `update_form` says now.

## 3.83.3 — an account you marked protected is now actually protected

**Marking a client account protected did not stop fourteen write paths from writing to it.**
The guard was real and it was in the right place for the public API, but this product also
writes to GoHighLevel through its own builder connection, and those calls never passed it.
So a build could create a pipeline and a form on an account you had explicitly told it never
to touch, and nothing would say so. Pipelines and forms are the first two things a build
creates. Every one of those paths now goes through the same check, including the template
installer, which writes a funnel and its pages — the case that matters most, because
GoHighLevel keeps only the first content save on a page.

**Verify no longer needs your own Anthropic key before it will let you start.** A new client
with no key met a disabled Run button, while the same screen said most people do not need to
paste anything there. Both were true at once: runs use whatever you are signed in to Claude
with and cost nothing extra, and the readiness check demanded a key anyway. The key is
reported now, not required. If you have one saved, runs bill it; if you do not, they use your
Claude sign-in.

**The Verify tile stopped promising something it could not do.** It said it submits a test
lead and deletes it. Since funnels left the build, no plan it creates has anywhere to send
one, and the report a screen later said so. It now says what it actually does for every plan,
and still discloses the test lead for the accounts that can take one.

**An older install can no longer roll your skills backwards.** The installer refreshed any
skill file it had written before, without checking whether the version doing the writing was
older than the one that wrote it. Running an older copy alongside a newer one quietly reverted
your skills. It refuses now and tells you it kept the newer ones.

## 3.83.2 — the static design-rule harness and its fixtures leave the package

A development harness and its four HTML fixtures were in the published package in
3.83.0 and 3.83.1. The harness reads static HTML, so it can only look at the design
rules that are visible without rendering a page, which is well under half of them.
Run on a real page it would look at much less than it appears to, so it does not
belong in an install.

They stay in the repository and keep running in CI. Nothing imported them and nothing
pointed at them in either earlier release. The design rules themselves
(`anti-default.json`, `web.json`, `anti-default-contract.mjs`) still ship, unchanged;
they are input to the design brief. The packaging guard now fails the build if the
harness or a fixture reappears in the package.

## 3.83.1 — the Blueprint skill stops asking for the funnel 3.83.0 removed

3.83.0 took funnels out of Blueprint installation, but the installed skill was not
updated with it. `skills/blueprint/SKILL.md` still listed `funnels` among the arrays
its plan must carry, and still asked "Build this funnel inside GoHighLevel, or as a
custom site you host?" with GoHighLevel as the stated default. The skill ships inside
the package, so anyone running Blueprint produced a plan carrying a key their own
plan format forbids and their own build step refuses.

The skill and its README now say plainly that Blueprint builds no funnel, page or
website, and that pages are a separate job after the account exists, on hosting the
member owns. A test pins the skill and the plan format together so they cannot
disagree again.


## 3.83.0 — a build you can check, and no funnel at the end of it

**Blueprint no longer builds a funnel, a page, or a website of any kind.** GoHighLevel's own page
builder was never good enough to hand a client, so Build stopped pretending. A build plan that asks
for one is refused rather than half-built: the plan format forbids it and the executor stops with
"GHL Command no longer includes funnels of any kind in Blueprint installation." Pages and funnels
become their own job after the account exists, on hosting the member owns.

**Verify now reads the account back and tells you what it found.** Every object the plan asked for is
looked up in the sub-account and reported as there or missing, one line each. "Not checked" is treated
as a defect, not a pass. Verify submits one test lead through the real form and deletes it again, and
it says so on screen before it does. Verify can no longer claim anything its own read-back contradicts.
"Verify passed" means the build did its job; the drafts and your own jobs are the next step's work.

**The report is written to be read.** The Verify page was 46 pages long if you printed it; it is now 8,
with a short findings list instead of sixty near-identical lines, and the duplicate "still to do" list
is gone. The client handoff is no longer one run-on sentence.

**Workflows arrive as drafts on purpose**, so nothing fires at a client before a person has read it.

Also in this release: staff that GoHighLevel will not let the API create are shown as your job rather
than reported as a failure; a create that cannot be confirmed is reported honestly instead of being
retried into a duplicate; and read-backs retry a timeout but never retry a genuine refusal.

**Members do not need an Anthropic key to run builds.** If you are signed in to Claude on the computer
running GHL Command, the stages use the plan you already pay for. Saving a key switches every stage to
usage billing, which the cockpit and the member hub now say plainly.


- Brief checks the intake and writes the review; the build plan is prepared when Build runs.
- Changed intake answers require a fresh review, changed build plans require verification, and live workflow repairs need approval of the listed changes.
- Client pages keep the selected client, open the shared intake answers, separate existing inventory from build evidence, and keep generated drafts for review.
- Verify prepares the client handoff. Local files are shown as prepared, not published, and handoff email delivery requires a reviewed recipient and explicit confirmation.
- Your agency now has a creation-only switch for sub-accounts from chat. Package preparation creates catalog products and leaves pricing and payment links as a visible manual task.


- Register an existing sub-account on either plan path, replace a saved account key, and see chat registrations in the cockpit without restarting. Creation checks the agency connection first and keeps an account visible when its key could not be saved.
- Intake keeps Interview answers, loads submitted answers, asks before replacing corrections, and identifies missing respondent details. Source uploads are limited to supported text formats and show the 60,000-character limit. Prepared forms and client documents have clickable links.
- Client Builds checks account access and setup before running, explains Intake’s writes, and uses the same practice-account rules everywhere. Failed verification blocks sign-off, and manual obligations remain visible until checked.
- Claude usage records distinguish stages, reviews, rewrites and intake extraction, including calls whose cost was not reported. Questionnaire sending leaves a manual task while the account’s sender cannot be verified.

- You can now save and reopen a client’s intake answers on this computer before its form exists. Form submission and sharing appear once the Intake stage creates the form, with a direct link to the client’s build steps and clear next steps if a request fails.

- The cockpit asks once whether your agency is on Agency Pro and remembers the answer in Your agency. It shows the create form or a masked paste-the-key card, then opens the resulting client’s intake on the same page. A creation refusal or success corrects the saved answer; a read-only company record is retained as evidence, never interpreted as a plan name. The key clears before registration starts and is excluded from confirmations, errors and logs.

- **Sub-account creation requirements clarified.** GHL Command creates sub-accounts on GoHighLevel's Agency Pro plan; on other plans you create it in GoHighLevel and register it in GHL Command with its key. Register each sub-account with its own Private Integration key; registering an agency key does not supply it.
- **Build a client account guidance corrected.** Existing clients are behind “Work on a client already registered →” and are never pre-selected. Registration confirms the sub-account name and id, never a key hint.
- **Deletion is separate from registration.** Removing a saved key does not delete the GoHighLevel account. Deletion requires separate authorization; no claim is made that the account's creation method determines whether it can be deleted.


## 3.82.0 — the cockpit laid out like the hub, for Command OS subscribers

The Command OS cockpit was built for one job, the Blueprint build, and that one section
still took over the page while everything added since sat behind it. This release lays
the cockpit out the way the member hub is: a sidebar in the order you work (Start,
Clients, Design Studio), and the board's four views on their own pages instead of one
long scroll: the Board, Build a Client Account, the Work Queue and Client Builds.

Under Clients there is now a page for each thing you can do for one client, twenty-five
of them in six groups: Set up, Messaging and automation, AI employee, Money and paperwork,
Reputation and content, Health and handoff. Each one reads what the account already has
(its calendars, workflows, Conversation AI bots, invoices, reviews and so on), says the
words to give your own Claude, links the step-by-step in the hub, and where a build is
safe to run without sending anything to a real person, offers a Build button that runs
through the same stage engine, as a draft. Pages whose only honest build would send or
post (documents, social posting, the demo) have no button on purpose.

Build a Client Account takes documents now: a PDF, a Word file, Markdown or plain text
such as a spec sheet, saved with the client on your own computer and used to pre-fill the
intake, beside a pasted link or a call recording. Your Agency carries the Agency OS inputs
(brand DNA, ideal client, the offer) once, and the assessment uses them; the assessment
itself now starts with the client and their business and saves to the account you choose.

Eight adversarial review rounds ran on this release (40 findings, every one closed with a
test that would catch it coming back): every live read of an account carries the seat,
one size guard on every button, a failure shown as a status and never as the raw error,
every value from an account or from the model escaped on screen, client logos cleaned
before they are stored or shown, and the intake invitation email escaped. None of this
changes the tool set of GHL Command or its count.

## 3.81.1 — the assessment opens from every published cockpit, and the product says its own name

**The assessment page was missing from every installed copy of the cockpit since 3.75.0.**
Click Assessment in the Command OS cockpit on a normal install and it answered "The
assessment page is not in this install. Update GHL Command and restart the cockpit." The
page had been left out of the package on purpose on the belief that nothing served it,
while the cockpit screen that serves it shipped the same day. Every check since ran from
a development copy, where a spare copy of the page hid the gap. The page now travels
inside the program itself, so there is no file to find and nothing to leave out, and the
fix was proven by installing the packed release and opening the assessment from it, which
is the check that had been missing for seven releases.

**What a member reads calls the product GHL Command.** The checkup's status table had
been labelling its first row "MCP version". That row now says "GHL Command version", and
the same correction runs through the health check, the version check, the current-account
readout, setup and the command line. The names you type to run those tools have not
changed.

**The $97 product is unchanged at 250 tools, 51 modules, 112 free.** The assessment is
behind the Command OS gate; the wording fix reaches everyone.

## 3.81.0 — one cockpit, and a launcher that makes itself, for Command OS subscribers

The Command OS cockpit had grown into four screens that read as four products: the
board in one dark theme and font, Your agency in another, Brands in light cream, and
the assessment in a third dark theme with no way back except the browser's Back button.
This release gives all four one shell: the same colours, type and top bar, and every
screen can reach the hub, the board and each other.

Text got bigger. Measured in a browser at a 20px base setting, the share of characters
smaller than 18px went from 77% to 15% on the board, 74% to 6% on the assessment and
45% to 5% on Brands. What is still small is a chip, a badge or a legend, never a sentence.

**Your Anthropic key has a home.** Command OS runs the stages and Design Studio on your
own Claude account, and until now the key had three places to live, none of which the
cockpit told you about. It is now a field on the Your agency screen. Before it is kept,
Anthropic is asked whether it works, because a key can be present and still refuse
every request. It is stored beside your licence, handed to the runs the cockpit starts,
and never shown back beyond its last four characters. Removing it hands back whatever
your computer already set, instead of throwing that away too.

**The desktop launcher makes itself.** On a Command OS licence the double-click
launcher is created the first time the cockpit starts, and when you run the install
command, once. Nothing asks you to paste a second line. If you delete the icon it stays
deleted. A launcher that was only half written no longer counts as installed, so one
failed write cannot block every later repair.

**The $97 product is unchanged at 250 tools, 51 modules, 112 free.**

**Also in this release**

A readiness note in the AI employee demo no longer names a private account. The
`list_templates` description said clinic, med spa, dental and more while exactly one
template ships; it now says so.

On board but not yet offered: Design Studio's conversion mode, a rulebook for a page
that has to sell. The code is in this version and nothing in the cockpit or the hub
reaches it yet. It gets its own release after a run on a real brand.

## 3.80.0 — the phone side of the AI employee, for Command OS subscribers

GoHighLevel's Voice AI answers a client's calls. Until now the only way to set one up
was by hand in the GoHighLevel screen. This release adds twelve tools that build, wire,
bind and prove a phone agent from a conversation, and **they belong to the Command OS
plan**: on a GHL Command licence Claude says they are not included and nothing runs.
**The $97 product is unchanged at 250 tools, 51 modules, 112 free.**

What they do: say whether a call can land on the sub-account at all (the numbers it
owns, which agent answers each, the backup rota, the number pools); list, read, create,
change and remove an agent; give it what it may do mid-call (transfer to a person, text
the caller, fill a contact field from what was said, book on a calendar, start a
workflow, answer from a knowledge base, call an outside API); and read the call logs
with their transcripts, which is how "it answered" is shown rather than claimed.

What they refuse, because GoHighLevel does not. A new agent is always born unable to
take a call: no number, no pool, and out of the backup rota. Binding the number is a
separate, deliberate step, and it checks the number is this sub-account's and that no
other agent already answers on it, because GoHighLevel will silently let a second agent
take a live number from the first. A draft workflow set to run after a call is refused:
GoHighLevel accepts it and it never runs. A custom API action carrying headers or
parameters is refused before the call, because they crash GoHighLevel's server and the
action is saved anyway. Every write answers "verified" or "asserted" from an
independent read after it, including when that read fails, so a write that landed is
never reported as an error that invites a duplicate.

Proven live, not claimed: every shape was read off GoHighLevel on a sandbox before a
line was written; the finished tools were driven through the built server with every
object deleted afterwards and the sandbox read back at zero; and a real 67-second test
call was listed and read back in full by the tools. Six adversarial review rounds to a
ship verdict, twenty-three findings fixed on the way.

Two things are stated as unproven rather than glossed: the shape of a number-pool
object, because no sandbox holds one, so pools are handled cautiously; and the
create-then-bind-then-ring path on an agent these tools created, which needs a number
that is not live to real callers.

**Also in this release**

The install guide now answers the three things buyers hit in their first ten minutes:
it names the Claude Code install line beside the Desktop one, says a fresh start is two
full quits (Cmd+Q on a Mac, the system tray on Windows, not just closing the window),
and adds three rows to its questions table: the website Claude cannot run the installer,
tools only load on a fresh start, and API access needs the $297 or $497 GoHighLevel plan.

Counts: 250 tools, 51 modules, free tier 112.

## 3.79.0 — the automations agencies actually sell, written down

The guide library grew by seven, and every one of them is a thing you can sell on a
call: answer a new lead in seconds, text back every missed call, win back a cold
list, rescue a no-show, remind people about their appointment, confirm a booking and
move the deal, and take over an account somebody else built.

These are not new capabilities. GHL Command already built every one of them inside
the Blueprint, and the library only taught one. So an owner who was not running a
whole Blueprint had no way to find the missed-call text-back or the speed-to-lead,
which are the two automations most agencies get paid for. Now each is its own guide,
with a prompt you paste and change.

Each was proven by running its own prompt against a real GoHighLevel account and
deleting the result in the same run. The proofs are deliberately narrow about what
they show: the workflow saved in the shape asked for, and every pipeline, stage,
calendar and user id inside it points at something real. That is the failure
GoHighLevel never warns you about, where a workflow saves cleanly and then quietly
does nothing. It is not a claim that the sequence was watched running against a real
person, and the guides say so.

The booking guide's proof is the interesting one, because it was allowed to fail. The
deal-move step was built the wrong way round on purpose, and the validator answered
with the warning that whole guide exists to prevent.

**Also in this release**

Two guides came off "Coming in an update" and are now open: **The Blueprint: build a
client account** and **Page Studio: pages and sites**. Both were rewritten where
running them proved the old text wrong. The Blueprint guide had been telling you your
client's intake answers arrive as a form submission; GoHighLevel does not report
submissions for any form created through the API, so that path never worked. The
answers are on a contact, and the guide now says so. `install_intake_form` also hands
back the link to send your client instead of leaving you to find it in the
GoHighLevel interface.

Page Studio's guide had promised a social-proof section that cannot be composed and a
live URL that needs publishing and a domain first. It now describes what the tool
actually does and shows you the preview instead.

The blog guide has been retired rather than left sitting as a card. GoHighLevel's
blog feature is not used anywhere we can reach, so it could never be proven, and a
card promising a guide that is never coming is worse than no card.

Library: 41 open guides across 10 categories. Counts unchanged: 250 tools, 51
modules, free tier 112.

## 3.78.1 — the template installer stops guessing which funnel it just made

Yesterday's release shipped `install_ghl_template` with the originals kept by default,
because the tool worked out which funnel it had created by comparing the account before
and after, and on a busy sub-account that comparison cannot be certain. Keeping the
originals was the safe answer to an open question.

The question now has an answer. GoHighLevel's install response names what it created: the
funnel id and its first page. The tool reads that id and waits for that funnel, so it
cannot land on one somebody else happened to be building at the same moment.

**It does not simply believe it.** The name only counts when your account agrees the
funnel is genuinely new, and the tool checks the response against a complete picture of
the account taken before the install. Two independent confirmations, because that one
field is what authorises removing anything. If the name and the account ever disagree, the
name is thrown away, nothing is removed, and the result says so.

With both confirmations in hand the originals go back to being removed once every branded
page has been read back and measured, which is how the tool was meant to work: one clean
funnel in your client's colours, not a doubled one you have to tidy. Pass
`keepOriginals: true` if you would rather see both.

Anything less and the originals stay, whatever you passed: no name in the response, a name
the account cannot corroborate, or an account with so many funnels that the before picture
was incomplete.

A split-test step, where one step holds more than one page, still stops any removal. Only
the first page of such a step is rebuilt, so removing the step would take the variants
with it.

Counts unchanged: 250 tools, 51 modules, free tier 112.

## 3.78.0 — GoHighLevel's own template library, repainted in your client's colours

GoHighLevel ships more than 900 designed funnel templates. You could always install
one and then spend an afternoon recolouring it by hand. Now it is one call.

`find_ghl_template` searches the library. `install_ghl_template` installs the one you
picked into the sub-account you are switched to, and with a `brandSlug` it repaints the
whole funnel in that brand's colours and typefaces on the way in.

**What "repaint" honestly means.** It reads every page of the template first and works
out ONE palette for the whole funnel, because per-page guessing gave five pages of the
same template two different second colours. It then swaps that palette for your client's.
Some large background bands keep the template's own colour, so the result is your
client's funnel in your client's colours, not a bespoke design. Every branded page is
read back and measured before anything is removed, and if a single page fails to land,
nothing is removed at all.

**It is careful about your account, and it does not delete anything unless you ask.** It
installs one funnel and its pages. It touches no workflow, form, pipeline, custom field or
calendar. The template's original pages are now KEPT by default, sitting beside the branded
ones, and only removed if you pass `keepOriginals: false`. That is deliberate: the tool
works out which funnel it just created by comparing the account before and after, and on a
busy sub-account, where somebody may be building a funnel in the UI at the same moment,
that comparison cannot be certain. If two new funnels appear it stops and brands nothing.
If your account holds so many funnels that it cannot see them all at once, or if a step
holds a split test, it refuses to remove anything and tells you why. Every result carries
the one call that undoes the whole thing.

Run it on a quiet sub-account when you can, and look at the result before asking it to
remove the originals.

These templates are other people's designs, and they are frequently not accessible. The
tool says so, and points you at `verify_design` on the installed page before a client
sees it.

**Also in this release**

Page Studio no longer serves images that belong to other GoHighLevel accounts.

A local security fix in the Command OS cockpit. When you start a design run from the
brand card, the text you type is passed to the run as data. Before this release a
value that began with two dashes could be read as an instruction instead, which could
have moved where the run wrote its files. It is contained structurally now, and the
build fails if anyone reintroduces the old shape.

The Security Controls guide PDF had been promising a switch for 233 tools since around
v3.56.0, because the PDF had no generator and nothing read it back. It now says 250, it
is rendered from the HTML beside it, and a test reads the finished PDF and fails on a
stale number.

Counts: 250 tools, 51 modules, free tier 112.

## 3.77.1 — the Blueprint can build a client account, and had been saying it could not

If you ran the Blueprint, it planned the account, showed you the two checklists, and then
printed this at the bottom:

> _v1 stops here. When you approve, the automatic list is ready for one-shot staging (phase 2)._

**That was wrong, and it had been wrong for months.** The Blueprint builds. You approve the
plan, it dry-runs the automatic list without writing anything, shows you exactly what it
would create, and on your go it builds: pipelines and stages, custom fields, tags, custom
values, calendars, forms, funnel structure, templates, and workflows chained and verified,
each object read back before anything that depends on it is created.

The plan-only sentence was true of the first version. The builder was finished afterwards
and nobody removed the old wording. It was in four files, and those files ship inside the
package, so it has been on your machine reading the wrong thing to you the whole time.

**Two of them were worse than a stale sentence.** One instructed the assistant to tell you
execution was not available yet, so it was not merely present, it was being read out. And
the description of `apply_build_plan` itself said `execute = live writes (not yet enabled)`
three hundred lines above the code that performs those writes, which means an assistant
reading that description could decline to run a build you had just asked for. Meanwhile the
same skill file gave the correct build instruction further down, so it contradicted itself
and the half you saw was the false one.

**What has not changed is the gate.** Nothing is built until you say so, the dry run still
writes nothing, and workflows are still created as drafts unless you ask for them
published. The old wording carried that promise correctly, which is a large part of why the
false half went unnoticed. The gate stays; only the claim that building does not exist is
gone.

A test now fails the build on any of the six ways this was phrased, anywhere in the skill or
in that tool, so it cannot come back quietly.

Counts unchanged: 248 tools, 51 modules, free tier 112.

## 3.77.0 — the account you aimed at, even when two things run at once

`switch_location` used to change your API key first and your location a moment later.
A failed switch was already put back exactly as it was, so that part was never the
problem. The gap in between was: anything running alongside the switch could read the
**new account's key against the old account's id**. Claude issues tool calls in
parallel, so that window was real, not theoretical.

The switch now verifies the new account with a throwaway probe and touches nothing until
that succeeds. Key and location are then committed together with no pause between them,
so another call in flight sees either the old account or the new one, never half of each.

**`health_check` stopped quoting you a tool count you do not have.** On a licence that
does not include every module it reported the full registered number, which is not what
you can run. It now states the tools your licence includes, and says separately how many
are not included and answer with a notice.

Everything else added in this release sits outside the GHL Command licence and answers
with a notice, the same as the tools already in that position.

Counts unchanged: 248 tools, 51 modules, free tier 112.

## 3.76.0 — the workflows you cannot edit, and the audit that said they were fine

GoHighLevel tightened what its workflow builder will save. Workflows built from older
snapshots carry three things it now refuses: step ids that are not proper UUIDs, wait
units written in the singular ("1 day" instead of "days"), and an older shape of the
internal notification step. **They run exactly as before. What broke is editing them.**
Change one step, save, and GoHighLevel rejects the whole workflow, naming steps you never
touched, so the error looks unrelated to the edit that caused it.

Measured across 23 accounts on 2026-09-01: 486 workflows, 78 affected. On one account,
20 of 43, while `audit_workflows` reported `status: "ok"`. That was the part that was
ours: the audit reassured you about the exact thing biting you.

**The audit now tells you.** Four new warning categories, never errors, because the
workflows genuinely run. Two say *it runs, you cannot edit it* (`legacy_step_id`,
`legacy_wait_unit`). Two say the reverse, *it saves, it runs, and it silently does
nothing* (`missing_required_attribute`, `missing_integration`). The advice for each is
opposite, so they are named separately.

**We no longer write the ids GoHighLevel refuses.** A build session could hand the
builder a step id like `oc-wait2` and we would save a workflow this same product could
never update again. A new step now gets a proper id, and every reference to it follows:
next, previous, siblings, if/else branches and their condition rows, goal references,
goto targets, and the branch table a multi-path step (Find Opportunity, Facebook
messenger) keeps inside itself.
Singular wait units and the old notification shape are corrected silently on every
write, because neither changes an id.

**A step that is already saved is never re-minted, and this is the important part.** A
contact part-way through a workflow is bound to the step they are waiting on. Change or
remove that step and they are dropped mid-sequence, silently, and there is no way to put
them back where they were. So every workflow write now checks who is parked first and
refuses to remove or alter a step somebody is standing on, or a step that step depends
on. There is no force flag. Deleting a whole workflow is the one exception: it refuses,
tells you how many people are inside, and you re-run naming that number. A workflow
already carrying bad ids is repaired by choice, step by step, with the parked count in
hand; the guide in your library, "Workflows that will not save", walks through it and
leads with the part that costs leads.

**Install slots stopped burning on a machine that cannot write its device file.** A
relative or unwritable config directory, or a read-only volume, meant the last-resort
identity was random, and every restart claimed a fresh slot until the licence capped.
The headline version of this was fixed in 3.69.0; this closes the fallback behind it.
That identity is now deterministic (user, platform, architecture, deliberately not the
hostname, which changes on a new network). Stated cost: two machines that share all
three and also cannot write their device file share one slot, which errs in your favour.

Counts unchanged: 248 tools, 51 modules, free tier 112.

## 3.75.0 — what you install is only what you bought

Your install shipped a 49KB page for a product that is not on sale, and named that
product in seven places your assistant reads on every message — including the
description of a tool you own, the intake-form installer. A tool that is not part of
your licence still appears in the list; only running it is refused. So the words in its
description reached you whether or not you could ever call it.

None of it was usable and none of it was an upgrade prompt you asked for. All of it is
gone. Tools now say what they DO, the page is no longer in the package, and the desktop
launcher — which installed an entry for something your licence cannot open — now checks
your licence first.

Two guards were added so this cannot come back: one reads the actual package npm would
publish and fails on the asset, and one registers every tool and reads the real
descriptions, which is the surface that reached people.

Support email is now `support@ghlcommand.com` everywhere.

Also in this release: accounts you protect are enforced at the tool layer and in both
HTTP clients, a Claude Code version floor with a first-run check, a per-run cost meter,
and `get_user_guide` now RETURNS the guide's contents instead of taking over your screen
with a browser window — pass `open=true` when you actually want it opened.

Counts unchanged: 248 tools, 51 modules, free tier 112.

## Also in 3.75.0 — your income is not all one shape

_This entry was headed "Unreleased" in the 3.75.0 package by mistake; the tools it describes shipped in 3.75.0._

Not part of the GHL Command licence; these tools answer with a notice. The commercial
record could hold exactly one kind of thing: a
client whose GoHighLevel account you run. Pointed at a real Stripe account it found
**26 customers and could record none of them**, because most of them buy software and
have no sub-account at all — so the brief kept answering "no client has a commercial
record yet", which reads as "you have not filled it in" when the truth was "there is
nowhere to put the answer".

Every record now carries what kind of income it is: **a client whose account you run**,
**software** they bought from you, or **other** — event fees, revenue shares, training.
The distinction is not a label. Only a client appears on your build board, so recording
that a licence buyer pays you can never make them show up as a build you have not
started; and only a client is measured against your price floor, so a $97 licence is
never reported as underpriced against a minimum you set for running whole accounts.

`set_client_engagement` takes `key` alongside `locationId` for income with no account,
and refuses a key that collides with a real one. `sync_stripe` gains `recordAsIncome`,
which turns an unpairable Stripe customer into its own record and fills in what they pay
and when they renew. Your recurring revenue now counts every kind and shows the split.

Counts unchanged: 248 tools, 51 modules, free tier 112.

## 3.74.0 — your brief can see your Stripe

Command OS only; on the $97 licence these tools answer with upgrade information and
nothing about the GoHighLevel product changes.

Your morning brief has asked for renewals and clients under your floor since the day it
shipped, and answered "no client has a commercial record yet" every morning, because
nothing was filling that record. Now something does.

Connect Stripe with a restricted read-only key and the brief gains three things it could
not know: **who owes you money**, oldest debt first with how late each invoice is;
**what each client pays**; and **when each agreement renews**, so a renewal three weeks
out is something you hear about three weeks out.

It shows you everything before it changes anything, and two refusals are built in rather
than left to good intentions:

- **It will not guess whose money is whose.** A matching billing email is a suggestion,
  not a decision — agencies bill one holding company for several clinics, and bookkeepers
  pay from their own address. An unmatched customer is reported and left alone until you
  confirm it. Debt it cannot attribute is reported as its own total, because "you are
  owed $3,000 I cannot put a name to" is true and leaving it out would make the total
  wrong.
- **It will not overwrite what you typed.** If Stripe says $1,997 and you wrote $2,000,
  both survive and it tells you they disagree. You may be right, and a tool that quietly
  corrects you is one you stop believing.

The key stays on your machine beside your GoHighLevel credentials, readable only by you.
No tool ever returns it — ask what is connected and you get the last four characters.
Nothing here writes to Stripe, which is why it asks for a key that cannot.

## 3.73.1 — the cockpit points at the right hub

The member hub is Command OS only and now lives at its own address behind a licence
gate. The cockpit was still sending you to the build host it was published from, which
is not where a licensee should land. One link, corrected.

Both hub addresses are also added to the list of hosts the product refuses to place a
client's status page on. That list exists because a client's page on a host we run would
mean we are holding a customer's data, and the hub moving is exactly the kind of change
that quietly leaves such a list out of date.

## 3.73.0 — the assessment, for Command OS licensees

An agency's first paid conversation with a prospect, and the document that comes out of
it. This is Command OS only: on the $97 licence these five tools answer with upgrade
information, and nothing about the GoHighLevel product changes.

You sit down with a business owner and go through forty-six questions across eleven
areas — what a job is worth, what happens to the phone, what happens after a quote goes
out, who is sitting in their database doing nothing. Each question has the words to say
out loud and a follow-up for when the answer is vague. At the end you have their numbers,
what those numbers are costing them each month, how many hours a week are going into work
a system should do, and a report you hand over with your name on it and nothing of ours.

Three rules are enforced in the code rather than left to good intentions, because all
three are the kind that quietly stop being true when a report needs to look impressive:

- **An answer nobody knows omits its finding.** It is never defaulted, estimated, or
  filled in from an industry average. The report says which numbers were missing and what
  to measure first.
- **Assumptions are yours and they print beside the figure they moved**, not in a footnote.
  Set one to zero and its finding disappears rather than falling back to a default.
- **There is no minimum number of findings**, and "there is not much here" is a verdict the
  tool will actually reach and say plainly. A report that always finds something will
  invent something.

`save_assessment` puts the whole thing in **your own** GoHighLevel — the prospect as a
contact, the answers as a note you can read in plain English, the deal on an Assessments
pipeline. We store none of it. That is also what lets someone else on your team pick it
up: they open the GoHighLevel they already log into.

`read_assessment_transcript` turns a recording into answers, and refuses anything the
owner did not say: every answer must carry their verbatim words, and those words are
checked against the transcript. Anything worked out rather than heard — "two or three a
week" becoming eleven a month — is held for you to confirm.

`write_assessment_report` writes the finished document to your machine, self-contained so
it opens anywhere, with a random filename and no-index because it holds another business's
revenue and customer numbers. We publish it nowhere; the host is yours.

## 3.72.3 — the waits in a built account can actually go live

Blueprint builds a client's account and then asks whether to publish the workflows or
leave them as drafts for review. Say publish, and the ones with a wait of a day or more
were refused by GoHighLevel and stayed as drafts. The build still reported them as
created, so the account looked finished when the follow-up sequences were not running.

The cause was one word. GoHighLevel takes `minutes` and `hour`, but for days it wants
`days`, and we were sending `day`. It is not a rule you can guess: a workflow that is
nothing but a wait accepts the singular quite happily, which is why this went unnoticed.
It only fails on the shape a real nurture has, a message followed by a wait, which is
every follow-up sequence the presets build. **123 of the waits in the shipped presets
were affected.**

Workflows already in an account are untouched and keep working; this was only ever about
newly built ones.

**The second half is the reporting.** A workflow that could not be published was still
counted as built, and the summary read the same as a run where nobody asked to publish at
all: it suggested turning on publishing, to someone who had just turned it on. The real
reason sat further down the report where nobody looks first. A build now says plainly
which workflows could not be published, what GoHighLevel said about each, that they are
sitting as drafts and not running, and that repeating the build will not help.

## 3.72.2 — a form you read back can be saved again

A subscriber tried to edit one of her forms the documented way: read it with
`get_form_full`, change one thing, hand it back to `update_form`. It was refused. She
tried again from an exact copy of the payload. Refused again. Then she found it herself,
stripped one key by hand, and the identical edit saved first time.

She was right. GoHighLevel's form builder writes every custom field with the field's id
under two spellings, `Id` and `id`, holding the same value. On her form 42 of 58 fields
carried the pair; standard fields like name and email never do. Sitting in GoHighLevel it
is harmless. The trouble starts on the way back out, because editing a form means
repeating the whole document, and a document that says the same id twice per field, once
capitalised and once not, is asking to be repeated wrongly.

**`get_form_full` now returns one of them, and `update_form` accepts either.** A key is
dropped only when another key beside it means the same thing in a different case and
holds exactly the same value, so nothing is lost: the value is still there under the
name that survives. Where two such keys hold *different* values, both are kept, because
that is real information and picking one would be a guess. Forms built by the intake
installer are unaffected; they save by a different route and were never part of this.

If you have been working around this by hand, you can stop. Proven before release
against a real form on a live account: saved with both spellings, saved again with one,
edit kept, every custom field intact.

Counts unchanged: 247 tools, 51 modules, free tier 111.

## 3.72.1 — a client's work stops shipping in the package

An audit of the published 3.72.0 tarball found somebody else's proprietary material
inside it. Not a leak of customer data, and nobody did it on purpose: the packaging
allow-list named `skills` as a whole directory, so every file that landed in that folder
published itself along with the release.

What was in there. A launch-event preset built from a partner agency's own client
blueprint, their compliance playbook and a protected live account: ten named workflows,
twenty-five custom values, an event calendar and the whole compliance handoff chain.
Three more presets credited real client accounts by name in their provenance lines. The
compiled bundle carried a real GoHighLevel account id with a label identifying whose it
was, and one shipped example held a real sandbox id.

That preset stays in the source tree, because it is a legitimate tool for the agency that
wrote it. It is now excluded from what gets published. Every client and partner name is
out of the published files, and the two account lists that used to be compiled into the
bundle now come from `account-rules.json` in your own application-support directory. A
fresh install declares no accounts at all, which is the stricter default: without a
declared practice account every account needs an explicit recorded approval before
anything is built, and an account you mark off-limits is refused even when an approval
exists.

Engineering comments recording where a workflow shape was captured are untouched. They
live in source that is never published, and they are how the next person knows a node
shape came from a real account rather than a guess.

A new check reads the actual file list `npm pack` would publish and fails the build if a
client name, a real-looking account id, or a file on the never-publish list appears in it.
It caught two files that a careful pass by hand had already missed.

Counts unchanged: 247 tools, 51 modules, free tier 111. No tool behaviour changed.


## 3.72.0 — an audit that counts your workflows once

A member ran the account audit on a large agency account. The report came back claiming
5,000 workflows with six percent of them scanned, printed every single finding three
times word for word, and closed by advising her to re-run the audit in further batches.
She did. It ran ninety minutes before she stopped it, with no idea how many tokens it
was burning.

None of those numbers were real. GoHighLevel's workflow list endpoint ignores the `skip`
parameter: ask it for the second page and it hands back the first one again. The catalog
builder believed it fifty times over, so one hundred-workflow page became a
five-thousand-workflow account, the scan list held the same hundred workflows three
times, and every finding was reported once per copy. There was no next batch to re-run
for, and there never had been. Accounts under a hundred workflows finish on the first
page and were never affected, which is why this reached a customer before it reached us.

**The audit now counts each workflow once.** A workflow already listed is never listed
again, so duplicate scans and tripled findings cannot happen whatever the endpoint does.
The total comes from GoHighLevel's own count, and that count is used carefully: it can
tell the audit it has seen less than the whole account, never that it has seen all of it.
Believing it in the other direction would let a stale number turn a workflow reference
that merely could not be checked into a confident "this is broken", which this audit
must never say. When the whole account cannot be listed, the report says so in plain
words, says how many were reached, and no longer sends you round a loop that cannot
finish.

Two things this also fixes, quietly. Workflows past the first hundred were never being
scanned on any account, while the report implied full coverage. And `validate_workflow`,
which shares the same catalog, was marking workflow-to-workflow references "unverified"
on those larger accounts for the same reason.

Counts: 247 tools across 51 modules, unchanged. The free read-only tier is unchanged at
111. Restart Claude after updating.

## 3.71.0 — a task you can close

A subscriber's Claude created a callback task on a contact and, come Monday, could not
close it. It said so plainly: the server can create tasks but has no way to update or
complete one, tick it off by hand. It was right. `create_contact_task` and
`get_contact_tasks` were the whole task surface.

**Three tools close the loop.** `complete_contact_task` marks a task done, or reopens it
with `completed: false`. `update_contact_task` changes a task's title, description, due
date, assignee or completed state, and sends only the fields you pass, so changing a due
date never blanks the title; an update with no fields is refused before any request goes
out. `delete_contact_task` removes a task for good and, like every delete in this server,
asks for `confirm: "DELETE"`. All three use the routes in
GoHighLevel's published contacts spec and were proven live before release on a throwaway
contact in a sandbox account: create, complete, read back completed, update, reopen,
delete, read back gone, contact removed (`docs/proofs/2026-08-26-task-lifecycle.md`).

Counts: 245 tools across 51 modules (was 242). The free read-only tier is unchanged at
111; the three new tools are writes and answer with upgrade information there. Restart
Claude after the update to see them.

## 3.70.0 — a clean validation now means something

A subscriber built an Instagram-comment-to-DM workflow, ran `validate_workflow`, got
back "0 issues, 0 warnings", published it, and watched the Create/Update Opportunity
step do nothing: no card in the pipeline, no card on the contact, and the workflow
carried on to the next step as if it had worked. They were right about why. The step
had only a pipeline and a stage. GoHighLevel's own documentation lists Opportunity
Name, Source and Status as mandatory for the combined Create/Update Opportunity step
to create anything; with only pipeline and stage it moves a card the contact already
has, and a brand-new lead has none. The
validator was checking that every id existed. It never asked whether the step could
do what its name says.

**`validate_workflow` and `audit_workflows` now check what a step can do.** A
Create/Update Opportunity step with only pipeline + stage and no opportunity in
context comes back as a warning that says exactly that and names the fix. "In
context" is worked out from the workflow itself: a create step earlier on the same
path, a Find Opportunity step whose "Opportunity Found" branch the step sits in, or a
trigger that fires on an opportunity. A step that carries an Opportunity Name and a
Status is taken as one that creates and is left alone; a Name with no Status is
reported as unverified with the thing to check. Before release the check ran against
every workflow in five of our own accounts (106 workflows) and flagged none; the run
is recorded in `docs/proofs/2026-08-26-validate-runtime-noop.md`. A path the validator
cannot trace, or a field this version does not know, is reported as unverified rather
than passed. `warnings_count` now includes these findings, the report says how many
steps were checked (`actions_checked`), and `audit_workflows` lists them under
`shape_warnings` with counts in its summary; `status` still flips only on an error,
never on a warning. A `task_notification` step spelled with the underscore, which
saves, validates and is skipped at runtime, is that kind of error now.

**Claude now builds the right node.** GoHighLevel has separated Create Opportunity
from Update Opportunity and is phasing the combined action out for new workflows. The
builder knows the newer Create Opportunity node (`internal_create_opportunity`),
validates it, normalizes it to the shape proven to create a card, and the reference
material Claude reads before building a workflow now says which node creates a card
and which one moves it, instead of pointing both jobs at the same node. (#57)

## 3.69.0 — the cockpit hardened, and the plan you approved is the plan that runs

Two items in this release exist because someone tried to break the cockpit
before a customer did.

**The cockpit refuses writes it did not ask for.** Command OS runs a small web
server on your own machine. It only ever listened on 127.0.0.1, which keeps the
internet out. It did not keep out a web page you happened to have open in another
tab, which can post to localhost without asking and without showing you anything.
Every route that changes something now checks three things before it acts: the
request came to a host we serve, from an origin we serve (or from no browser at
all), with a JSON body that a foreign page cannot send without a preflight we
refuse. Tested against the real exploit shapes, including a plain HTML form with
no JavaScript. (#17)

**An approval is a decision, and decisions do not sync inbound.** The shared board
mirrors a client's progress into their own sub-account so every seat sees the
same thing. Progress still merges, last write wins. The human approve gate never
moves because of anything read back from the client's CRM: their staff, a VA, or
an ordinary "set custom value" workflow step cannot tick your safety checkbox.
(#29)

**Headless installs stopped burning an install slot on every restart.** An
env-var install with no credentials file minted a fresh device id each boot and
locked itself out after three. It now keeps a stable id. Existing installs keep
their slot. (#33)

**The plan you approved is the plan that runs.** `apply_build_plan` saves the
approved plan on execute and a re-run reuses it with `useSavedPlan`; a new plan
needs `replaceSavedPlan` and operator approval. A differently named plan while
one is saved is refused, with the two valid moves named. Why: a re-run that
re-authored the plan created a second pipeline beside the first. The handoff
compares what was built against the saved plan. (#45, #51, #55)

**`verify_funnel` is a write and leaves the free tier.** It submits the funnel's
form with a test contact and can fire your automations, so it belongs with the
writes. Free read-only count 112 → 111; the total is still 242 tools across 51
modules. (#52)

**Command OS, from two timed client builds.**
- Spec sheets and an inspector for every module: promise, routes, writes,
  verification, with an optional `note` on a step and `needs` on a verification.
  The inspector fails a sheet that lies. (#35, #51)
- A hard tool boundary for headless stage runs: a stage reaches exactly the
  tools it declares, enforced by a computed deny list. The build stage carries
  the plan guide in its prompt and runs on a 25-minute / 40-turn budget. (#45)
- The review reads intake answers by contact id, tag, or form name, no more
  search-index lag. (#45)
- An already-open page notices a running stage and a finished stage on its own;
  no manual reload, and a second seat sees the same. The page never refreshes
  while you are typing. (#37, #46)
- The handoff document is a designed web page with a print-to-PDF view: a client
  copy and an operator copy, complete sentences, your agency's branding, a real
  footer with page numbers, and unconfirmed items are never written as done.
  (#36–#44, #47, #48)
- Intake: multi-choice questions are checkboxes and save as a list; the
  prefilled form is keyed the way the form reads it (two answers were silently
  dropped before); values a link cannot carry are listed for the operator to set
  by hand. "Is your sending email / domain set up?" is now "Is your sending
  domain set up?" because browser autofill kept filling it with saved addresses;
  existing installs keep the old field. (#51)
- The verify stage may list funnels first and says plainly that the funnel check
  submits one test contact. The intake stage may rename questions to the
  agency's wording. (#51)

The second-build fixes are unit-tested and were live-probed on the sandbox form.
The third timed build, the proof that they hold together, has not run yet.

**Smaller.** `get_courses` called a route GHL does not have, and the workflow
builder is now actually watched by the health check (#34). The upgrade nudge no
longer points at an older version than the one you run (#16, #19). Dependencies:
zod 4, TypeScript 7, dotenv 17 and the Actions bumps, each built and tested
before merging (#21–#27). CI: the automated checks were made trustworthy (#18);
the GHL drift canary says plainly when it is not configured and carries the
server's own reason instead of a guess (#30), and no longer logs a cache-save
failure it cannot avoid (#32); each canary now has its own licence secrets after
the two collided (#54).

## 3.68.0 — the checkup

A subscriber asked the question this release is named after: "I'd love for you to
check my GHL Command to make sure I am using it all and it's all working. Or tell
me how to check it? Like a checkup from the neckup."

`health_check` already answered half of that, the install half: is the license
valid, is the key good, is the account reachable. It never answered either of the
questions people actually have.

Say "run a checkup" and you get three sections.

**Connection** is the old health check, unchanged, folded in so there is one
thing to run rather than two.

**What still works** is new, and it is the part that matters. Every capability
area gets one real read against your account, right now: contacts, pipelines,
calendars, workflows, funnels, forms, invoices, social, phone, the lot. The whole
section turns on a distinction nothing else in the product made: a clean read
returning nothing means the feature works and you own none of these, while a 404
means GoHighLevel changed something. Those two look identical from the outside
and mean opposite things, so they are never merged. A permission gap and a rate
limit each get their own verdict too, because each has a different fix, and a
rate limit has none.

**What you are not using yet** is worked out from what your account contains, not
from any record of what you have clicked. Nothing about your usage is collected
or transmitted, which was true before and stays true. Every line ends with the
exact sentence to paste back.

It is read-only end to end, it works on the free tier, and it is safe to run on a
live client account.

Two things the first live runs changed, both worth knowing.

GoHighLevel's `/users/` route answers 401 to a perfectly valid key about one call
in six. Measured, not guessed: two rejections in twelve back-to-back reads, while
`/contacts/` was clean twelve out of twelve. A single-shot probe would therefore
tell a paying customer their API key lacks permission roughly every sixth
checkup. So no failure is reported until it survives three separate reads. Only
failures retry, so a healthy account pays nothing for it.

And an expired browser login was briefly reported as "Workflow Builder BROKEN,
send this to support". It is neither broken nor ours, and it has a sixty-second
fix. A report that cries wolf gets ignored, so token expiry, rate limits, server
errors and anything else that says nothing about GoHighLevel now say exactly
that instead of raising an alarm.

**The other half of this release does not ship to anyone.** `scripts/ghl-drift-canary.mjs`
runs on a schedule against our own test sub-account, boots the real server the
way Claude does, calls the same `run_checkup` tool, and compares every capability
against a committed baseline. Nothing watched GoHighLevel before this. The test
suite mocks the API, so a route they move breaks a shipped tool with every test
still green, and the first signal was always a support email. Now a capability
that worked yesterday and fails today opens an alert issue. It also reports its
own blind spots, because a probe baselined in a broken state can never alert
again and a canary that quietly shrinks is worse than none.

New tool: `run_checkup`. 242 tools, 51 modules.

## 3.67.0 — the offboarding kit

Every agency has an onboarding checklist. Almost none have an offboarding one, so
the day a client leaves turns into a week of hunting for logins, exports, and
"wait, who owns the pixel?"

Ask Claude to build the offboarding kit for an account and you get the whole exit
package in one go: every contact as a real export rather than the first hundred,
an inventory of what the account contains, a map of every person and connected
account that can still get in, and a handoff document written for whoever picks
the account up next.

Three things it deliberately will not do.

It will not print your keys. GoHighLevel does not hand key values to any tool,
including this one, so instead you get a list of every place a key lives and the
instruction to rotate it. That was the useful half anyway.

It will not guess who owns what. GoHighLevel stores nothing about ownership, so
the kit asks once and writes the answers into the client's own account as custom
values, where they outlive this tool and the client can read them without asking
you. Answer them the day you take the client on and every later run is instant.
Anything unanswered is listed as unanswered, never assumed.

It will not let a short export look like a complete one. The contact export
reports its own count and GoHighLevel's index count side by side rather than
claiming they agree, and if it stopped early the handoff document says INCOMPLETE
in its own text, not in a field somebody has to notice. A section that could not
be read says so and why, instead of showing zero.

The account you name is the account you get. Both new tools require the
sub-account explicitly and reach for that account's own key, and the parts that
depend on the workflow builder refuse outright when the builder is pointed
somewhere else. Handing one client's webhooks to another on their way out is the
one mistake an exit package must not be able to make.

**New: `build_offboarding_kit` and `record_asset_ownership`.** 241 tools.

## 3.66.2 — an export could describe the wrong client

If you asked for one client's account and the server had been started pointing at
another, part of the report came from the other account. Not an error, not a
warning: the workflows and funnels of a different business, sitting under the
name of the one you asked about.

It happened because the export asked two different places for "which account",
and only one of them listened to the account you named. That is now a single
answer. If the two ever disagree, the export refuses those sections and tells you
which account it is actually holding and how to switch, rather than answering for
the wrong one. Everything else in the report was always correct.

Every section also explains itself when it fails now. They used to all say
"Error fetching", which told you nothing and hid a real permissions error behind
what looked like a hiccup.

**New guide: Clone and rebrand a site.** Point Claude at a page you have the
rights to, and get it rebuilt for your client with their name, colours and
contact details, plus a review list of everything still belonging to whoever
owned the original: where the leads and payments still go, other people's
testimonials, claims that came along with the copy. Proven against a real site
before it was written.

That guide is deliberately separate from building a page inside GHL. Cloning an
existing page and composing a new one are different jobs that fail in different
ways, and one guide covering both served neither.

**Blog guide parked.** Reading a list of blog posts does not work — the address
it calls does not exist, on any account. Rather than ship a guide that depends on
it, the blog tools are marked as not guide material until that is fixed.

## 3.66.1 — the tool count was wrong, and so was the thing that checked it

GHL Command has served 239 tools since v3.60.0. Every place we stated the number
said 238, and had done for twelve releases.

The cause is worth writing down, because the number was the smaller half of it.
The count was derived from a total that a person kept up to date by hand, and the
test that checked our claims derived its answer from that same total. So the
claim and the check always agreed, and both were wrong together. Nothing in the
suite could see the gap, because everything in the suite was looking at the same
number.

Three figures were each one low and are now correct: **239 tools**, **184 without
the optional builder step**, **111 on the free tier**. The upgrade still unlocks
55 tools; that figure was always right, because the error cancelled out of a
subtraction.

The mechanism is fixed too, which matters more than the digits. The tools counted
here are now read from the list the server registers them from, so a tool added
tomorrow changes the number the day it lands rather than the day someone
remembers. There is also a new release check that starts a real server, asks it
what tools it has, and compares that to what we advertise. That is the only check
that could have caught this, because it is the only one that does not get its
answer from the code it is checking.

Earlier entries in this file state counts that were one low at the time. They are
left as they were written rather than quietly corrected.

Also: `update_webhook` now carries the same warning as the other webhook tools
about `tags` arriving as comma separated text rather than a list.

## 3.66.0 — webhooks work now

The five webhook tools have never worked for anyone. They called an address
GoHighLevel does not have, so every call returned "not found" no matter what you
asked for. They are rebuilt, and this time each step was proven against a real
account before it was written down.

GoHighLevel does not offer a way to manage webhooks directly. What it does offer
is a workflow step that sends data out, so that is what these tools now build.
A webhook is a small workflow in the client's own account, which turns out to be
better than the thing that was missing:

- **It can fire on almost anything**, not just a new contact. A tag added, a
  pipeline stage changed, an appointment no showed, a call missed.
- **The client can see it.** It sits in their Automation list where they can read
  it, pause it, and edit it, instead of being invisible plumbing.
- **You can add your own fields and your own headers**, and merge fields fill in,
  so the contact's first name arrives already filled. All of that works on every
  plan.

**Two things worth knowing before you point one at production**, both measured
live rather than assumed:

- **`tags` arrives as one line of comma separated text, not a list.** Code that
  checks whether it contains "vip" will also match a contact tagged
  "vip-waitlist". This is in every one of the tool descriptions now.
- **If the receiving app is down, GoHighLevel keeps retrying**, about five
  minutes later and then about ten. Nothing is thrown away. But the contact waits
  at that step, and so does every step after it. If the send is not critical, put
  it at the end of a workflow or give it a workflow of its own.

Deleting a webhook refuses to run if that workflow also does other things, so
"delete the webhook" can never quietly take a client's nurture sequence with it.

New guide: **Send GHL data to another app.** Guide coverage is now 39 of 50.

## 3.65.3 — the guide now covers most of what you are paying for

The library had guides for 20 of the 50 areas the product touches. Six shipped
guides were listed as covering things they never actually mentioned. That is
now 38, and the new material was checked against two real accounts before it
was written, not recalled.

- **Your team, numbers, and files** are covered in account admin: who has access,
  which phone numbers the account owns, what is in media storage.
- **Surveys** are covered alongside forms, including why "show me my forms" never
  returns them and where a client's missing intake answers usually are.
- **Email campaigns** are covered alongside templates, including how to spot an
  old sequence still sending against a client's list.
- **Review links, connected social accounts and bulk campaigns** each got the
  section their guide had been promising.

**Webhooks do not work and never did.** All five webhook tools call an address
GoHighLevel does not have. Four different paths, two API versions, two accounts:
404 every time. They are now marked as not guide material rather than left
looking available. Whether they get repaired or withdrawn is a separate call.

The billing guide added in 3.65.2 also covers estimates now, so the second
half-written billing guide has been retired rather than left on the shelf.

## 3.65.2 — invoicing never worked, and the guide stopped being one long page

- **Fix: creating an invoice failed every single time.** The call sent five
  fields; GoHighLevel needs eleven, and it names line-item amounts differently
  than we did. Given a payload it could not read, their server crashed rather
  than telling us what was missing, so this looked like an outage on their side
  rather than a bug on ours. It has never worked for anyone. Invoices now carry
  the currency, the dates, your business name and the client's own details, and
  are created as real payable drafts unless you ask for a test one.

**The user guide is a library again, not a scroll.**

- **Cards open their own page.** The guide had drifted into a single document
  that printed the index and then every guide beneath it, growing by one whole
  guide each release. Opening a card now shows that guide on its own, from the
  top, and the back button works.
- **The category menu is back**, above the cards.
- **It stays light.** The guide used to follow your computer into dark mode,
  which is wrong for something people read at length, print, and save as a PDF.
- **It prints properly now.** One guide per job, no navigation, no buttons, no
  index bleeding into the page.
- Fixed a layout bug that had been chopping numbered steps into narrow columns
  whenever a step contained a command. The install guide's first step was the
  worst hit, which is the first thing a new customer reads.

**Three new guides, each proven live before shipping:**

- **What your AI bot knows, and where to put it.** Custom values resolve in a
  bot's prompt and are silently ignored in its knowledge base. Asked a question
  whose answer sat behind an unresolved token, a test bot named a nearby
  sentence as the person responsible. Worse, if the same fact also sits in the
  prompt the bot quietly repairs the answer, so the bug passes exactly the test
  you would run.
- **Bill a client and see what you were paid.** Invoices, products, coupons, and
  the difference between money invoiced and money received.
- **Reuse a build across client accounts.** Snapshots, and the honest list of
  what does not travel with one.

## 3.65.1 — the guide caught up with the product

No code changes. The built-in user guide ships inside the install, so it goes
out as its own release.

- **Fix: the booking-calendar guide was telling you to do something by hand that
  the product now does.** It said minimum booking notice lived on GHL's calendar
  screen "and not here", and sent you there to set it. 3.65.0 made it settable
  directly. Anyone following that page yesterday was sent clicking through GHL
  for no reason.
- **Booking calendars**: daily caps and minimum notice are now in the guide, in
  the same ask that sets your hours and slot length. Plus what to do if an older
  version reset your slot lengths or opening hours when you changed something
  else.
- **Nurture sequences**: quiet hours. What they are, why every workflow that
  texts needs its own, and the one thing to go check: if you set quiet hours
  before 3.65.0, they were dropped the next time that workflow was saved. Open
  anything that texts and set them once more.
- **Contact lists**: how to save a group as a list you can reopen, rather than
  rebuilding the same filter every Monday. These tools never worked before
  3.65.0, so if you tried this and found nothing in GHL, that is why.
- **Blueprint**: how appointment, reply, missed-call and number-check triggers
  get scoped to the thing you actually meant, and why a stage move needs to find
  its deal first.

Every prompt in the guide is bound to a run that actually happened in a real
sub-account. The three new ones were proven live before this shipped.

## 3.65.0 — nine calls that reported success and did the wrong thing

Found while building a 139-requirement home-care front office end to end. Every
fix below has the same shape: the call succeeds, the response reads correctly,
and something quietly did not happen. None raised an error. Four were findable
only by firing the system and watching, not by reading settings back.

**Three of these were damaging live accounts.**

- **Fix: saving a calendar wiped its own settings.** Updating one field on a
  calendar sent only that field, and GoHighLevel treated the omissions as
  deletions — slot duration, slot interval, buffers and the entire open-hours
  grid were erased. The call returned 200 and the calendar looked fine in the
  list. Calendar updates now read the existing record first and merge; if that
  read fails the update refuses rather than falling back to a write that would
  clobber.
- **Fix: saving a workflow silently deleted its quiet hours.** The update never
  sent the sending-window field, so any workflow with contact-hour restrictions
  lost them on the next save, with no warning. For anyone using quiet hours for
  TCPA compliance, the protection disappeared the first time the workflow was
  touched.
- **Fix: every smart-list tool pointed at the wrong service.** All five called an
  endpoint that does not serve contact smart lists, so they never worked.

Silent no-ops:

- **Fix: appointment triggers ignored the calendar you chose** and fired for every
  calendar in the account. A caregiver interview booking would start the family
  consultation sequence.
- **Fix: reply triggers fired on every inbound message**, because the conditions
  were emitted empty.
- **Fix: moving a deal to a new stage did nothing** unless the workflow had first
  looked the opportunity up. The step reported success at every save. Now caught
  before the build runs.

New:

- **`create_contact_smart_list`, `get_contact_smart_list`, `delete_contact_smart_list`.**
  238 tools total, 110 free.
- **Calendar per-day limits and minimum booking notice** are now reachable
  (`appointmentPerDay`, `allowBookingAfter`, `allowBookingAfterUnit`).
- **Workflow quiet hours** can now be set through the tool — a sending window
  with days, applied per workflow.
- **Four trigger builders**: calendar-scoped appointments, reply-intent routing,
  call status, and phone-number validation failures.

## 3.64.1 — three more calls that failed quietly

- **Fix: client pricing was mangled on the way into a build plan.** The Blueprint
  intake parsed price answers by splitting on line breaks only, and read the
  hyphen in a price range as the separator between a product and its price. An
  answer typed as a sentence — "Contouring packages 1200-3500. Injectables
  400-900." — became a single entry named "Contouring packages 1200" priced at
  "3500", with the second offering discarded entirely, and the brief still
  reported as valid. Prices now survive as ranges, sentences and semicolons
  separate entries, and nothing is silently dropped.
- **Fix: listing invoices always failed.** GoHighLevel requires both a limit and
  an offset on that endpoint and rejects the call without them, so the ordinary
  "show me my invoices" request never worked. Both are now always sent.
- **Fix: blog authors and categories always failed**, for the same reason and
  with the same fix. The paths were never wrong — only the request was.

## 3.64.0 — four silent failures fixed, four more guides open

Every fix here is the same shape: a call that succeeded, returned
plausible-looking data, and was wrong. None of them raised an error, so none of
them were visible until a guide was written against them and run for real.

- **Fix: comparing two sub-accounts reported the other one as empty.** A
  Private Integration key only works on the sub-account it was created in, so
  reading a second account returned 403 — which `compare_locations` caught,
  stored as null, and counted as zero. Comparing a client account against your
  template answered "nothing is missing". It now uses each account's own saved
  key (the same registry `switch_location` uses), reports a section it genuinely
  cannot read as "unavailable" rather than as zero, and returns the NAMES
  present in one account and missing from the other — which it always claimed to
  do but never did. Live-proven: a template that read as "0 pipelines,
  0 workflows, 0 tags" actually holds 8, 12 and 11.
- **Fix: filtering reviews by star rating did nothing.** The filter used a field
  name GoHighLevel does not have, and an unknown filter field is dropped
  silently, so asking for five-star reviews returned two-star ones. Correct
  field and range form captured from GoHighLevel's own reputation filter.
  Adds `minRating`/`maxRating`, so "every review under four stars" now works.
- **Fix: `get_users` advertised `limit` and `skip`.** GoHighLevel rejects both
  outright. Anyone who used them got an error instead of users. Removed; the
  endpoint returns every user in one response.
- Four more guides open in the library, each proven against a real account
  before shipping: see and manage appointments, create forms and read
  submissions, watch your reviews, account admin basics. 24 open, 6 still
  being written.

## 3.63.0 — the guide library grows from 3 guides to 20

- **`get_user_guide` now opens a 20-guide library.** Seventeen guides were
  written and proven live but had never shipped, so anyone running
  `get user guide` was still getting the three-guide edition. The bundled
  offline guide goes from 43KB to 111KB: 20 open guides, 10 still marked
  "Available in the app" until their prompts are proven. Every prompt marked
  "Proven live" was run against a real GoHighLevel account before it shipped.
- Same library renders to ghlcommand.com/skills, so the in-product guide and
  the website always match.
- **Fix: reading appointments returned nothing.** `get_calendar_events` sent
  ISO timestamps, but GHL's `/calendars/events` needs epoch milliseconds and
  answers a wrong-format request with `200` and an empty array instead of an
  error — so a calendar holding real confirmed bookings read back as "nothing
  booked". Now converts via the timezone-aware helper free-slots already used,
  plus an optional `timezone` parameter so a bare `YYYY-MM-DD` window resolves
  in the sub-account's timezone (end date inclusive). Live-proven both
  directions.

## 3.62.1 — update_pipeline works again (Bug 8)

- **Fix: `update_pipeline` no longer 422s.** GHL's pipeline PUT rejects unknown
  body properties, and the request body was seeded with `locationId` — so every
  update died with "property locationId should not exist" before the stages were
  read. locationId now rides as a query parameter (the same pattern
  `delete_pipeline` already used). Live-proven on the sandbox with the case that
  matters: existing stage IDs survive the update (rename + reorder + append in
  one call), so open opportunities are never orphaned. New regression proof:
  `scripts/pipeline-update-proof.mjs`.
- Trigger reference: `custom_date_reminder` real UI-generated shape captured
  (conditions + custom_date_reminder_config; before-days variant — after-days
  still unverified).
- Action reference: the workflow Email action has NO "to" field — it always
  mails the enrolled contact; third parties are copied via `cc` (merge fields
  supported). Documented so nobody builds a "send to a third party" node that
  silently mails the contact.


## 3.62.0 — The user guide ships inside the product

- **New tool `get_user_guide`** (free tier included): opens the full GHL Command
  guide library in your browser — plain-English guides with copy-paste prompts,
  bundled with the install as a single offline file (`guide/guide.html`) that
  updates with the product. The response always includes the file path, so a
  blocked browser launch still leaves a copyable route in.
- The same library is on the web at https://ghlcommand.com/skills/ — the bundled
  edition is the identical content, rendered for offline in-app use (no forms,
  no site-relative links; roadmap cards read "Coming in an update").
- Guide render drift-guard now also hashes the page CSS/JS (a style-only fix
  could previously ship stale rendered pages without failing the build) and
  covers the bundled `guide/guide.html`.
- Tool count: 235 across 50 modules (free tier: 109 usable read-only tools).
  /free page copy in the website repo should say 109 when next touched.
- Docs: CLAUDE.md corrected — bundle size note (~1.1MB, was stale at ~428KB) and
  `internal_notification.selectedUser` now documented as REQUIRING a real user
  ID (GHL rejects the old empty-string "all users" form; live-verified 2026-08-06).

## 3.61.0 — Verified counts: get_contact_count + the GHL Reports skill

A customer asked for "new contacts in the last 7 days" and watched Claude burn
97,000 tokens across 15 minutes, cap out at one page of a 39,776-contact book,
and finally offer a number it admitted it never verified. The gap was
structural: `search_contacts` rides the legacy list endpoint (no date filters),
so counting forced client-side pagination and improvisation.

- **New tool `get_contact_count`** (free tier included): one call to GHL's own
  index answers any "how many contacts in this window" question. Date-only
  bounds resolve in the LOCATION's timezone (from = start of day, to = end of
  day, both inclusive) and the response echoes the exact resolved window next
  to the total. When the count can't be verified it answers
  `status: "unavailable"` with the reason — never an estimate, never a silent 0.
- **One counting code path product-wide:** the windowed-count logic is shared
  with `get_account_health_summary` (extracted to `src/contact-window.ts`), so
  two parts of the product can never report two different "new contacts"
  numbers for the same window.
- **Anti-guessing rules now live in the tool descriptions** (always loaded, no
  restart needed): `search_contacts` points count questions at
  `get_contact_count`; the counter's description mandates reporting the number
  with its window echo.
- **New bundled skill: GHL Reports** — count/list/report recipes (one-call
  counts, CSV list pagination with count reconciliation, composed account
  summaries), auto-installed like Blueprint and Clone Site.
- Tool count: 233 → 234 (free tier 107 → 108). Sites listing free-tier counts
  (ghlcommand.com/free) need the 108 figure.

## 3.60.0 — One install for every tier, and upgrading is just the license key

A real free-tier user installed, was told "Setup complete!", asked for the
account audit every email promises — and got "Tool not found." The audit
needs a one-time browser login that was framed as optional and buried after
the success message. Worse: a free user who completed that login and then
upgraded had it silently ERASED by the upgrade itself. This release makes
the install one thing, for everyone:

- **"Complete" now means complete.** When the browser login hasn't happened
  yet, setup says "Step 1 of 2 done" and names the next command
  (`capture_firebase_interactive`, now available before the restart) — one
  restart total, not two. The word "optional" is gone.
- **The auditor never vanishes.** Before the unlock, `audit_workflows` and
  `validate_workflow` answer with 60-second finish-your-install directions
  instead of not existing.
- **Upgrading is one paste.** `setup_ghl_mcp` now accepts just your email and
  the new license key: the GHL credentials and Workflow Builder unlock saved
  on your machine are reused and kept. (Previously, re-running setup wiped
  the unlock — fixed, with tests that keep it fixed.) Supply the GHL key and
  location ID only on a first-time setup, always as a pair.
- **A rejected credential update can no longer destroy a working one** — if
  you re-run setup with bad Firebase values, the good saved ones stay.
- `health_check` now points at the one-click unlock and current URLs.

The free and paid tiers install identically; the license key alone decides
what unlocks. Free stays read-only; paid unlocks everything, on the same
install, with the same key-swap.

## 3.59.0 — One-command install: `npx -y @elitedcs/ghl-mcp cli install`

The old install step said "Edit Config, paste this block." Edit Config opens a
folder, not a text box — and the block was a complete config object, so anyone
who already had other MCP servers and followed the instruction literally wiped
them. A real buyer hit exactly that this week.

This release replaces the hand-edit with one command:

    npx -y @elitedcs/ghl-mcp cli install

It finds Claude Desktop's settings file (including the Microsoft Store build's
relocated path on Windows), adds GHL Command **alongside** whatever servers are
already there, and saves a verified backup before touching anything. If the
file has a formatting problem — often caused by our own old instructions — it
repairs what it can and preserves the original in the backup. If the file can't
be read at all, it writes a fresh working config and keeps the original so
nothing is ever lost.

Safety properties, all tested (33 new tests):

- **Never writes without a verified backup** of an existing file, and aborts if
  the backup can't be confirmed byte-for-byte.
- **Never fights Claude for the file**: writes are atomic, lock errors retry
  briefly then stop with plain instructions, and if anything else modifies the
  file mid-run it stops rather than overwrite the newer change.
- **Never guesses between multiple config files** — it stops and asks.
- **Never touches** symlinked configs pointing somewhere unexpected, UTF-16
  files, or files whose content it can read but not safely restructure.
- `--dry-run` previews every decision without writing; `--print-only` emits the
  merged JSON for locked-down machines.

Also in this release: the license-failure message now points at ghlcommand.com
(and mentions the free tier) instead of a retired page, and `setup_ghl_mcp`'s
description finally includes a URL for people who reach it without a license.

## 3.58.0 — Anonymous install stats, so we can find out where installs get stuck

Most people who install GHL Command never get it working, and until now there
was no way to tell whether that was 50 people or 1,200. This release adds a
small, anonymous count of install and startup events so the gap can be measured
and fixed. It is the first release that sends anything off your machine, so the
disclosure ships in the same version as the feature, not the one after.

**Turn it off with either of these and no network call is made at all:**

    GHL_TELEMETRY=0
    DO_NOT_TRACK=1

Run `health_check` to see whether it is on and to confirm an opt-out took effect.

- **Exactly twelve fields, and the server rejects anything else.** A random
  device id, the event name, package version, Node major, platform, three
  automation flags (CI / TTY / container), your licence tier, seconds since this
  machine first ran GHL Command, a failure reason code, and a timestamp. The
  endpoint refuses a payload carrying an unknown field rather than quietly
  dropping it, and the database enforces the same list a second time — so the
  shape cannot drift without both being changed deliberately.
- **Never sent:** your licence key, email, GHL API key, location ID, contact or
  client data, hostname, usernames, file paths, or raw error text. Nothing about
  your GoHighLevel account or your clients' accounts leaves your machine. The
  failure reason is one of eleven fixed labels, never a server message, because
  those can name a sub-account.
- **Separate identity from your licence.** The telemetry device id is generated
  independently and shares no code with the licence fingerprint, so it cannot
  affect activation or consume an install slot. A consequence worth stating: a
  telemetry row cannot be linked back to a customer.
- **It cannot slow you down.** Sending is fire-and-forget with a 2-second
  timeout, never awaited, and every failure is swallowed. Identity and
  environment are resolved once per process, so a tool call performs no disk
  access at all.
- `health_check` gains an "Anonymous usage stats" row reporting ON or OFF.

## 3.57.0 — Clone Site: copy a live page for a client, with the guardrails built in

A second bundled skill. Point Claude at any live URL and get a working,
rebranded copy for a client — same layout, images, video and CSS, with the
new business's details in place of the original's. It installs itself like
Blueprint; there is nothing to download and no new tools to learn.

Cloning normally fails because the model *regenerates* the page instead of
copying it, and you get a worse version of what you pointed at. This skill
runs real scripts: they download the bytes, swap the strings, and verify the
result. Nothing is retyped from memory.

- **The rights question is step 0 and cannot be skipped.** Before anything is
  copied, the skill asks who owns the page — you, your client (authorized),
  written permission, or none — and records the answer in the run report and
  in the final report header. It does not verify ownership; the declaration is
  yours to make. The mirror script refuses to run without it.
- **"None" gets a real lane, not a refusal.** It extracts the page's actual
  design system — palette, CSS variables, font stack and type scale, spacing
  rhythm, radii, shadows, breakpoints, and the section skeleton as shape — so
  the rebuild starts from the real design instead of a from-memory redraw. No
  bytes, images, video or copy are copied in that lane.
- **Substitution that doesn't leak the old brand.** Derives the bare brand word,
  every phone format, the address as a unit (not just the city), and domain and
  email variants; runs across JS bundles as well as HTML and CSS, because
  compiled sites keep contact details only in the bundle. Asset filenames are
  protected from rewriting. Dry-run counts first, a zero-check after, and a
  NEAR MISS warning when one of your facts is *almost* right.
- **Catches the leak a string search cannot see.** A brand mark is usually
  two-tone — `GHL <span style="color:#D4AF37;">Command</span>` — which reads as
  the old brand on screen while that string appears nowhere in the file. Every
  literal check calls it clean. Clone Site renders the page to text while
  keeping a map back to source offsets, so it reports exactly which occurrences
  are split by markup, shows the markup, and fails the zero-check until they are
  hand-edited. Found on a real page, in the header and the footer, after a
  file-level rebrand had already "passed."
- **Asset capture from all four places** — HTML, CSS `url()`, CDN URLs in JS
  bundles, and root-relative paths inside JS bundles (the one that silently
  breaks images). Oversized files are reported for object storage, never
  hot-linked back to the original owner's CDN.
- **A mandatory pre-launch REVIEW REQUIRED report** on every run: form endpoints,
  webhooks, payment links and publishable keys, booking embeds, analytics and ad
  pixels (MUST REPOINT); names, addresses, testimonials and likenesses; and
  inherited claims with regulated health/efficacy language flagged separately.
  Nothing is auto-deleted — findings go to a human.
- **Verification by content-type, not status code**, because static hosts answer
  200 with the page shell for assets that don't exist. It retries once before
  calling anything broken (a CDN rollout can answer a single request with HTML),
  and separates references the *source* page was already serving badly from
  breakage the clone caused.
- Reports are written outside the deployable folder, so a rights declaration and
  a liability list can never be published with the site.

Everything below was found by running the tool against real production sites
rather than test fixtures, and every one of them shipped a fix:

- **Whole-site cloning.** A one-page mirror inherits the original's entire
  navigation, so every menu item 404s the moment a client clicks it.
  `--crawl --depth N --max-pages N` follows the site's own navigation, writes
  each page at its own path, and rewrites links between your pages as
  root-relative so the nav works locally and after deploy — while canonical and
  og:url keep their host so the rebrand can point them at the client's domain.
  The skill asks how deep to go rather than assuming. Real numbers: a 36-page
  contractor site came to 2,104 files and 935 MB at depth 2.
- **Assets that only exist at runtime.** Page-builders publish an asset base
  path and concatenate chunk filenames onto it in JavaScript, so those files
  appear in no attribute, stylesheet or string literal. Six missing Elementor
  chunks meant no section background was painted at all: the hero rendered blank
  and its white headline was invisible on white, while every file-level check
  reported success. New `repair.mjs` plus a mandatory render-and-repair loop
  fetches whatever the running page asks for and cannot find.
- **Media hidden in escaped JSON.** Background slideshows and galleries store
  image URLs inside entity-encoded, backslash-escaped JSON attributes. Those are
  now extracted and localised, escaping preserved.
- **Brand marks split across tags.** `GHL <span style="color:#D4AF37">Command</span>`
  reads as the old brand on screen while that string exists nowhere in the file.
  Detected exactly via a rendered-text-to-source offset map; the zero-check now
  fails until they are hand-edited instead of reporting a false clean.
- **HTML-entity brand forms.** A brand containing `&` is stored as `&amp;`. On
  one page the plain token matched twice and the encoded form 56 times.
- **Brand baked into image pixels.** Logos and graphics carrying the old phone
  number or web address are flagged — substitution cannot edit artwork, and the
  logo is the most visible way a clone gives itself away.
- **New audit rules from real pages:** contractor and professional licence
  numbers (the highest-severity item on any trades or medical clone), consumer
  financing links, review widgets that load the original's reviews at runtime,
  and internal links with no page in the clone.
- **Bare-word derivation is now cautioned**, because a brand beginning with a
  place name ("Arizona AC & Heating") would otherwise rewrite ordinary sentences.
- **Style lane returns the brand's palette, not the framework's.** Plugin CSS
  repeats neutral greys hundreds of times while the real brand colours appear
  twice; vendor stylesheets are now separated, accents are attributed to their
  source file, declared colour tokens are surfaced first, and CSS variables are
  resolved so the type scale comes back as real pixel values.
- **The style lane's deliverable is a page, not a document.** Its rebuild is now
  five explicit steps, including a media step and a leak check against the
  original page.

## 3.56.0 — Security off-switches: any tool can be disabled; sub-account create/delete is opt-in

Shaped directly by subscriber feedback: some teams keep account-level
operations human-only, so a prompt-injected or misbehaving AI session can
never touch them. Two new controls, both enforced at server startup from
your config — there is deliberately NO way to change them from inside a
Claude conversation.

- **Sub-account create/delete is now OFF by default.** `create_sub_account`
  and `delete_sub_account` register only when `GHL_ENABLE_ACCOUNT_ADMIN=1`
  is set. The setup wizard asks (default No). The allowlist cannot bypass
  the flag. If you were using these tools, add the flag and restart.
- **Universal deny-list**: `GHL_DISABLED_TOOLS` / `GHL_DISABLED_MODULES`
  switch off any tool or module. A disabled tool never registers — it is
  invisible to the model, so it can't be called *or discovered* from a
  conversation. Deny wins over the allowlist and the always-on set,
  including the credential helpers (`enable_workflow_builder`,
  `auto_capture_firebase_script`, `capture_firebase_interactive`) and
  `install_skills` (denying it also skips the bundled-skills auto-install).
- **Un-brickable**: the recovery core (`setup_ghl_mcp`, `request_license`,
  `get_mcp_version`, `health_check`) ignores denial, with a logged warning.
- **Verifiable**: `health_check` gained a "Tool gating" section reporting
  exactly what's disabled, whether account admin is on, and any misspelled
  name in your gating vars (typos do NOT disable anything — they warn).
- Advertised default tool count is now 233 (235 with account admin enabled).
- README: new "Turning tools off (security)" section with examples.

## 3.55.0 — Skills ship inside the package: Blueprint installs itself

The gap between "the tools exist" and "the guided experience exists" is
closed. The npm package now bundles guided skills and installs them into
your `~/.claude/skills/` automatically on every start — nothing to download,
no separate zip.

- **Blueprint is now actually delivered**: the full guided skill (intake →
  brief → build plan → approval → staged build) that orchestrates the six
  Blueprint tools ships in the package. Previously the README sold it but
  only the raw tools shipped.
- Installer is idempotent and never clobbers your edits: files you've
  modified are kept and reported, updates only touch files the installer
  itself wrote (tracked by content hash in `.ghl-command-skills.json`).
- New `install_skills` tool (every tier — it only writes to your machine)
  to verify what's installed or repair a deleted skill.
- Restart Claude fully after an update so new skills load.

## 3.54.0 — Page Studio: `compose_page` + `compose_website` build designer-grade pages

The gap between "GHL Command creates funnels/websites" and "the pages look
professionally designed" is closed. A section library cloned field-for-field
from a professionally designed GHL template (topbar, nav, image hero, icon
value cards, feature blocks, CTA banners, FAQ, map, calendar booking, inline
form with A2P disclosures, compliant footer) composes complete pages that
GHL's renderer verifiably renders.

- `compose_page`: a complete opt-in funnel page in one call — hero, quotable
  value cards, inline form (embeds your real form), FAQ, footer with legal
  name/address/privacy/terms. Refuses to build an opt-in page without the
  A2P-required business facts.
- `compose_website`: a complete multi-page website in one call (home, about,
  services, booking, contact) — nav wired to the real pages, booking page
  embeds a real calendar, every page carries the compliant footer.
- Brand-themeable: pass brand colors + Google fonts; copy can use
  `{{location.*}}` merge fields and localizes per account automatically.
- Safe by contract: `dry_run` writes nothing; `create` refuses any page that
  already has content, any page it cannot read, and any page belonging to a
  different funnel — existing pages can never be damaged. All saves are drafts
  and verified after writing. (There is deliberately no overwrite mode: GHL
  only honors the first save onto a page; to redo a page, delete its step and
  compose onto a fresh one.)
- Includes the 3.53.2 licensing fixes (stable device identity, honest
  install-limit message, deterministic identity resolution).

## 3.53.2 — Licensing: fix the 3.53.1 identity regression

3.53.1's device fingerprint could return a random id in the no-argument path,
so cached attestations failed to verify and every restart could claim a fresh
install slot. 3.53.2 makes identity resolution deterministic. If you are on
3.53.1, update. (No members were affected — verified against the license DB.)

## 3.53.1 — Licensing: stable device identity + honest install-limit message

Superseded by 3.53.2 (see above) — do not install this version.

## 3.53.0 — `create_funnel` can now create websites

Subscriber report: the tool description said "funnel/website" but the handler
hardcoded `type:"funnel"`. New optional `type` parameter (`funnel` | `website`,
default `funnel`). Live-verified: websites persist and appear under
Sites → Websites; omitting the parameter behaves exactly as before.

## 3.52.1 — Docs: positioning vs. HighLevel's official Anthropic MCP

No behavior changes; README only.

- Added a "How this differs from HighLevel's official MCP" callout: HighLevel's
  official Anthropic MCP reads/writes your CRM over the public API (free, good);
  GHL Command adds the builders the public API doesn't expose — workflow builder,
  funnel/page builder, form builder, deep cloning, `audit_workflows`, and
  Blueprint. Official MCP = read/write your CRM; GHL Command = build and fix it.
  They stack together.

## 3.52.0 — Fix: `update_page_content` now actually writes page content

`update_page_content` was a silent no-op — it POSTed the page envelope to GHL's
prebuilt-section template-sync route (`/funnels/builder/prebuilt-section/sync/changes`),
which accepts the request, ignores `pageData`, returns `{"prebuiltSectionTemplates":[]}`,
and persists nothing (the page stayed at version 1 with no `pageDataDownloadUrl`).
Present since v3.7.0 — this capability had never worked. Reported by a customer
(v3.47 + v3.50, custom MCP client) and reproduced.

- **Re-pointed to GHL's real builder autosave endpoint**
  `POST /funnels/builder/autosave/{pageId}`, captured live from the page builder
  2026-07-09. The server uploads the page to Firebase Storage, bumps the version,
  and regenerates the preview snapshot, returning the new `pageDataUrl` /
  `pageDataDownloadUrl`. Verified end-to-end: a from-scratch API write bumps the
  version and the content round-trips through `get_page_content`.
- **Body shape** is `{funnelId, pageVersion, pageData: {…envelope, pageVersion,
  pageType, manualSave, integrations}}`. The tool auto-resolves `funnelId` and the
  current `pageVersion` from the page metadata (both optional to pass), computes
  the `integrations` element-type counts from the content, and sends the builder
  origin/referer + `Version` header the endpoint's IAM check requires.
- `update_page_content` gains an optional `funnelId` arg (auto-resolved when
  omitted). No tool-count change.

## 3.51.0 — Agency sub-account provisioning (`create_sub_account` / `delete_sub_account`)

The missing first step of fully programmatic client provisioning: create the
sub-account itself (optionally loading a snapshot at creation), then Blueprint
builds inside it and verify_funnel proves it.

- **`create_sub_account`** — agency-API location create with optional
  `snapshot_id` (see `list_snapshots`), prospect/owner info, address, timezone.
  Probes whether the agency key can operate the new location's data plane and
  auto-registers it in the token registry when it can (switch_location works
  immediately). Non-idempotent create is retry-suppressed. Requires an
  agency-level key with the locations.write scope; the error path says exactly
  that when the scope is missing.
- **`delete_sub_account`** — agency-API location delete, confirm-gated,
  refuses the currently active location, cleans the token registry entry.
- **Tool counts corrected: the real full count is 232 (180 core), free tier
  106 (88).** A live tools/list dump exposed that `get_mcp_version` registers
  outside the counted registry in both modes — every prior claim (229, 231,
  free 105/87) was one short. The drift-guard constant now documents all four
  normal-mode extras.
- **Test-isolation fix: the suite can never clobber a real install again.**
  credentials-store's atomicity tests wrote fixture credentials to the REAL
  per-user credentials.json (no GHL_MCP_CONFIG_DIR override) — any dev running
  the suite silently broke their own install on next restart. Tests now run in
  a temp dir AND writeCredentials refuses to run under vitest without an
  override.


## 3.50.0 — FREE read-only tier

A free license (`ghlcommand.com/free`, key by email, no card) now runs the same
npm package in read-only mode on the user's own GHL account.

- **Tier gating in the tool registry** (`tool-filter.ts`, second axis alongside
  the env allowlist). Free tier: every read-only tool works for real — all
  `get_*`/`list_*`/`search_*` plus `audit_workflows`, `validate_workflow`,
  `get_account_health_summary`, `compare_locations`, `verify_funnel`,
  `check_blog_slug`, `workflow_builder_status`. That's **105 usable tools with
  the Firebase login (87 before it)**, enumerated from the registry by the
  drift-guard test. Write tools still register with full schemas but their
  handlers return a clean upgrade message (checkout link, same-key upgrade
  note) — never a stack trace, never a half-executed write. Excluded from free
  by design: `export_account`, the whole Blueprint (`intake-to-build`) module,
  and the multi-location registry writes (`register_location`,
  `register_agency_key`, `switch_location`, …) — free is 1 location, 1 machine.
- **Tier is cryptographically bound.** The license server now signs `tier`
  inside the Ed25519 attestation payload; the MCP derives the session tier ONLY
  from a verified attestation (legacy attestations without the field = 'full').
  Hand-editing credentials.json cannot unlock writes. Old MCP versions ignore
  the extra payload field — fully backward compatible.
- **Firebase capture stays available on free** (`capture_firebase_interactive`,
  `enable_workflow_builder`, `auto_capture_firebase_script`): the one-time GHL
  login is what powers the read-only auditor suite. Builder writes stay gated.
- **setup_ghl_mcp / request_license** are tier-aware: free installs see the
  read-only counts and the auditor-unlock next step; request_license now offers
  the free tier (`ghlcommand.com/free`) alongside purchase.
- **Drift guard extended** (`tool-count.test.ts`): free-tier counts (105/87)
  are computed from the registry and asserted against README + setup-tool copy;
  a classification change breaks the build until the claims are re-synced. The
  ghlcommand.com `/free` page and the key-delivery email carry the same number —
  re-sync those manually when this test moves (different repos).
- Server side (elite-dcs/website + Supabase, deployed separately): `licenses.status`
  gains `'free'`; `register_device` returns the row status; `validate-license`
  returns + signs `tier`; the Stripe webhook upgrades a free row IN PLACE on
  purchase (same key, installs 1→3, `source: stripe_97mo_from_free`) — proven
  against a live throwaway row and a mocked-fetch webhook harness. New
  `/api/free-license` provisioning endpoint (signup → row → key email → 3NA51
  tag `ghl-command-free-tier`).

## 3.49.1 — Docs patch: one-click capture is now the primary Milestone-2 path

No behavior changes; text only.

- README "Enable the Workflow Builder": `capture_firebase_interactive` (log into a
  Chrome window, zero pasting) is the supported path; the console-paste script is
  demoted to a collapsed fallback (still the path for locked-down machines and for
  capturing CLIENT accounts in multi-tenant). Rotation note now points at the
  silent re-capture.
- `enable_workflow_builder` and `auto_capture_firebase_script` tool descriptions
  point to `capture_firebase_interactive` first.


## 3.49.0 — One-click Workflow Builder unlock (`capture_firebase_interactive`)

The buyer's whole job is now: log into GHL in a Chrome window the tool opens.
No DevTools, no console paste, no JSON shuttling.

- **New tool `capture_firebase_interactive`** (normal mode). Spawns a detached
  helper (`dist/capture-helper.js`) that launches the buyer's own installed
  Chrome (playwright-core channel discovery — Edge fallback, zero browser
  downloads) into a dedicated persistent profile, waits for a GHL login, reads
  the three Firebase values straight out of IndexedDB (same selection logic as
  the console script), validates them against Firebase, and saves them via the
  same path as `enable_workflow_builder`.
- **Silent re-capture for token rotations.** The persistent profile keeps the
  GHL session cookie, so every capture after the first tries HEADLESS first —
  refresh-token rotation becomes "run the tool again, no window appears".
- **Timeout-proof by design.** The helper is fully detached and communicates
  through a state file; the tool call polls with a generous window and tells
  the buyer to re-run if they're still mid-login. An MCP client timeout can
  never strand the flow.
- **Fallbacks intact.** No Chrome/Edge, closed window, or login timeout all
  return clear instructions pointing at `auto_capture_firebase_script` (the
  console-paste path, unchanged).
- Tool count: 228 → 229 (177 without Firebase).


## 3.48.2 — Docs patch: tool-count claims corrected (the real number is 228)

No behavior changes; text only, plus one new test.

- **Tool counts reconciled everywhere.** The claims had drifted three ways (setup tool
  said 212/163, README and package.json said 220, ghlcommand.com said 219). The real
  numbers, enumerated from the registry itself: **228 tools across 48 modules** on a
  fully configured install, **176** without the Firebase add-on, **+52** unlocked by
  `enable_workflow_builder`. All copy in `setup_ghl_mcp`, `enable_workflow_builder`,
  README, and package.json now states these.
- **Drift guard added.** `src/tools/tool-count.test.ts` computes the real count from
  `registerAllTools` and fails the suite if package.json, README, or setup-tool copy
  ever disagrees again.


## 3.48.1 — Docs patch: Firebase capture friction + live pricing copy

No behavior changes; text only.

- **Chrome "allow pasting" gotcha documented.** `auto_capture_firebase_script`'s steps now
  tell buyers that when Chrome blocks the console paste, typing `allow pasting` in the
  Console unblocks it. This was the single most common silent failure in the capture flow.
- **`request_license` copy corrected to the live offer.** Was "$97 one-time, no
  subscription" pointing at the old elitedcs.com page; now "$97/mo, every sub-account you
  manage" pointing at ghlcommand.com. The old copy misquoted the price to prospects
  installing from npm.
- **README: one-paste capture is now the primary Workflow Builder path.** The manual
  7-step IndexedDB walk is demoted to a collapsed fallback for locked-down browsers;
  multi-tenant instructions reference the script instead of the manual steps.


## 3.48.0 — Blueprint: complete workflow-building (opportunities, branching, appointment timers, opt-in publish)

Completes the `apply_build_plan` executor so a Blueprint build produces workflows that
create and move real opportunities, branch on whether a contact has one, fire on
appointment-relative timers, and (opt-in) go live instead of staging as drafts. Every
item below was proven live on a throwaway account and read back before shipping.

- **Fixed: `create_opportunity` now actually creates an opportunity.** The executor was
  emitting GHL's *update* node (`internal_update_opportunity`), which only updates an
  existing opp and silently no-ops when none exists. It now emits the real
  `internal_create_opportunity` node (pipeline hoisted to the attribute level, stage + name
  in the custom input fields), so a new-lead workflow lands a deal in the pipeline as
  intended.
- **New: `wait_appointment` action** — appointment-relative reminder timers (e.g. "24 hours
  before the appointment"), emitting GHL's appointment-relative wait node. The workflow
  builder's action-chain validation accepts the appointment-relative `appointmentStartAfter`
  shape alongside the standard time-based `startAfter`.
- **New: optional `monetaryValue`** on `create_opportunity` / `update_opportunity` so a deal
  carries its value.
- **New: `find_opportunity` multi-path branching.** The first branching logical action: it
  loads a contact's latest opportunity in a pipeline, then routes to a `found` / `not-found`
  branch (each with its own actions, including waits and sends). This is what makes a
  move-the-opp step inside a branch work — the find loads the opp the move then acts on.
- **Fixed: backward opportunity moves (win-back / reactivation) silently no-op'd.** GHL's
  "Move opportunity" defaults `allowBackward:false`, which lets a forward stage move through
  but silently refuses a move to an earlier stage — breaking every reactivation build (whose
  whole point is a backward move). The executor now emits `allowBackward:true` so the target
  stage is honored in either direction; forward moves are unaffected.
- **New: opt-in `publishWorkflows`.** Workflows still build as DRAFT by default (the safe
  default). Passing `publishWorkflows:true` publishes ungated, newly-built workflows live
  (re-syncing their triggers); a workflow gated DRAFT by an unmet handoff is never
  auto-published, and a publish failure never halts or rolls back the build (it surfaces a
  "publish it yourself" note and the build still succeeds).
- **New: funnel page content template** (`templates/funnel-page-content-template.md`) —
  brand-themeable CSS plus always-on A2P/10DLC opt-in, SEO, and AI-search (GEO) structure
  for the manual funnel-page-content step.

## 3.47.0 — Blueprint Cap-0: cross-workflow exit chaining + native trigger builders

Two foundational executor fixes so `apply_build_plan` builds workflows that actually
chain together AND actually fire — the prerequisites for a non-hollow account build.

- **Fixed: a cross-workflow `remove_from_workflow` / `add_to_workflow` halted the build.**
  When one workflow's exit action targeted another workflow in the SAME plan, execute
  halted, reporting the (already-built) target as unresolved — so the canonical "pull the
  lead out of nurture when they book / reply" pattern (workflows that enrol each other)
  could not fully execute. The executor now resolves EVERY workflow's identity in a first
  pass (never-clobber bind existing, else create an empty DRAFT) before expanding any
  actions, so forward, backward, AND mutual cross-workflow references all resolve. Safety
  rails intact and hardened: never-clobber (existing workflows are never modified),
  empty-orphan detection (now halts rather than blind-binds when an existing workflow's
  action count is unreadable), verify-after, and rollback of every unsaved shell on a halt
  — with any orphan id that could not be deleted SURFACED in the halt message (never
  swallowed) and dropped from the returned id map.
- **New: native trigger builders for the trigger types real accounts actually use.**
  Previously only `contact_tag` triggers were auto-built; every other workflow shipped
  trigger-less (inactive) — the #1 reason builds were hollow. Blueprint now builds
  `form_submission`, `appointment` (status-conditioned, e.g. confirmed / no-show),
  `customer_reply`, `pipeline_stage_updated`, `inbound_webhook`, and `payment_received`
  triggers natively. Each saved shape was CAPTURED from a real, live, UI-built trigger in a
  reference account (not guessed) so the synthesized trigger reads back identical and fires.
  Refs in trigger conditions (form / pipeline / stage) resolve to real GHL ids at build
  time. A trigger type Blueprint doesn't yet build natively — or one missing the field it
  needs (e.g. an `appointment` trigger with no status) — is still surfaced as a manual step.
- Plan validation now rejects two workflows that share a name (the executor binds workflows
  by name, so duplicates would collapse onto one shell).

## 3.46.0 — `verify_funnel`: prove a funnel actually captures leads (+ external-funnel wiring bundle)

The live-runtime companion to `audit_workflows`. `audit_workflows` finds dead workflow
steps (static); **`verify_funnel`** proves a public funnel actually **captures** a lead AND
the machine **acts** on it — because the UI is never proof. (Motivated by two real failures
from the field: a funnel whose thank-you page showed while every lead silently evaporated,
and a handler that wrote guessed field keys so values were dropped on save.)

- **New tool `verify_funnel`.** Submits a clearly-marked sentinel lead to the live funnel
  URL, then reads GHL back to verify what REALLY happened:
  - **Backend truth** — the contact is actually created (`search_contacts`), with submit→appear latency.
  - **Field-value fidelity** — each expected field PERSISTED with the exact submitted value
    (a wrong field id makes upsert succeed while silently dropping the value).
  - **Trigger tag landed**, else the speed-to-lead workflow can't fire.
  - **SMS/A2P pre-check** — flags an account with no SMS-capable number (any SMS step would silently fail).
  - **Workflow status** — flags a DRAFT workflow (never fires on real leads).
  - **Outreach fired** — an outbound message was actually logged (proves enrollment + send, not a green log).
  - **Consent recorded** when messaging fires (TCPA/CAN-SPAM).
  - **Duplicate-contact dedup** + **concurrent double-submit race** (lag-stable polling, no false-pass).
  - **Routing/opportunity/attribution** (optional) and **multi-surface** (extra URLs).
  - Booking can't be faked server-side → surfaced as a manual "book one real appointment" step.
  - Safety: creates a real (clearly-marked `blueprint-qa`) contact and may fire real automation;
    cleans the sentinel(s) up afterward by default (cleanup runs even on error), with a verdict
    that turns any failed assertion into an overall FAIL (no silent skips).
- **`apply_build_plan` execute now returns an `externalWiring` bundle.** For each
  `target:"external"` funnel: the location id, the **verified GHL custom-field ids** the
  external form must send (never name-guessed keys), the booking URL, and the contact-tag
  trigger to add — with an `unresolved` list for anything not yet built. This is what you
  template into the self-hosted lead bridge (`templates/external-funnel/`).
- External-funnel README hardened: the contactless-inbound-webhook rationale (why the bridge
  uses `/contacts/upsert`, not a raw trigger) + a "verify after deploy" QA checklist.

## 3.35.0 — `create_social_post` is now draft-first (closes the silent "everything published at once" trap)

**Behavior change.** `create_social_post` no longer defers go-live to GoHighLevel's
server-side default. Before, when `status` was omitted the field was dropped from the
request and GHL decided whether the post went live — an undocumented default that, in
a loop, is exactly the "all posts fired at once, no error, no warning" failure operators
hit. The most expensive failures are the silent ones, so the tool now takes a defensive
stance by default.

- **Draft-first default.** Omit `status` and the post is created as a `draft` (editable,
  never live). Going live is now an explicit choice (`status:"published"`).
- **Scheduling is coupled to status.** Pass `scheduledAt` with no `status` and it
  auto-resolves to `"scheduled"`. Pass `scheduledAt` alongside any *other* status, or
  `status:"scheduled"` with no `scheduledAt`, and the call is rejected with an actionable
  error instead of silently publishing now or falling back to the server default. This
  kills the "I thought it was scheduled" class of failure (CHANGELOG 3.18.0 always
  required both fields; nothing enforced the pairing until now).
- Logic extracted to the pure, tested `resolvePostStatus(status, scheduledAt)` helper;
  tool and field descriptions updated to document the contract. 5 regression tests added.
- **Migration:** any caller that relied on an un-statused post going live must now pass
  `status:"published"` explicitly. No other tool is affected.

## 3.34.10 — `get_free_slots` accepts ISO dates; `update_form` name fallback for fresh forms

Two small fixes that close rough edges found while live-verifying v3.34.9 through
the MCP tools. Neither changes a proven capability; both have trivial workarounds.

- **`get_free_slots` now accepts ISO dates (its documented input) — not just epoch
  milliseconds.** GHL's `GET /calendars/{id}/free-slots` requires `startDate` /
  `endDate` as epoch **milliseconds** and 422s (`startDate must be a number…`) on an
  ISO date, yet the tool's own schema documented `YYYY-MM-DD` and forwarded it
  unconverted — so the documented input always failed. The tool now accepts a bare
  `YYYY-MM-DD` (taken as start-of-day for `startDate`, end-of-day for `endDate`, in
  the supplied `timezone`, default UTC), a full ISO datetime (`Date.parse`), or an
  already-numeric epoch-millis value (passed through, back-compat) and converts to
  the epoch ms GHL needs. Logic extracted to `buildFreeSlotsParams` / `toEpochMillis`
  with tests (incl. the live Phoenix anchor `2026-06-15 → 1781506800000`).
- **`update_form` no longer throws on the natural `create_form` → `update_form`
  sequence.** When `name` is omitted, `update_form` reads the current name to
  preserve it. A form freshly created by `create_form` has no `name` in its builder
  doc until its first save, so that read returned none and the call threw
  (`Could not resolve current form name`). It now falls back to the public forms list
  (which carries the name) and only errors — with actionable guidance to pass `name`
  — if both sources come up empty. The form-builder tools now also receive the public
  `GHLClient` for this lookup (it already had Firebase auth for the builder routes).
  Helpers `pickFormName` / `findFormNameInList` extracted, with tests.

## 3.34.9 — Calendar availability: create_calendar / update_calendar now set `openHours`

Closes the on-camera "assign availability hours" gap. `create_calendar` and
`update_calendar` previously exposed no availability parameter, so every calendar
was created with `openHours: {}` and a caller could not set business hours through
the MCP — the calendar only offered GHL's default window.

- **New `openHours` (and `availabilityType`) params on `create_calendar` and
  `update_calendar`.** `openHours` is an array of `{daysOfTheWeek:[0-6], hours:
  [{openHour,openMinute,closeHour,closeMinute}]}` (0=Sun … 6=Sat, 24-hour clock).
  When `openHours` is supplied the builder also sends `availabilityType: 0`
  (standard weekly hours) — the exact payload GHL honors — unless the caller
  overrides it.
- **Multi-day blocks are auto-expanded to one block per day.** GHL's CREATE
  endpoint 422s (`openHours.0.must be a valid day of week`) on a block listing more
  than one weekday. Single-day-per-block is GHL's own canonical form (what it reads
  back) and is accepted by both create and update. The builder accepts the natural
  compact form (`daysOfTheWeek:[1,2,3,4,5]`) and expands it to per-day blocks. Logic
  extracted to `buildCreateCalendarBody` / `buildUpdateCalendarBody` /
  `expandOpenHours`, with tests.

**Corrects the 3.34.8 note.** The earlier guess — that round-robin availability
comes only from the user's working hours and not a calendar `openHours` field — was
wrong. Pinned live on a real sub-account, the full model is:

- `openHours` **does** drive availability. Setting it changes `/free-slots`
  immediately (a Wed-only 13:00–15:00 window collapsed slots to Wednesdays
  13:00–14:30; a Mon–Fri 9–6 window produced exactly 09:00–17:30 each weekday).
- On **event / simple** calendars `openHours` is the sole source — every configured
  day/time becomes bookable (a Sat 10–1 window booked Saturdays).
- On **round_robin** "OptimizeForAvailability" calendars the bookable slots are the
  **intersection** of `openHours` and each assigned user's working hours, so a day
  only opens if a team member is also available then (the same Sat 10–1 window
  produced zero slots when the lone user had no Saturday availability). To open a day
  on round_robin, set the user's availability too (UI-gated), or use an event-type
  calendar where `openHours` alone controls. The tool descriptions state this.

Write-verified live: created calendars with hours through the builders, read the
hours back, and confirmed `/free-slots` returned exactly the configured days/times;
all throwaways deleted. Suite 289 pass / 1 skip.

## 3.34.8 — create_opportunity + get_funnel_pages no longer 422 on omitted required params

Two real defects found during a full end-to-end build test on a live sub-account
(every advertised object created, read back, and deleted). Both are cases where GHL
requires a parameter the tool treated as optional, so the call 422'd unless the
caller happened to pass it.

- **`create_opportunity` now defaults `status` to `"open"`.** GHL's
  `POST /opportunities/` rejects a create with no status (`422 "status should not
  be empty"`). The tool exposed `status` as optional and omitted it from the body,
  so a bare create always failed. It now sends `status: "open"` when the caller
  doesn't specify one. Logic extracted to `buildCreateOpportunityBody` with tests.
- **`get_funnel_pages` now defaults `limit`/`offset`.** GHL's `GET /funnels/page`
  requires both (`422 "limit should not be empty" / "offset should not be empty"`)
  and caps `limit` at 20. The tool only sent them when provided, so listing a
  funnel's pages failed by default. It now sends `limit: 20, offset: 0` when
  omitted. (`get_funnels` / `GET /funnels/funnel/list` does not require them and is
  unchanged.) Logic extracted to `buildFunnelPagesParams` with tests.

Verified working in the same test pass (no change needed): create_funnel,
create_funnel_page, create_form, create_calendar (incl. team-member assignment),
create_custom_field, create_location_tag, create_contact, and the full workflow
lifecycle (create → update actions → validate → publish). Suite 280 pass / 1 skip.

Known follow-up (not in this release): exposing calendar availability / `openHours`.
Investigation showed round-robin "OptimizeForAvailability" calendars derive
availability from the assigned user's working hours, not a calendar `openHours`
field (every live round-robin calendar inspected had `openHours: {}`), so this needs
a design + live write-verification pass before shipping rather than a guessed shape.

## 3.34.7 — Audit now sees UUID pipeline-stage ids (dead-stage silent failure)

- **`audit_workflows` / `validate_workflow` now catch a dead pipeline STAGE
  reference inside move/create/update-opportunity actions.** GHL pipeline **stage**
  ids are hyphenated UUIDs (e.g. `ce81a710-7b63-4324-b5dc-c170ad5b8773`), but the
  id-shape gate (`ID_SHAPE`) only matched hyphen-free alphanumeric ids. Every
  action-level stage reference (`internal_update_opportunity`,
  `internal_create_opportunity`, `create_opportunity`) was therefore silently
  dropped before validation: a workflow that moved or created an opportunity into a
  **deleted** stage passed the audit clean. This is exactly the silent-failure class
  the tool exists to catch, and it affected every account whose pipelines use UUID
  stages (the GHL norm). `validate_workflow` on such a workflow now reports both the
  pipeline and the stage reference (previously only the pipeline).
- **Fix:** `ID_SHAPE` accepts both shapes — `[A-Za-z0-9]{17,}` and the canonical
  UUID. The broadening cannot cause a false break: display names ("Jane Doe") and
  standard field names still never match, and anything that cannot be resolved is
  reported as `unverified`, never `error`. Trigger-condition stage refs were already
  un-gated and unaffected.
- Tests: 4 new regression cases (dead UUID stage in each of the three opportunity
  action shapes must flag; a valid UUID stage must produce zero errors and now be
  extracted). Full suite 273 pass / 1 skip.

## 3.34.6 — Revert v3.34.5 (internal_update_opportunity save-abort regression)

- **Reverts v3.34.5.** That release made `validateActionChain` throw on *every*
  `internal_update_opportunity` action — including ones round-tripped from
  `get_workflow_full` — unless `__customInputFields__` carried both a `pipelineId`
  and a `pipelineStageId` entry, and it removed v3.34.4's id-based pass-through for
  already-saved nodes. Because that check runs at the top of `buildActionChain`,
  one such node aborted the **entire** `update_workflow_actions` save. Re-saving any
  workflow that already contained an "Update Opportunity" / move-to-stage action
  could hard-fail. The v3.34.5 normalizer was also not shape-preserving for a real
  round-tripped node (it dropped extra per-field keys), risking corruption on
  re-save. An independent (non-Claude) audit confirmed both failure modes.
- **v3.34.4 is retained** — its merge-tag blank-render audit and the honest
  Update-Opportunity guard were audited clean. Creating an
  `internal_update_opportunity` from scratch is guarded again with the clear "build
  that one step in the GHL UI" message; a round-tripped one passes through untouched.
- Net effect: restores the proven v3.34.3 / v3.34.4 save behavior. A correct
  from-scratch builder for Update-Opportunity will be re-attempted separately, with
  live verification, before any future republish.

## 3.34.4 — Audit catches blank-render merge tags + honest guard on Update-Opportunity

- **`audit_workflows` / `validate_workflow` now flag dead `{{contact.X}}` merge tags.**
  A custom field deleted or renamed while still referenced by a `{{contact.x}}`
  merge tag in message copy (sms body, email html/subject, notes, notifications)
  renders **blank** at send time. Unlike a dead structured id — which silently
  skips the action and everything after it — this only produces an empty value, so
  it is reported as a **warning**, never an error, and never flips a workflow to
  "broken". Validated by custom-field key (`fieldKey`), with a generous standard-
  field allowlist and nested-path skipping (`{{contact.attributionSource.x}}`) so
  it never cries wolf on `{{contact.first_name}}` and friends; goes `unverified`
  (not warned) if the custom-field list fails to load. Surfaced in a new
  `merge_field_warnings` section + `warnings_count`. (10 regression tests; verified
  against real GHL `fieldKey` shapes.)
- **`update_workflow_actions`: honest fail-fast on `internal_update_opportunity`.**
  GHL's builder rejects a *synthesized* move/update-opportunity node with "action
  has a corrupted type" and fails the entire save with a cryptic error. The builder
  now detects a freshly-built one and throws a clear, actionable message up front
  (build that one step in the GHL UI, or round-trip an existing one via
  `get_workflow_full` — those keep their node id and pass through untouched). No
  more one-bad-node-nukes-the-whole-save surprise. Tool + schema docs updated to
  state the limitation plainly.

## 3.34.3 — Audit catches dead workflow hand-offs + get_social_posts 422 fix

- **`audit_workflows` / `validate_workflow` now scan `add_to_workflow` targets.**
  Only `remove_from_workflow` was scanned before, so a step that hands a contact
  off to a workflow that was later deleted (the classic cleanup/snapshot mistake)
  went undetected — exactly the silent-skip the audit exists to catch. GHL drops
  the hand-off and every action after it with no error. Now flagged like any
  other dead reference; deduped so the same id in `workflowId` and
  `workflow_id[]` reports once. Still never false-alarms: valid targets pass, and
  a self-reference is a warning, not an error. (4 regression tests added.)
- **`get_social_posts`:** fixed the GHL 422 ("skip/limit must be a number
  string"; "status should not exist"). `skip`/`limit` are now sent as strings and
  `status` is no longer placed in the request body.

Known gap (queued, deferred to avoid false alarms): a custom field deleted or
renamed but still referenced only through a `{{contact.x}}` merge tag in message
copy is not yet flagged. That renders blank rather than killing the action, and
catching it safely needs a key-aware, standard-field-allowlisted pass so the
audit keeps its never-false-alarm guarantee.

## 3.34.2 — Docs: launch pricing + the task-notification trap

- README pricing updated for launch: $97/mo at ghlcommand.com, unlimited
  sub-accounts on 3 machines, first-month-back guarantee.
- Workflow docs corrected: the task action type is `task-notification`
  (hyphen). The underscore form saves and validates but is silently
  skipped at runtime — live-verified. Validator enforcement ships next.

## 3.34.1 — CLI list-locations shows companyId

`ghl-mcp cli list-locations` now includes each location's `companyId` (same as
the `list_registered_locations` tool) — the quick way to tell whether your
locations live under one agency company or several. Different companyIds =
separate agency accounts, each needing its own `firebaseByCompany` entry for
the workflow builder.

## 3.34.0 — Headless/server deployment hardening

Built for production server installs (Linux droplets, containers, MCP
gateways). License scope, stated plainly: **one license covers every
GoHighLevel sub-account you manage — unlimited, across multiple agency
companies — on up to 3 of your own machines. Never metered per account.**
Nothing in the code ever capped registered locations; the docs now say so.

- **Headless seed CLI** behind an explicit sentinel (`ghl-mcp cli …`), so
  gateway-supplied argv can never trigger a subcommand:
  `register-location`, `register-company-firebase`, `register-agency-key`,
  `list-locations`. Live validation identical to the interactive tools
  (`--no-validate` for air-gapped builds), strict arg parsing, deterministic
  exit codes (0/2/3/4), config-dir writability preflight, never prints keys.
- **`.ghl-tokens.json` schema documented** with a pre-populatable example —
  the registry is read at boot, so a hand-seeded file just works. Full guide
  at `docs/HEADLESS.md` (+ new README "Server / Headless Deployment" section).
- **Firebase scope documented:** credentials are per-COMPANY, not
  per-sub-account — one set covers every sub-account under a company; each
  company's token rotates and persists independently.
- **`GHL_MCP_CONFIG_DIR`** — explicit absolute-path config-dir override so all
  state (including auto-rotated Firebase tokens) lives on a persistent
  mounted volume. Relative paths fail fast at boot.
- **Node 20 supported** (`engines: >=20`, esbuild target lowered to node20).
  Verified on real Node 20: full test suite, server boot, CLI, and Ed25519
  attestation verification (one harmless ExperimentalWarning). The publish
  smoke test now runs on a Node 20/22/24 matrix.
- **Graceful shutdown** on SIGTERM/SIGINT (bounded 3s) for supervised
  deployments.
- **`GHL_MCP_DISABLE_UPDATE_CHECK=1`** disables the startup npm version ping
  (air-gapped/firewalled servers).
- Transport remains stdio by design (gateways supervise it as a child
  process); an optional Streamable-HTTP mode is on the roadmap for the
  server-deployment track.

## 3.33.0 — Account-wide silent-failure audit + trust hardening

**`audit_workflows`** — scans EVERY workflow in the current location for
references to pipelines, stages, custom fields, users, workflows, forms,
calendars, and surveys that don't exist — the GHL bug where one bad ID silently
kills that action and every action after it. Returns a prioritized report:
what's broken, what couldn't be scanned, what couldn't be fully verified.
Conservative by construction: it never reports a false break (uncertain checks
are `unverified`, not `error`). The same engine upgrade sharpens
`validate_workflow`: four opportunity action shapes (including the dominant
UI-native `internal_create_opportunity`), if/else condition-node custom-field
checks, `create_update_contact` field refs, hyphenated `task-notification`
nodes, and custom-field trigger conditions are now covered. 18 new unit tests;
verified live against 35 real workflows with zero false alarms.

**`register_agency_key`** — new tool to store the agency-level (company-scoped)
API key, with live validation before saving. Previously the snapshot tools
required an agency key that no tool could register.

**Trust + reliability hardening** (from the 2026-06-10 audit):

- List tools now attach `_pagination` (returned / limit / total / complete +
  an explicit INCOMPLETE note) so a single page can never silently read as the
  full dataset: `search_contacts`, `search_opportunities`,
  `search_conversations`, `list_invoices`, `list_workflows_full`.
- Version is read from package.json at runtime instead of baked at build time —
  a stale local build can no longer misreport its version.
- `health_check` now reports a corrupted token registry as a FAIL with recovery
  steps (previously only visible on stderr, i.e. invisible in the Desktop App).
- Clearer guidance errors: missing locationId now points at `switch_location` /
  `list_registered_locations`; missing agency key points at
  `register_agency_key`.
- Firebase token-refresh URL now percent-encodes the API key (consistency).
- New regression tests pin that error messages never contain API keys or
  Firebase refresh tokens.

## 3.32.0 — Account-health summary + phone reads

Three read tools. The composite is the second roadmap build and the first
"how's the account doing" answer (GHL has no public reporting API, confirmed by
probe — so this composes existing reads).

- **`get_account_health_summary`** — one call returns, for a location: total
  contacts + NEW contacts in a window (default 30d), total opportunities + counts
  by status (open/won/lost/abandoned), total conversations, and phone-number
  count.
- **`list_phone_numbers`** — provisioned LC Phone numbers (sid, number, label).
- **`list_number_pools`** — configured number pools.

**Honesty by construction.** Every metric is explicitly labeled `scope`
(`all_time` vs `window`, with start/end on windowed ones) so an all-time number
can never be read as a recent one. Any metric that can't be read returns
`{status:"unavailable", reason}` — never a misleading `0`. Each sub-read is
isolated, so one failure degrades only its own section, not the whole summary.

**Scope (verified against the live API):** windowed *new contacts* use the
`/contacts/search` `dateAdded` range filter; opportunity status counts use
filtered `meta.total`. Conversations are all-time only (the API's
`startAfterDate` is a cursor, not a count filter). Revenue (transactions are
403 for sub-account tokens) and appointments (no location-wide events endpoint)
are intentionally excluded.

## 3.31.0 — Snapshots: list + share-link (agency tooling)

Two new agency-level tools, the first build toward removing manual steps from the
client-provisioning runbook (snapshot selection + handoff for new sub-accounts).

- **`list_snapshots`** — list the agency's snapshots (`id`, `name`, `type`) so you
  can pick the right one by name. Read-only.
- **`create_snapshot_share_link`** — mint a load/share link for a snapshot
  (`gohighlevel.com/?share=…`) to import it into a sub-account. Requires an explicit
  `share_type` (`link`, `permanent_link`, `agency_link`, `location_link`,
  `marketplace_link`).

Both use the agency/company-scoped key (`getAgencyKey()`), not a sub-account PIT —
snapshots are an agency resource. The tools resolve `companyId` with strict rules
(explicit param > active location's company; mismatch is rejected, not guessed) so
a multi-tenant install can't list the wrong agency's snapshots, and they fail with
an actionable message when no agency key is registered.

**Notes / limits (verified against the live API):**
- Creating a sub-account from a snapshot is NOT exposed: `POST /locations/` returns
  401 for PIT auth. Snapshot *apply* stays a guided GHL-UI step.
- `create_snapshot_share_link` is not idempotent (each call mints a new link, no
  revoke API — remove in the UI). The HTTP client gained an internal `noRetry`
  option so a lost-response retry can't silently create a duplicate.

## 3.30.0 — Workflow trigger stays active after edit/publish (Bug 7)

Editing a trigger on a published workflow via `update_workflow_actions` silently
disabled it (`active` flipped to `false`), and there was no MCP path to turn it
back on. Any contract/tag/form trigger you edited stopped firing.

**Root cause.** The internal trigger-write helper hardcoded `status: "draft"` on
every trigger POST/PUT. GHL derives a trigger's stored `active` flag from that
write-time `status`. Verified live against the sandbox:

- `status:"published"` → `active:true`
- `status:"draft"` → `active:false` (and so do `"active"`, `"live"`, and
  omitting `status` entirely)

So every trigger edit re-drafted the trigger, and nothing flipped it back —
`publish_workflow` didn't either, because it sent no triggers at all.

**Fix.** Trigger writes now mirror the workflow's own published/draft state:

- `update_workflow_actions` on a published workflow writes its triggers as
  `published`, so editing a trigger condition keeps it live.
- `publish_workflow` now re-syncs the workflow's triggers, so a draft →
  published transition (including the documented create → add-trigger →
  publish flow) activates them.

Action-chain linking and the workflow-setting preservation from 3.29.0 are
unchanged.

## 3.29.0 — Documents & Contracts API fix + workflow/contact bug fixes

Five confirmed bugs fixed, verified live against the GHL API.

**Documents & Contracts (the big one).** The document tools were hitting a
non-existent `/documents/` path and returning 404. GHL's real Documents &
Contracts API lives under `/proposals/*`:

- `list_documents` now calls `GET /proposals/document` with real filters:
  `status` (draft/sent/viewed/completed/declined), `paymentStatus`, `query`,
  `dateFrom`/`dateTo`, and pagination (`limit` capped at GHL's max of 21, `skip`).
- `get_document` resolves a document by id by scanning the list (GHL exposes no
  public get-by-id route).
- `send_document` now calls `POST /proposals/document/send` to dispatch an
  existing document to its recipients.
- `delete_document` reports honestly that GHL's public API has no delete/void
  route (do it in the UI) instead of failing on a dead path.
- **New:** `list_document_templates` (`GET /proposals/templates`) lists your
  reusable contract templates.
- **New:** `send_document_template` (`POST /proposals/templates/send`) creates
  and sends a contract to a contact from a template.

**Workflow triggers.** `get_workflow_full` and `update_workflow_actions` no
longer crash on the Documents & Contracts trigger (`proposal_estimate_update`,
`masterType: "internal"`). The trigger union now accepts any `masterType` so
reads never throw, and `proposal_estimate_update` has typed support.

**Workflow settings preserved.** `update_workflow_actions` previously reset
`allowMultiple`, `stopOnResponse`, `autoMarkAsRead`,
`removeContactFromLastStep`, and `allowMultipleOpportunity` to defaults on every
save. It now preserves the workflow's current values, and exposes all five as
optional parameters.

**Contact search.** `search_contacts` now advances pages correctly (sends both
`startAfter` and `startAfterId` cursors), and uses the correct sort parameters:
`order` (asc/desc) instead of the rejected `sortOrder`, with `sortBy` limited to
`date_added`/`date_updated`.

## 3.28.0 — Tool allowlist (issue #1): cut context cost on big installs

Two new optional env vars let you gate which tools register at startup so
unused schemas never load into context. Closes [issue #1](https://github.com/drjerryrelth/ghl-command-feedback/issues/1).

**New env vars (both optional, both default-empty):**

- `GHL_ENABLED_MODULES` — comma-separated module names to register
  (e.g. `contacts,conversations,locations,custom-objects`).
- `GHL_ENABLED_TOOLS` — comma-separated tool names to register
  (e.g. `search_contacts,get_contact,update_custom_value`).

**Filter precedence:**

- Neither set → every tool registers (backward compatible).
- Modules only → tools in those modules register.
- Tools only → those exact tool names register.
- Both set → UNION (a tool registers if its module is enabled OR its
  name is explicitly listed).

**Always-on tools** never get filtered out, so setup and recovery still
work even with a heavily-restricted allowlist:
`setup_ghl_mcp`, `request_license`, `get_mcp_version`,
`auto_capture_firebase_script`, `enable_workflow_builder`, `health_check`.

**Validation + logging:**

- Whitespace and commas both work as separators. Matching is case-insensitive.
- Unrecognized names log a one-line WARNING to stderr and are ignored
  (startup never aborts).
- When the allowlist is active, startup logs one summary:
  `Tool allowlist active: registered N of M tools (modules=[...]; explicit-tools=[...])`.
- When unset, zero extra noise — existing logs are unchanged.

**Motivation (from the issue):** running 25+ registered locations with
all 212 tools registered burned tokens on every message in chats that
didn't need GHL. Now buyers can restrict the surface to the modules
they actually use.

## 3.27.2 — Fix license metadata: MIT → Proprietary

Metadata-only release. Corrects the npm package's stated license, which
was wrongly set to MIT and contradicted the paid/proprietary nature of
the product.

- `package.json` `license` field: `"MIT"` → `"SEE LICENSE IN LICENSE"`
- `LICENSE` file replaced with the actual proprietary commercial license
  (Copyright Elite DCs, LLC; usage requires a paid license from
  elitedcs.com/ghl-mcp-server; no redistribution, no reverse engineering,
  no license-gate circumvention).

No code changes. Same 212-tool surface as 3.27.1. The runtime license-key
validation flow is unchanged.

## 3.27.1 — README: real buyer testimonials + 30-day guarantee

Documentation-only release. Surfaces four real, verbatim buyer replies on the npm package README:

- Andres G. (paying customer): "I've already set it up, I'm using it and it works perfectly!"
- Garret W. (paying customer, running it headless on a Linux droplet): "Wow! That makes me VERY happy..."
- Ryan T. (paying customer): "That worked. Thank you." (bug reported Monday, hotfix shipped Tuesday)
- Frankie B.: "Thank you, Jerry. You're a star."

Also surfaces the 30-day time-back guarantee terms directly in the install header. No code or schema changes; same tool surface as 3.27.0.

## 3.27.0 — Multi-tenant Firebase binding fix

**Fixes the bug that blocked workflow-builder tools in client sub-accounts even after a successful `register_company_firebase`.**

**Root cause.** GHL uses two different company identifiers. The ID shown in the agency dashboard URL is NOT the `companyId` that `/locations/{id}` returns or that the Firebase token carries in its `company_id` claim. `register_company_firebase` stored the credentials under the human-typed (agency-URL) ID, but `switch_location` resolves a sub-account's owner from `/locations/{id}.companyId` — the internal ID. The two never matched, so the lookup missed, the workflow builder fell back to home auth, and every Firebase-gated call 401'd. The warning even told the user to run `register_company_firebase` again — which they already had.

**Fixes:**

- **Store under the token's real company.** `register_company_firebase` now decodes the Firebase ID token's `company_id` claim and keys the registry entry on THAT value, not the typed one. Pass any company ID you have; it self-corrects. A note tells you when the stored ID differs from what you entered.
- **Verification now exercises the real path.** The optional end-to-end test resolves the test location's company the same way `switch_location` does, confirms the registry lookup actually hits, then makes a live workflow-builder call. The old probe used an inline client that bypassed the lookup, so it passed even when the stored key would never be found.
- **PIT/Firebase mismatch detection.** Every time the client mints an ID token, it compares the token's `company_id` claim against the company we intend to act on. A mismatch (the exact signature of this bug, or any future regression) writes a loud one-time stderr warning instead of failing silently. Direct Firebase-gated tool calls bypass `switch_location`'s routing, so this lives at the token-mint layer.
- **Honest `health_check`.** Firebase auth now reports the company the token ACTUALLY authenticates as, and returns `fail` (not a bare `pass`) when that disagrees with the active company.
- **Registry key normalization.** Company IDs are trimmed on store/lookup so a stray pasted space can't fork one company into two entries.
- **Misleading warning fixed.** When a sub-account's company has no matching key, `switch_location` now distinguishes "nothing registered" from "registered under a different ID" and names the mismatch, instead of blindly telling the user to re-register.
- **Updated `register_company_firebase` description + DevTools doc** so we stop telling users to copy the company ID from the agency URL.

**Known behavior (by design):** after an MCP restart you must `switch_location` once into a client account before its workflow-builder tools work — the active company resets to home on launch. `health_check` and the mismatch warning now make this obvious.

**Tests:** new `workflow-builder-mismatch.test.ts` covers claim decoding, registry key normalization, mismatch warn/no-warn, and warn-once dedup. Full suite green (163 passing).

## 3.26.0 — Codex adversarial audit fixes

**Tool count unchanged (213). No new features — closes a set of silent bugs Codex flagged in a third-party adversarial review of the v3.21.0–v3.25.0 release arc.**

Now that paid customers run this code, we ran the same Codex-rescue review pattern we use on the PN tracker app. Codex turned up 10 issues; v3.26.0 ships the ones with concrete fixes. Deferred items (server-outage recovery, rate-limiting at the edge) are documented in the project memory.

**Security tightening:**

- **Cancelled-subscription detection.** Before: a cancelled license kept running for up to 44 days (30-day attestation TTL + 14-day grace) because the MCP never reacted to a server "cancelled" response — it just kept using the cached attestation until expiry. Now: `validateLicense` distinguishes `cancelled` / `unauthorized` / `unreachable`, and on `cancelled`/`unauthorized` the cached attestation is wiped from `credentials.json` so the next restart drops to bootstrap. The buyer's current session keeps running (we don't kill tools mid-call); the gate latches on next launch.
- **Background renewal on every startup.** Previously only triggered when the attestation was nearing expiry. Now every startup fires a fire-and-forget renewal so cancellations propagate within one restart instead of up-to-44-days.
- **Fail-closed when the server stops issuing signed attestations.** Before: if `ATTESTATION_PRIVATE_KEY` ever dropped off Cloudflare (env reset, accidental rotation, partial deploy), validate-license would still return `valid:true` without a `signed_attestation` and the MCP would silently fall back to legacy trust — re-opening the credentials.json bypass v3.24.0 closed. Now: after the `TRANSITIONAL_ATTESTATION_DEADLINE` (2026-06-15), an unsigned `valid:true` response is treated as bootstrap; before the deadline we still accept it for migration but log a warning with the cutoff date.

**Correctness:**

- **Atomic `credentials.json` writes.** Replaced the bare `writeFileSync` with write-to-temp-then-rename plus process-unique temp suffixes. Mid-write crashes can no longer corrupt the file, and concurrent writers (e.g. `enable_workflow_builder` racing a Firebase token rotation) can't leave it half-formed. New tests in `credentials-store.test.ts` cover both the happy path and a 20-way concurrent-write fan-out.
- **`update_calendar` schema is now `.strict()`.** Unknown fields on `teamMembers` or `locationConfigurations` (e.g. `userid` instead of `userId`, or a new GHL field we haven't captured) now error at the MCP boundary with a useful 422 instead of being silently dropped before the GHL PUT. Buyers stop wondering why a field they passed didn't save.

**Observability:**

- **`pipeline_skip` telemetry event.** When `validate-license` can't advance a buyer's pipeline opportunity (no opportunity exists, or all opportunities are closed), we now emit a distinct `[telemetry] pipeline_skip` line with the reason. Lets us surface "buyer's opportunity was accidentally closed" cases for manual reopen instead of silently failing forever.
- **Telemetry key-name redaction.** The `recordEvent` helper now scrubs any `extra` key whose name matches `/email|license|key|token|secret|refresh|password|pit/i` before logging. Belt-and-suspenders against a future caller accidentally passing a secret as a metric tag.

**UX:**

- **`enable_workflow_builder` restart messaging.** Now explicit that workflow-builder tools called BEFORE the restart will keep using the old Firebase auth and 401 — even though the tool reported success. Removes the "I ran the tool, why doesn't it work" confusion.
- **Welcome email "fifth field" not "sixth."** Off-by-one in the advanced-shortcut paragraph corrected.
- **Firebase capture script handles multiple GHL logins.** Previously the script returned the first matching `firebase:authUser:` row, which could be the wrong account if the buyer had multiple GHL logins in the same browser. Now it enumerates ALL candidates, prints each with email + uid + last-login timestamp, picks the first one (still freshest by Firebase's storage order), and tells the buyer how to re-run against a different account.

## 3.25.0 — One-paste Firebase capture

**New bootstrap-and-normal-mode tool. Tool count goes from 212 to 213 (the new tool counts whether you're set up or not).**

The worst-pain step of GHL Command setup has been the three separate IndexedDB lookups buyers had to do for Firebase credentials. v3.0.x tried bookmarklets; per memory that failed because buyers couldn't drag them onto their bookmarks bar. v3.25.0 ships the working version: a tool that hands the buyer a 25-line Chrome console script. One copy, one paste in DevTools, one Enter — the script extracts all three Firebase fields from IndexedDB and copies the result to the clipboard as a JSON object. The buyer pastes the JSON back into `setup_ghl_mcp` (or `enable_workflow_builder`) via a new `firebase_paste` parameter. Three separate captures → one paste.

**New tool: `auto_capture_firebase_script`**. Available in bootstrap mode AND normal mode (the latter so existing buyers can re-grab a fresh token when their refresh rotates). Takes no arguments; returns the script plus a numbered walkthrough.

**Updated tools:**

- `setup_ghl_mcp` accepts a new optional `firebase_paste` parameter. When provided, it parses the three Firebase fields out of the JSON and treats them as if you'd filled `ghl_user_id`, `ghl_firebase_api_key`, `ghl_firebase_refresh_token` manually. The three separate fields still work (manual fallback for locked-down browsers).
- `enable_workflow_builder` accepts the same `firebase_paste` parameter, again with the three separate fields as fallback.

**Parser is forgiving.** Strips markdown code fences (chat-copy artifact), normalizes curly quotes (email-client artifact), trims whitespace, and demands the API key actually start with `AIza` (catches misdirected pastes — e.g. pasting the PIT key here by mistake).

**Website + email also updated.** `elitedcs.com/ghl-mcp-firebase` now leads with a copy-button that grabs the script, falls back to manual capture in a collapsed details element. The Stripe-webhook welcome email points buyers at `auto_capture_firebase_script` instead of the old four-step DevTools dive.

Locked in by 19 new tests covering parse paths (clean JSON, fence-wrapped, curly-quoted, whitespace, malformed, missing fields, wrong API-key prefix) plus the script contract (self-contained IIFE, IndexedDB + clipboard usage, expected field names, not-logged-in branch).

## 3.24.0 — Signed-attestation gate closes the credentials.json bypass

**Security fix. Tool count unchanged (212 across 43 modules).**

Through v3.23.0 the license gate trusted any `credentials.json` whose `verified_at` and `license_key` fields were non-empty strings. A technical user could hand-write the file with their own GHL API key and load all 163 core tools without paying. v3.24.0 replaces that trust with an Ed25519-signed attestation issued by `elitedcs.com/api/validate-license`. The MCP bundles only the public key, so it can verify but can't forge — hand-crafted credentials now fail on the next startup and the MCP falls into bootstrap mode.

**How it works.** Every successful online license validation now returns a `signed_attestation` token binding `{email, license_key, device_fingerprint, installs_max, expires_at}` together. The MCP stores this in `credentials.json` and verifies it on startup:

- **Valid + fresh** → boot normally.
- **Valid + nearing expiry (<7d)** → boot normally, refresh in the background.
- **Expired but within 14-day grace window** → boot normally for this session, attempt re-renew (covers transient license-server outages).
- **Past grace OR bad signature OR wrong device OR wrong email/license** → force online re-validate. If the server confirms the buyer, mint a new attestation. If not, drop into bootstrap mode and require `setup_ghl_mcp`.

**Migration is automatic.** Existing v3.20.0–v3.23.0 buyers have a `credentials.json` without `signed_attestation`. On first startup under v3.24.0 the MCP detects the missing field, calls `validate-license`, and writes the new signed token. Buyers see a one-time `[ghl-mcp] License gate: renewed-from-legacy` log line and nothing else changes. The transitional path also tolerates a license server that hasn't been upgraded yet — `setup_ghl_mcp` still works against an older server, the attestation just gets backfilled on the next restart after the server deploys.

**Funnel telemetry shipped on the server side (no MCP impact).** `validate-license`, `capture-lead`, and `stripe-webhook` now emit `[telemetry] {event, success, reason, email_hash, ...}` log lines that Cloudflare captures. Lets us measure the npm-install → setup → purchase funnel without storing PII.

**Pipeline automation shipped on the server side (no MCP impact).** A successful `validate-license` call now advances the buyer's GHL Command opportunity:

- Purchased → Onboarding on first install (installs_used 0 → 1).
- Onboarding → Active on second+ install or any reinstall.

Existing buyers backfilled manually based on their current `installs_used` count.

## 3.23.0 — `update_calendar` can assign team members again (Henry fix, part 2)

**Critical fix. Tool count unchanged (212 across 43 modules). The `teamMembers` parameter is back on `update_calendar` — and actually persists this time — with a typed schema that surfaces GHL's strict validation up front.**

Reported by Henry Boulton, 2026-05-26.

**Root cause.** Same UI-vs-API pattern we hit in v3.22.0 (forms) and v3.15.0 (email templates). v3.21.0's diagnosis ("GHL silently drops `teamMembers` on PUT, no public-API path exists") was based on testing against an Event calendar — that calendar TYPE doesn't have team members, so GHL accepts the call and ignores the field. On round-robin and class-booking calendars the field is meaningful, and PUT validates it strictly: `priority` must be exactly `0`, `0.5`, or `1` (anything else 422s), `locationConfigurations` needs at least one entry, and `userId` / `selected` / `isPrimary` must be present. Henry's original attempt likely either targeted the wrong calendar type or passed a non-enum priority.

**Fix.** `teamMembers` is back on `update_calendar`, typed via an exported `CalendarTeamMemberSchema` that mirrors GHL's validation:

- `priority` constrained to `z.union([z.literal(0), z.literal(0.5), z.literal(1)])` — invalid values fail at the MCP layer with a clear error instead of an opaque GHL 422.
- `locationConfigurations` requires `min(1)` and a non-negative integer `position`.
- Tool description spells out that team-member assignment only applies to calendar types that support it (round_robin, class_booking); on Event calendars the field is silently ignored by GHL — that's the type, not us.

End-to-end verification 2026-05-26 against MCP Testing: create round_robin → assign user → update via PUT (priority 0.5 → 1, location string change) → GET back the new shape → cleanup.

Five new tests in `src/tools/tool-payloads.test.ts` lock the schema in.

## 3.22.0 — `update_form` works again (Henry fix)

**Critical fix. Tool count unchanged (212 across 43 modules). The `update_form` tool, which v3.21.0 marked "currently unavailable," is back — wired to the live save endpoint and verified end-to-end against MCP Testing.**

Reported by Henry Boulton, 2026-05-26.

**Root cause.** v3.21.0's diagnosis was half right and half wrong. The half that was right: the v2 form builder UI does run in a cross-origin iframe (`leadgen-apps-form-survey-builder.leadconnectorhq.com`) and the UI does read form state through Firestore — top-frame network capture only sees a Firestore `Listen` channel, never a REST save. That's exactly what made the route look gone. The half that was wrong: we concluded the REST save route therefore *didn't exist* and the tool needed a Firestore-client integration. It does exist. The new UI just doesn't drive it. Same UI-vs-API divergence we saw with email templates in v3.15.0 ([reference_email_template_firestore](https://github.com/drjerryrelth/ghl-command-mcp/blob/main/CHANGELOG.md#3150)) — the REST routes survive even when the editor moves to Firestore.

**Fix.** `update_form` now calls `POST /forms/{formId}?locationId={loc}` with body exactly `{name, formData}`. Verified shape constraints (server rejects everything else with 422 "property X should not exist"):

- `locationId` belongs in the query string only — putting it in the body fails.
- `name` and `formData` are both required together — one without the other 422s.
- `_id`, `deleted`, `productType`, `dateAdded`, `dateUpdated`, `source`, `updatedBy`, `version`, `updatedAt`, `versionHistory` must not appear in the body — `get_form_full` → pass through fails, so callers should pass only the `formData` they want to write.

If `name` is omitted, the tool pre-fetches the current form (one extra GET) so the existing name is preserved. Body and path are factored into pure `buildUpdateFormBody` / `buildUpdateFormPath` helpers and locked in by `src/tools/tool-payloads.test.ts` (3 new tests) — a refactor can't silently regress the tool back to v3.21.0's "unavailable" state.

End-to-end verification 2026-05-26: read form (version 5) → mutate first field's `placeholder` → save (201) → re-read (placeholder persisted) → restore. MCP Testing sandbox left clean.

## 3.21.0 — Workflow Builder now loads for npm-install buyers (Henry + Ryan fix)

**Critical fix. Tool count unchanged (212 across 43 modules), but for `npx`-install buyers the 49 Firebase-gated tools (workflow builder, funnel builder, form builder, pipeline builder, smart lists, reputation, email campaigns, memberships, email-builder internal, plus the pre-deploy validator) now actually register after `enable_workflow_builder` — previously they stayed "Not configured" no matter how many times you restarted.**

Reported independently by Henry Boulton and Ryan Thomas, 2026-05-26.

**Root cause.** Index startup folded `GHL_USER_ID` + the Firebase trio from `credentials.json` into `process.env`, but never folded `GHL_API_KEY` or `GHL_LOCATION_ID`. `WorkflowBuilderClient.fromEnv()` reads all five directly from `process.env`, so for the recommended `npx -y @elitedcs/ghl-mcp@latest` install path — where no env vars are set anywhere — `fromEnv()` returned `null` and every Firebase-gated tool was skipped. Wrapper-script installs (e.g. `start-mcp.sh`) were unaffected because they exported all the vars manually. Henry's workaround of passing the values as `--env` flags on `claude mcp add` "fixed" it by route, which is why this looked like a credentials-file persistence bug.

**Fix.** All credentials-file values now fold into `process.env` at startup (env still wins when already set, so wrapper-script + headless paths are unchanged). Pulled the fold into a single `foldCredentialsIntoEnv()` helper with regression tests that lock in all five Firebase-gated fields.

Also in this release:

- **`create_form`** now uses the correct path. GHL silently retired `POST /forms?locationId=…` (returns 404); the working path is `POST /forms/?locationId=…` (trailing slash). Verified against MCP Testing.
- **`update_form`** is honestly marked unavailable, with the actual root cause documented for the first time: the GHL form builder runs in a cross-origin iframe (`leadgen-apps-form-survey-builder.leadconnectorhq.com`) and writes saves **directly to Firestore via the Firebase JS SDK** — `firestore.googleapis.com/.../databases/(default)` against the `highlevel-backend` project — rather than any REST endpoint. That's why every REST probe returns 404 or "Form does not exist or is deleted." Wiring this up needs a Firestore-client integration (separate from the workflow-builder's REST-over-Firebase-token pattern). Tracked for a focused future release. Workaround: `delete_form` + `create_form` (both work).
- **`update_calendar`** removed `teamMembers` from the input. GHL's public API silently drops the field on PUT (200 OK, nothing persists) across every shape and API version we tested. The tool description now tells the caller to set the assignee in the GHL UI instead of returning a fake success.

## 3.20.0 — License gate for headless / env-var installs

**Security + headless support. No tool changes (212 across 43 modules).**

Closes a gap where setting `GHL_API_KEY` + `GHL_LOCATION_ID` via environment variables loaded the full tool set **without ever validating a license**. The server now requires a verified license before exposing the full tools:

- A `credentials.json` written by `setup_ghl_mcp` carries `verified_at` (validated at setup) and is trusted as before — **no change for normal Desktop/Claude Code installs.**
- The env-var / headless path must now supply `GHL_LICENSE_EMAIL` + `GHL_LICENSE_KEY`. These are validated once at boot and the verified result is cached to `credentials.json`, so there's no per-restart phone-home and device activations aren't re-counted on a stable machine.
- Without a verified license, the server stays in bootstrap mode (only `setup_ghl_mcp`, `request_license`, `get_mcp_version`) with a clear message.

This makes headless/server installs first-class: set `GHL_LICENSE_EMAIL`, `GHL_LICENSE_KEY`, `GHL_API_KEY`, `GHL_LOCATION_ID` (plus the Firebase vars for the workflow builder) and the server boots into the full tool set.

## 3.19.1 — MCP registry prep + README UTM tracking

- Added `mcpName` (`io.github.drjerryrelth/ghl-command`) to package.json so the package can be published to the official MCP registry (many directories pull from it).
- npm README buy links now carry `?utm_source=npm&utm_medium=readme` so npm-sourced traffic to the order page is identifiable once analytics is enabled.

## 3.19.0 — npm lead capture + $97 pricing alignment

**One new bootstrap-mode tool. Normal-mode count unchanged (212 across 43 modules).**

- **`request_license`** (bootstrap mode): someone who installs from npm without a license can leave their email instead of hitting a dead end. It records them as a tracked GHL lead (`ghl-command-lead` tag → lead-nurture sequence, New Lead opportunity) and returns the purchase link. Turns anonymous npm installs into sellable pipeline.
- Bootstrap messaging now points no-license users to `request_license`.
- Pricing aligned to **$97 one-time** across the README (was inconsistently listed as $297); corrected the post-setup count to 163 core tools (212 with the optional Workflow Builder Firebase add-on).

## 3.18.0 — Social Planner: location-scoped endpoints, scheduled posts, typed media

**No new tools (still 212 across 43 modules) — fixes plus a capability add to the existing Social Planner tools.**

- Every Social Planner call (`get_social_posts`, `get_social_post`, `delete_social_post`, `get_social_media_accounts`, `create_social_post`) now hits the correct location-scoped v2 path (`/social-media-posting/{locationId}/...`). The bare paths were returning errors.
- `create_social_post` now supports **scheduled posts**: pass `scheduledAt` (sent as `scheduleDate`) with `status: "scheduled"`. `status` is a typed enum: `in_review`, `scheduled`, `draft`, `published`.
- Media items are typed — each URL is sent as `{ url, type }` with the MIME type inferred. Scheduled posts reject untyped media (and Instagram requires at least one media item).

## 3.17.1 — Onboarding messaging fixes + public feedback tracker

**No tool changes — still 212 across 43 modules.** Bug-driven fixes from a buyer support ticket (Ryan Thomas, 2026-05-25), plus a public place to file feedback.

### Firebase / Workflow Builder onboarding was steering buyers wrong

`workflow_builder_status` told users to "set env vars in start-mcp.sh or .env" to enable the 49 Firebase-gated tools. On the npm / Claude Desktop install path there is no `start-mcp.sh` or `.env`, so buyers added the Firebase values to the `claude_desktop_config.json` env block — which Claude Desktop passes unreliably — and got `health_check` → "Firebase: SKIP" after a restart. Now both `workflow_builder_status` and the `health_check` Firebase-SKIP detail steer to `enable_workflow_builder` (the supported path: it validates the values and writes them to `credentials.json`), explicitly warn off the config env-var path, and demote the raw env vars to an advanced/self-hosted note.

### Dead npm "Repository" link → public feedback repo

`package.json` `repository`/`bugs` pointed at the **private** source repo, which npm renders as a clickable link that 404s for buyers. Now both point to the new public feedback tracker — **https://github.com/drjerryrelth/ghl-command-feedback** — where users can file bug reports and feature requests via issue templates.

## 3.17.0 — Multi-tenant bootstrap, internal hardening, test coverage

**No new tools — still 212 across 43 modules. Internal hardening + a multi-tenant fix.**

### Multi-tenant: bootstrap from a client company's Firebase

A multi-tenant-only install (an agency operating purely in clients' accounts, no home Firebase) previously got **none** of the Firebase-gated tools: `WorkflowBuilderClient.fromEnv()` returned null, so every gated module skipped registration even after `register_company_firebase`. Now, when there's no home Firebase, the builder client bootstraps from the first registered company that has both Firebase creds and a registered location (`fromFirstCompany`). `switch_location` still routes to other companies afterward. Token rotation on a bootstrapped install now persists back to that company's `firebaseByCompany` slot instead of being dropped (the same class of bug Don Harris hit with home-token persistence).

### Internal hardening

- **`safeTool()` consistency.** Migrated the Firebase-gated modules (memberships, reputation, email campaigns, email-template/snippet internal tools) from hand-rolled `try/catch` + `jsonResponse` to the `safeTool()` wrapper, matching the public-API tools and the type-safety standard.
- **Extracted request-body builders** (`buildCoursePayload`, `buildCategoryPayload`, `buildLessonPayload`, `buildOfferPayload`, `buildSnippetPayload`, `buildReviewsQuery`) as pure, exported functions.
- **+16 unit tests** (101 → 117) locking in the v3.16.0 request shapes and the nested-`filterParams` reviews query, plus the new multi-tenant bootstrap + its rotation persistence.
- **Doc count drift:** fixed remaining stale "8 / 30 / 168" tool counts in `setup_ghl_mcp`, `health_check`, and the README tool table (now consistently 163 base / 49 Firebase-gated / 212 total).

## 3.16.1 — Docs: correct enable_workflow_builder tool counts

**No tool changes. Still 212 tools across 43 modules (163 without Firebase, 49 Firebase-gated).**

The `enable_workflow_builder` tool description and its success message had drifted out of date, still quoting the pre-v3.15 numbers ("30 additional tools across 6 modules", "163 to 203", "203 total"). Corrected to the current 49 Firebase-gated tools (163 → 212) and expanded the module list to match what's actually gated (smart lists, reputation, email campaigns, email templates, and memberships, plus the pre-deploy validator, were all missing).

## 3.16.0 — Membership/course creates, SMS templates, reviews list (Firebase-gated)

**212 tools across 43 modules (+6). Build courses and SMS templates from Claude; read the reviews that come back.**

Six new write/read tools, all reverse-engineered from DevTools captures of the live GHL UI against the MCP Testing sandbox and verified end-to-end (each returns 2xx with the exact request shape these tools send). All require Firebase auth — the public bearer key 401s on each — so the no-Firebase tool count stays 163.

### New tools (6)

- **`create_course`** — create a membership course (a "product") with title + description.
- **`create_membership_category`** — add a category/module inside a course (groups lessons; supports drip days).
- **`create_membership_lesson`** — add a lesson (a "post") inside a category, with HTML body and content type.
- **`create_membership_offer`** — create the access grant that enrolls contacts into one or more courses. Free by default; supports recurring/one-time pricing.
- **`create_sms_template`** — create an SMS/text template (a "snippet") for the composer and SMS workflow actions; merge fields supported. Also creates email snippets via `type: "email"`.
- **`list_reviews`** — list the reviews a location has received (Google, Facebook, etc.) with rating, author, text, and reply status. **Fixes the long-standing "No Location Found" error:** the reviews endpoint resolves location through nested `filterParams[locationId][0][value]`, not a flat `locationId`. Responding to a review is still pending (needs a live review to capture).

Course/category/lesson/offer hit `backend.leadconnectorhq.com/membership/locations/{loc}/*`; reviews hit `backend.leadconnectorhq.com/reputation/reviews`; SMS templates hit `services.leadconnectorhq.com/snippets/{loc}`. All with the workflow-builder Firebase token.

### Still pending

Email-campaign delete/send/schedule live in a cross-origin iframe (`email-home-prod.leadconnectorhq.com`) that can't be black-box captured and whose REST paths 404 on the public host — deferred until a capture from inside that iframe.

## 3.15.0 — Email-template delete, rename, archive (Firebase-gated)

**206 tools across 43 modules (+3). Manage email templates without leaving Claude.**

Until now you could create and update email templates through the public API, but deleting or renaming one meant clicking through the GHL UI — the public bearer API 404s on both. Those mutations live on the internal API behind Firebase auth. Confirmed working 2026-05-24 against the MCP Testing sandbox and verified end-to-end (create → rename → archive → unarchive → delete, round-trip clean).

### New tools (3, all require Firebase configured)

- **`delete_email_template`** — hard delete a template by id. Irreversible. Breaks any workflow email action or draft campaign that references it.
- **`rename_email_template`** — change a template's display title only; HTML content and sender settings untouched.
- **`archive_email_template`** — archive (`archived: true`) or restore (`archived: false`) a template. Removes it from the active list without deleting it — the reversible alternative to delete.

All three hit `backend.leadconnectorhq.com/emails/builder` with the workflow-builder Firebase token (the GHL UI persists via Firestore, but the REST routes exist server-side). Registered by `registerEmailBuilderInternalTools` in the internal-API block. `update_email_template`'s description now points at `rename_email_template` / `delete_email_template` instead of telling you to use the UI.

## 3.14.0 — Multi-tenant Firebase: run the workflow builder in clients' accounts

**203 tools across 43 modules (+2). The workflow builder is no longer single-company.**

GHL Firebase refresh tokens are **company-scoped**. Until now `switch_location` swapped the per-location PIT key (public API) but never the Firebase auth, so the moment you switched into a client's GHL — even one where you're an admin user — every Firebase-gated tool (workflow builder, funnels, forms, pipelines, smart lists, reputation, email campaigns, memberships) returned 401. Those accounts were effectively read-only.

Now one install can operate the workflow builder across multiple clients' GHL accounts.

### How it works

- **Per-company Firebase in the registry.** New `firebaseByCompany` map keyed by GHL companyId, alongside the existing "home" Firebase. Each registered location now also records its owning `companyId` (auto-detected by `register_location`).
- **`switch_location` routes Firebase automatically.** It resolves the target location's company and swaps the workflow builder's Firebase auth to match — or restores your home auth when you switch back. If a company has no Firebase registered, it says so loudly instead of silently 401ing.
- **Rotation is slot-aware.** When a refresh token rotates, it's persisted to the company that's currently active — a client's token never overwrites your home token, and vice versa.

### New tools (2, both work without Firebase configured)

- **`register_company_firebase`** — store a client company's Firebase creds (companyId, name, refresh token, user id; api key defaults to your home key). Optional `test_location_id` runs a real workflow-builder call to verify the credentials actually work for that company before you rely on them.
- **`unregister_company_firebase`** — remove a company's Firebase creds.

`list_registered_locations` now shows each location's company and which companies have Firebase wired up; `health_check` reports the active company. See the README "Working in Clients' Accounts" section for the full flow.

## 3.13.1 — Firebase token persistence fixes (reported by Don Harris)

**Bug fixes. No tool changes — still 201 tools across 43 modules.**

Two related Firebase refresh-token persistence bugs that hit the `npx @elitedcs/ghl-mcp@latest` install path. Both reported by Don Harris (flasheffect@sbcglobal.net) on 2026-05-24.

### 1. Rotated refresh token was silently dropped

`WorkflowBuilderClient.persistRefreshToken` only wrote to `cwd/.env`. Under npx there is no `.env` in cwd, so the rotated token was discarded and the next restart reloaded the stale token from `credentials.json` — eventually failing Firebase auth once the old token expired.

Now persists to **`credentials.json`** (the authoritative store for npm installs) and still writes `.env` when present (legacy wrapper-script / local-dev path). Both writes are independent and non-fatal.

### 2. Token registry lived inside the npm cache

`TokenRegistry`'s default path resolved via `__dirname` into `~/.npm/_npx/<hash>/...`. Every `@latest` update produced a new hash, orphaning the previous registry (and losing it on cache cleanup).

Now defaults to `.ghl-tokens.json` in the per-user app-data dir, alongside `credentials.json`. A one-time migration moves any legacy package-root registry on first run. The registry directory is created (0700) on save if absent, and the file is hardened to 0600.

## 3.13.0 — Memberships read + 200-tool milestone (gap-closure round 6, final)

**201 tools across 43 modules. Bundle: ~318 KB.**

Closes the last Phase-2 gap. Memberships read tools — and the tool count crosses 200.

### 3 new tools (Firebase-gated, read-only)

- **`list_membership_offers`** — offers + products in one call (`{products, offers}`). Products are courses/communities; offers are the access grants.
- **`list_membership_categories`** — all course categories in a location.
- **`list_membership_lessons`** — all course lessons in a location.

Most useful for fetching the IDs that membership trigger conditions reference: `offer_access_granted`, `product_completed`, `category_completed`, `lesson_completed`, etc.

### Endpoint discovery

From the workflow bundle's `LocationMembership` class:
```js
membershipUrl = `${config.membershipURL}`  // → backend.leadconnectorhq.com/membership
endpoint = `${membershipUrl}/smart-list/offers-products/${locationId}`
```

Verified endpoints (Firebase auth):
- `GET /membership/smart-list/offers-products/{loc}`
- `GET /membership/smart-list/location/{loc}/workflow?type=category`
- `GET /membership/smart-list/location/{loc}/workflow?type=lesson`

Bearer/PIT returns 401 — Firebase-gated, same as the other internal-API tools.

### Read-only by design

The workflow bundle only READS membership data (to populate trigger/action dropdowns). Creating/editing courses, offers, and lessons lives in the Memberships app and isn't exposed here — that would need a DevTools capture against a live account with actual courses. The per-product (`?product_id=`) and per-category (`/category/{id}/lessons`) filtered endpoints returned 401 for synthetic IDs and couldn't be verified without real course data, so they're not exposed; the location-scoped "list all" endpoints return everything anyway.

### Phase-2 complete

All 6 Phase-2 gap areas from the v3.8.1 analysis have now been addressed:

| Area | Result |
|---|---|
| Email templates | list/create/update (v3.10.0) |
| SMS/snippet templates | list-only — write is scope-walled (v3.10.1) |
| Smart Lists | full CRUD (v3.11.0) |
| Reputation | review-link-list; reviews capture-pending (v3.11.1) |
| Email campaigns | list (fixed) + create-draft; send capture-pending (v3.12.0) |
| Memberships | read-only (this release) |

The recurring limiter: high-value write actions (send a campaign, list reviews, create a course, delete a template) sit behind either GHL's IAM wall or location-resolution quirks that need a DevTools capture against a live account. Read + create-draft surfaces were reachable; full write automation for those areas is the next frontier when a live-account capture session happens.

### Tool count impact

- Total: 198 → 201 (+3) — crosses 200
- Modules: 42 → 43

### Files changed

- `src/tools/memberships.ts` — NEW
- `src/tools/index.ts` — registered membership tools
- `src/setup-tool.ts` — count 198 → 201

## 3.12.0 — Email campaigns: create-draft + list fix (gap-closure round 5)

**198 tools across 42 modules. Bundle: ~316 KB.**

### Bug fix: `get_email_campaigns` was broken

The existing tool hit `/emails/` which returns 404 — it had been returning errors for buyers. Repointed to `/emails/schedule` (the real email-broadcast list endpoint). Now returns campaigns with id, name, status (draft/scheduled/sent), templateId, and send config. Added an optional `limit` param. (For the older automation-campaign-builder feature, `get_campaigns` is the separate tool.)

### New tool: `create_email_campaign` (Firebase-gated)

Create an email campaign/broadcast DRAFT from an existing email template. Required: `templateId` (make one with `create_email_template`). Optional: name, subject, fromName, fromEmail, isPlainText, enableResendToUnopened, hasUtmTracking.

### The IAM wall + the workaround

`POST /emails/schedule` with a bearer (Private Integration) token returns `"This route is not yet supported by the IAM Service"` — a hard GHL wall. **Firebase auth gets past it** (same internal-API path as workflow-builder). So create_email_campaign is Firebase-gated.

### Honest scope: draft-only

The campaign is created as `status: "draft"`. What's NOT available via any discoverable endpoint (probed bearer + Firebase, every path variant):
- **send / schedule-to-send** — finish in the GHL UI (Marketing → Emails)
- **delete** — drafts are removed via the GHL UI
- **update** a draft
- **get single** campaign

These live in the Marketing app and would need a DevTools capture against a live account. The create tool's response includes a `_note` field stating the draft-only limitation so the LLM relays it to the buyer. Despite the limits, this gets a campaign ~90% built programmatically (template, subject, sender, tracking all set) — the buyer just clicks send.

### Tool count impact

- Total: 197 → 198 (+1 new; get_email_campaigns is a fix, not a new tool)
- Modules: 41 → 42 (new email-campaigns module)
- create_email_campaign is Firebase-gated; get_email_campaigns works on public API

### Files changed

- `src/tools/emails.ts` — fixed get_email_campaigns endpoint (/emails/ → /emails/schedule)
- `src/tools/email-campaigns.ts` — NEW: create_email_campaign (Firebase)
- `src/tools/index.ts` — registered email-campaigns module
- `src/setup-tool.ts` — count 197 → 198

## 3.11.1 — Reputation: review-link list (partial gap-closure round 4)

**197 tools across 41 modules. Bundle: ~315 KB.**

Partial close on the reputation gap. One verified tool shipped; the bigger reviews surface needs a DevTools capture against a live account.

### New tool (Firebase-gated)

- **`get_review_link_list`** — lists the review-link destinations (Google, Facebook, etc.) configured for a location. Each entry has a label + the public review URL. Useful for building review-request workflows: the workflow goal condition `review_request_clicked` (shipped in v3.8.0) references these review-link ids.

Endpoint: `GET backend.leadconnectorhq.com/reputation/integrations/review-link-list?locationId=X` via Firebase auth. Verified 2026-05-22.

### What's NOT shipped + why

The core reputation surface — listing the reviews that come BACK from Google/Facebook, responding to them, review-request campaigns — sits behind `/reputation/reviews`. That endpoint returns `401 "Unauthorized Access: undefined - No Location Found"` regardless of:
- bearer (PIT) vs Firebase auth
- which location header/param is sent (`location-id`, `locationid`, `location`, query param, path segment)

The `undefined` in the error means the endpoint reads a location field from the request that comes back empty — the GHL reputation UI sends something specific that black-box probing can't reproduce. **This needs a DevTools capture** of the exact request the reputation UI makes, against a live account that has connected review platforms (MCP Testing has none, so there'd be nothing to verify against anyway).

Documented as capture-pending in the tool's own description so the LLM gives buyers an honest answer.

### Tool count impact

- Total: 196 → 197 (+1)
- Firebase-gated (the new tool uses internal-API auth)
- Modules: 40 → 41 (new reputation module)

### Files changed

- `src/tools/reputation.ts` — NEW
- `src/tools/index.ts` — registered `registerReputationTools`
- `src/setup-tool.ts` — Firebase-mode count 196 → 197

## 3.11.0 — Smart Lists CRUD (gap-closure round 3)

**196 tools across 40 modules. Bundle: 315.1 KB.**

Closes the Smart Lists gap with full CRUD. Smart Lists are saved searches over contacts or opportunities — agencies use them heavily for segmentation, campaign targeting, and workflow trigger filters.

### 5 new tools (Firebase-gated)

- **`list_smart_lists`** — paginated by `objectKey` (`contacts` or `opportunity`). Supports free-text name search.
- **`get_smart_list`** — single list with full filter spec, columns, permissions.
- **`create_smart_list`** — required: name + objectKey. Optional: filters, columns, pipelineIds, defaultInPipelines.
- **`update_smart_list`** — partial updates work. objectKey can't be changed after creation.
- **`delete_smart_list`** — confirm-gated.

### Discovery story

The workflow-builder bundle declares `smartListBackendURL` as config but doesn't actually use it. Real path was in the same bundle under `SmartListService extends BaseService`:

```js
new SmartListService(`${config.marketPlaceBackendURL}/lists/dynamic/`, ...)
```

That resolves to `backend.leadconnectorhq.com/lists/dynamic/{locationId}` — verified all 5 CRUD endpoints (list / get / create / update / delete) end-to-end.

### Why Firebase-gated, not public

Tried bearer-token auth on the same endpoints — returns 400 `"Cannot list smartlists"`. Smart Lists require the internal-API auth path (Firebase ID token via the `token-id` header), same gate as workflow-builder, funnel-builder, form-builder, and pipeline-builder.

### Tool count impact

- Total: 191 → 196 (+5)
- With Firebase: 191 → 196 (+5 unlocked when enable_workflow_builder runs)
- Without Firebase: 161 (unchanged — these are internal-API tools)
- Modules: 39 → 40 (new smart-lists module)

### Verified end-to-end against MCP Testing

Round-trip on `objectKey: "contacts"`: create → get → list (search) → update (rename) → delete. 5/5 success, no probe pollution left in the sub-account.

### Files changed

- `src/tools/smart-lists.ts` — NEW
- `src/tools/index.ts` — registered `registerSmartListTools` alongside other internal-API builders
- `src/setup-tool.ts` — bumped Firebase-mode tool count (191 → 196)

## 3.10.1 — `list_message_templates` (SMS / email-snippet / WhatsApp read)

**191 tools across 39 modules. Bundle: ~304.7 KB.**

Small patch closing the SMS template read gap.

### New tool

- `list_message_templates(type?, limit?, skip?, locationId?)` — list snippet-style message templates. `type` filters by `sms` / `email` / `whatsapp`; omit to list all types.

### What this is (and isn't)

GHL has **two separate template systems**:
1. **Email builders** (drag-and-drop) at `/emails/builder` — already covered by `list_email_templates` + `create_email_template` + `update_email_template` from v3.10.0
2. **Snippet templates** (simpler, multi-type) at `/locations/{locationId}/templates` — covered by this new tool

This patch closes the **read** side of system #2. The write side is a real wall: `POST /locations/{locationId}/templates` returns 401 *"The token is not authorized for this scope"* with a Private Integration token, regardless of body shape. GHL gates template creation behind a scope (`templates.write` or similar) that PITs don't have. Creating message templates either happens in the GHL UI or requires an OAuth integration. Honest scope documented in the tool description.

### Endpoint discovery story

Found in the workflow-builder JS bundle (`templateEndpoint = /locations/${locationId}/templates`). Endpoint accepts `type=sms|email|whatsapp` to filter; without `type` returns all template kinds. Same endpoint serves single-template GET at `/locations/{locationId}/templates/{id}` (`get_message_template_by_id` could be added if a use case emerges; for now, the list returns enough detail).

### Tool count impact

- Total: 190 → 191 (+1)
- Without Firebase: 160 → 161 (public API)

### Files changed

- `src/tools/emails.ts` — added `list_message_templates`

## 3.10.0 — Email Templates (gap-closure round 2)

**190 tools across 39 modules. Bundle: 304.6 KB.**

Closes another buyer-visible gap from the v3.8.1 analysis. Email templates power both standalone marketing emails and workflow email actions — agencies have been asking for programmatic management since day one.

### How GHL's API is structured
GHL calls these "builders" in the public API (the term comes from the drag-and-drop email-builder UI). One template is one builder. Each template has metadata (id, title, type, version, lastUpdated) plus HTML content stored separately in Firebase storage.

### 3 new tools
- **`list_email_templates`** — paginated list of all email templates in a location
- **`create_email_template`** — create a template shell. Required: `title`, `type` (one of `html`, `folder`, `import`, `builder`, `blank`, `ai_template`, `vibe-editor`). Response includes the new template `id`.
- **`update_email_template`** — save HTML content into an existing template. Takes `templateId`, `html`, `editorType` (one of `html`, `builder`), and optional `updatedBy` (defaults to "mcp"). Response includes the Firebase storage preview URL.

### Endpoint discovery transparency
Probed extensively. **Three endpoints work on the public API:**
- `GET /emails/builder?locationId=X` — list ✅
- `POST /emails/builder` with `{locationId, title, type}` — create ✅
- `POST /emails/builder/data` with `{locationId, templateId, html, editorType, updatedBy}` — save content ✅

**Three operations are NOT on the public API:**
- `GET /emails/builder/{id}` returns 404 — single-get doesn't exist
- `PUT /emails/builder/{id}` returns 404 — renaming after create is not exposed
- `DELETE /emails/builder/{id}` returns 404 — deletion is not exposed (also tried on the internal backend.leadconnectorhq.com host with the same auth — still 404)

These three likely live on GHL's internal API behind Firebase auth. Reaching them would need the same DevTools-capture work that unlocked workflow-builder and funnel-builder. Buyers can delete/rename via the GHL UI for now.

### Round-trip verified against MCP Testing
- 3/3 new tools registered (190 total)
- Created template → saved HTML content with merge-field syntax → listed and confirmed the template appeared with the new `templateType: html` flag and a Firebase storage preview URL.

### Tool count impact
- Total: 187 → 190 (+3)
- Without Firebase: 157 → 160 (all new tools are public API)

### Field-name quirk worth knowing
On WRITE, the title field is called `title`. On READ in the list response, it shows up as `name`. The `create_email_template` tool's parameter is `title` (matching write); the list response gives buyers `name` (matching GHL's read shape). Not something Claude needs to worry about — the tools handle the conversion implicitly.

### Files changed
- `src/tools/emails.ts` — added 3 template tools alongside the existing `get_email_campaigns`

## 3.9.0 — Products + Trigger Links CRUD (gap-closure round 1)

**187 tools across 39 modules. Bundle: 300.9 KB.**

Closes two known buyer-visible write gaps surfaced in the v3.8.1 gap analysis. Both use the public GHL API (no Firebase required) so they unlock for every install, not just buyers who've completed Firebase setup.

### Products (6 new tools)
New module `src/tools/products.ts`. Products underlie invoices, memberships, courses, and e-commerce — previously invisible to MCP write tools.

- `list_products` — paginated list with optional name+description search
- `get_product` — full details (media, variants, taxes, status)
- `create_product` — name + productType (DIGITAL / PHYSICAL / SERVICE / PHYSICAL_DIGITAL), optional description, image, statement descriptor
- `update_product` — partial updates work (fetch-then-merge: only the fields you pass change)
- `delete_product` — confirm:"DELETE" gated
- `list_product_prices` — read prices on a single product

**Honest scope note:** Price writes (POST/PUT/DELETE on `/products/{id}/price`) return 403 with sub-account Private Integration scopes. Probably need agency-level auth. Not exposed.

### Trigger Links (3 new tools, on top of existing `get_trigger_links`)
Agencies use trigger links heavily for campaign tracking. The list endpoint was already there but write tools were missing.

- `create_trigger_link` — name + redirectTo (URL or merge-field like `{{contact.website}}`)
- `update_trigger_link` — rename or change destination; tracking key + short URL stay the same
- `delete_trigger_link` — confirm:"DELETE" gated

**Verified end-to-end** against MCP Testing: created → updated → listed → deleted, both modules.

### Gap analysis findings (what we DIDN'T ship and why)

Probed GHL's public API for every Tier 1+2 item from the v3.8.1 gap analysis. Results:

| Area | Public API status | Action |
|---|---|---|
| Products CRUD | ✅ 200 | **Shipped in v3.9.0** |
| Trigger Links CRUD | ✅ 200 / 201 | **Shipped in v3.9.0** |
| Email templates | ❌ 404 (no endpoint) | Would need internal-API reverse-engineering |
| SMS templates / snippets | ❌ 404 | Same |
| Email campaigns create/send | ❌ 404 (only list works) | Same |
| Reviews / reputation | ❌ 404 | Same |
| Smart Lists / saved searches | ❌ 404 / 400 | Same |
| Memberships sub-features (offers, lessons) | ❌ 404 | Same |
| Product price writes | ❌ 403 with sub-account scope | Likely agency-only |

For the 404 items, the same DevTools-capture methodology we used for workflow-builder and funnel-builder would work, but each one is a Phase-2-style investment.

### Tool count impact
- Total: 178 → 187 (+9)
- Without Firebase: 148 → 157 (all new tools use public API, no Firebase required)
- With Firebase: 178 → 187
- Modules: 38 → 39 (new products module)

### Files changed
- `src/tools/products.ts` (NEW)
- `src/tools/trigger-links.ts` — added 3 write tools
- `src/tools/index.ts` — registered new products module
- `src/setup-tool.ts` — refreshed tool counts (148 → 157, 178 → 187)

## 3.8.1 — Vitest test suite + pre-publish gate

**178 tools across 38 modules. Bundle: 291.3 KB (unchanged).**

Strict safety patch. No new features, no behavior changes — just tests covering the high-risk code paths so regressions get caught at build time instead of in production.

### New: Vitest test suite (87 tests, 154ms)
Four test files added under `src/`:

- `retry.test.ts` (15 tests) — `computeRetryDelay`: Retry-After delta-seconds + HTTP-date parsing + clamping + full-jitter backoff math. Catches the "5junk" mixed-string regression and the "doubles per attempt" backoff guarantee.
- `trigger-schemas.test.ts` (60 tests) — Every one of the 57 native trigger types parses through its typed variant. Unknown future types fall through to the permissive fallback. Real-world `form_submission` payload preserves passthrough fields.
- `workflow-builder-client.test.ts` (7 tests) — `normalizeRemoveFromWorkflowAction` covers all branches (no-op when both fields present, synthesize string from array, synthesize array from string, multi-element array picks first, empty array no-op, non-target action type no-op, missing attributes no-op).
- `version-check.test.ts` (5 + tests) — `fetchLatestVersion` + `getVersionStatus` with mocked `fetch`: 200/non-2xx/network-error/missing-version-field/non-string-version cases.

### CI gate
The npm publish workflow now runs `npm test` before the tag-vs-version check. **Tests must pass for a release to ship.** Combined with the post-publish smoke test that verifies the package boots cleanly on a fresh machine, both surfaces are now covered:

- Pre-publish: schema correctness, normalization logic, retry math
- Post-publish: server boots, tool registry includes required tools

### New npm scripts
- `npm test` — run once, exit
- `npm run test:watch` — re-run on file change during development

### Dev-only dep added
- `vitest@^4.1.6` (devDependencies — not shipped to consumers; the npm tarball is unchanged size)

### `normalizeRemoveFromWorkflowAction` now exported
Was private in v3.8.0. Made public so tests can exercise it directly. Still not exposed via MCP — it's a runtime detail used by `getWorkflow` (read) and `buildActionChain` (write).

### Files changed
- `src/retry.test.ts` (NEW)
- `src/trigger-schemas.test.ts` (NEW)
- `src/workflow-builder-client.test.ts` (NEW)
- `src/version-check.test.ts` (NEW)
- `src/workflow-builder-client.ts` — `normalizeRemoveFromWorkflowAction` now exported
- `package.json` — `vitest` dev dep + `test` / `test:watch` scripts
- `.github/workflows/publish.yml` — added pre-publish test step

## 3.8.0 — Bundle re-extraction wins (triggers + goal events) + correctness fixes

**178 tools across 38 modules. Bundle: 291.3 KB.**

Three improvements that all flow from one bundle dive into `client-app-automation-workflows.leadconnectorhq.com`:

### Goal-event catalogue — all 10 conditions documented (was 1)
v3.6.0 shipped `build_goal_event` with one verified `goal_condition` (`review_request_clicked`) and a permissive string for everything else. The bundle's `GoalCondition` enum reveals the full set:

- `email_event` — extras: `{ stepIds: [] }`
- `link_click` — extras: `{ linkIds: [] }`
- `add_contact_tag` — extras: `{ tags: [] }` (inferred)
- `remove_contact_tag` — extras: `{ tags: [] }` (inferred)
- `appointment_status` — extras: `{ calendarId }`
- `payment_received` — extras: `{ globalProductIds: [] }`
- `form_submission` — extras: `{ formIds: [] }`
- `document_status` — extras: `{ templateId }`
- `invoice_paid` — extras: `{ invoiceStepId }`
- `review_request_clicked` — extras: `{ reviewTypes, reviewLinkId }`

`build_goal_event`'s `goal_condition` is now a `z.enum` of these 10 values (was permissive `z.string()`). The per-condition extras shapes are documented in the tool description.

### Goal-event action enum fixed
v3.6.0's tool advertised `action: "exit" | "continue" | "goto"`. The actual `GoalAction` enum in the bundle is `continue | wait | exit` — **no `goto`**. Fixed. Also removed the `target_node_id` parameter that paired with the bogus goto. `WorkflowGoalAttributes` type updated to match.

### 7 of 9 previously-uncaptured triggers now documented
Trigger field paths extracted from per-trigger validator functions in the bundle:

- `affiliate_created` → `affiliate.id, contact.tags`
- `scheduler_trigger` → `scheduler.interval, scheduler.cron, scheduler.frequency`
- `user_log_in` → `product.id, category.id, lesson.id, offer.id, contact.tags` (shares membership-course validator)
- `order_submission` → `order.funnel_id, order.line_item_global_product_ids, order.line_item_funnel_product_ids, payment.calendar.id, payment.global_product_ids, payment.form.id`
- `facebook_lead_gen` → `facebook.formId, facebook.pageId, contact.tags`

Plus 2 more handled explicitly:
- `conv_ai_trigger`, `conv_ai_autonomous_trigger` — confirmed fieldless (no specific validator in the bundle)
- `custom_object_created`, `custom_object_changed` — fields are user-defined per object using the `customObject.<field>` prefix; schema now documents this dynamic shape

Coverage: **57/57 native triggers, 0 type-only-pending** (was 9 type-only-pending).

### Bug fix: `remove_from_workflow` read/write asymmetry
Don Harris flagged at the end of his 2026-05-14 funnel audit that some GHL workflows return only the `workflow_id` array on read, while writes require both that AND the `workflowId` string. Couldn't reproduce in MCP Testing — both reads I checked returned both fields — but added defensive symmetric normalization to both paths in `workflow-builder-client.ts`. If only one form is present in either direction, the other is synthesized. No-op when both are already present.

### Bug fix: accurate "without Firebase" tool count
Empirical audit: basic-creds mode (no Firebase) actually exposes **148 tools**, not 168 as the v3.5.1 messaging claimed. Firebase adds **30 tools** across 6 modules (workflow builder, funnel builder, form builder, pipeline builder, workflow cloner, validate_workflow). `setup_ghl_mcp`, `enable_workflow_builder`, README, and CLAUDE.md all corrected.

### Bundle re-extraction methodology (for future audits)
1. Fetch `https://client-app-automation-workflows.leadconnectorhq.com/` → find current `index-*.js` bundle URL
2. Pull the bundle (~9.5 MB minified)
3. Search for `var GoalCondition=(...)` and `var GoalAction=(...)` for enum extraction
4. Search for `<triggerName>Validator=n=>` patterns for per-trigger field paths
5. Watch for i18n translation pollution — filter out windows containing non-ASCII Danish/German/Swedish characters

### Files changed
- `src/trigger-schemas.ts` — 7 uncaptured triggers now documented; 2 confirmed fieldless; 2 use dynamic `customObject.*` schema
- `src/tools/workflow-builder.ts` — `build_goal_event` enum tightened; goto removed; description lists all 10 goal_condition values + extras shapes
- `src/workflow-action-types.ts` — `WorkflowGoalAttributes.action` corrected to `continue | wait | exit`
- `src/workflow-builder-client.ts` — `normalizeRemoveFromWorkflowAction()` helper applied symmetrically on read + write
- `src/setup-tool.ts` — 168 → 148; "9 workflow builder tools" → "30 tools across 6 modules"
- `README.md`, `CLAUDE.md` — synced

## 3.7.0 — Funnel-builder fixes (Don Harris audit)

**178 tools across 38 modules. Bundle: 288.4 KB.**

Implements all 7 fixes from Don Harris's 2026-05-14 audit. Don captured the correct endpoints via Chrome DevTools against `page-builder.leadconnectorhq.com` and `app.gohighlevel.com`, then verified each fix with curl round-trips against two live sub-accounts. Full credit to Don for the diagnostic work.

### The root cause
All funnel write tools were hitting endpoints that don't exist on `backend.leadconnectorhq.com/funnels/...`. The real save paths live on the same host but with different verbs, methods, and body shapes. Plus, mutating endpoints require `Origin` + `Referer` headers that `funnelRequest()` wasn't sending, causing 401 `{"message":"Error calling IAM service"}` on every write.

### Header fix (unblocks every funnel write)
Added `Origin: https://app.gohighlevel.com` and `Referer: https://app.gohighlevel.com/` to `funnelRequest()`. Without these, GHL's IAM rejects writes regardless of bearer token validity.

### Endpoint fixes
| Tool | Was | Now |
|---|---|---|
| `update_page_content` | `PUT /page/{pageId}` | `POST /builder/prebuilt-section/sync/changes` with `{ locationId, pageId, pageData, write: true, isPublished }` |
| `update_funnel` | `PUT /funnel/{funnelId}` | `POST /funnel/update-settings` with full settings body. Rewritten with fetch-then-merge so you can change one field without blowing away the others. |
| `create_funnel_page` | `POST /page` | `POST /funnel/create-step` with client-generated step UUID + nested `step` object. Now also returns the generated `stepId` so the caller can use it with `update_funnel_step` / `delete_funnel_page` without a separate lookup. |
| `delete_funnel_page` | `DELETE /page/{pageId}` | `POST /funnel/delete-step` with `{ funnelId, stepId }`. **SIGNATURE CHANGED**: now takes `funnelId + stepId` instead of `pageId`. |
| `delete_funnel` | `DELETE /funnel/{id}` | `POST /funnel/delete` with `{ funnelId, locationId, userId }` |

### `update_funnel_step` (NEW)
Per-step renames, URL slugs, and domain attachments. `PUT /funnel/step/{funnelId}` with `{ stepId, name, url, domainName }`. Use this when you want to rename a single page entry inside a funnel without touching funnel-level settings.

### `WorkflowBuilderClient.getUserId()` (NEW, internal)
Public getter on the builder client. Required because `delete_funnel` needs `userId` in the request body. Not exposed via MCP.

### Round-trip verified in MCP Testing
- 6/6 tested funnel writes return success against real GHL backend (the 7th, `update_page_content`, was already verified by Don's 112KB real-page round-trip in his audit email).
- Created → renamed → added step → renamed step → deleted step → deleted funnel, all in one test pass.

### Honest scoping
- The 168/178 "without Firebase / with Firebase" split documented in `setup_ghl_mcp` and `enable_workflow_builder` is carried forward from v3.5.1 and hasn't been audited empirically. It's likely off in absolute terms (funnel/form/pipeline builders all gate on Firebase too). TODO to do a real audit.
- The bonus `update_workflow_actions` / `remove_from_workflow` read/write asymmetry Don flagged at the end of his email is not addressed in this release — separate concern, separate fix when prioritized.

### Files changed
- `src/tools/funnel-builder.ts` — full rewrite of all 6 broken endpoints + `update_funnel_step` added
- `src/workflow-builder-client.ts` — `getUserId()` public getter
- `src/setup-tool.ts` — tool counts refreshed

## 3.6.0 — `build_goal_event` (closes Don-e's last open complaint)

**177 tools across 38 modules. Bundle: 281.9 KB.**

Goal-event support, end-to-end. Don-e flagged on May 4 that the MCP couldn't read or edit goal events. v3.1.0's permissive schema fixed the read side. v3.6.0 fixes the build side.

### `build_goal_event` tool (NEW)
A high-level helper that emits a correctly-shaped `workflow_goal` node — same pattern as `build_if_else_branch`. Takes a goal condition, optional extras, and an action mode (exit / continue / goto). Returns a single node JSON ready to insert into your actions array.

Example:
```
build_goal_event(
  goal_condition: "review_request_clicked",
  extras: { reviewTypes: ["sms", "email"], reviewLinkId: "" },
  action: "exit",
)
```

### Goal events explained
Goal events sit inline in the workflow's action chain — they're NOT branching nodes. The previous action's `next` points to the goal node, and the goal node itself has no `next`. When the goal condition fires during workflow execution (e.g., a contact clicks a review request link), the configured action runs:
- `exit` — terminates this workflow path (verified)
- `continue` — passes through to a downstream node (schema accepts but not yet captured from UI)
- `goto` — jumps to a specific action node (requires `target_node_id`)

### Verified end-to-end
- Built a goal-event node via `build_goal_event` matching Jerry's UI-built sample. **13 of 13 structural checks passed.**
- Saved it to a fresh workflow in MCP Testing, read back via `get_workflow_full`. **5 of 5 persistence checks passed** — GHL accepted the shape and round-tripped it cleanly.
- Test workflow cleaned up via `delete_workflow_full`.

### Schema updates
- `src/workflow-action-types.ts` — added `WorkflowGoalAttributes` interface + `workflow_goal` variant in the discriminated union (was previously caught by the catch-all fallback)
- `templates/action-schemas.json` — added a `workflow_goal` entry with the canonical shape, notes, and a pointer to the builder
- `update_workflow_actions` description updated to mention `workflow_goal` + `build_goal_event`

### Honest scoping
Only one `goal_condition` value is currently verified end-to-end: `review_request_clicked`. GHL's UI exposes others (tag-based, appointment-based, etc.) — those pass through the builder via the permissive `goal_condition: string` parameter but haven't been captured as specific cases. A future patch can add named helpers / enum values per goal_condition type when more samples are captured.

### Files changed
- `src/tools/workflow-builder.ts` — `build_goal_event` tool; `update_workflow_actions` description refresh
- `src/workflow-action-types.ts` — new `WorkflowGoalAttributes` + union variant
- `templates/action-schemas.json` — `workflow_goal` reference entry

## 3.5.1 — doc-string accuracy

**176 tools across 38 modules. Bundle: 278.7 KB.**

Three stale tool counts caught during a documentation sweep:

- `setup_ghl_mcp`'s tool description told buyers "load all 175 tools" — visible in Claude's tool list. Updated to "176 tools (168 if you skip the optional Firebase fields; add Firebase later with enable_workflow_builder)" — both more accurate and mentions the new second-phase tool.
- Internal docstrings in `setup-tool.ts` and `credentials-store.ts` referenced hardcoded counts (171 and 175). Replaced with "the full tool set" so future tool additions don't make them stale again.

No functional changes. Strictly a docs/string-accuracy patch.

## 3.5.0 — `enable_workflow_builder` tool (one-call Firebase upgrade)

**176 tools across 38 modules. Bundle: 278.7 KB.**

A clean second-phase setup path for buyers who skipped Firebase on day one.

### The friction this removes
Before v3.5.0, a buyer who installed without Firebase (the 90% case for non-technical buyers — they get the 168 core tools and skip workflow builder until they need it) had to **re-run `setup_ghl_mcp` with all 7 fields** when they were ready to add Firebase later. Email, license key, GHL API key, location ID — all re-entered even though they were already saved.

### The fix
New tool `enable_workflow_builder` takes only the 3 Firebase fields (`ghl_user_id`, `ghl_firebase_api_key`, `ghl_firebase_refresh_token`). It reads the existing credentials file, validates the Firebase token refresh against `securetoken.googleapis.com`, merges the Firebase fields in, and writes back. Quit + restart Claude → 8 additional tools live.

If Firebase validation fails, the error message names the two most common causes (rotated refresh token, mismatched API key + refresh token from different rows) and links to the DevTools capture page.

### Discoverability
- `setup_ghl_mcp`'s success message now tells buyers about `enable_workflow_builder` when Firebase wasn't provided: *"To enable Workflow Builder later (8 extra tools): run enable_workflow_builder with your three Firebase values. No need to re-enter license/API key/location ID."*
- `health_check`'s Firebase "skip" detail mentions the tool too. Buyers running `health_check` for the first time see exactly how to add the missing piece.

### Registration
- **Non-bootstrap mode only.** Requires existing credentials — bootstrap mode buyers should run `setup_ghl_mcp` first.
- Returns a clear error if no credentials file exists yet.

### Verified end-to-end
- Tool registers (176 total).
- Valid Firebase credentials → 4 basic fields preserved + 3 Firebase fields added.
- Bogus Firebase credentials → clear error message + `isError: true`.
- Test cleanup left real credentials intact.

### Files changed
- `src/setup-tool.ts` — added `registerEnableWorkflowBuilderTool` + import `readCredentials`; updated `setup_ghl_mcp` success message to mention the new tool when Firebase isn't provided.
- `src/index.ts` — registers `enable_workflow_builder` in non-bootstrap mode.
- `src/tools/diagnostics.ts` — `health_check` Firebase skip message now points at `enable_workflow_builder`.

## 3.4.1 — fixes from real-world testing of v3.4.0 tools

**175 tools across 38 modules. Bundle: 275.2 KB.**

Two bugs caught when exercising v3.4.0's `health_check` and `validate_workflow` end-to-end against a real sandbox workflow:

1. **`health_check` API-key probe used the wrong endpoint.** `/locations/search` is agency-level — sub-account Private Integration keys (the kind buyers have) get 403, not 200, even when the key is fine. Fixed to use `/locations/{locationId}` which sub-account keys can call. Also added explicit handling for 403 with a clearer message ("doesn't have access to this location" vs. just "limited").

2. **`validate_workflow` errored when checking workflow references.** Called `builderClient.listWorkflowsFull()`, which doesn't exist — the actual method is `listWorkflows()`. Any workflow containing a `remove_from_workflow` action or a trigger with an `add_to_workflow` reference would crash the validator. Fixed.

Also: `src/index.ts` startup `validateApiKey()` had the same wrong-endpoint bug as `health_check`; fixed for consistency. Buyers will now see "API key validated" or a clear 403 message on startup instead of silent ambiguity.

### Verified end-to-end
Re-ran the spawn-handshake test against a protected account:
- `health_check` returns 5/5 PASS ("All systems go.")
- `validate_workflow` on the CLL Onboarding Form Submitted workflow correctly scans 2 references (form.id + self-referencing workflow.id), reports 0 issues, surfaces the self-reference as an informational note.

### Files changed
- `src/tools/diagnostics.ts` — endpoint fix + 403 handling
- `src/tools/validators.ts` — method name fix
- `src/index.ts` — same endpoint fix in startup probe

## 3.4.0 — health_check tool + validate_workflow expansion + smoke-test CI

**175 tools across 38 modules. Bundle: 274.6 KB.**

Three improvements that buyers don't have to ask for.

### `health_check` tool (NEW)
A single diagnostic that runs five probes in parallel and reports a structured pass/fail/warn list:

1. **npm registry + version** — is the registry reachable? Are you on the latest version?
2. **GHL API key** — does it validate against `/locations/search`? (catches revoked/expired keys)
3. **Default location** — can you actually read it? Reports the location name on success.
4. **Firebase auth** — refreshes the workflow-builder token; reports `skip` if not configured, `pass`/`fail` if it is.
5. **Token registry** — how many sub-accounts are registered for `switch_location`?

Use case: "is my install OK?" first-line troubleshooting, post-restart sanity check after credentials rotate, support back-and-forth with buyers.

### `validate_workflow` expansion
The v3.3.1 validator now also checks **form IDs**, **calendar IDs**, and **survey IDs** in trigger conditions. Coverage went from 5 → 8 reference categories:
- Pipeline IDs
- Pipeline Stage IDs
- Custom Field IDs
- User IDs
- Workflow IDs
- **Form IDs** (NEW — checks `form.id` in form_submission triggers)
- **Calendar IDs** (NEW — checks `calendar.id` in appointment / customer_appointment triggers)
- **Survey IDs** (NEW — checks `survey.id` in survey_submission triggers)

### Post-publish smoke-test CI
A new job in `.github/workflows/publish.yml` runs after npm publish: waits for registry propagation (~30s typical), then `npx`-installs the just-published version in a clean machine, spawns it with the full MCP handshake (initialize → notifications/initialized → tools/list), and asserts that `get_mcp_version` and `setup_ghl_mcp` show up in the response.

Catches the kind of publish regression where the package builds locally and uploads to npm fine but fails to boot on a buyer's machine because of a missing file, bad permission, or runtime error during tool registration. Adds ~2 minutes to the release cycle, makes future buyers safer.

### Tool count tidy-up
Fixed stale "171 tools" / "165 tools" strings in `setup-tool.ts` that buyers saw in the bootstrap-mode setup message. Now correctly reports 175 (full) / 167 (without workflow builder).

### Files changed
- `src/tools/diagnostics.ts` — NEW: `registerDiagnosticTools` + `health_check`.
- `src/workflow-builder-client.ts` — public `checkAuth()` method for Firebase probe.
- `src/tools/validators.ts` — form/calendar/survey reference extraction + lookups.
- `src/tools/index.ts` — registered diagnostics module.
- `src/setup-tool.ts` — corrected tool counts (171 → 175, 165 → 167).
- `.github/workflows/publish.yml` — added smoke-test job.

## 3.3.1 — validate_workflow + catalogue in npm package

**174 tools across 37 modules. Bundle: 266.2 KB.**

Two small wins:

### `validate_workflow` tool (NEW)
Pre-flight ID validation for a deployed workflow. Scans every trigger and action for references to pipelines, pipeline stages, custom fields, users, and other workflows. Verifies each ID exists in the current location. Returns a structured report with `references_scanned`, `issues_count`, and a `findings[]` list naming exactly which action / trigger / field path holds an invalid reference.

This catches the silent-failure bug documented in CLAUDE.md: *"Invalid IDs silently kill all subsequent actions in a workflow."* GHL doesn't surface the error anywhere — actions just stop firing. Run `validate_workflow <workflowId>` after editing, or whenever a workflow stops behaving as expected.

Coverage in this release:
- Pipeline IDs (in `internal_update_opportunity` actions + trigger conditions)
- Pipeline Stage IDs (cross-checked against their parent pipeline's stage list)
- Custom Field IDs (in `update_contact_field` actions)
- User IDs (in `internal_notification.selectedUser`, `task_notification.assignedTo`, trigger conditions)
- Workflow IDs (in `remove_from_workflow` actions + trigger `add_to_workflow` actions)

Forms, calendars, and surveys are referenced in trigger conditions for specific trigger types (form_submission, customer_appointment, survey_submission) — those checks are deferred to a later release.

### `templates/trigger-schemas.json` now ships in the npm package
The 1.1 MB trigger catalogue (57 native + 434 marketplace trigger entries) was previously left out of the published package — buyers running `npx -y @elitedcs/ghl-mcp@latest` didn't receive it. Added to `package.json` `files[]` so installs always include it.

### Files changed
- `src/tools/validators.ts` — NEW: registerValidatorTools + validate_workflow.
- `src/tools/index.ts` — wired validators into the registry.
- `package.json` — added trigger-schemas.json to `files[]`; bumped version; tool count 173 → 174; description updated.
- README, CLAUDE.md, CHANGELOG — synced.

## 3.3.0 — 100% native trigger coverage

**173 tools across 36 modules. Bundle: 255.4 KB.**

All **57 of 57** native GHL workflow trigger types now have their own typed Zod variant in `WorkflowTriggerSchema`. Claude can discriminate every trigger type by name and read documented field paths where the catalogue captured them.

Field-documentation completeness (per trigger, marked in the schema's `.describe()` so the LLM knows):

- **42 fully documented** — every filter field path captured (e.g., `contact_changed` with 12 fields, `opportunity_decay` with 9, `survey_submission`, `birthday_reminder`, etc.)
- **4 partial** — some fields captured, others not: `custom_date_reminder`, `inbound_trigger`, `facebook_comment_on_post`, `ig_comment_on_post`. Reads still pass; LLM is told docs are partial.
- **2 fieldless by design** — `inbound_webhook`, `payment_received` (fire on any event)
- **9 uncaptured** — typed-but-no-docs: `affiliate_created`, `scheduler_trigger`, `user_log_in`, `order_submission`, `conv_ai_trigger`, `conv_ai_autonomous_trigger`, `custom_object_created`, `custom_object_changed`, `facebook_lead_gen`. Most rarely used. Type discrimination still works; field paths to be backfilled when buyers need them.

### Backfills
- The 4 originally-typed triggers (contact_tag, appointment, customer_reply, pipeline_stage_updated) had incomplete field lists in v3.2.0. Now enriched with all captured fields. `customer_reply` went from "no fields documented" to `workflow.id, message.type, message.body, contact.tags`.

### Coverage delta
| State | Typed | Reachable-but-untyped | Total |
|---|---:|---:|---:|
| Before v3.1.0 | 4 | 0 (reads crashed) | 57 |
| v3.1.0 | 4 | 53 | 57 |
| v3.2.0 | 13 | 44 | 57 |
| **v3.3.0** | **57** | **0** | **57** |

### Future-proof
Even though every native trigger has a typed variant now, `UnknownTriggerSchema` is still in the union as the last fallback — if GHL ships a new trigger type, reads will pass through cleanly instead of throwing.

### Verified
- 57 / 57 typed variants matched their own type literal via runtime z.union parse test
- A synthetic "future_trigger_ghl_invents" type still passes through via the fallback
- Build: 255.4 KB (up from 248.4 KB; +7 KB for the 44 new variants)

### Files changed
- `src/trigger-schemas.ts` — all 57 typed variants + 4 doc-completeness statuses (`documented`, `partial`, `fieldless`, `uncaptured`) so the LLM's expectations match each trigger's reality.

## 3.2.0 — Deep trigger typing

**173 tools across 36 modules. Bundle: 248.4 KB.**

v3.1.0 made all 57 native trigger types READ cleanly via a permissive fallback. v3.2.0 deeply types 9 more of the most common ones — `form_submission`, `opportunity_created`, `opportunity_changed`, `opportunity_status_changed`, `payment_received`, `inbound_webhook`, `mailgun_email_event`, `note_add`, `task_added` — bringing typed coverage from **4 / 57 (7%) to 13 / 57 (23%)**.

For Claude that means: when you ask it to read, build, or edit a workflow using one of these triggers, it now knows the discriminated trigger type AND the field paths each one supports (e.g., `opportunity.pipelineId`, `opportunity.monetaryValue`, `form.id`, `mailgun.event`, etc.). Less guessing, fewer rejected payloads.

### Shared trigger-schema module
- `src/trigger-schemas.ts` (NEW): single source of truth for `WorkflowTriggerSchema`. Both the read path (`workflow-builder-client.ts`) and the write path (`tools/workflow-builder.ts`) now import from this module instead of duplicating definitions.
- Bundle shrank from 249.7 KB → 248.4 KB thanks to the dedup.

### Field paths documented per trigger
- Each typed trigger's `field` is `z.string()` with a `.describe()` listing the known field paths (extracted from `templates/trigger-schemas.json`, captured 2026-05-14). Permissive enough that new GHL fields don't crash reads, structured enough that the LLM knows what fields exist.

### Coverage delta
| State | Typed | Reachable-but-untyped | Total |
|---|---:|---:|---:|
| Before v3.1.0 | 4 | 0 (reads crashed) | 57 |
| v3.1.0 | 4 | 53 | 57 |
| v3.2.0 | **13** | **44** | 57 |

### Round-trip verified
- Real `form_submission` workflow from a protected account parses cleanly through the new typed variant.
- 8 synthetic typed-trigger samples match their respective variants and are correctly rejected by sibling variants (discrimination works as expected).

### Files changed
- `src/trigger-schemas.ts` — NEW: 13 typed trigger variants + `UnknownTriggerSchema` fallback in a single module.
- `src/workflow-builder-client.ts` — removed inline trigger schemas, imports from shared module.
- `src/tools/workflow-builder.ts` — removed inline trigger schemas, imports from shared module.

## 3.1.1 — Auto-update visibility

**173 tools across 36 modules. Bundle: 249.7 KB.**

The v3.0 install email promises the MCP auto-updates on Claude restart, and it does. But the old `checkForUpdates()` only worked for local git-checkout dev installs — for buyers running `npx -y @elitedcs/ghl-mcp@latest` it silently returned. This release makes the upgrade visible and verifiable.

### `checkForUpdates` rewritten for the npm install path
- Queries `registry.npmjs.org/@elitedcs/ghl-mcp/latest` instead of running `git fetch`.
- Writes a clear stderr line when a newer version is available: `Update available: vX → vY. Fully quit Claude (Cmd+Q on Mac) and reopen to upgrade.`
- 5 s timeout, non-blocking.

### `get_mcp_version` tool (NEW)
- Returns installed version, latest published version on npm, and the one-line restart instruction if not current.
- Registered in BOTH bootstrap and normal modes — buyers without configured GHL credentials can still verify their MCP version.
- Use case: "Hey Claude, check your version" → instant confirmation an upgrade landed.

### Version baked into `get_current_location`
- Adds `MCP version: vX.Y.Z` line to the response so the version surfaces naturally on the first GHL tool call of a session, no separate query needed.

### Files changed
- `src/index.ts` — replaced git-based update check with npm registry check; registers meta tools in both modes; threads version into `registerAllTools`.
- `src/version-check.ts` — NEW: shared `fetchLatestVersion()` + `getVersionStatus()` used by startup banner and the tool.
- `src/tools/meta.ts` — NEW: `get_mcp_version` tool.
- `src/tools/index.ts` — `registerAllTools` accepts optional `mcpVersion` and threads it to the location switcher.
- `src/tools/location-switcher.ts` — appends `MCP version: vX.Y.Z` to `get_current_location` response.

## 3.1.0 — Workflow trigger read-side coverage

**172 tools across 35 modules.**

GHL ships 57 native trigger types. The MCP previously typed only 4 (`customer_reply`, `appointment`, `contact_tag`, `pipeline_stage_updated`). Workflows using any of the other 53 — `form_submission`, `payment_received`, `opportunity_*`, `inbound_webhook`, etc. — threw Zod errors on `get_workflow_full`. This release unlocks all of them on the read side.

### Permissive `WorkflowTriggerSchema`
- `z.union` of the 4 typed variants plus an `UnknownTriggerSchema` fallback that accepts any string-keyed `type` and a `conditions` array of arbitrary records.
- Write side is unchanged — typed triggers still get their typed conditions; unknown triggers pass through untouched on save.
- Defined in BOTH `src/workflow-builder-client.ts` and `src/tools/workflow-builder.ts` (read path + tool input schema).

### `get_trigger_registry` tool (NEW)
- Calls GHL's marketplace endpoint `/marketplace/core/search/module?type=triggers` and returns ~85 third-party apps (Zoom, WooCommerce, Shopify, Monday, etc.) with their available triggers and `customVars`.
- Use case: discovering which marketplace triggers a sub-account can wire into workflows.
- `installedOnly` flag defaults to false (full catalogue).

### `templates/trigger-schemas.json` reference (dev-side)
- Full catalogue: 57 native trigger types + 85 marketplace apps + 434 marketplace trigger entries, captured from the workflow-builder JS bundle + the marketplace API.
- Not shipped to npm consumers (excluded from `package.json` `files[]`) — kept in-repo as the source of truth for future typed-trigger work.

### Coverage delta
- Before: 4 / 57 native trigger types typed (7%).
- After: 4 typed + 53 reachable via the permissive fallback (100% read coverage).

## 2.7.0 — Complete Type Safety (Boris Cherny A+)

**171 tools across 35 modules. Bundle: 214KB.**

Closes every remaining type safety issue from the Boris Cherny audit. Zero escape hatches, runtime-validated API responses on critical paths, type-safe tool registration, and defensive property access everywhere.

### account-export.ts — no more unsafe casts
- Replaced `contacts as Record<string, unknown> & { meta?: ... }` with defensive runtime checks (`typeof`, `Array.isArray`) before property access.
- `count()` helper uses `Object.values()` instead of indexing a cast object.
- Switched to `jsonResponse`/`errorResponse` helpers.

### Type-safe tool registration
- `src/tools/index.ts` now uses a typed `ReadonlyArray<[ToolRegistrar, string]>` registry. The compiler verifies every function reference exists — adding an import without a registry entry or vice versa is a compile error.
- Builder tools (Firebase auth) registered separately with explicit `WorkflowBuilderClient` parameter.

### WorkflowFull — no more escape hatch
- Removed `[key: string]: unknown` index signature from `WorkflowFull` interface. Replaced with explicit optional fields (`stopOnResponse`, `allowMultiple`, etc.) for all properties accessed in the codebase.

### API response validation on critical paths
- New `src/api-schemas.ts` — Zod schemas with `.passthrough()` for contacts, pipelines, and calendars.
- `search_contacts` validates response against `ContactSearchResponseSchema`.
- `get_contact` validates against `ContactResponseSchema`.
- `get_pipelines` validates against `PipelinesResponseSchema`.
- `get_calendars` validates against `CalendarsResponseSchema`.
- Schema violations throw at the tool level with clear error messages instead of silently passing malformed data.

### Startup update check
- Server runs a non-blocking `git fetch` on startup and warns via stderr if a newer version is available on the remote.
- Version is now read from `package.json` at build time instead of hardcoded — startup shows `v2.7.0 connected`.

### Files changed
- `src/index.ts` — dynamic version from package.json, background update check
- `src/tools/account-export.ts` — defensive type access, response helpers
- `src/tools/index.ts` — typed registry array
- `src/workflow-builder-client.ts` — removed `[key: string]: unknown`
- `src/api-schemas.ts` — NEW: Zod response schemas
- `src/tools/contacts.ts` — output validation on search + get
- `src/tools/opportunities.ts` — output validation on get_pipelines
- `src/tools/calendars.ts` — output validation on get_calendars

## 2.6.0 — A+ Type Safety

**171 tools across 35 modules. Bundle: 210KB.**

Eliminated all generic `as T` type casts from both HTTP clients, added Zod runtime validation on API responses and template files, added pre-flight workflow action validation, and documented pagination strategies across all tools.

### Honest types — no more `as T` casts

- `GHLClient.request()` and all HTTP methods (`get`, `post`, `put`, `patch`, `delete`) now return `Promise<unknown>` instead of `Promise<T>`. No more `JSON.parse(text) as T` — the type system no longer lies about response shapes.
- `WorkflowBuilderClient.request()` — same treatment. Returns `Promise<unknown>`.
- `getWorkflow()` and `createWorkflow()` now validate responses against a `WorkflowFullSchema` (Zod) before returning typed `WorkflowFull`. Runtime-validated, not compile-time-assumed.
- `location-switcher.ts` — replaced `client.get<LocationResponse>(...)` with honest `Record<string, unknown>` + defensive property access.

### Template Zod validation

- `template-deployer.ts` now validates template JSON against `TemplateSchema` on load — catches malformed templates with clear error messages instead of crashing mid-deploy.
- `resolveObj()` is now generic `<T>(obj: T): T` — preserves input types through string replacement, eliminating 5 downstream `as Record<string, unknown>` casts.
- Template structure (tags, customFields, pipelines, workflows, calendars) is now a single Zod source of truth.

### Workflow action validation

- `validateActionChain()` — pre-flight validation before sending actions to GHL. Checks: SMS needs `body` + `attachments`, email needs `subject` + `html` + `trackingOptions`, tags need `tags[]`, wait needs `startAfter`, create_opportunity needs `pipelineId` + `stageId`, remove_from_workflow needs both `workflowId` and `workflow_id[]`.
- `hasId()` type guard — replaces `as string` casts on action ID filtering with proper TypeScript narrowing.
- Removed `!` non-null assertion on `action.next` assignment — replaced with explicit null check.

### Pagination documentation

- 11 tools now include pagination strategy in their descriptions (offset-based, page-based, or cursor-based).
- Updated: get_funnels, get_funnel_pages, get_orders, get_subscriptions, get_transactions, list_invoices, get_blog_posts, get_form_submissions, get_survey_submissions, get_form_submissions_full, list_available_locations.

### Files changed

- `src/ghl-client.ts` — removed all generic `<T>`, returns `Promise<unknown>`
- `src/workflow-builder-client.ts` — removed generics, added `WorkflowFullSchema`, `hasId()`, `validateActionChain()`
- `src/tools/location-switcher.ts` — removed `<LocationResponse>` generic, honest casts
- `src/tools/template-deployer.ts` — added `TemplateSchema`, generic `resolveObj<T>`, removed 6 unsafe casts
- 8 tool files — pagination documentation updates

## 2.5.0 — Type Safety & Boilerplate Overhaul

**171 tools across 35 modules. Bundle: 206KB (down from 246KB).**

Boris Cherny-style type safety audit and overhaul. Eliminated ~40KB of duplicated boilerplate, added runtime validation, guarded destructive operations, and introduced typed workflow action schemas.

### `safeTool()` wrapper — eliminates boilerplate (Phase A)

- New `safeTool()` higher-order function in `tool-helpers.ts` — wraps tool handlers with automatic try/catch and JSON response formatting
- Converted 25 tool modules (~120 tools) from manual try/catch/JSON.stringify to `safeTool()` — handlers now just return data
- Net reduction: ~1000 lines of duplicated error handling eliminated
- Files skipped (complex handlers): bulk-operations, template-deployer, location-switcher, account-export, workflow-cloner, builder modules

### Zod-validated token registry (Phase B)

- `token-registry.ts` now validates `.ghl-tokens.json` against a Zod schema on load
- Types derived from schemas (`z.infer<>`) instead of manual interfaces — single source of truth
- Malformed registry files fail with a clear validation error instead of silent type-assertion cast

### Delete confirmation guards (Phase C)

- 10 destructive delete operations now require `confirm: "DELETE"` parameter, matching existing `bulk_delete_contacts` pattern
- Guarded: `delete_contact`, `delete_opportunity`, `delete_calendar`, `delete_appointment`, `delete_business`, `delete_workflow_full`, `delete_pipeline`, `delete_form`, `delete_funnel`, `delete_funnel_page`
- Low-risk deletes (media, webhooks, social posts, coupons, tags, etc.) left unguarded

### Discriminated union for WorkflowAction (Phase D)

- New `src/workflow-action-types.ts` — typed attribute interfaces for all 11 documented action types (sms, email, wait, add_contact_tag, remove_contact_tag, internal_notification, update_contact_field, add_notes, task_notification, remove_from_workflow, create_opportunity)
- `WorkflowAction` is now a discriminated union on `type` with a `string` fallback for unknown types
- Compile-time enforcement that SMS actions have `body` + `attachments`, emails have `subject` + `html` + `trackingOptions`, etc.

### Files Changed

- `src/tool-helpers.ts` — Added `safeTool()` wrapper with MCP SDK type imports
- `src/token-registry.ts` — Zod schema validation, types derived from schemas
- `src/workflow-action-types.ts` — NEW: discriminated union type definitions
- `src/workflow-builder-client.ts` — Imports WorkflowAction from new types file
- 25 tool modules converted to `safeTool()` pattern
- 8 tool files updated with delete confirmation guards

## 2.4.0 — Reliability & Consistency Audit

**171 tools across 35 modules.**

Full audit and hardening pass — every tool reviewed for reliability, error handling, and LLM usability.

### Reliability Fixes

- **Retry with exponential backoff** — Both `ghl-client.ts` and `workflow-builder-client.ts` now retry on 429 rate limits, 5xx server errors, and transient network errors (ECONNRESET, ETIMEDOUT). 3 retries with 500ms base delay. Respects `Retry-After` header.
- **Clear timeout errors** — Request timeouts now throw `"Request timeout (30s): GET /path"` instead of cryptic `AbortError`.
- **Firebase token refresh lock** — Concurrent requests now share a single token refresh instead of racing. Eliminates duplicate Firebase calls and potential token corruption.
- **Firebase cache clear on failure** — If token refresh fails, the stale cache is cleared immediately instead of persisting for 55 minutes of cascading failures.
- **Startup API key validation** — Server validates the API key at startup (non-blocking) and logs a clear warning if it returns 401.
- **Token registry corruption backup** — If `.ghl-tokens.json` is corrupted, it's backed up as `.ghl-tokens.json.corrupted.<timestamp>` before falling back to empty.

### Consistency Fixes (LLM Usability)

- **Standardized locationId handling** — All 13 affected tool files now use consistent `.optional()` locationId with `resolveLocationId()` fallback. Removed 25 instances of incorrect `await` on the synchronous `resolveLocationId()` method.
- **Fixed tools missing resolveLocationId()** — `users.ts`, `businesses.ts`, `courses.ts`, `social-planner.ts` now properly resolve locationId from env var fallback instead of passing raw (possibly undefined) values.
- **Hidden altId/altType from LLM** — `media.ts` tools now accept `locationId` and map to `altId`/`altType` internally, matching the pattern used by invoices, estimates, coupons, and payments.
- **Improved builder tool descriptions** — All `_full` builder tools now mention "Requires Firebase auth" and clarify when to use them vs public API versions. `update_workflow_actions` now says "Call get_workflow_full first". `create_workflow` shows the explicit 3-step flow.
- **Complete action/trigger type lists** — `update_workflow_actions` description now lists all supported action and trigger types instead of "etc."

### Files Changed

- `src/ghl-client.ts` — Retry/backoff, timeout error handling
- `src/workflow-builder-client.ts` — Retry/backoff, timeout, token refresh lock + cache clear
- `src/token-registry.ts` — Corruption backup on load failure
- `src/index.ts` — Startup API key validation
- `src/tools/blogs.ts` — locationId optional + remove await
- `src/tools/campaigns.ts` — locationId optional + remove await
- `src/tools/emails.ts` — locationId optional + remove await
- `src/tools/forms.ts` — locationId optional + remove await
- `src/tools/funnels.ts` — locationId optional + remove await
- `src/tools/invoices.ts` — locationId optional + remove await
- `src/tools/payments.ts` — locationId optional + remove await
- `src/tools/surveys.ts` — locationId optional + remove await
- `src/tools/trigger-links.ts` — locationId optional + remove await
- `src/tools/users.ts` — locationId optional + add resolveLocationId
- `src/tools/businesses.ts` — locationId optional + add resolveLocationId
- `src/tools/courses.ts` — locationId optional + add resolveLocationId
- `src/tools/social-planner.ts` — locationId optional + add resolveLocationId
- `src/tools/media.ts` — Replace altId/altType with locationId
- `src/tools/workflow-builder.ts` — Improved tool descriptions

## 2.3.0 — Multi-Sub-Account Token Registry

**171 tools across 35 modules.**

### New: Token Registry

- **`src/token-registry.ts`** — Per-location API key storage for seamless multi-sub-account access
- Stores API keys in `.ghl-tokens.json` (gitignored) — no server restart needed to switch locations
- Also stores Firebase credentials and agency-level API key

### New: Location Registry Tools (3 tools)

- `register_location` — Add a sub-account and its API key to the token registry
- `unregister_location` — Remove a sub-account from the token registry
- `list_registered_locations` — List all sub-accounts stored in the token registry

### Improved: Location Switcher

- `switch_location` now automatically swaps the API key from the token registry when switching sub-accounts
- `get_current_location` now shows token registry status

### Documentation

- Updated README tool count from 167 → 171 and version from 2.2.0 → 2.3.0
- Added **Known Limitations** section to README — documents what the MCP can't do (contact merge, templates, calls, analytics, etc.)
- Added **CONTRIBUTING.md** — dev setup, branching, PR guidelines, how to add tools
- Updated **Access & Collaboration** section with contributor (Write) role alongside customer (Read) role
- Updated project structure tree with `token-registry.ts`, `action-schemas.json`, `CONTRIBUTING.md`
- Updated CLAUDE.md architecture section and build notes for token registry
- Generated website update prompt at `templates/website-update-2026-04-01.md`

## 2.2.0 — Builder Suite + Power Tools

**164 tools across 34 modules.**

### New: Funnel/Page Builder (9 tools)
- `list_funnels_full`, `get_page_full`, `get_page_content` — read full page builder data (sections, elements, CSS, tracking codes)
- `create_funnel`, `update_funnel`, `delete_funnel` — funnel CRUD
- `create_funnel_page`, `update_page_content`, `delete_funnel_page` — page CRUD

### New: Form Builder (5 tools)
- `get_form_full` — read all fields, conditional logic, auto-responder, email notifications
- `create_form`, `update_form`, `delete_form` — form CRUD
- `get_form_submissions_full` — full submission data via internal API

### New: Pipeline Builder (5 tools)
- `list_pipelines_full`, `get_pipeline_full` — full stage details via internal API
- `create_pipeline`, `update_pipeline`, `delete_pipeline` — pipeline/stage CRUD

### New: Bulk Operations (5 tools)
- `bulk_add_tags`, `bulk_remove_tags` — batch tag management
- `bulk_update_contacts` — batch field updates
- `bulk_add_to_workflow` — batch workflow enrollment
- `bulk_delete_contacts` — batch deletion with safety confirmation
- All operations rate-limited with success/failure reporting

### New: Account Export & Comparison (2 tools)
- `export_account` — full sub-account backup to JSON
- `compare_locations` — side-by-side diff of two sub-accounts

### New: Workflow Cloner (1 tool)
- `clone_workflow` — deep clone with UUID remapping for all actions, triggers, and references

### New: Location Switcher (3 tools)
- `get_current_location`, `switch_location`, `list_available_locations`
- Switch between sub-accounts mid-session without restarting

### Fixes
- Added `source: WEB_USER` header (required by funnel/form internal endpoints)
- Made `buildHeaders()` public on WorkflowBuilderClient for reuse by builder modules

## 2.1.0 — Workflow Builder (Internal API)

**134 tools across 27 modules.**

### New: Full Workflow CRUD

- **WorkflowBuilderClient** — authenticates via Firebase refresh token to access GHL's internal backend API (`backend.leadconnectorhq.com`)
- 6 new MCP tools for complete workflow management:
  - `list_workflows_full` — list with full metadata and permissions
  - `get_workflow_full` — read every action, trigger, condition, if/else branch, email template, SMS body
  - `create_workflow` — create new workflows (start as draft)
  - `update_workflow_actions` — add, edit, remove actions/triggers/branches
  - `delete_workflow_full` — permanently delete workflows
  - `publish_workflow` — publish drafts to make them active
- Supports all GHL action types: sms, email, add_contact_tag, wait, if_else, webhook, custom_code, and more
- Supports all GHL trigger types: contact_tag, form_submission, appointment, inbound_webhook, and more
- Automatic version tracking for safe concurrent editing
- Graceful degradation — if Firebase env vars not set, standard tools still work

### New Environment Variables (Optional)

- `GHL_USER_ID` — required for workflow builder
- `GHL_FIREBASE_API_KEY` — Firebase API key from GHL auth
- `GHL_FIREBASE_REFRESH_TOKEN` — Firebase refresh token (may rotate)

## 2.0.0 — CommonJS Rebuild

**128 tools across 26 modules.**

### Breaking Changes

- Converted from ESM to CommonJS — removed `"type": "module"` from package.json
- Build system switched from `tsc` to `esbuild` — tsc OOMs on TS 5.9.3 with MCP SDK types
- MCP registration now uses `claude mcp add --scope user` (registers in `~/.claude.json`, NOT `~/.claude/mcp.json`)

### New Modules (6)

- **Custom Objects** (7 tools) — List schemas, CRUD records
- **Associations** (3 tools) — Link custom object records to contacts/opportunities/other records
- **Estimates** (6 tools) — Create, update, delete, send quotes/estimates
- **Coupons** (5 tools) — Create and manage promo codes
- **Webhooks** (5 tools) — CRUD webhook subscriptions
- **Documents** (4 tools) — List, get, delete, send documents for signature

### Improvements

- Added `dotenv` for `.env` fallback (wrapper script is primary env source)
- Improved error handling: `unhandledRejection` handler, stderr logging
- API key is now optional — server starts gracefully and reports auth errors per-tool
- Added `start-mcp.sh` wrapper script for reliable env var injection

## 1.0.0 — Initial Release

**98 tools across 20 modules.**

### Modules

- **Contacts** (15 tools) — Full CRM: search, create, update, delete, tags, notes, tasks, appointments, workflow management
- **Conversations** (8 tools) — Messaging: search threads, send SMS/email/WhatsApp, manage message status
- **Opportunities** (7 tools) — Pipeline: create/update/delete deals, move through stages, manage status
- **Calendars** (11 tools) — Scheduling: manage calendars, check availability, book/update/cancel appointments
- **Locations** (15 tools) — Admin: sub-account settings, custom fields, custom values, location tags
- **Invoices** (8 tools) — Billing: create, send, void, record payments
- **Social Planner** (5 tools) — Social media: create, list, delete posts, view connected accounts
- **Businesses** (5 tools) — Business entities: full CRUD
- **Blogs** (5 tools) — Blog management: posts, authors, categories, URL slugs
- **Payments** (4 tools) — Orders, subscriptions, transaction history
- **Funnels** (2 tools) — List funnels and pages
- **Forms** (2 tools) — List forms, view submissions
- **Surveys** (2 tools) — List surveys, view submissions
- **Users** (2 tools) — Team member management
- **Media** (2 tools) — Media file browsing and deletion
- **Campaigns** (1 tool) — Campaign listing
- **Workflows** (1 tool) — Workflow listing
- **Courses** (1 tool) — Course listing
- **Emails** (1 tool) — Email campaign listing
- **Trigger Links** (1 tool) — Trigger link listing

### Infrastructure

- MCP server with stdio transport
- GHL API v2 HTTP client with auth, versioning, and error handling
- Zod schema validation on all tool inputs
- One-command setup script (`setup.sh`)
