Alert Slack when a HubSpot deal is stuck in a stage for over 14 days using a Claude Code skill

low complexityCost: Usage-based

Prerequisites

Compatible agents

This skill works with any agent that supports the Claude Code skills standard, including Claude Code, Claude Cowork, OpenAI Codex, and Google Antigravity.

Prerequisites
  • One of the agents listed above
  • HubSpot private app with crm.objects.deals.read and crm.schemas.deals.read scopes
  • Slack bot with chat:write permission, added to the target channel
Environment Variables
# HubSpot private app token (Settings > Integrations > Private Apps)
HUBSPOT_ACCESS_TOKEN=your_value_here
# Slack bot token starting with xoxb- (chat:write scope required)
SLACK_BOT_TOKEN=your_value_here
# Slack channel ID starting with C (right-click channel > View channel details)
SLACK_CHANNEL_ID=your_value_here

Why a Claude Code skill?

The other approaches in this guide are deterministic: they run the same logic every time, the same way. An Claude Code skill is different. You tell Claude what you want in plain language, and the skill gives it enough context to do it reliably.

That means you can say:

  • "Check for stale deals and post alerts to Slack"
  • "Which deals in Negotiation haven't moved in 3 weeks?"
  • "Post a summary of stale deals grouped by rep to #sales-leadership"

The skill contains workflow guidelines, API reference materials, and a message template that the agent reads on demand. When you invoke the skill, Claude reads these files, writes a script on the fly, runs it, and reports results. If you ask for something different next time — a different staleness threshold, a specific pipeline, deals grouped by stage instead of owner — the agent adapts without you touching any code.

How it works

The skill directory has three parts:

  1. SKILL.md — workflow guidelines telling the agent what steps to follow, which env vars to use, and what pitfalls to avoid
  2. references/ — HubSpot API patterns (endpoints, request shapes, response formats) so the agent calls the right APIs with the right parameters
  3. templates/ — a Slack Block Kit template so messages are consistently formatted across runs

When invoked, the agent reads SKILL.md, consults the reference and template files as needed, writes a Python script, executes it, and reports what it posted. The reference files act as guardrails — the agent knows exactly which endpoints to hit and what the responses look like, so it doesn't have to guess.

What is a Claude Code skill?

An Claude Code skill is a reusable command you add to your project that Claude Code can run on demand. Skills live in a .claude/skills/ directory and are defined by a SKILL.md file that tells the agent what the skill does, when to run it, and what tools it's allowed to use.

In this skill, the agent doesn't run a pre-written script. Instead, SKILL.md provides workflow guidelines and points to reference files — API documentation, message templates — that the agent reads to generate and execute code itself. This is the key difference from a traditional script: the agent can adapt its approach based on what you ask for while still using the right APIs and message formats.

Once installed, you can invoke a skill as a slash command (e.g., /stale-deals), or the agent will use it automatically when you give it a task where the skill is relevant. Skills are portable — anyone who clones your repo gets the same commands.

Step 1: Create the skill directory

mkdir -p .claude/skills/stale-deals/{templates,references}

This creates the layout:

.claude/skills/stale-deals/
├── SKILL.md                          # workflow guidelines + config
├── templates/
│   └── slack-alert.md                # Block Kit template for Slack messages
└── references/
    └── hubspot-deals-api.md          # HubSpot API patterns

Step 2: Write the SKILL.md

Create .claude/skills/stale-deals/SKILL.md:

---
name: stale-deals
description: Find HubSpot deals with no activity for 14+ days and post alerts to Slack
disable-model-invocation: true
allowed-tools: Bash, Read
---
 
## Goal
 
Search HubSpot for open deals that haven't been modified in a given time window (default: 14 days). Exclude closed-won and closed-lost deals. Post a summary to Slack listing each stale deal with its name, current stage, amount, and days since last activity.
 
## Configuration
 
Read these environment variables:
 
