Skip to content

Add Zendesk Support ticketing integration - #225

Merged
daltoniam merged 7 commits into
mainfrom
feat/zendesk-integration
Sep 7, 2026
Merged

Add Zendesk Support ticketing integration#225
daltoniam merged 7 commits into
mainfrom
feat/zendesk-integration

Conversation

@daltoniam

Copy link
Copy Markdown
Owner

Summary

  • Add a Zendesk Support adapter so agents can search, create, update, and comment on tickets without leaving Switchboard.
  • Cover the adjacent support workflow too: users, organizations, groups, views, macros, Help Center articles, tags, and CSAT ratings.

Test plan

  • Adapter unit tests (go test ./integrations/zendesk/)
  • Config defaults and env mapping tests
  • make ci (build, vet, race tests, lint, gosec); govulncheck still reports existing Go 1.26.5 stdlib findings
  • Enable Zendesk in the web UI with subdomain + API token (or OAuth token) and run search / execute for zendesk_search_tickets

daltoniam and others added 2 commits September 6, 2026 14:19
Expose tickets, comments, users, organizations, views, and Help Center
articles through Switchboard so agents can triage customer support
without leaving the MCP workflow.

Co-Authored-By: Crush <noreply@anthropic.com>
Keep the Zendesk adapter on current main so CI can run against a
clean, up-to-date branch.

Co-Authored-By: Crush <noreply@anthropic.com>

@acmacalister acmacalister left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid new adapter — wiring, dispatch/compaction/markdown parity, and auth dual-path all look clean, and CI is green (build/test/lint/security/compose/rust-sdk). Three things worth tightening before this hits real tenants, mainly around search query shaping and create/update payloads.

