Skip to content

feat(nodes): add tool_gohighlevel CRM tool node - #1695

Draft
charliegillet wants to merge 1 commit into
developfrom
feat/RR-1676-tool-gohighlevel
Draft

feat(nodes): add tool_gohighlevel CRM tool node#1695
charliegillet wants to merge 1 commit into
developfrom
feat/RR-1676-tool-gohighlevel

Conversation

@charliegillet

@charliegillet charliegillet commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Exposes the GoHighLevel (LeadConnector) v2 REST API to agents: 101 tools across 17 resource groups (contacts, notes, tasks, opportunities, pipelines, conversations, messages, calendars, calendar groups, appointments, appointment notes, custom fields, custom values, location tags, locations, businesses, users), plus a generic request tool that reaches any remaining v2 endpoint through the same auth, rate-limit and read-only layer.

Full coverage is more tools than an LLM chooses between reliably, so gohighlevel.toolGroups controls which groups publish. The default ten groups publish 74 tools; ["all"] publishes everything. Filtering happens in IInstance._collect_tool_methods(), so a gated tool is invisible to tool.query and refused by tool.invoke alike.

Read-only mode hides write tools rather than only refusing them. This differs from tool_pipedrive, which refuses at invoke time. A tool the agent can only fail on is wasted context, so readOnly: true publishes 45 tools instead of 102 and leaks none of the 57 write-stamped tools.

Authentication: Private Integration Token only

No OAuth, deliberately. GoHighLevel refresh tokens are single-use and rotating, verbatim from their OAuth docs: "Once you use a Refresh Token to obtain a new Access Token, the original Refresh Token becomes invalid." Access tokens live 24 hours and a node has nowhere durable to persist the replacement. An OAuth credential would therefore work for ~24h, refresh, discard the new token it cannot store, run another ~24h, then present the invalidated original and die, unattended, with no config-only recovery: re-pasting the burned token does nothing. Adding OAuth needs a durable secret store the node can write on every refresh plus a lock against concurrent runs, which is what HighLevel's own SDK ships MongoDB/Redis session storage for.

Consequence worth naming for whoever supports this: one credential reaches exactly one sub-account. Agency fan-out is refused at the API (/oauth/installedLocations returns 401, /locations/search returns 403, both confirmed live), so N sub-accounts means N node instances. locationId is a required config field because the PIT is opaque and carries no location.

Client details specific to GoHighLevel

