feat(nodes): add tool_gohighlevel CRM tool node - #1695
Conversation
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>
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
🤖 Internal: Discord sync markerAuto-managed by the Discord notification workflow. Stores the linked Discord message ID and forum thread ID. Do not edit or delete. |
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
requesttool 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.toolGroupscontrols which groups publish. The default ten groups publish 74 tools;["all"]publishes everything. Filtering happens inIInstance._collect_tool_methods(), so a gated tool is invisible totool.queryand refused bytool.invokealike.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, soreadOnly: truepublishes 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/installedLocationsreturns 401,/locations/searchreturns 403, both confirmed live), so N sub-accounts means N node instances.locationIdis 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:
Versionis per operation, never global:2021-04-15for calendars and conversations,2021-07-28elsewhere. 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.)/contacts/{id}/appointmentsreturnsevents), socall()does not unwrap and each tool names its key.GET /opportunities/searchrequireslocation_idin snake case and rejects the camelCase form with a 422. Every other endpoint is camelCase.locationIdor agency-scope refusal, so retrying it burns budget and hides the real error.x-ratelimit-daily-resetis a duration in milliseconds, not an epoch; sleeping on it as a timestamp would produce a ~55-year wait.Retry-After(triggered deliberately and captured; it arrives on request 26 of a 25-request window), so backoff comes fromx-ratelimit-interval-milliseconds.messagesometimes a string and sometimes a list, and a bodystatusCodethat can disagree with the HTTP status (POST /contacts/answers HTTP 400 carryingstatusCode: 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 wrongVersionis the most expensive silent mistake available on this API._build.yamlhas no pytest step, itsTeststep runs./builder test(which does not include node pytest), andos-matrix.yml(the workflow that would) isworkflow_dispatchplus a monthly cron rather than a per-PR check. Node pytest lives behindbuilder 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: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) behindGHL_PIT/GHL_LOCATION_ID, with record-creating tests additionally behindGHL_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_getreturned{}for every appointment.GET /calendars/events/appointments/{eventId}answers underappointment, while both published specs declareevent. 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
message_send, both schedule-cancels,message_transcription_getandmessage_email_getneed a phone or email provider provisioned on the sub-account; a trial has neither.contact_workflow_add/contact_workflow_removecan never be proven from a sub-account, because GoHighLevel publishes no endpoint that lists workflows, so a validworkflowIdcannot 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.rocketlibandai.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.appointment_getbug 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_updatecannot set a custom field withoutcustom_field_listto resolve the field id, andassignedToneeds 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_clientcan still be pointed athttps://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
requestescape hatch has nooutput_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 ondevelopand feat(nodes): add tool_pipedrive CRM tool node #1687 also edits it, so touching it buys a conflict for zero accuracy.tool_pipedrive) is oneCHANGELOG.mdbullet. Whichever merges second rebases.project()helper that raises when a non-empty record projects to nothing. That is the failure mode behind theappointment_getbug, 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