Add Gong conversation intelligence integration - #208
Conversation
Expose Gong call recordings, transcripts, users, library folders, and coaching stats so agents can review sales conversations through Switchboard. 💘 Generated with Crush Assisted-by: Crush:grok-4.5
acmacalister
left a comment
There was a problem hiding this comment.
Clean PR — CI/status checks are passing and I didn't find any blocking issues. LGTM.
acmacalister
left a comment
There was a problem hiding this comment.
Solid adapter scaffolding overall — config/env wiring, dispatch parity, Basic auth, and the call/transcript filter validation look good. CI is green (build/test/lint/security/rust-sdk). A few tools look misaligned with Gong's public API though and will fail at runtime; details inline.
| "toDateTime": to, | ||
| "cursor": cursor, | ||
| } | ||
| data, err := g.get(ctx, "/v2/logs%s", queryEncode(params)) |
There was a problem hiding this comment.
Gong's GET /v2/logs requires a logType query param (AccessLog, UserActivityLog, UserCallPlay, ExternallySharedCallAccess, or ExternallySharedCallPlay) — see https://help.gong.io/apidocs/retrieve-logs-data-by-type-and-time-range-v2logs. Right now we only forward the date range/cursor, so this call will 400 in production.
Worth adding a required log_type tool arg and mapping it into logType, and making to_date_time optional to match the API.
| } | ||
|
|
||
| func listDataPrivacy(ctx context.Context, g *gong, args map[string]any) (*mcp.ToolResult, error) { | ||
| data, err := g.get(ctx, "/v2/data-privacy/data-for-all-users%s", queryEncode(cursorParam(args))) |
There was a problem hiding this comment.
/v2/data-privacy/data-for-all-users isn't a Gong endpoint. The public Data Privacy API only has per-subject lookups:
GET /v2/data-privacy/data-for-email-address?emailAddress=...GET /v2/data-privacy/data-for-phone-number?phoneNumber=...
(and the corresponding erase variants). This tool will 404 as written. Probably want to split into email/phone tools (or one tool with a required subject) instead of a bulk "all users" list.
| if err != nil { | ||
| return mcp.ErrResult(err) | ||
| } | ||
| data, err := g.post(ctx, "/v2/stats/activity/scorecards", body) |
There was a problem hiding this comment.
listStatsScorecards reuses statsBody, which posts filter.fromDate / filter.toDate / filter.userIds / filter.workspaceId. The scorecards endpoint expects a different shape: callFromDate/callToDate, reviewFromDate/reviewToDate, reviewedUserIds, scorecardIds (no fromDate/userIds/workspaceId) — https://help.gong.io/apidocs/retrieve-answered-scorecards-for-applicable-reviewed-users-or-scorecards-for-a-date-range-v2statsactivityscorecards.
Activity/interaction can keep the shared helper; scorecards needs its own body builder (and tool params) or every scorecard call will fail validation.
| - requestId | ||
| gong_list_stats_scorecards: | ||
| spec: | ||
| - scorecardStats |
There was a problem hiding this comment.
Response field here is off too — Gong returns answeredScorecards, not scorecardStats. Once the request body is fixed, this compaction path will still drop the payload unless we keep answeredScorecards (and probably records.cursor).
| - requestId | ||
| gong_list_stats_activity: | ||
| spec: | ||
| - peopleActivityStats |
There was a problem hiding this comment.
Activity aggregate returns usersAggregateActivityStats, not peopleActivityStats (that name is closer to the interaction endpoint's peopleInteractionStats). Compaction will strip the actual stats array as written.
| Required: []string{"from_date", "to_date"}, | ||
| }, | ||
| { | ||
| Name: mcp.ToolName("gong_list_logs"), Description: "List Gong API and activity logs for auditing integration usage", |
There was a problem hiding this comment.
Same logs issue from the tool side: description/params don't expose the required log_type, and they mark to_date_time required even though Gong treats it as optional. Once the handler takes logType, mirror that here so the model can actually call it correctly.
| Required: []string{"from_date_time", "to_date_time"}, | ||
| }, | ||
| { | ||
| Name: mcp.ToolName("gong_list_data_privacy"), Description: "List Gong data-privacy deletion requests and compliance status", |
There was a problem hiding this comment.
Description says "deletion requests and compliance status", but the real privacy endpoints return references for a specific email/phone (or erase that subject). Once the path is corrected, the tool name/description/params should reflect a subject lookup rather than a paginated request list.
acmacalister
left a comment
There was a problem hiding this comment.
Nice follow-up on the earlier logs/privacy/scorecard field-name feedback — those look aligned now, and CI is green across build/test/lint/security/rust-sdk. A few remaining Gong schema mismatches will still break or empty out real responses; details inline.
| } | ||
| body["scorecardIds"] = ids | ||
| } | ||
| data, err := g.post(ctx, "/v2/stats/activity/scorecards", body) |
There was a problem hiding this comment.
The scorecards request body still doesn't match Gong's schema. POST /v2/stats/activity/scorecards expects { "filter": { callFromDate, callToDate, ... }, "cursor": ... } — see https://help.gong.io/apidocs/retrieve-answered-scorecards-for-applicable-reviewed-users-or-scorecards-for-a-date-range-v2statsactivityscorecards.md. Right now we put callFromDate/callToDate/etc. at the top level, so this will 400 once it hits the real API.
Also worth noting Gong treats the call/review date fields as optional (any subset is valid), so forcing call_from_date/call_to_date is stricter than the API.
filter := map[string]any{
"callFromDate": callFrom,
"callToDate": callTo,
}
// optional review*/reviewedUserIds/scorecardIds on filter...
body := map[string]any{"filter": filter}The existing test only asserts the flat keys we send, so it won't catch this until live use.
| } | ||
| filter := body["filter"].(map[string]any) | ||
| if workspaceID != "" { | ||
| filter["workspaceId"] = workspaceID |
There was a problem hiding this comment.
workspaceId isn't part of Gong's activity/interaction filter (MultipleUsersWithDates only has fromDate, toDate, optional userIds, and optional created* timestamps) — https://help.gong.io/apidocs/retrieve-aggregated-activity-for-defined-users-by-date-v2statsactivityaggregate.md. Sending it is at best ignored and at worst a 400 depending on how strict Gong is.
Same for the tool params on gong_list_stats_activity / gong_list_stats_interaction — dropping workspace_id there keeps the model from inventing a filter the API doesn't support. Cursor is supported on these endpoints too if we want pagination parity.
| - requestId | ||
| gong_get_data_privacy: | ||
| spec: | ||
| - requestId |
There was a problem hiding this comment.
This compaction path only keeps requestId, so after processResult the useful privacy payload disappears. Gong returns emails, calls, meetings, customerData, and customerEngagement (phone variant also has phone match fields) — https://help.gong.io/apidocs/retrieve-all-references-to-an-email-address-v2data-privacydata-for-email-address.md.
Something like:
gong_get_data_privacy:
spec:
- emails
- calls
- meetings
- customerData
- customerEngagement
- requestId(plus any phone-specific top-level fields you care about).
| spec: | ||
| - id | ||
| - name | ||
| - folderContent |
There was a problem hiding this comment.
Library folder content doesn't return folderContent — the response is folder metadata plus a calls[] array (id, title, note, addedBy, created, url, snippet) per https://help.gong.io/apidocs/list-of-calls-in-a-specific-folder-v2libraryfolder-content-2.md. As written, compaction will drop the calls list.
gong_get_library_folder:
spec:
- id
- name
- createdBy
- updated
- calls[].id
- calls[].title
- calls[].url
- calls[].note
- requestIdRelated: the handler also forwards workspaceId, but that endpoint only takes folderId.
acmacalister
left a comment
There was a problem hiding this comment.
Solid Gong adapter — config/env wiring, Basic auth, dispatch parity, and the earlier schema fixes (logs/privacy/scorecards/library) all look good. CI is green across build/test/lint/security/rust-sdk. One small pagination gap in the stats compaction specs inline; otherwise LGTM.
| - requestId | ||
| gong_list_stats_interaction: | ||
| spec: | ||
| - peopleInteractionStats |
There was a problem hiding this comment.
Activity and interaction both return a records object with a pagination cursor (same shape as scorecards/logs), but these two specs only keep the stats array + requestId. After processResult the cursor disappears, so multi-page stats responses can't be continued.
Worth mirroring the scorecards/logs pattern:
gong_list_stats_activity:
spec:
- usersAggregateActivityStats
- records.cursor
- requestId
gong_list_stats_interaction:
spec:
- peopleInteractionStats
- records.cursor
- requestId
acmacalister
left a comment
There was a problem hiding this comment.
Nice cleanup pass on the earlier Gong schema mismatches — logs/privacy/scorecards/workspace filters look aligned with the public API now, and CI is green across build/test/lint/security/rust-sdk. A few compaction-shape issues will still empty out the most important payloads at runtime; details inline.
| - callTranscripts[].transcript[].topic | ||
| - callTranscripts[].transcript[].sentences[].text | ||
| - callTranscripts[].transcript[].sentences[].start | ||
| - callTranscripts[].transcript[].sentences[].end |
There was a problem hiding this comment.
gong_get_transcripts is the core value of this adapter, but this compaction path doesn't survive the nested-array rules in docs/field-compaction.md. Multiple specs on the same nested array overwrite each other, so a real payload like:
{"callTranscripts":[{"callId":"c1","transcript":[{"speakerId":"s1","topic":"pricing","sentences":[{"text":"hello","start":0,"end":1}]}]}]}compacts down to roughly {"callTranscripts":[{"callId":"c1","transcript":["pricing"]}]} — speaker IDs and all sentence text disappear after processResult.
Same pattern bites gong_list_calls_extensive parties (calls[].parties[].emailAddress/name/userId collapses to a flat id list).
For transcripts, project the monologue whole (and cap size), similar to gmeet:
gong_get_transcripts:
spec:
- callTranscripts[].callId
- callTranscripts[].transcript
- records.totalRecords
- records.cursor
- requestId
max_bytes: 200000And for extensive parties, either keep calls[].parties whole or drop to a single projected field intentionally.
| } | ||
| if reviewedUserIDsRaw == "" { | ||
| reviewedUserIDsRaw = r.Str("user_ids") | ||
| } |
There was a problem hiding this comment.
These back-compat aliases (from_date / to_date / user_ids) never reach the handler through normal execute — validateArgs rejects anything not declared on the tool schema, and gong_list_stats_scorecards only lists the call_* / review_* / reviewed_user_ids names.
So this is dead code today (and slightly misleading if someone reads the handler as the source of truth). Safer to drop the aliases, or declare them on the tool if we really want both spellings.
| gong_list_stats_activity: | ||
| spec: | ||
| - usersAggregateActivityStats | ||
| - requestId |
There was a problem hiding this comment.
Activity/interaction responses include records.cursor for paging (same as scorecards/logs), but these specs only keep the stats array + requestId. Once the first page is compacted the cursor is gone, so the model can't page.
Worth mirroring the other list tools:
- usersAggregateActivityStats
- records.cursor
- requestId(and the same for peopleInteractionStats).
acmacalister
left a comment
There was a problem hiding this comment.
Solid Gong adapter — earlier schema fixes (logs/privacy/scorecards/compaction field names) look correct, and CI is green across build/test/lint/security/rust-sdk. A few remaining defaults/docs gaps that will bite on first real use; details inline.
| if cursor != "" { | ||
| body["cursor"] = cursor | ||
| } | ||
| if contentSelectorRaw != "" { |
There was a problem hiding this comment.
When content_selector is omitted, Gong only returns metaData per call — parties/content/media/collaboration only appear when the matching contentSelector.exposedFields.* flags are set true (see https://help.gong.io/apidocs/retrieve-detailed-call-data-by-various-filters-v2callsextensive-2).
The tool description advertises parties/content/media/collaboration, and the compaction spec keeps calls[].parties / content.trackers / content.topics, but the default request shape won't populate any of that. Worth defaulting a useful selector when the arg is empty, e.g.:
if contentSelectorRaw != "" {
// parse as today
} else {
body["contentSelector"] = map[string]any{
"exposedFields": map[string]any{
"parties": true,
"content": map[string]any{
"topics": true,
"trackers": true,
},
},
}
}(or document that callers must pass content_selector and drop those sections from the default description/spec).
| tools: | ||
| gong_list_calls: | ||
| spec: | ||
| - calls[].id |
There was a problem hiding this comment.
CallBasicData includes a Gong web url on list responses (same shape as gong_get_call), and that's usually the highest-value field after id/title for "open this call" workflows. gong_get_call already keeps call.url — worth mirroring here so list → deep-link doesn't need an extra get:
- calls[].url| gong_get_transcripts: | ||
| spec: | ||
| - callTranscripts[].callId | ||
| - callTranscripts[].transcript |
There was a problem hiding this comment.
Keeping callTranscripts[].transcript whole is the right fix for nested monologues, but a single multi-hour call can still blow past a useful context budget (gmeet caps transcript-style tools at max_bytes: 200000). Worth adding the same guard so oversized pages become a structured response_too_large instead of flooding the model:
gong_get_transcripts:
spec:
- callTranscripts[].callId
- callTranscripts[].transcript
- records.totalRecords
- records.cursor
- requestId
max_bytes: 200000| Name: mcp.ToolName("gong_get_data_privacy"), Description: "Look up Gong data-privacy references for a subject email or phone number", | ||
| Parameters: map[string]string{ | ||
| "email": "Subject email address (provide exactly one of email or phone_number)", | ||
| "phone_number": "Subject phone number (provide exactly one of email or phone_number)", |
There was a problem hiding this comment.
Gong's privacy phone endpoint requires the number to start with + (country code); anything else 400s. A short hint on the param would save a failed round-trip:
"phone_number": "Subject phone number starting with + and country code (provide exactly one of email or phone_number)",
acmacalister
left a comment
There was a problem hiding this comment.
Nice cleanup pass — earlier Gong schema mismatches (logs/privacy/scorecards/contentSelector default/transcript max_bytes) look aligned with the public API now, and CI is green across build/test/lint/security/rust-sdk. A few remaining default/compaction gaps inline; nothing blocking.
| "trackers": true, | ||
| "pointsOfInterest": true, | ||
| }, | ||
| "media": true, |
There was a problem hiding this comment.
Defaulting media: true here is a bit sharp — Gong docs say clients that request contentSelector.exposedFields.media also need the api:calls:read:media-url scope (https://help.gong.io/apidocs/retrieve-detailed-call-data-by-various-filters-v2callsextensive-2). On keys without that scope this can turn the default extensive path into a 403, and we don't keep media in the compaction spec anyway so the URLs get stripped even when the call succeeds.
Safer default is parties + content only, and let callers opt into media via content_selector when they have the scope:
body["contentSelector"] = map[string]any{
"exposedFields": map[string]any{
"parties": true,
"content": map[string]any{
"topics": true,
"trackers": true,
"brief": true,
},
},
}(pointsOfInterest is also marked deprecated in the OpenAPI schema.)
| - calls[].metaData.duration | ||
| - calls[].metaData.direction | ||
| - calls[].metaData.primaryUserId | ||
| - calls[].metaData.workspaceId |
There was a problem hiding this comment.
Same deep-link gap we just fixed on gong_list_calls — extensive metaData is CallBasicData and includes url, but we don't keep it here. After list_extensive the model still needs a separate get_call just to open the recording in Gong.
- calls[].metaData.url| - meetings | ||
| - customerData | ||
| - customerEngagement | ||
| - requestId |
There was a problem hiding this comment.
Phone-subject responses don't use customerEngagement — they return suppliedPhoneNumber, matchingPhoneNumbers, and emailAddresses instead (https://help.gong.io/apidocs/retrieve-all-references-to-a-phone-number-v2data-privacydata-for-phone-number). As written, a phone lookup keeps the shared email/call arrays but drops the match metadata that tells you what Gong actually resolved.
gong_get_data_privacy:
spec:
- emails
- calls
- meetings
- customerData
- customerEngagement
- suppliedPhoneNumber
- matchingPhoneNumbers
- emailAddresses
- requestId| } | ||
| body["contentSelector"] = cs | ||
| } else { | ||
| body["contentSelector"] = map[string]any{ |
There was a problem hiding this comment.
Worth a small httptest assertion that the default branch actually posts this selector (and that an explicit content_selector still wins). The last review rounds kept catching schema drift that unit tests never saw — this default is the path every real list_calls_extensive call will take.
| Required: []string{"call_id"}, | ||
| }, | ||
| { | ||
| Name: mcp.ToolName("gong_list_calls_extensive"), Description: "Retrieve extensive Gong call details (parties, content, media, collaboration) for filtered calls. Prefer over repeated get_call when many fields are needed. Requires a date range and/or call_ids.", |
There was a problem hiding this comment.
Description still advertises collaboration, but neither the default contentSelector nor the compaction spec requests/keeps it. Either drop it from the blurb or add collaboration.publicComments: true (and a matching compact path) so the model isn't promised data it won't get.
acmacalister
left a comment
There was a problem hiding this comment.
Solid Gong adapter — earlier schema fixes (logs/privacy/scorecards/contentSelector/transcripts) look aligned with the public API, and CI is green across build/test/lint/security/rust-sdk. One small default vs compaction mismatch on extensive call briefs inline; nothing blocking.
| - calls[].metaData.url | ||
| - calls[].parties | ||
| - calls[].content.trackers | ||
| - calls[].content.topics |
There was a problem hiding this comment.
Default extensive path turns on content.brief (handlers.go default selector), and Gong returns that as calls[].content.brief (string spotlight brief) when the flag is set — https://help.gong.io/apidocs/retrieve-detailed-call-data-by-various-filters-v2callsextensive-2. We keep topics/trackers here but not brief, so after processResult the brief we just paid for gets stripped.
- calls[].content.briefEither keep it, or drop brief: true from the default selector so we aren't requesting a field we never surface.
acmacalister
left a comment
There was a problem hiding this comment.
Solid Gong adapter — earlier schema fixes (logs/privacy/scorecards/contentSelector/transcripts/brief) look aligned with the public API, and CI is green across build/test/lint/security/rust-sdk. Two small non-blocking notes inline.
| { | ||
| Name: mcp.ToolName("gong_list_calls"), Description: "List Gong sales call recordings and conversations in a date range. Start here for call review, coaching, deal conversations, and conversation intelligence workflows.", | ||
| Parameters: map[string]string{ | ||
| "from_date_time": "Start of range (ISO 8601, required by Gong; max 90-day window)", |
There was a problem hiding this comment.
Gong's public docs for GET /v2/calls don't document a max date-range span (no 90-day window in the OpenAPI for fromDateTime/toDateTime — https://help.gong.io/apidocs/retrieve-call-data-by-date-range-v2calls-2). Calling out "max 90-day window" here will push the model to unnecessarily chop ranges or treat larger windows as invalid. Safer to drop that claim (or rephrase as a soft guidance tip if we've seen it fail in practice).
| g := &gong{} | ||
| _, ok := g.CompactSpec("gong_nonexistent") | ||
| assert.False(t, ok, "unknown tools should return false") | ||
| } |
There was a problem hiding this comment.
We're missing a shape-parity test against representative Gong payloads. Earlier rounds on this PR kept catching compaction paths that compiled fine but emptied real responses (peopleActivityStats vs usersAggregateActivityStats, nested transcript monologues, library calls[], etc.), and those only showed up at runtime.
Project convention for adapters with compact.yaml is parity + shape tests (see integrations/amazon/compact_specs_test.go TestFieldCompactionSpecs_ShapeParity). A small table of fixture JSON per tool asserting compaction isn't {}/[] would lock in the schema fixes already landed here.
Pull in Ramp from main, drop the incorrect 90-day call-list claim, and add compaction shape-parity fixtures so schema drift is caught in CI. 💘 Generated with Crush Assisted-by: Crush:grok-4.5
acmacalister
left a comment
There was a problem hiding this comment.
Clean PR — CI is green (build/test/lint/security/rust-sdk) and the earlier Gong schema fixes (logs, privacy, scorecards, contentSelector defaults, transcript/brief compaction, shape-parity fixtures) all look solid. No blocking issues. LGTM.
Summary
GONG_ACCESS_KEY/GONG_ACCESS_KEY_SECRET(optional workspace-specificGONG_BASE_URL)Test plan
make build vet test-race lintandgovulncheckclean locallysearchforgong callsexecute gong_list_callswith a date range andgong_get_transcriptsfor a known call ID