- `HUBSPOT_ACCESS_TOKEN` — HubSpot private app token (required)
- `SLACK_BOT_TOKEN` — Slack bot token starting with xoxb- (required)
- `SLACK_CHANNEL_ID` — Slack channel ID starting with C (required)
 
Default staleness threshold: 14 days. The user may request a different threshold.
 
## Workflow
 
1. Validate that all required env vars are set. If any are missing, print which ones and exit.
2. Fetch pipeline stages from HubSpot to build a stage ID → display label map. See `references/hubspot-deals-api.md` for the endpoint and response format.
3. Calculate a cutoff timestamp (default: 14 days ago) in Unix milliseconds.
4. Search for deals where `hs_lastmodifieddate` is before the cutoff using the HubSpot CRM Search API. See `references/hubspot-deals-api.md` for the request and response format.
5. Filter out deals in closed stages (closedwon, closedlost) client-side.
6. For each remaining deal, calculate days stale, resolve the stage name, and include it in a summary message.
7. Post a single summary to Slack using the template in `templates/slack-alert.md`.
8. Print a summary of how many stale deals were found and posted.
 
## Important notes
 
- HubSpot's CRM Search API expects date filter values as Unix timestamps in **milliseconds** (multiply seconds by 1000).
- Use `LT` (less than) operator for `hs_lastmodifieddate` to find deals modified BEFORE the cutoff.
- `hs_lastmodifieddate` updates on ANY property change, not just stage transitions. Some deals with recent notes may not appear as stale even if they haven't moved stages. Mention this in your summary.
- Stage IDs are internal strings like `closedwon` or `appointmentscheduled`, not display labels. You must resolve them via the Pipelines API (step 2).
- `SLACK_CHANNEL_ID` must be the channel ID (starts with `C`), not the channel name.
- The Slack bot must be invited to the target channel or `chat.postMessage` will fail with `not_in_channel`.
- Use `urllib.request` for HTTP calls (no external dependencies required).

Understanding the SKILL.md

Unlike the script-based approach, this SKILL.md doesn't contain a Run: command pointing to a script. Instead, it provides:

SectionPurpose
GoalTells the agent what outcome to produce
ConfigurationWhich env vars to read and what defaults to use
WorkflowNumbered steps with pointers to reference files
Important notesNon-obvious context that prevents common mistakes

The allowed-tools: Bash, Read setting lets the agent both read reference files and execute code. The agent writes its own script based on the workflow steps and reference materials.

Step 3: Add reference files

templates/slack-alert.md

Create .claude/skills/stale-deals/templates/slack-alert.md:

# Slack Alert Template
 
Use this Block Kit structure for the stale deals summary message.
 
## Block Kit JSON
 
```json
{
  "channel": "<SLACK_CHANNEL_ID>",
  "text": "<count> stale deal(s) need attention",
  "blocks": [
    {
      "type": "header",
      "text": {
        "type": "plain_text",
        "text": "Stale Deal Alert"
      }
    },
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "*<count> deal(s)* haven't been updated in <threshold>+ days:\n\n- <https://app.hubspot.com/contacts/<PORTAL_ID>/deal/<DEAL_ID>|Deal Name> — *Stage* — $amount — Xd stale\n- ..."
      }
    },
    {
      "type": "context",
      "elements": [
        {
          "type": "mrkdwn",
          "text": "Update or close these deals in HubSpot to clear them from future alerts."
        }
      ]
    }
  ]
}
```
 
## Notes
 
- The top-level `text` field is required by the Slack API as a fallback for notifications and accessibility.
- List all stale deals in a single message. If there are more than 20 deals, summarize the total and list the top 20 by staleness.
- Sort deals by days stale (most stale first).
- Include a direct HubSpot link for each deal.

references/hubspot-deals-api.md

Create .claude/skills/stale-deals/references/hubspot-deals-api.md:

# HubSpot Deals API Reference
 
## Get pipeline stages
 
Build a stage ID → display label map so you can resolve internal IDs like `closedwon` to labels like "Closed Won".
 
**Request:**
 
```
GET https://api.hubapi.com/crm/v3/pipelines/deals
Authorization: Bearer <HUBSPOT_ACCESS_TOKEN>
```
 
