Audit VIP 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
  • Slack bot with chat:write permission added to the target channel
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
# Slack bot token with chat:write permission
SLACK_BOT_TOKEN=your_value_here
# Target Slack channel ID for VIP escalation alerts
SLACK_CHANNEL_ID=your_value_here

Overview

This agent skill performs a batch audit of recent Zendesk tickets — it finds any tickets from VIP organizations that weren't tagged as VIP, flags them with the correct tag and priority, and sends Slack alerts for each one. This catches tickets that slip through when an organization's VIP tag is added after a ticket was already created, or when a trigger misconfigures. Think of it as a safety net behind your Zendesk trigger.

Step 1: Create the skill directory

mkdir -p .claude/skills/zendesk-vip-audit/scripts

Step 2: Write the SKILL.md

Create .claude/skills/zendesk-vip-audit/SKILL.md:

---
name: zendesk-vip-audit
description: Audits recent Zendesk tickets to find and flag any from VIP organizations that weren't tagged as VIP.
disable-model-invocation: true
allowed-tools: Bash(python *)
---
 
Audit recent tickets for missed VIP flags:
 
1. Run: `python $SKILL_DIR/scripts/vip_audit.py`
2. Review the output — it shows tickets from VIP orgs that were missing the vip tag
3. Any newly flagged tickets will have Slack alerts sent automatically

The key settings:

  • disable-model-invocation: true — the skill has external side effects (tagging tickets in Zendesk and posting to Slack), so it only runs when you explicitly invoke it with /zendesk-vip-audit
  • allowed-tools: Bash(python *) — restricts execution to Python scripts only, preventing unintended shell commands

Step 3: Write the audit script

Create .claude/skills/zendesk-vip-audit/scripts/vip_audit.py:

#!/usr/bin/env python3
"""
Zendesk VIP Audit
Finds recent tickets from VIP organizations that weren't tagged and flags them.
"""
import os
import json
import base64
import urllib.request
import urllib.parse
 
SUBDOMAIN = os.environ["ZENDESK_SUBDOMAIN"]
EMAIL     = os.environ["ZENDESK_EMAIL"]
TOKEN     = os.environ["ZENDESK_API_TOKEN"]
SLACK_TOKEN   = os.environ.get("SLACK_BOT_TOKEN", "")
SLACK_CHANNEL = os.environ.get("SLACK_CHANNEL_ID", "")
VIP_GROUP_NAME = os.environ.get("VIP_GROUP_NAME", "VIP Support")
 
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",
}
 
 
def zendesk_get(path: str) -> dict:
    req = urllib.request.Request(f"{BASE_URL}{path}", headers=HEADERS)
    with urllib.request.urlopen(req) as resp:
        return json.loads(resp.read())
 
 
def zendesk_put(path: str, data: dict) -> dict:
    body = json.dumps(data).encode()
    req = urllib.request.Request(
        f"{BASE_URL}{path}", data=body, headers=HEADERS, method="PUT"
    )
    with urllib.request.urlopen(req) as resp:
        return json.loads(resp.read())
 
 
def slack_post(text: str, blocks: list) -> None:
    if not SLACK_TOKEN or not SLACK_CHANNEL:
        return
    data = json.dumps(
        {"channel": SLACK_CHANNEL, "text": text, "blocks": blocks}
    ).encode()
    req = urllib.request.Request(
        "https://slack.com/api/chat.postMessage",
        data=data,
        headers={
            "Authorization": f"Bearer {SLACK_TOKEN}",
            "Content-Type": "application/json",
        },
    )
    with urllib.request.urlopen(req) as resp:
        json.loads(resp.read())
 
 
def get_vip_org_ids() -> set:
    orgs = zendesk_get("/organizations.json?per_page=100").get(
        "organizations", []
    )
    return {o["id"] for o in orgs if "vip" in (o.get("tags") or [])}
 
 
def get_groups() -> dict:
    groups = zendesk_get("/groups.json").get("groups", [])
    return {g["name"]: g["id"] for g in groups}
 
 
