Auto-triage and tag Zendesk tickets using an agent skill

low complexityCost: Usage-based

Prerequisites

Prerequisites
  • Claude Code, Cursor, or another AI coding agent that supports skills
  • Zendesk account with API access enabled
  • Anthropic API key for Claude-powered classification
Environment Variables
# Your Zendesk subdomain (e.g. acme for acme.zendesk.com)
ZENDESK_SUBDOMAIN=your_value_here
# Zendesk agent email for API authentication
ZENDESK_EMAIL=your_value_here
# Zendesk API token from Admin Center > Apps and Integrations > APIs
ZENDESK_API_TOKEN=your_value_here
# Anthropic API key for Claude ticket classification
ANTHROPIC_API_KEY=your_value_here

Overview

This approach uses an agent skill that fetches untagged open tickets from Zendesk, passes each one to Claude (claude-haiku-4-5) for topic classification, and writes the tag and group assignment back via the Zendesk API. Because Claude reads the full ticket body and understands intent rather than matching keywords, it handles vague, multilingual, and conversational tickets that keyword triggers would miss.

Step 1: Create the skill directory

mkdir -p .claude/skills/zendesk-triage/scripts

Step 2: Write the SKILL.md

Create .claude/skills/zendesk-triage/SKILL.md:

---
name: zendesk-triage
description: Classifies untagged Zendesk tickets by topic using Claude, then applies tags and assigns to the correct support group via the Zendesk API.
disable-model-invocation: true
allowed-tools: Bash(python *)
---
 
Triage and tag untagged Zendesk tickets:
 
1. Run: `python $SKILL_DIR/scripts/triage.py`
2. Review the output — it lists each ticket classified and the assigned topic
3. Spot-check a few in Zendesk to verify accuracy

The key settings:

  • disable-model-invocation: true — the skill has external side effects (writing tags and group assignments to Zendesk), so it only runs when you explicitly invoke it with /zendesk-triage. The script calls Claude's API directly for classification — disable-model-invocation prevents the skill framework from making additional model calls, not the script itself.
  • allowed-tools: Bash(python *) — restricts execution to Python scripts only, preventing unintended shell commands

Step 3: Write the triage script

Create .claude/skills/zendesk-triage/scripts/triage.py:

#!/usr/bin/env python3
"""
Zendesk Ticket Triage
Fetches untagged open tickets → classifies with Claude → applies topic tags and group assignment.
"""
import os
import json
import base64
import urllib.request
import urllib.parse
 
try:
    from anthropic import Anthropic
except ImportError:
    os.system("pip install anthropic -q")
    from anthropic import Anthropic
 
SUBDOMAIN = os.environ["ZENDESK_SUBDOMAIN"]
EMAIL     = os.environ["ZENDESK_EMAIL"]
TOKEN     = os.environ["ZENDESK_API_TOKEN"]
BASE_URL  = f"https://{SUBDOMAIN}.zendesk.com/api/v2"
 
credentials = base64.b64encode(f"{EMAIL}/token:{TOKEN}".encode()).decode()
HEADERS = {
    "Authorization": f"Basic {credentials}",
    "Content-Type": "application/json",
}
 
TOPIC_MAP = {
    "BILLING":   "topic-billing",
    "SHIPPING":  "topic-shipping",
    "PRODUCT":   "topic-product",
    "ACCOUNT":   "topic-account",
    "TECHNICAL": "topic-technical",
    "OTHER":     "topic-uncategorized",
}
 
# Map topic tags to Zendesk group names — update these to match your Zendesk groups
GROUP_NAMES = {
    "topic-billing":        "Billing Support",
    "topic-shipping":       "Shipping Support",
    "topic-product":        "Product Support",
    "topic-account":        "Account Support",
    "topic-technical":      "Technical Support",
    "topic-uncategorized":  "General Support",
}
 
client = Anthropic()
 
 
def zendesk_get(path: str) -> dict:
    url = f"{BASE_URL}{path}"
    req = urllib.request.Request(url, headers=HEADERS)
    with urllib.request.urlopen(req) as resp:
        return json.loads(resp.read())
 
 
def zendesk_put(path: str, data: dict) -> dict:
    url = f"{BASE_URL}{path}"
    body = json.dumps(data).encode()
    req = urllib.request.Request(url, data=body, headers=HEADERS, method="PUT")
    with urllib.request.urlopen(req) as resp:
        return json.loads(resp.read())
 
 
def get_untagged_tickets(limit: int = 50) -> list:
    topic_tags = " ".join(f"-tags:{t}" for t in TOPIC_MAP.values())
    query = urllib.parse.quote(f"type:ticket status:open {topic_tags}")
    data = zendesk_get(f"/search.json?query={query}&per_page={limit}")
    return data.get("results", [])
 
 
