Auto-triage and tag Zendesk tickets using an agent skill
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
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/scriptsStep 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 accuracyThe 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-invocationprevents 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
- 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 - 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
- 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 - Updates the ticket — calls
PUT /tickets/{id}.jsonto merge the topic tag into existing tags and assign the ticket to the correct support group - 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.pyThe 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.pyOption 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 }}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.