Auto-close stale Gorgias tickets using an agent skill

low complexityCost: Usage-based

Prerequisites

Prerequisites
  • Claude Code, Cursor, or another AI coding agent that supports skills
  • GORGIAS_EMAIL, GORGIAS_API_KEY, GORGIAS_DOMAIN environment variables
  • Python 3.9+ with requests installed

Overview

This agent skill finds Gorgias tickets where an agent has replied but the customer hasn't responded within a configurable time window. It sends a polite follow-up message and, on the next run, closes tickets where the follow-up was already sent and the customer still hasn't replied. Unlike static Rules, the skill can apply custom logic — checking tags, ticket age, or even CRM data before deciding whether to close.

Step 1: Create the skill directory

mkdir -p .claude/skills/auto-close-stale/scripts

Step 2: Write the SKILL.md

Create .claude/skills/auto-close-stale/SKILL.md:

---
name: auto-close-stale
description: Finds Gorgias tickets with no customer reply after 48 hours and sends a follow-up message. Closes tickets where the follow-up was sent and 24 more hours have passed.
disable-model-invocation: true
allowed-tools: Bash(python *)
---
 
Close stale support tickets:
 
1. Run: `python $SKILL_DIR/scripts/close_stale.py`
2. Review the output — it shows follow-ups sent and tickets closed
3. Use `--dry-run` to preview without making changes

Step 3: Write the close script

Create .claude/skills/auto-close-stale/scripts/close_stale.py:

#!/usr/bin/env python3
"""
Gorgias Auto-Close Stale Tickets
Sends follow-up messages to stale tickets, then closes them if no reply arrives.
"""
import os
import sys
from datetime import datetime, timezone
 
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"]
BASE_URL       = f"https://{GORGIAS_DOMAIN}.gorgias.com/api"
AUTH           = (GORGIAS_EMAIL, GORGIAS_KEY)
 
FOLLOW_UP_HOURS = int(os.environ.get("FOLLOW_UP_HOURS", "48"))
CLOSE_HOURS     = int(os.environ.get("CLOSE_HOURS", "72"))
EXCLUDE_TAGS    = {"vip", "escalated", "priority"}
DRY_RUN         = "--dry-run" in sys.argv
 
FOLLOW_UP_MESSAGE = """Hi {name},
 
Just checking in — did our previous reply resolve your question? If you still need help, simply reply to this message and we'll pick things right back up.
 
If we don't hear from you, we'll close this ticket shortly to keep things tidy. You can always reach out again anytime.
 
Best,
The Support Team"""
 
 
def get_pending_tickets(limit: int = 50) -> list:
    resp = requests.get(
        f"{BASE_URL}/tickets",
        auth=AUTH,
        params={"status": "pending", "limit": limit},
    )
    resp.raise_for_status()
    return resp.json().get("data", [])
 
 
def hours_since_update(ticket: dict) -> float:
    updated = datetime.fromisoformat(
        ticket["updated_datetime"].replace("Z", "+00:00")
    )
    return (datetime.now(timezone.utc) - updated).total_seconds() / 3600
 
 
def get_tags(ticket: dict) -> set:
    return {(t.get("name") or t) for t in ticket.get("tags", [])}
 
 
def last_message_is_agent(ticket: dict) -> bool:
    messages = ticket.get("messages", [])
    if not messages:
        return False
    return messages[-1].get("source", {}).get("type") == "helpdesk"
 
 
def send_follow_up(ticket_id: int, customer_name: str) -> None:
    body = FOLLOW_UP_MESSAGE.format(name=customer_name or "there")
    requests.post(
        f"{BASE_URL}/tickets/{ticket_id}/messages",
        auth=AUTH,
        json={
            "body_text": body,
            "channel": "email",
            "from_agent": True,
            "source": {
                "type": "helpdesk",
                "from": {
                    "name": "Support Team",
                    "address": f"support@{GORGIAS_DOMAIN}.com",
                },
            },
        },
    ).raise_for_status()
 
    # Tag to track that follow-up was sent
    requests.put(
        f"{BASE_URL}/tickets/{ticket_id}",
        auth=AUTH,
        json={"tags": [{"name": "auto-close-sent"}]},
    ).raise_for_status()
 
 
def close_ticket(ticket_id: int) -> None:
    requests.put(
        f"{BASE_URL}/tickets/{ticket_id}",
        auth=AUTH,
        json={
            "status": "closed",
            "tags": [{"name": "auto-closed"}],
        },
    ).raise_for_status()
 
 
def main() -> None:
    if DRY_RUN:
        print("DRY RUN — no changes will be made\n")
 
    print("Fetching pending tickets...")
    tickets = get_pending_tickets()
    print(f"Found {len(tickets)} pending ticket(s)\n")
 
    follow_ups = 0
    closes = 0
 
    for ticket in tickets:
        tags = get_tags(ticket)
 
        # Skip excluded tickets
        if tags & EXCLUDE_TAGS:
            continue
 
        # Only process tickets where the last message is from an agent
        if not last_message_is_agent(ticket):
            continue
 
        hours = hours_since_update(ticket)
        tid = ticket["id"]
        subject = ticket.get("subject", "")[:50]
        name = ticket.get("requester", {}).get("firstname", "")
 
        if "auto-close-sent" not in tags and hours >= FOLLOW_UP_HOURS:
            if DRY_RUN:
                print(f"  WOULD send follow-up: #{tid} {subject!r} ({hours:.0f}h)")
            else:
                send_follow_up(tid, name)
                print(f"  Sent follow-up: #{tid} {subject!r} ({hours:.0f}h)")
            follow_ups += 1
 
        elif "auto-close-sent" in tags and hours >= CLOSE_HOURS:
            if DRY_RUN:
                print(f"  WOULD close: #{tid} {subject!r} ({hours:.0f}h)")
            else:
                close_ticket(tid)
                print(f"  Closed: #{tid} {subject!r} ({hours:.0f}h)")
            closes += 1
 
    print(f"\nDone. Follow-ups sent: {follow_ups}, Tickets closed: {closes}")
 
 
if __name__ == "__main__":
    main()
Use --dry-run first

Always run with --dry-run the first time to preview which tickets would be affected. This prevents accidentally closing tickets that should stay open.

Step 4: Test the skill

# Preview what would happen (no changes made)
python .claude/skills/auto-close-stale/scripts/close_stale.py --dry-run
 
# Run for real
python .claude/skills/auto-close-stale/scripts/close_stale.py
 
# Or via Claude Code
/auto-close-stale

Step 5: Customize the timing

Adjust the follow-up and close windows via environment variables:

# Send follow-up after 24 hours, close after 48 hours total
FOLLOW_UP_HOURS=24 CLOSE_HOURS=48 python .claude/skills/auto-close-stale/scripts/close_stale.py
CLOSE_HOURS is total time, not time after follow-up

If FOLLOW_UP_HOURS is 48 and CLOSE_HOURS is 72, the follow-up goes out at 48 hours and the ticket closes at 72 hours (24 hours after the follow-up). Make sure CLOSE_HOURS is always greater than FOLLOW_UP_HOURS.

Step 6: Schedule with cron

# crontab -e — run every hour during business hours
0 * * * * cd /path/to/project && python .claude/skills/auto-close-stale/scripts/close_stale.py >> /tmp/auto-close.log 2>&1

For 24/7 coverage, remove the hour restriction. The script is safe to run frequently since it only acts on tickets that have exceeded the time threshold.

Cost

  • No AI model calls — this skill uses only the Gorgias REST API
  • Gorgias API usage is included in all paid plans
  • If running via cron on a server: minimal compute cost

Need help implementing this?

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