Send Slack alerts for low Zendesk CSAT scores using an agent skill
Prerequisites
- Claude Code, Cursor, or another AI coding agent that supports skills
- Zendesk account with CSAT surveys enabled and actively collecting responses
- Slack bot with
chat:writepermission added to the target channel
Overview
This agent skill queries the Zendesk satisfaction ratings API for recent bad scores, fetches ticket context for each one, and posts formatted alerts to Slack. No Claude AI classification is needed — Zendesk already categorizes ratings as "Good" or "Bad," so this is pure API integration.
The script uses only Python standard library (no pip dependencies), tracks previously seen rating IDs to avoid duplicate alerts, and runs as a single command.
Step 1: Create the skill directory
mkdir -p .claude/skills/zendesk-csat-alert/scriptsStep 2: Write the SKILL.md
Create .claude/skills/zendesk-csat-alert/SKILL.md:
---
name: zendesk-csat-alert
description: Checks Zendesk for new bad CSAT ratings and sends Slack alerts with ticket details.
disable-model-invocation: true
allowed-tools: Bash(python *)
---
Check for bad CSAT ratings and alert Slack:
1. Run: `python $SKILL_DIR/scripts/check_csat.py`
2. Review the output — it shows any new bad ratings found and Slack alerts sentThe key settings:
disable-model-invocation: true— the skill has external side effects (posting to Slack), so it only runs when you explicitly invoke it with/zendesk-csat-alertallowed-tools: Bash(python *)— restricts execution to Python scripts only, preventing unintended shell commands
Step 3: Write the CSAT monitoring script
Create .claude/skills/zendesk-csat-alert/scripts/check_csat.py:
#!/usr/bin/env python3
"""
Zendesk CSAT Alert
Polls Zendesk for bad satisfaction ratings and sends Slack alerts.
"""
import os
import json
import base64
import urllib.request
import urllib.parse
from datetime import datetime, timedelta, timezone
from pathlib import Path
SUBDOMAIN = os.environ["ZENDESK_SUBDOMAIN"]
EMAIL = os.environ["ZENDESK_EMAIL"]
TOKEN = os.environ["ZENDESK_API_TOKEN"]
SLACK_TOKEN = os.environ["SLACK_BOT_TOKEN"]
SLACK_CHANNEL = os.environ["SLACK_CHANNEL_ID"]
CHECK_WINDOW = int(os.environ.get("CHECK_WINDOW_MINUTES", "60"))
BASE_URL = f"https://{SUBDOMAIN}.zendesk.com/api/v2"
credentials = base64.b64encode(f"{EMAIL}/token:{TOKEN}".encode()).decode()
ZD_HEADERS = {
"Authorization": f"Basic {credentials}",
"Content-Type": "application/json",
}
SEEN_FILE = Path(__file__).parent / ".csat_seen_ids.json"
def load_seen_ids() -> set:
if SEEN_FILE.exists():
return set(json.loads(SEEN_FILE.read_text()))
return set()
def save_seen_ids(ids: set) -> None:
SEEN_FILE.write_text(json.dumps(list(ids)[-500:])) # Keep last 500
def zendesk_get(path: str) -> dict:
req = urllib.request.Request(f"{BASE_URL}{path}", headers=ZD_HEADERS)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
def slack_post(blocks: list, text: str) -> None:
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:
result = json.loads(resp.read())
if not result.get("ok"):
print(f" Slack error: {result.get('error')}")
def main() -> None:
print(f"Checking for bad CSAT ratings in the last {CHECK_WINDOW} minutes...\n")
seen = load_seen_ids()
cutoff = (datetime.now(timezone.utc) - timedelta(minutes=CHECK_WINDOW)).isoformat()
data = zendesk_get("/satisfaction_ratings?score=bad&sort_order=desc&per_page=20")
ratings = data.get("satisfaction_ratings", [])
new_bad = [
r for r in ratings
if str(r["id"]) not in seen and r["updated_at"] > cutoff
]
if not new_bad:
print("No new bad ratings found.")
return
print(f"Found {len(new_bad)} new bad rating(s)\n")
for rating in new_bad:
ticket = zendesk_get(f"/tickets/{rating['ticket_id']}.json").get("ticket", {})
subject = ticket.get("subject", "Unknown")
requester_id = ticket.get("requester_id", "")
ticket_url = f"https://{SUBDOMAIN}.zendesk.com/agent/tickets/{rating['ticket_id']}"
comment = rating.get("comment") or "(no comment)"
blocks = [
{"type": "header", "text": {"type": "plain_text", "text": "Bad CSAT Rating Received", "emoji": True}},
{"type": "section", "fields": [
{"type": "mrkdwn", "text": f"*Ticket:* <{ticket_url}|#{rating['ticket_id']}>"},
{"type": "mrkdwn", "text": f"*Subject:* {subject}"},
]},
{"type": "section", "text": {"type": "mrkdwn", "text": f"*Customer feedback:* {comment}"}},
{"type": "actions", "elements": [
{"type": "button", "text": {"type": "plain_text", "text": "View Ticket"}, "url": ticket_url}
]},
]
slack_post(blocks, f"Bad CSAT on #{rating['ticket_id']}: {subject}")
seen.add(str(rating["id"]))
print(f" #{rating['ticket_id']} {subject[:50]!r} -> Slack alert sent")
save_seen_ids(seen)
print(f"\nAlerted on {len(new_bad)} bad rating(s).")
if __name__ == "__main__":
main()Troubleshooting
What the script does
- Loads seen IDs — reads a JSON file of previously alerted satisfaction rating IDs to prevent duplicate Slack messages across runs
- Fetches bad ratings — calls the Zendesk
/satisfaction_ratingsendpoint filtered toscore=bad, sorted by most recent, and filters to ratings updated within the configurable check window (default 60 minutes) - Enriches with ticket context — for each new bad rating, fetches the full ticket via
GET /tickets/{id}.jsonto get the subject, requester, and other details - Posts a Slack alert — sends a Block Kit message via the Slack API with the ticket number, subject, customer feedback comment, and a "View Ticket" button linking to the Zendesk agent view
- Persists seen IDs — writes the updated set of alerted rating IDs back to disk, trimmed to the last 500 entries to prevent unbounded growth
Step 4: Run the skill
# Via Claude Code
/zendesk-csat-alert
# Or run directly
python .claude/skills/zendesk-csat-alert/scripts/check_csat.pyA typical run looks like:
Checking for bad CSAT ratings in the last 60 minutes...
Found 2 new bad rating(s)
#48201 'Billing issue not resolved' -> Slack alert sent
#48195 'Slow response to outage report' -> Slack alert sent
Alerted on 2 bad rating(s).The CHECK_WINDOW_MINUTES environment variable controls how far back the script looks. Set it to roughly 2x your scheduling interval for overlap. The seen-IDs file prevents duplicate alerts, so overlapping windows are safe.
Step 5: Schedule it
Option A: cron (local or server)
# crontab -e — every 30 minutes during business hours
*/30 8-18 * * 1-5 cd /path/to/project && python .claude/skills/zendesk-csat-alert/scripts/check_csat.pyFor 24/7 coverage with international customers:
0 * * * * cd /path/to/project && python .claude/skills/zendesk-csat-alert/scripts/check_csat.pyOption B: GitHub Actions
name: CSAT Alert
on:
schedule:
- cron: '*/30 * * * *' # Every 30 minutes
workflow_dispatch: {}
jobs:
check-csat:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Check CSAT ratings
env:
ZENDESK_SUBDOMAIN: ${{ secrets.ZENDESK_SUBDOMAIN }}
ZENDESK_EMAIL: ${{ secrets.ZENDESK_EMAIL }}
ZENDESK_API_TOKEN: ${{ secrets.ZENDESK_API_TOKEN }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }}
run: python .claude/skills/zendesk-csat-alert/scripts/check_csat.pyThe seen-IDs file (.csat_seen_ids.json) is written locally. In GitHub Actions, each run starts fresh — meaning it relies entirely on the time window for deduplication. Set CHECK_WINDOW_MINUTES to 35 (slightly longer than the 30-minute schedule) to avoid gaps without re-alerting on old ratings.
Cost
No Claude API costs — this skill uses disable-model-invocation: true so it runs as pure API integration. The only costs are Zendesk API calls (2-3 per run, well within rate limits) and Slack API calls (1 per bad rating). Both are free for this use case.
Need help implementing this?
We build and optimize automation systems for mid-market businesses. Let's discuss the right approach for your team.