Send Slack alerts for low Zendesk CSAT scores using an agent skill

low complexityCost: Usage-based

Prerequisites

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: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 CSAT alerts
SLACK_CHANNEL_ID=your_value_here

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/scripts

Step 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 sent

The 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-alert
  • allowed-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

  1. Loads seen IDs — reads a JSON file of previously alerted satisfaction rating IDs to prevent duplicate Slack messages across runs
  2. Fetches bad ratings — calls the Zendesk /satisfaction_ratings endpoint filtered to score=bad, sorted by most recent, and filters to ratings updated within the configurable check window (default 60 minutes)
  3. Enriches with ticket context — for each new bad rating, fetches the full ticket via GET /tickets/{id}.json to get the subject, requester, and other details
  4. 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
  5. 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.py

A 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).
Adjust the check window to match your schedule

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.py

For 24/7 coverage with international customers:

0 * * * * cd /path/to/project && python .claude/skills/zendesk-csat-alert/scripts/check_csat.py

Option 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.py
State file and GitHub Actions

The 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.