def classify_ticket(subject: str, description: str) -> str:
    prompt = (
        "You are a support ticket classifier. Classify this ticket into exactly one topic.\n\n"
        f"Subject: {subject}\n"
        f"Description (first 500 chars): {description[:500]}\n\n"
        "Reply with exactly one of:\n"
        "- BILLING — invoices, charges, payments, subscriptions, pricing\n"
        "- SHIPPING — delivery, tracking, packages, lost items, shipping delays\n"
        "- PRODUCT — product quality, defects, wrong item, size/color issues\n"
        "- ACCOUNT — login, password, account access, profile settings\n"
        "- TECHNICAL — errors, bugs, crashes, API issues, integrations\n"
        "- OTHER — doesn't fit any of the above\n\n"
        "Reply with only the label, nothing else."
    )
    message = client.messages.create(
        model="claude-haiku-4-5-20251001",
        max_tokens=20,
        messages=[{"role": "user", "content": prompt}],
    )
    return message.content[0].text.strip().upper()
 
 
def get_groups() -> dict:
    data = zendesk_get("/groups.json")
    return {g["name"]: g["id"] for g in data.get("groups", [])}
 
 
def main() -> None:
    print("Fetching untagged open tickets...")
    tickets = get_untagged_tickets()
    print(f"Found {len(tickets)} untagged tickets to classify\n")
 
    if not tickets:
        print("No untagged tickets to process.")
        return
 
    groups = get_groups()
    classified = 0
 
    for ticket in tickets:
        subject = ticket.get("subject", "")
        description = ticket.get("description", "")
 
        label = classify_ticket(subject, description)
        tag = TOPIC_MAP.get(label, "topic-uncategorized")
        group_name = GROUP_NAMES.get(tag, "General Support")
        group_id = groups.get(group_name)
 
        existing_tags = ticket.get("tags", [])
        new_tags = list(set(existing_tags + [tag]))
 
        update_data = {"ticket": {"tags": new_tags}}
        if group_id:
            update_data["ticket"]["group_id"] = group_id
 
        zendesk_put(f"/tickets/{ticket['id']}.json", update_data)
        classified += 1
        print(f"  #{ticket['id']}  {subject[:60]!r}{tag} ({group_name})")
 
    print(f"\nClassified {classified} tickets.")
 
 
if __name__ == "__main__":
    main()

Troubleshooting

What the script does

  1. Searches for untagged tickets — uses the Zendesk Search API (/search.json) to find open tickets that don't already have any topic tag, using negative tag filters for each category
  2. Classifies each ticket with Claude — sends the subject and first 500 characters of the description to claude-haiku-4-5, which returns one label: BILLING, SHIPPING, PRODUCT, ACCOUNT, TECHNICAL, or OTHER
  3. Maps labels to tags and groups — converts the Claude label to a Zendesk tag (e.g., topic-billing) and looks up the corresponding support group ID (e.g., "Billing Support") from the groups API
  4. Updates the ticket — calls PUT /tickets/{id}.json to merge the topic tag into existing tags and assign the ticket to the correct support group
  5. Logs results — prints each ticket ID, truncated subject, assigned tag, and group name for verification

Step 4: Run the skill

# Via Claude Code
/zendesk-triage
 
# Or run the script directly
python .claude/skills/zendesk-triage/scripts/triage.py

The script processes up to 50 untagged open tickets per run. At claude-haiku-4-5 pricing, classifying 50 tickets costs roughly $0.05 in API calls.

Step 5: Schedule it

Option A: Cron

# crontab -e — run every 30 minutes during business hours on weekdays
*/30 8-18 * * 1-5 cd /path/to/project && python .claude/skills/zendesk-triage/scripts/triage.py

Option B: GitHub Actions

name: Zendesk Ticket Triage
on:
  schedule:
    - cron: '*/30 13-22 * * 1-5'  # 8 AM–5 PM ET, weekdays
  workflow_dispatch: {}
jobs:
  triage:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: pip install anthropic
      - run: python .claude/skills/zendesk-triage/scripts/triage.py
        env:
          ZENDESK_SUBDOMAIN: ${{ secrets.ZENDESK_SUBDOMAIN }}
          ZENDESK_EMAIL:     ${{ secrets.ZENDESK_EMAIL }}
          ZENDESK_API_TOKEN: ${{ secrets.ZENDESK_API_TOKEN }}
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
AI classification adds latency compared to native triggers

Unlike keyword triggers that fire instantly, each ticket takes 1-2 seconds to classify with Claude. For instant triage on obvious keywords, combine this skill with Zendesk native triggers and use the skill to catch the tickets that slip through without a topic tag.

When to use this approach

  • You need semantic classification — Claude understands "my parcel never showed up" as a shipping issue even without the word "shipping"
  • You have multilingual customers and keyword triggers fail on non-English tickets
  • You want to test category definitions before hardcoding them into Zendesk triggers

Cost

  • Claude Haiku: ~$0.001 per ticket classified
  • 500 tickets/month = ~$0.50 in API costs

Need help implementing this?

We build and optimize automation systems for mid-market businesses. Let's discuss the right approach for your team.