Each of these was verified against a live sub-account, not taken from the docs:

  • Version is per operation, never global: 2021-04-15 for calendars and conversations, 2021-07-28 elsewhere. Sending one value globally breaks that whole family with a 4xx that reads like an auth failure. (n8n's connector hardcodes one value and is wrong here.)
  • There is no response envelope. Every endpoint names its own top-level key and the key is not always the resource (/contacts/{id}/appointments returns events), so call() does not unwrap and each tool names its key.
  • GET /opportunities/search requires location_id in snake case and rejects the camelCase form with a 422. Every other endpoint is camelCase.
  • Retry on 429 only. A 403 here means a missing locationId or agency-scope refusal, so retrying it burns budget and hides the real error.
  • Rate-limit budgets are read from headers, never assumed. A trial PIT measured 25 requests per 10s and 10,000/day, against the 100/10s and 200,000/day published for marketplace apps. x-ratelimit-daily-reset is a duration in milliseconds, not an epoch; sleeping on it as a timestamp would produce a ~55-year wait.
  • The 429 carries no Retry-After (triggered deliberately and captured; it arrives on request 26 of a 25-request window), so backoff comes from x-ratelimit-interval-milliseconds.
  • Ids are opaque 20-24 character strings, so id validation is string-based rather than integer-based.
  • Five distinct error body shapes, message sometimes a string and sometimes a list, and a body statusCode that can disagree with the HTTP status (POST /contacts/ answers HTTP 400 carrying statusCode: 422), so status always comes from the response.

Testing

203 unit tests, no credentials and no network. The largest class is TestVersionHeader (86 tests), because a wrong Version is the most expensive silent mistake available on this API.

⚠️ These tests do not run in CI, and neither do any other node tests. I assumed they did and checked before claiming it: _build.yaml has no pytest step, its Test step runs ./builder test (which does not include node pytest), and os-matrix.yml (the workflow that would) is workflow_dispatch plus a monthly cron rather than a per-PR check. Node pytest lives behind builder nodes:test, which no workflow invokes. So the green checks on this PR mean lint, secrets, links, dependency constraints, the shell contract, and a three-OS engine build, not this node's tests. To run them:

./builder nodes:test --pytest-pattern="tool_gohighlevel"

That is a pre-existing repo gap rather than anything this PR introduces, but it is worth a reviewer knowing, and it means the 203 passing tests rest on my local run rather than on CI.

Live suite (test_tools.py, 87 tests) behind GHL_PIT / GHL_LOCATION_ID, with record-creating tests additionally behind GHL_ALLOW_WRITES. Run against a real trial sub-account before this PR went up.

Exercised live, full create/read/update/delete lifecycles: contacts (incl. tags, followers, upsert, duplicate check), contact notes, contact tasks, opportunities (incl. a status transition confirmed by re-read), pipelines, conversations, calendars, appointments, custom fields, custom values, location tags, businesses, users, locations.

The live suite found a real bug that no stubbed test could: appointment_get returned {} for every appointment. GET /calendars/events/appointments/{eventId} answers under appointment, while both published specs declare event. The unwrapper only strips a key the payload carries, so the whole envelope reached the cleaner, which matched none of its read keys and emitted nothing. Fixed, and the fix was then verified end to end against the live API (create calendar, create appointment, read back a populated record, delete both). Every other envelope key in all 101 tools was subsequently audited against the OpenAPI specs.

Not proven, stated plainly

  • Messaging is unproven against the API. message_send, both schedule-cancels, message_transcription_get and message_email_get need a phone or email provider provisioned on the sub-account; a trial has neither.
  • The agency-scoped surface is unproven (locations search, location create/update/delete, companies): refused to a sub-account PIT by design.
  • contact_workflow_add / contact_workflow_remove can never be proven from a sub-account, because GoHighLevel publishes no endpoint that lists workflows, so a valid workflowId cannot be obtained.
  • contact_duplicate_check's 200 body is undocumented in both specs and unattested anywhere; the code reads it tolerantly but the shape is a guess.
  • The node has not been exercised by the real engine: both suites stub rocketlib and ai.common. The three-OS build passes with the node present, but that builds the engine rather than importing this node or registering its tools. The contract suite and the IDE canvas have not been run. This is the main reason it is a draft.
  • Pagination past the first page was not exercised under real volume (one trial account, five seeded contacts).
  • 34 tools on surfaces the live suite could not reach (most of calendars, appointment notes, several message tools) are spec-verified only, and the appointment_get bug shows the specs can be wrong.

Scope, and the Phase 1 framing in the issue

The issue frames Phase 1 as contacts, opportunities, conversations/messages, calendars/appointments. That is 76 of these 101 tools. The other 25 are the id-resolution ring Phase 1 does not work without: contact_update cannot set a custom field without custom_field_list to resolve the field id, and assignedTo needs a user lookup. Breadth is implemented, publication is gated, and the default group set maps onto the issue's Phase 1 list.

186 operations were inventoried; 101 are here. 74 are deferred and 11 are out of scope (9 agency-only with the refusal confirmed live, 2 needing a conversation-provider marketplace app). Suggest tracking the deferred 74 as a checklist on a follow-up issue rather than losing them when this closes #1676.

Also considered: bridging their MCP server

GoHighLevel ships an official MCP server, so it was measured rather than dismissed: 36 tools against 576 raw v2 operations (6.3%), only 19 of which touch the 186 operations above (10.2%). It is sub-account only and gates irreversible writes behind an interactive safety confirmation an unattended pipeline node cannot answer. tool_mcp_client can still be pointed at https://services.leadconnectorhq.com/mcp/ with zero code as a complementary exploratory path, but it does not meet the "no functional limitations" bar in the issue.

Notes for review

  • The request escape hatch has no output_schema, deliberately: it returns whatever the endpoint returns. Flagging in case a permissive schema is wanted anyway.
  • docs/README-nodes.md: the row is appended at the end of the Agent Tools table and the node-count line on line 14 is untouched. It is already wrong on develop and feat(nodes): add tool_pipedrive CRM tool node #1687 also edits it, so touching it buys a conflict for zero accuracy.
  • Overlap with feat(nodes): add tool_pipedrive CRM tool node #1687 (tool_pipedrive) is one CHANGELOG.md bullet. Whichever merges second rebases.
  • Suggested follow-up from the envelope audit: a shared project() helper that raises when a non-empty record projects to nothing. That is the failure mode behind the appointment_get bug, and it is currently indistinguishable from "not found" for about ten read tools. It changes failure semantics across every read tool, so it did not belong in this PR.

Fixes #1676

🤖 Generated with Claude Code

Exposes the GoHighLevel (LeadConnector) v2 REST API to agents as 101 tools
across 17 resource groups, plus a generic request tool that reaches any
remaining v2 endpoint through the same auth, rate-limit and read-only layer.

Authentication is a sub-account Private Integration Token only. OAuth is
deliberately absent: GoHighLevel refresh tokens are single-use and rotating,
and a node has nowhere durable to persist the replacement, so an OAuth
credential would work for 24h, refresh, discard the new token, and die
unrecoverably a day later with no config-only recovery.

Full coverage is more tools than an LLM chooses between reliably, so
gohighlevel.toolGroups controls which groups publish; the default ten groups
publish 74 tools. Read-only mode hides every write tool rather than only
refusing it at invoke time, so a read-only agent never sees a tool it cannot
use.

Client details specific to GoHighLevel, each verified against a live
sub-account rather than the published docs:

- Version is a per-operation header, not a global constant: 2021-04-15 for
  calendars and conversations, 2021-07-28 elsewhere.
- There is no response envelope. Every endpoint names its own top-level key,
  and the key is not always the resource, so call() does not unwrap and each
  tool names its key.
- GET /opportunities/search requires location_id in snake case and rejects
  the camelCase form with a 422; every other endpoint is camelCase.
- Retry on 429 only. A 403 means a missing locationId or agency-scope
  refusal, so retrying it burns budget and hides the real error.
- Rate-limit budgets are read from response headers, never assumed: a trial
  token measured 25 requests per 10s and 10,000 per day, against the 100 per
  10s and 200,000 per day published for marketplace apps.
- x-ratelimit-daily-reset is a duration in milliseconds, not an epoch.
- Ids are opaque 20 to 24 character strings, so id validation is string-based.

Tests: 203 unit tests that need no credentials or network, plus an env-gated
live suite behind GHL_PIT and GHL_LOCATION_ID, with record-creating tests
additionally behind GHL_ALLOW_WRITES.

Refs #1676

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 93d475bb-7672-4251-b419-eb2b7f5ad4fd

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/RR-1676-tool-gohighlevel

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added docs Documentation module:nodes Python pipeline nodes labels Jul 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor
🤖 Internal: Discord sync marker

Auto-managed by the Discord notification workflow. Stores the linked Discord message ID and forum thread ID. Do not edit or delete.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation module:nodes Python pipeline nodes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(nodes): tool_gohighlevel — full GoHighLevel API v2 CRM tool

1 participant