Audit VIP Zendesk tickets using an agent skill
Prerequisites
- Claude Code, Cursor, or another AI coding agent that supports skills
- Zendesk account with API access enabled
- Slack bot with
chat:writepermission added to the target channel
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/scriptsStep 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 automaticallyThe 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-auditallowed-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
- Loads VIP organizations — calls the Zendesk
/organizations.jsonendpoint and collects IDs of organizations tagged withvip - Fetches untagged tickets — uses the Search API to find open and pending tickets that do not already have the
viptag - Cross-references organization membership — for each untagged ticket, checks whether its
organization_idmatches any VIP organization - Flags missed VIP tickets — calls
PUT /tickets/{id}.jsonto add theviptag, set priority tourgent, and optionally reassign to the VIP Support group - 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.pyA 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.pyOr set up a GitHub Actions workflow on a schedule for a server-hosted alternative.
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.