**Response shape:**
 
```json
{
  "results": [
    {
      "id": "default",
      "label": "Sales Pipeline",
      "stages": [
        {
          "id": "appointmentscheduled",
          "label": "Appointment Scheduled"
        },
        {
          "id": "closedwon",
          "label": "Closed Won"
        }
      ]
    }
  ]
}
```
 
Iterate over `results[].stages[]` to build the map: `stage["id"] → stage["label"]`.
 
## Search for stale deals
 
Find deals whose `hs_lastmodifieddate` is before a cutoff date. Use `LT` (less than) to find deals modified BEFORE the cutoff.
 
**Request:**
 
```
POST https://api.hubapi.com/crm/v3/objects/deals/search
Authorization: Bearer <HUBSPOT_ACCESS_TOKEN>
Content-Type: application/json
```
 
**Body:**
 
```json
{
  "filterGroups": [
    {
      "filters": [
        {
          "propertyName": "hs_lastmodifieddate",
          "operator": "LT",
          "value": "<cutoff_timestamp_ms>"
        }
      ]
    }
  ],
  "properties": ["dealname", "amount", "dealstage", "hs_lastmodifieddate", "hubspot_owner_id"],
  "sorts": [{"propertyName": "hs_lastmodifieddate", "direction": "ASCENDING"}],
  "limit": 100
}
```
 
- `value` is a Unix timestamp in **milliseconds** (multiply seconds by 1000).
- `LT` means "less than" — deals whose last modified date is BEFORE the cutoff.
- `limit` max is 100. If there are more results, use the `after` cursor from `paging.next.after` to paginate.
- Sorting ascending by `hs_lastmodifieddate` puts the most stale deals first.
 
**Response shape:**
 
```json
{
  "total": 12,
  "results": [
    {
      "id": "12345",
      "properties": {
        "dealname": "GlobalTech Platform Upgrade",
        "amount": "45000",
        "dealstage": "qualificationscheduled",
        "hubspot_owner_id": "67890",
        "hs_lastmodifieddate": "2026-02-15T10:30:00.000Z"
      }
    }
  ],
  "paging": {
    "next": {
      "after": "100"
    }
  }
}
```
 
## Excluding closed deals
 
The search API supports `NOT_IN` for the `dealstage` filter, but it's safer to filter client-side since custom closed stages may have different IDs. After fetching results, exclude deals where `dealstage` is `closedwon` or `closedlost`.
 
## Calculating days stale
 
Parse `hs_lastmodifieddate` as an ISO 8601 datetime and subtract it from the current UTC time. Convert the timedelta to days.

Step 4: Test the skill

Invoke the skill conversationally:

/stale-deals

Claude will read the SKILL.md, check the reference files, write a script, run it, and report the results. A typical run looks like:

Checking for deals with no activity for 14+ days...
  Loaded 12 pipeline stages
  Found 8 stale open deal(s)
  Posted summary to Slack
Done. Flagged 8 stale deal(s).
Note: hs_lastmodifieddate updates on any property change, so some deals
with recent notes may not appear as stale even if they haven't moved stages.

What the Slack alert looks like

What you'll get
#sales-alerts
Pipeline Botapp9:41 AM

Stale Deal Alert

8 deal(s) haven't been updated in 14+ days:

- GlobalTech Platform UpgradeQualification — $45,000 — 18d stale

- Contoso Annual ContractNegotiation — $72,000 — 16d stale

- BrightPath AnalyticsProposal Sent — $28,000 — 15d stale

Update or close these deals in HubSpot to clear them from future alerts.

Because the agent generates code on the fly, you can also make ad hoc requests:

  • "Which deals in Negotiation haven't moved in 3 weeks?" — the agent filters by stage and adjusts the threshold
  • "Show me stale deals grouped by rep" — the agent restructures the output
  • "Post stale deals over $50K to #executive-deals" — the agent adds an amount filter and changes the channel
Test with real data

