Automate a sales-to-CS handoff when a HubSpot deal closes won using an agent skill

low complexityCost: Usage-based

Prerequisites

Prerequisites
  • Claude Code, Cursor, or another AI coding agent that supports skills
  • HubSpot private app token stored as an environment variable (HUBSPOT_TOKEN)
  • Slack Bot Token stored as an environment variable (SLACK_BOT_TOKEN)
  • A Slack channel ID for delivery (SLACK_CHANNEL_ID)
  • Your CS rep's HubSpot owner ID (CS_OWNER_ID)

Overview

Instead of building a persistent webhook or automation, you can create an agent skill that processes recent closed-won deals on demand. It checks for deals that moved to Closed Won, sends a handoff message to Slack, and creates a HubSpot task for the CS rep. This works with Claude Code, and the open Agent Skills standard means the same skill can work across compatible tools.

This approach is ideal for teams that want to run handoffs manually or on a schedule without setting up webhook infrastructure.

Step 1: Create the skill directory

mkdir -p .claude/skills/cs-handoff/scripts

Step 2: Write the SKILL.md file

Create .claude/skills/cs-handoff/SKILL.md:

---
name: cs-handoff
description: Processes recent closed-won deals from HubSpot, sends handoff notifications to the CS Slack channel, and creates onboarding tasks in HubSpot.
disable-model-invocation: true
allowed-tools: Bash(python *)
---
 
Process recent closed-won deals by running the bundled script:
 
1. Run: `python $SKILL_DIR/scripts/handoff.py`
2. Review the output for any errors
3. Confirm handoff messages were posted to Slack and tasks were created

Step 3: Write the handoff script

Create .claude/skills/cs-handoff/scripts/handoff.py:

#!/usr/bin/env python3
"""
Sales-to-CS Handoff: HubSpot -> Slack + HubSpot Task
Finds recently closed-won deals, posts handoff to Slack, creates CS onboarding tasks.
"""
import os
import sys
import json
from datetime import datetime, timezone, timedelta
 
try:
    import requests
except ImportError:
    os.system("pip install requests slack_sdk -q")
    import requests
 
from slack_sdk import WebClient
 
# --- Config ---
HUBSPOT_TOKEN = os.environ.get("HUBSPOT_TOKEN")
SLACK_TOKEN = os.environ.get("SLACK_BOT_TOKEN")
SLACK_CHANNEL = os.environ.get("SLACK_CHANNEL_ID")
CS_OWNER_ID = os.environ.get("CS_OWNER_ID")
 
if not all([HUBSPOT_TOKEN, SLACK_TOKEN, SLACK_CHANNEL]):
    print("ERROR: Missing required env vars: HUBSPOT_TOKEN, SLACK_BOT_TOKEN, SLACK_CHANNEL_ID")
    sys.exit(1)
 
HEADERS = {"Authorization": f"Bearer {HUBSPOT_TOKEN}", "Content-Type": "application/json"}
PROCESSED_FILE = os.path.join(os.path.dirname(__file__), ".processed_deals.json")
 
# --- Load processed deals for deduplication ---
processed = set()
if os.path.exists(PROCESSED_FILE):
    with open(PROCESSED_FILE) as f:
        processed = set(json.load(f))
 
# --- Search for recently closed-won deals ---
print("Searching for closed-won deals in the last 24 hours...")
one_day_ago = str(int((datetime.now(timezone.utc) - timedelta(hours=24)).timestamp() * 1000))
 
resp = requests.post(
    "https://api.hubapi.com/crm/v3/objects/deals/search",
    headers=HEADERS,
    json={
        "filterGroups": [{"filters": [
            {"propertyName": "dealstage", "operator": "EQ", "value": "closedwon"},
            {"propertyName": "hs_lastmodifieddate", "operator": "GTE", "value": one_day_ago},
        ]}],
        "properties": [
            "dealname", "amount", "closedate", "hubspot_owner_id",
            "contract_length", "description",
        ],
        "limit": 100,
    },
)
resp.raise_for_status()
deals = resp.json()["results"]
 
# Filter out already processed
new_deals = [d for d in deals if d["id"] not in processed]
print(f"Found {len(deals)} closed-won deals, {len(new_deals)} new")
 
if not new_deals:
    print("No new deals to process.")
    sys.exit(0)
 
slack = WebClient(token=SLACK_TOKEN)
 
