Monitor CSAT scores and alert Slack on detractors using an agent skill
Prerequisites
- Claude Code, Cursor, or another AI coding agent that supports skills
GORGIAS_EMAIL,GORGIAS_API_KEY,GORGIAS_DOMAINenvironment variablesSLACK_WEBHOOK_URLenvironment variable (Slack Incoming Webhook for your #csat-alerts channel)- Gorgias Satisfaction Surveys enabled and actively collecting responses
Overview
This agent skill fetches recent satisfaction survey responses from the Gorgias API, identifies low scores (detractors), and posts a formatted alert to Slack for each one. Unlike the webhook-based approaches, this runs on a schedule — checking for new low scores every 30-60 minutes and alerting only on scores that haven't been flagged yet.
Step 1: Create the skill directory
mkdir -p .claude/skills/csat-alert/scriptsStep 2: Write the SKILL.md
Create .claude/skills/csat-alert/SKILL.md:
---
name: csat-alert
description: Checks recent Gorgias satisfaction survey scores and alerts Slack when a customer gives a low rating, so detractors get immediate follow-up.
disable-model-invocation: true
allowed-tools: Bash(python *)
---
Check for low CSAT scores and alert Slack:
1. Run: `python $SKILL_DIR/scripts/check_csat.py`
2. Review the output — it shows each low score and whether a Slack alert was sent
3. Low scores are tagged in Gorgias for trackingStep 3: Write the CSAT monitoring script
Create .claude/skills/csat-alert/scripts/check_csat.py:
#!/usr/bin/env python3
"""
CSAT Survey Monitor
Fetches recent satisfaction survey responses from Gorgias,
identifies low scores, and alerts Slack.
"""
import os
import json
from datetime import datetime, timedelta, timezone
from pathlib import Path
try:
import requests
except ImportError:
os.system("pip install requests -q")
import requests
GORGIAS_EMAIL = os.environ["GORGIAS_EMAIL"]
GORGIAS_KEY = os.environ["GORGIAS_API_KEY"]
GORGIAS_DOMAIN = os.environ["GORGIAS_DOMAIN"]
SLACK_WEBHOOK = os.environ["SLACK_WEBHOOK_URL"]
BASE_URL = f"https://{GORGIAS_DOMAIN}.gorgias.com/api"
AUTH = (GORGIAS_EMAIL, GORGIAS_KEY)
LOW_SCORE_THRESHOLD = 2 # Alert on scores 1-2 out of 5
LOOKBACK_HOURS = 2 # Check surveys from the last N hours
# Track alerted surveys to avoid duplicates
SKILL_DIR = Path(__file__).parent.parent
STATE_FILE = SKILL_DIR / ".alerted_surveys.json"
def load_alerted_ids() -> set:
if STATE_FILE.exists():
data = json.loads(STATE_FILE.read_text())
return set(data.get("ids", []))
return set()
def save_alerted_ids(ids: set) -> None:
STATE_FILE.write_text(json.dumps({"ids": list(ids)[-500:]}))
def get_recent_surveys() -> list:
"""Fetch satisfaction surveys from recent tickets."""
since = datetime.now(timezone.utc) - timedelta(hours=LOOKBACK_HOURS)
resp = requests.get(
f"{BASE_URL}/satisfaction-surveys",
auth=AUTH,
params={
"created_datetime__gte": since.isoformat(),
"limit": 50,
"order_by": "created_datetime:desc",
},
)
resp.raise_for_status()
return resp.json().get("data", [])
def get_ticket(ticket_id: int) -> dict:
resp = requests.get(f"{BASE_URL}/tickets/{ticket_id}", auth=AUTH)
resp.raise_for_status()
return resp.json()
def send_slack_alert(survey: dict, ticket: dict) -> None:
score = survey.get("score", "?")
customer = ticket.get("requester", {})
customer_name = customer.get("name", "Unknown")
customer_email = customer.get("email", "")
subject = ticket.get("subject", "No subject")
agent = ticket.get("assignee_user", {})
agent_name = agent.get("name", "Unassigned") if agent else "Unassigned"
ticket_url = f"https://{GORGIAS_DOMAIN}.gorgias.com/app/ticket/{ticket['id']}"
payload = {
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": f"Low CSAT Score: {score}/5",
},
},
{
"type": "section",
"fields": [
{"type": "mrkdwn", "text": f"*Customer:* {customer_name}"},
{"type": "mrkdwn", "text": f"*Email:* {customer_email}"},
{"type": "mrkdwn", "text": f"*Subject:* {subject}"},
{"type": "mrkdwn", "text": f"*Agent:* {agent_name}"},
],
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "Open Ticket"},
"url": ticket_url,
}
],
},
]
}
resp = requests.post(SLACK_WEBHOOK, json=payload)
resp.raise_for_status()
def tag_ticket(ticket_id: int, tag: str) -> None:
resp = requests.put(
f"{BASE_URL}/tickets/{ticket_id}",
auth=AUTH,
json={"tags": [{"name": tag}]},
)
resp.raise_for_status()
def main() -> None:
print(f"Checking CSAT surveys from the last {LOOKBACK_HOURS} hours...")
surveys = get_recent_surveys()
print(f"Found {len(surveys)} recent survey response(s)\n")
alerted = load_alerted_ids()
new_alerts = 0
for survey in surveys:
survey_id = survey.get("id")
score = survey.get("score")
ticket_id = survey.get("ticket_id")
if survey_id in alerted:
continue
if score is not None and score <= LOW_SCORE_THRESHOLD:
ticket = get_ticket(ticket_id)
send_slack_alert(survey, ticket)
tag_ticket(ticket_id, "csat-low")
alerted.add(survey_id)
new_alerts += 1
customer = ticket.get("requester", {}).get("name", "Unknown")
print(f" #{ticket_id} Score: {score}/5 Customer: {customer}")
print(f" Subject: {ticket.get('subject', '')[:60]}")
print(f" -> Slack alert sent, tagged csat-low\n")
else:
alerted.add(survey_id)
print(f" #{ticket_id} Score: {score}/5 (above threshold, skipped)")
save_alerted_ids(alerted)
if new_alerts == 0:
print("\nNo new low scores found.")
else:
print(f"\nDone. Sent {new_alerts} Slack alert(s).")
if __name__ == "__main__":
main()Step 4: Run the skill
# Via Claude Code
/csat-alert
# Or run directly
python .claude/skills/csat-alert/scripts/check_csat.pyA typical run looks like:
Checking CSAT surveys from the last 2 hours...
Found 8 recent survey response(s)
#15201 Score: 1/5 Customer: Maria S.
Subject: Damaged item received
-> Slack alert sent, tagged csat-low
#15198 Score: 5/5 (above threshold, skipped)
#15195 Score: 2/5 Customer: James T.
Subject: Late delivery
-> Slack alert sent, tagged csat-low
Done. Sent 2 Slack alert(s).If you run the skill every hour, set LOOKBACK_HOURS = 2 for overlap. The state file prevents duplicate alerts, so overlapping windows are safe and ensure no survey slips through the cracks.
Step 5: Schedule it
# crontab -e — every 30 minutes during business hours
*/30 8-18 * * 1-5 cd /path/to/project && python .claude/skills/csat-alert/scripts/check_csat.pyFor 24/7 coverage (recommended if you have international customers), run every hour around the clock:
0 * * * * cd /path/to/project && python .claude/skills/csat-alert/scripts/check_csat.pyThe script stores IDs of already-alerted surveys in .alerted_surveys.json. If you delete this file, the next run will re-alert on all recent low scores within the lookback window. Keep it in your .gitignore.
Step 6: Customize the threshold
Edit the constants at the top of check_csat.py to match your team's preferences:
LOW_SCORE_THRESHOLD = 2— alert on 1-2 out of 5 (detractors only)LOW_SCORE_THRESHOLD = 3— alert on 1-3 out of 5 (detractors + passives)LOOKBACK_HOURS = 2— how far back to check on each run
Cost
- No API costs beyond the Gorgias and Slack APIs (both free for this use case)
- If you extend the script to use Claude for summarizing ticket context in the alert, add ~$0.001 per alert using Haiku
Need help implementing this?
We build and optimize automation systems for mid-market businesses. Let's discuss the right approach for your team.