If no deals have been inactive for 14 days, temporarily ask Claude to "check for deals not modified in the last 1 day" to verify the skill works. Then adjust back to your preferred threshold.

Step 5: Schedule it (optional)

Option A: Cron + Claude CLI

# Run every weekday at 8:30am
30 8 * * 1-5 cd /path/to/your/project && claude -p "Run /stale-deals" --allowedTools 'Bash(*)' 'Read(*)'

Option B: GitHub Actions + Claude

name: Stale Deal Alerts
on:
  schedule:
    - cron: '30 13 * * 1-5'  # Weekdays at 8:30am ET
  workflow_dispatch: {}       # Manual trigger for testing
jobs:
  alert:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: anthropics/claude-code-action@v1
        with:
          prompt: "Run /stale-deals"
          allowed_tools: "Bash(*),Read(*)"
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          HUBSPOT_ACCESS_TOKEN: ${{ secrets.HUBSPOT_ACCESS_TOKEN }}
          SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
          SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }}
GitHub Actions cron uses UTC

30 13 * * 1-5 runs at 1:30pm UTC, which is 8:30am ET. GitHub Actions cron may also have up to 15 minutes of delay. For time-sensitive alerting, use cron on your own server.

Option C: Cowork Scheduled Tasks

Claude Desktop's Cowork supports built-in scheduled tasks. Open a Cowork session, type /schedule, and configure the cadence — hourly, daily, weekly, or weekdays only. Each scheduled run has full access to your connected tools, plugins, and MCP servers.

Scheduled tasks only run while your computer is awake and Claude Desktop is open. If a run is missed, Cowork executes it automatically when the app reopens. For always-on scheduling, use GitHub Actions (Option B) instead. Available on all paid plans (Pro, Max, Team, Enterprise).

Troubleshooting

When to use this approach

  • You want on-demand pipeline audits — "which deals haven't moved in 3 weeks?" — during pipeline reviews or standups
  • You want conversational flexibility — different thresholds, stage filters, or grouping options without editing code
  • You're already using Claude Code and want skills that integrate with your workflow
  • You want to run tasks in the background via Claude Cowork while focusing on other work
  • You prefer guided references over rigid scripts — the agent adapts while staying reliable

When to switch approaches

  • You need a daily automated check with no LLM cost → use n8n (free self-hosted) or the Code + Cron approach
  • You want a no-code setup with a visual builder → use Make
  • You need to group alerts by owner with complex routing logic → use n8n's Code node or the Code approach
hs_lastmodifieddate is not stage-specific

HubSpot's hs_lastmodifieddate updates on any property change — notes, email logging, owner reassignment — not just stage transitions. A deal with recent activity but no stage movement may not appear as stale. The SKILL.md documents this so the agent can flag it in its output. For true stage-based staleness, track stage changes with a custom property.

Common questions

Why not just use a script?

A script runs the same way every time. The Claude Code skill adapts to what you ask — different staleness thresholds, stage filters, grouping by owner or pipeline, a different channel. The reference files ensure it calls the right APIs even when improvising, so you get flexibility without sacrificing reliability.

Does this use Claude API credits?

Yes. The agent reads skill files and generates code each time. Typical cost is $0.01-0.05 per invocation depending on how many deals are returned and how much the agent needs to read. The HubSpot and Slack APIs themselves are free.

Can I change the staleness threshold without editing files?

Yes. Just tell Claude: "Run /stale-deals with a 7-day threshold." The SKILL.md specifies 14 days as the default, but the agent can override it based on your request.

Can I run this skill on a schedule without a server?

Yes. GitHub Actions (Option B in Step 5) runs Claude on a cron schedule using GitHub's infrastructure. The free tier includes 2,000 minutes/month.

Cost

  • Claude API — $0.01-0.05 per invocation (the agent reads files and generates code)
  • HubSpot API — included in all plans, no per-call cost
  • Slack API — included in all plans, no per-call cost
  • GitHub Actions (if scheduled) — free tier includes 2,000 minutes/month

Looking to scale your AI operations?

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