for deal in new_deals:
    props = deal["properties"]
    print(f"\nProcessing: {props['dealname']}")
 
    # Fetch associated contacts
    assoc_resp = requests.get(
        f"https://api.hubapi.com/crm/v3/objects/deals/{deal['id']}",
        headers=HEADERS,
        params={"associations": "contacts"},
    )
    assoc_resp.raise_for_status()
    contact_ids = [
        c["id"] for c in
        assoc_resp.json().get("associations", {}).get("contacts", {}).get("results", [])
    ]
 
    contact = {"properties": {}}
    if contact_ids:
        c_resp = requests.get(
            f"https://api.hubapi.com/crm/v3/objects/contacts/{contact_ids[0]}",
            headers=HEADERS,
            params={"properties": "firstname,lastname,email,phone,jobtitle,company"},
        )
        c_resp.raise_for_status()
        contact = c_resp.json()
 
    # Resolve owner
    owner_name = "Unknown"
    if props.get("hubspot_owner_id"):
        o_resp = requests.get(
            f"https://api.hubapi.com/crm/v3/owners/{props['hubspot_owner_id']}",
            headers=HEADERS,
        )
        if o_resp.ok:
            od = o_resp.json()
            owner_name = f"{od.get('firstName', '')} {od.get('lastName', '')}".strip()
 
    c_props = contact["properties"]
    amount = float(props.get("amount") or 0)
 
    # --- Post to Slack ---
    blocks = [
        {"type": "header", "text": {"type": "plain_text", "text": "\U0001F389 New Closed-Won Deal \u2014 CS Handoff"}},
        {"type": "section", "fields": [
            {"type": "mrkdwn", "text": f"*Deal*\n{props['dealname']}"},
            {"type": "mrkdwn", "text": f"*Value*\n${amount:,.0f}"},
            {"type": "mrkdwn", "text": f"*Sales Rep*\n{owner_name}"},
            {"type": "mrkdwn", "text": f"*Close Date*\n{props.get('closedate', 'N/A')[:10]}"},
        ]},
        {"type": "divider"},
        {"type": "section", "text": {"type": "mrkdwn", "text": (
            f"*Primary Contact*\n"
            f"{c_props.get('firstname', '')} {c_props.get('lastname', '')} "
            f"({c_props.get('jobtitle', 'No title')})\n"
            f"\U0001F4E7 {c_props.get('email', 'No email')}\n"
            f"\U0001F4DE {c_props.get('phone', 'No phone')}"
        )}},
        {"type": "section", "text": {"type": "mrkdwn", "text":
            f"*Contract Length*\n{props.get('contract_length', 'Not specified')}"
        }},
    ]
 
    if props.get("description"):
        blocks.append({"type": "section", "text": {
            "type": "mrkdwn",
            "text": f"*Sales Notes*\n{props['description'][:500]}",
        }})
 
    result = slack.chat_postMessage(
        channel=SLACK_CHANNEL, text=f"CS Handoff: {props['dealname']}",
        blocks=blocks, unfurl_links=False,
    )
    print(f"  Slack: posted ({result['ts']})")
 
    # --- Create HubSpot task ---
    if CS_OWNER_ID:
        task_resp = requests.post(
            "https://api.hubapi.com/crm/v3/objects/tasks",
            headers=HEADERS,
            json={
                "properties": {
                    "hs_task_subject": f"Onboarding: {props['dealname']}",
                    "hs_task_body": (
                        f"New closed-won deal ready for CS onboarding.\n\n"
                        f"Deal: {props['dealname']}\nValue: ${amount:,.0f}\n"
                        f"Sales Rep: {owner_name}\n"
                        f"Contact: {c_props.get('email', 'N/A')}"
                    ),
                    "hs_task_status": "NOT_STARTED",
                    "hs_task_priority": "HIGH",
                    "hubspot_owner_id": CS_OWNER_ID,
                },
                "associations": [{
                    "to": {"id": deal["id"]},
                    "types": [{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 204}],
                }],
            },
        )
        task_resp.raise_for_status()
        print(f"  HubSpot: task created ({task_resp.json()['id']})")
    else:
        print("  HubSpot: CS_OWNER_ID not set — skipping task creation")
 
    processed.add(deal["id"])
 
# --- Save processed deals ---
with open(PROCESSED_FILE, "w") as f:
    json.dump(list(processed), f)
 
print(f"\nDone. Processed {len(new_deals)} handoff(s).")

Step 4: Run the skill

# Claude Code
/cs-handoff
 
# Or run directly
python .claude/skills/cs-handoff/scripts/handoff.py

The script tracks processed deal IDs in a local JSON file to avoid duplicate handoffs on subsequent runs.

Step 5: Schedule it (optional)

Option A: Claude Desktop Cowork

  1. Open Cowork and go to the Schedule tab
  2. Click + New task
  3. Set description: "Run /cs-handoff to process any new closed-won deals"
  4. Set frequency to Hourly or Every 2 hours during business hours
Desktop must be open

Cowork scheduled tasks only run while your computer is awake and the Claude Desktop app is open. For reliable handoffs, consider the cron or GitHub Actions option.

Option B: Cron + CLI

# crontab -e — check every hour during business hours
0 8-18 * * 1-5 cd /path/to/project && python .claude/skills/cs-handoff/scripts/handoff.py

Option C: GitHub Actions

name: CS Handoff Check
on:
  schedule:
    - cron: '0 13-23 * * 1-5'  # 8 AM - 6 PM ET, weekdays
  workflow_dispatch: {}
jobs:
  handoff:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: pip install requests slack_sdk
      - run: python .claude/skills/cs-handoff/scripts/handoff.py
        env:
          HUBSPOT_TOKEN: ${{ secrets.HUBSPOT_TOKEN }}
          SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
          SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }}
          CS_OWNER_ID: ${{ secrets.CS_OWNER_ID }}
Deduplication on GitHub Actions

The processed deals file is stored locally, so it won't persist between GitHub Actions runs. Use GitHub Actions cache or store processed IDs in a HubSpot custom property to avoid duplicates.

When to use this approach

  • You want handoffs running today without setting up webhooks or n8n
  • You're testing the handoff workflow before investing in dedicated infrastructure
  • You want to process handoffs on demand — "run handoffs now" after a big deal closes
  • Your team closes deals infrequently enough that polling every hour is sufficient

When to graduate to a dedicated tool

  • You need instant notifications (under 1 minute) — use n8n or Make with webhook triggers
  • You need reliable 24/7 execution without depending on your machine
  • You want visual execution history to audit every handoff
Portable skill

Because this skill uses the open Agent Skills standard, the same SKILL.md and script work across Claude Code, Cursor, and other compatible tools. The script itself is just Python — it runs anywhere.

Need help implementing this?

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