Automate a sales-to-CS handoff when a HubSpot deal closes won using an agent skill
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/scriptsStep 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 createdStep 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.pyThe 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
- Open Cowork and go to the Schedule tab
- Click + New task
- Set description: "Run
/cs-handoffto process any new closed-won deals" - Set frequency to Hourly or Every 2 hours during business hours
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.pyOption 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 }}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
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.