def main() -> None:
    print("Loading VIP organizations...")
    vip_ids = get_vip_org_ids()
    print(f"Found {len(vip_ids)} VIP organization(s)\n")
 
    if not vip_ids:
        print(
            "No VIP organizations found. "
            "Tag organizations with 'vip' in Zendesk first."
        )
        return
 
    groups = get_groups()
    vip_group_id = groups.get(VIP_GROUP_NAME)
 
    query = urllib.parse.quote(
        "type:ticket status:open status:pending -tags:vip"
    )
    tickets = zendesk_get(f"/search.json?query={query}&per_page=100").get(
        "results", []
    )
    print(f"Checking {len(tickets)} untagged tickets...\n")
 
    flagged = 0
    for ticket in tickets:
        org_id = ticket.get("organization_id")
        if org_id not in vip_ids:
            continue
 
        existing_tags = ticket.get("tags", [])
        new_tags = list(set(existing_tags + ["vip"]))
        update = {"ticket": {"tags": new_tags, "priority": "urgent"}}
        if vip_group_id:
            update["ticket"]["group_id"] = vip_group_id
 
        zendesk_put(f"/tickets/{ticket['id']}.json", update)
        flagged += 1
 
        subject = ticket.get("subject", "Unknown")
        ticket_url = (
            f"https://{SUBDOMAIN}.zendesk.com/agent/tickets/{ticket['id']}"
        )
        print(f"  #{ticket['id']}  {subject[:50]!r}  ->  flagged as VIP")
 
        slack_post(
            f"Missed VIP ticket #{ticket['id']}: {subject}",
            [
                {
                    "type": "header",
                    "text": {
                        "type": "plain_text",
                        "text": "Missed VIP Ticket Flagged",
                        "emoji": True,
                    },
                },
                {
                    "type": "section",
                    "fields": [
                        {
                            "type": "mrkdwn",
                            "text": f"*Ticket:* <{ticket_url}|#{ticket['id']}>",
                        },
                        {
                            "type": "mrkdwn",
                            "text": f"*Subject:* {subject}",
                        },
                    ],
                },
                {
                    "type": "context",
                    "elements": [
                        {
                            "type": "mrkdwn",
                            "text": (
                                "This ticket was from a VIP org but wasn't "
                                "tagged. It's been flagged and escalated now."
                            ),
                        }
                    ],
                },
            ],
        )
 
    print(
        f"\nFlagged {flagged} missed VIP ticket(s) "
        f"out of {len(tickets)} checked."
    )
 
 
if __name__ == "__main__":
    main()

Troubleshooting

What the script does

  1. Loads VIP organizations — calls the Zendesk /organizations.json endpoint and collects IDs of organizations tagged with vip
  2. Fetches untagged tickets — uses the Search API to find open and pending tickets that do not already have the vip tag
  3. Cross-references organization membership — for each untagged ticket, checks whether its organization_id matches any VIP organization
  4. Flags missed VIP tickets — calls PUT /tickets/{id}.json to add the vip tag, set priority to urgent, and optionally reassign to the VIP Support group
  5. Sends Slack alerts — posts a Block Kit message for each newly flagged ticket with the ticket number, subject, and a note explaining it was a missed VIP ticket that has now been escalated

Step 4: Run the skill

# Via Claude Code
/zendesk-vip-audit
 
# Or directly
python .claude/skills/zendesk-vip-audit/scripts/vip_audit.py

A typical run looks like this:

Loading VIP organizations...
Found 12 VIP organization(s)
 
Checking 47 untagged tickets...
 
  #98234  'Cannot access billing portal'  ->  flagged as VIP
  #98251  'API rate limit question'        ->  flagged as VIP
 
Flagged 2 missed VIP ticket(s) out of 47 checked.

Step 5: Schedule with cron or GitHub Actions

Run the audit every hour to catch missed VIP tickets quickly:

# crontab -e — run every hour during business hours
0 8-18 * * 1-5 cd /path/to/project && python .claude/skills/zendesk-vip-audit/scripts/vip_audit.py

Or set up a GitHub Actions workflow on a schedule for a server-hosted alternative.

This is a safety net, not a replacement for the trigger

The Zendesk trigger (the recommended approach) handles instant escalation when VIP organizations are properly tagged. This audit skill catches tickets that slip through — usually because an organization's VIP tag was added after the ticket was already created, or because the trigger conditions didn't match. Run both for full coverage.

Cost

No Claude API costs — the skill uses disable-model-invocation: true and calls the Zendesk and Slack APIs directly via Python. The only costs are the API calls themselves, which are included in your Zendesk plan and Slack's free tier.

Need help implementing this?

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