return mcp.ErrResult(err)
}
if !strings.Contains(query, "type:") {
query = "type:ticket " + query

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

strings.Contains(query, "type:") will false-positive on free-text that just happens to include that substring. Something like prototype: broken login skips the type:ticket prefix and searches across users/orgs too, which is the opposite of what this tool promises.

A token-aware check is safer:

hasType := false
for _, tok := range strings.Fields(query) {
	if strings.HasPrefix(tok, "type:") {
		hasType = true
		break
	}
}
if !hasType {
	query = "type:ticket " + query
}

Worth a regression case in TestSearchTickets for a query containing prototype:.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 92f1389: hasTypeFilter now tokenizes on whitespace and only matches type: prefixes, so prototype: broken login still gets type:ticket.

Comment thread integrations/zendesk/handlers.go Outdated
if tags != "" {
ticket["tags"] = splitCSV(tags)
}
if customFieldsRaw != "" {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

custom_fields only accepts a JSON string. When an LLM (or script) passes a native array — which is the natural shape for [{id, value}, ...]r.Str fails with a type error and the whole create/update bails.

Other adapters that take structured blobs usually accept both string and already-parsed values. Something like:

if raw, ok := args["custom_fields"]; ok && raw != nil {
	switch v := raw.(type) {
	case string:
		if v != "" {
			if err := json.Unmarshal([]byte(v), &fields); err != nil {
				return nil, fmt.Errorf("invalid custom_fields JSON: %w", err)
			}
		}
	case []any:
		fields = v
	default:
		return nil, fmt.Errorf("custom_fields must be a JSON array or string")
	}
	ticket["custom_fields"] = fields
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 92f1389: parseCustomFields accepts a JSON string or a native array/object so LLM/script payloads no longer 400 on type conversion.

Comment thread integrations/zendesk/handlers.go Outdated
ticket["type"] = typ
}
if requesterID != "" {
ticket["requester_id"] = requesterID

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requester_id / assignee_id / group_id / organization_id are typed as integers in the Zendesk Ticket API, but we always ship them as JSON strings via r.Str. Most tenants coerce, but some requests 422 on the type mismatch — especially once IDs come back as numbers from a prior tool call and we re-stringify them.

Prefer parsing to int64 when present so the outbound body matches the schema:

if requesterID != "" {
	id, err := strconv.ParseInt(requesterID, 10, 64)
	if err != nil {
		return nil, fmt.Errorf("requester_id: %w", err)
	}
	ticket["requester_id"] = id
}

Same idea for assignee/group/org (and the empty-assignee unassign path can stay nil).

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 92f1389: optionalIntID now emits integer IDs for requester/assignee/group/organization so the outbound ticket body matches the Zendesk schema.

Keep type:ticket from matching free-text like prototype, send numeric
IDs as integers, and accept native custom_fields arrays so create and
update requests match the Zendesk API.

Co-Authored-By: Crush <noreply@anthropic.com>

@acmacalister acmacalister left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice adapter — dispatch/compaction/markdown parity, dual auth, and the follow-up hardening for search + ticket payloads all look solid. CI is green across build/test/lint/security/compose/rust-sdk. Two small LLM-input edges on public and tags inline; neither is blocking.

Comment thread integrations/zendesk/handlers.go Outdated
body := r.Str("body")
public := true
if v := r.Str("public"); v != "" {
public = v != "false"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

public is parsed as a string and only treated as private when it equals exactly "false". A native bool works because ArgStr lowercases it, but values like "False", "FALSE", "0", or "f" all stay public — the opposite of what the agent intended for an internal note.

r.Bool / mcp.ArgBool already accept those forms. Presence-check + Bool keeps the default-true behavior:

public := true
if _, ok := args["public"]; ok {
	public = r.Bool("public")
}

Same pattern would help ticketPayload's public / verified fields below.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4884e90: public and verified now use r.Bool with a presence check, so False/FALSE/0/f become private notes as intended.

Comment thread integrations/zendesk/handlers.go Outdated
ticket["organization_id"] = id
}
if tags != "" {
ticket["tags"] = splitCSV(tags)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tags only comes through r.Str + splitCSV, so a native array (the shape tickets already return) fails the type conversion and aborts create/update.

r.StrSlice accepts CSV strings and []string/[]any the same way Datadog/GitHub do for labels/tags:

if tags := r.StrSlice("tags"); len(tags) > 0 {
	ticket["tags"] = tags
}

Worth doing in userPayload too so update-user doesn't hit the same wall.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4884e90: tickets and users now take r.StrSlice("tags"), so native arrays and CSV both work.

daltoniam and others added 2 commits September 7, 2026 11:10
Parse public/verified with Bool so False/0/f become internal notes,
and accept tag slices as well as CSV so create/update matches the
shapes tickets already return.

Co-Authored-By: Crush <noreply@anthropic.com>
Bring HubSpot and other main updates onto the Zendesk branch so the
PR stays mergeable.

Co-Authored-By: Crush <noreply@anthropic.com>

@acmacalister acmacalister left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid Zendesk adapter — dual auth, dispatch/compaction/markdown parity, and the earlier search/payload hardening all look good. CI is green across build/test/lint/security/compose/rust-sdk. One non-blocking edge on clearing tags inline.

ticket["organization_id"] = id
}
if len(tags) > 0 {
ticket["tags"] = tags

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update_ticket advertises tags as replacing the existing set, but we only send tags when len(tags) > 0. An explicit clear (tags: [] or tags: "") gets dropped, so the previous tags stick around — same shape as assignee_id empty-to-unassign, just without the presence check.

if _, present := args["tags"]; present {
	if tags == nil {
		tags = []string{}
	}
	ticket["tags"] = tags
}

Worth the same treatment in userPayload a few lines down.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c41c0a0: ticket and user payloads now send tags whenever the argument is present, including empty lists, so an explicit clear replaces the existing set.

daltoniam and others added 2 commits September 7, 2026 12:53
Keep Zendesk alongside Intercom and Okta so the branch stays current
with main.

Co-Authored-By: Crush <noreply@anthropic.com>
Honor explicit empty tags on ticket and user updates so agents can
replace the existing set instead of leaving stale tags in place.

Co-Authored-By: Crush <noreply@anthropic.com>

@acmacalister acmacalister left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #225 Review: Add Zendesk Support ticketing integration

Verification (existing CI)

Check Result
build Pass
test Pass
lint Pass
security Pass
rust-sdk Pass
compose Pass

What'''s Good

  • Clean hexagonal adapter: unexported struct, exported New(), dual auth (OAuth + API token Basic), dispatch map with full parity tests
  • Compaction + markdown coverage for document-shaped tools, shared mcp helpers throughout
  • Earlier review rounds landed solid hardening (token-aware type: filter, int IDs, native tags/custom_fields, empty-tag clears) with regression tests

Must Fix (Blocking)

No issues found

Should Fix (Non-Blocking)

No issues found

Consider (Nice to Have)

  • Tool param copy still says tags are comma-separated only; handlers also accept native arrays — a one-line description tweak would match behavior
  • subdomain is required in the UI credential schema even when base_url alone is enough for Configure (test/proxy setups)

Clean PR — CI is green across build/test/lint/security/compose/rust-sdk and I didn'''t find any blocking issues. LGTM.

@daltoniam
daltoniam merged commit 7f50e6a into main Sep 7, 2026
6 checks passed
@daltoniam
daltoniam deleted the feat/zendesk-integration branch September 7, 2026 18:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants