Auto-archive stale HubSpot deals using a Claude Code skill

low complexityCost: Usage-based
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.objects.deals.write 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 fully automated — they find stale deals and close them on a schedule without asking. An Claude Code skill is different. You stay in the loop on every close decision.

That means you can say:

  • "Find stale deals and show me what you'd archive"
  • "Archive everything except the Acme Corp deal"
  • "Just show me deals owned by Sarah that are stale"

The skill contains workflow guidelines, API reference materials, and a notification 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 shows you the results before taking action. The interactive confirmation step means no deal is ever closed without your explicit approval.

How it works

The skill directory has three parts:

  1. SKILL.md — workflow guidelines telling the agent to find stale deals, present them for review, and only archive after confirmation
  2. references/ — HubSpot API patterns (deal search, deal update, pipelines, owners) so the agent calls the right APIs with the right parameters
  3. templates/ — a Slack Block Kit template so archive notifications are consistently formatted

When invoked, the agent reads SKILL.md, consults the reference and template files as needed, writes a script, executes it, and shows you the stale deals. It then waits for your confirmation before closing anything. 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, notification 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., /archive-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/archive-stale-deals/{templates,references}

This creates the layout:

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

Step 2: Write the SKILL.md

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

---
name: archive-stale-deals
description: Find HubSpot deals with no activity for 60+ days, present them for review, and archive confirmed deals to Closed Lost with Slack notification
disable-model-invocation: true
allowed-tools: Bash, Read
---
 
## Goal
 
Find all active deals in HubSpot with no activity for 60+ days, display them for user review, and close confirmed deals to Closed Lost with a Slack notification.
 
## 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 stale threshold: 60 days. The user may request a different threshold.
 
## Workflow
 
### Phase 1: Discovery
 
1. Validate that all required env vars are set. If any are missing, print which ones and exit.
2. Fetch pipeline stage definitions from HubSpot to build a stage ID → label map. See `references/hubspot-deals-api.md` for the endpoint.
3. Fetch all deal owners from HubSpot to build an owner ID → name map.
4. Search for deals where `hs_lastmodifieddate` is older than the stale threshold and `dealstage` is not `closedwon` or `closedlost`. Paginate if needed.
5. Print a formatted table showing: Deal ID, Deal Name, Owner, Stage, Amount, Days Stale.
6. Print the total count and total pipeline value at risk.
 
### Phase 2: Confirmation
 
7. **STOP and ask the user** which deals to archive. Wait for explicit confirmation. The user may say:
   - "Archive all of them"
   - "Archive everything except deal 12345"
   - "Just archive the ones owned by [name]"
   - "Skip it for now"
 
### Phase 3: Archive
 
8. For each confirmed deal, PATCH the deal stage to `closedlost` with `closed_lost_reason` set to "Stale — auto-archived after 60 days".
9. Post a single Slack notification listing all archived deals using the format in `templates/slack-archive-notification.md`.
10. Print a summary of how many deals were closed and how many failed.
 
## Important notes
 
- Deal amounts may be null or empty. Treat missing amounts as $0.
- The Deals Search API returns max 100 results per request. Use the `after` cursor from `paging.next.after` to paginate.
- Use `hs_lastmodifieddate` (not `closedate`) to determine if a deal is stale.
- Stage IDs in deal properties are internal identifiers — map them to human-readable labels using the pipelines endpoint.
- The `closed_lost_reason` property name depends on your HubSpot configuration. Some portals use `hs_closed_lost_reason` or a custom property.
- NEVER close a deal without explicit user confirmation. The confirmation step is mandatory.
- Use the `requests` library for HTTP calls and `slack_sdk` for Slack. Install them with pip if needed.

Understanding the SKILL.md

Unlike a script-based skill, 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
WorkflowThree-phase flow: discover → confirm → archive
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 three-phase workflow with a mandatory confirmation step ensures no deal is ever closed without your approval.

Step 3: Add reference files

templates/slack-archive-notification.md

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

# Slack Archive Notification Template
 
Use this Block Kit structure when notifying Slack about archived deals.
 
## Block Kit JSON
 
```json
{
  "channel": "<SLACK_CHANNEL_ID>",
  "text": "Stale deals auto-archived",
  "blocks": [
    {
      "type": "header",
      "text": {
        "type": "plain_text",
        "text": "🗄️ Deals Auto-Archived"
      }
    },
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "*<count> deals* moved to Closed Lost after 60+ days of inactivity:\n\n<deal_lines>"
      }
    },
    {
      "type": "context",
      "elements": [
        {
          "type": "mrkdwn",
          "text": "Archived <date_string> • Total value: $<total_value>"
        }
      ]
    }
  ]
}
```
 
## Deal line format
 
Each deal line: `• <Deal Name> — $<Amount> (<X>d stale)`
 
## Notes
 
- The top-level `text` field is required by Slack as a fallback for notifications.
- Format dollar amounts with commas (e.g., $150,000).
- If only one deal was archived, use "1 deal" (singular).
- Omit the amount from deal lines if the deal has no amount set.

references/hubspot-deals-api.md

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

# HubSpot Deals API Reference
 
## Get pipeline stages
 
Build a stage ID → label map for displaying human-readable stage names.
 
**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": "qualifiedtobuy", "label": "Qualified to Buy" },
        { "id": "closedwon", "label": "Closed Won" },
        { "id": "closedlost", "label": "Closed Lost" }
      ]
    }
  ]
}
```
 
## Get deal owners
 
Build an owner ID → name map for the stale deals table.
 
**Request:**
 
```
GET https://api.hubapi.com/crm/v3/owners
Authorization: Bearer <HUBSPOT_ACCESS_TOKEN>
```
 
**Response shape:**
 
```json
{
  "results": [
    {
      "id": "12345",
      "firstName": "Jane",
      "lastName": "Smith",
      "email": "jane@company.com"
    }
  ]
}
```
 
## Search for stale deals
 
Use the CRM Search API to find deals with no activity for 60+ days.
 
**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>"
        },
        {
          "propertyName": "dealstage",
          "operator": "NOT_IN",
          "values": ["closedwon", "closedlost"]
        }
      ]
    }
  ],
  "properties": ["dealname", "amount", "dealstage", "hubspot_owner_id", "hs_lastmodifieddate"],
  "sorts": [{ "propertyName": "hs_lastmodifieddate", "direction": "ASCENDING" }],
  "limit": 100
}
```
 
- `limit` max is 100. Use the `after` cursor from `paging.next.after` to paginate.
- `value` for the date filter must be a Unix timestamp in **milliseconds** (not seconds).
- Compute the cutoff as: `int((now - timedelta(days=60)).timestamp() * 1000)`
- `amount` may be null or empty for deals without a dollar value set.
- `hs_lastmodifieddate` is an ISO 8601 timestamp (e.g., `"2026-01-15T10:30:00.000Z"`).
- `dealstage` is an internal stage ID — map it using the pipelines endpoint above.
 
**Response shape:**
 
```json
{
  "total": 12,
  "results": [
    {
      "id": "98765",
      "properties": {
        "dealname": "Acme Corp — Enterprise",
        "amount": "150000",
        "dealstage": "qualifiedtobuy",
        "hubspot_owner_id": "12345",
        "hs_lastmodifieddate": "2025-12-20T14:30:00.000Z"
      }
    }
  ],
  "paging": {
    "next": { "after": "100" }
  }
}
```
 
## Update a deal (close as lost)
 
**Request:**
 
```
PATCH https://api.hubapi.com/crm/v3/objects/deals/<deal_id>
Authorization: Bearer <HUBSPOT_ACCESS_TOKEN>
Content-Type: application/json
```
 
**Body:**
 
```json
{
  "properties": {
    "dealstage": "closedlost",
    "closed_lost_reason": "Stale — auto-archived after 60 days"
  }
}
```
 
- The `dealstage` value `closedlost` is the default internal name. Your pipeline may use a different ID — check HubSpot > Settings > Objects > Deals > Pipelines.
- The `closed_lost_reason` property name may vary. Some portals use `hs_closed_lost_reason` or a custom property.
- Returns 200 on success with the updated deal object.

Step 4: Test the skill

Invoke the skill conversationally:

/archive-stale-deals

Claude will read the SKILL.md, check the reference files, write a script, install any missing dependencies, run it, and show you the stale deals. A typical run looks like:

Fetching pipeline stages... found 6 stages
Fetching deal owners... found 12 owners
Searching for stale deals (60+ days)...
 
Found 8 stale deals:
 
ID          Deal Name                    Owner          Stage              Amount    Days Stale
─────────────────────────────────────────────────────────────────────────────────────────────
98765       Acme Corp — Enterprise       Jane Smith     Proposal Sent      $150,000        85
87654       Beta Inc — Starter           Unassigned     Qualified          $12,000         72
...
 
Total pipeline value at risk: $340,000
 
Which deals should I archive? You can say "all", list specific IDs, or "skip".

You respond, and the agent archives only the deals you confirmed.

What the Slack notification looks like

What you'll get
#sales-pipeline
Archive Botapp9:41 AM

🗄️ Deals Auto-Archived

5 deals moved to Closed Lost after 60+ days of inactivity:

• Acme Corp — Enterprise — $150,000 (85d stale)

• Beta Inc — Starter — $12,000 (72d stale)

• Gamma Ltd — Pro — $45,000 (68d stale)

Archived Mar 5, 2026 • Total value: $207,000

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

  • "Show me just deals over $50K that are stale" — the agent adds an amount filter
  • "Archive only deals in the Proposal Sent stage" — the agent filters by stage
  • "Find deals stale for 90 days instead of 60" — the agent adjusts the threshold
Test with real data

Make sure you have at least a few open deals in your pipeline. If no stale deals exist, the skill correctly reports "No stale deals found. Pipeline is clean!" — that's not an error.

Step 5: Schedule it (optional)

Option A: Cron + Claude CLI

# Run every Friday at 9 AM for weekly pipeline cleanup
0 9 * * 5 cd /path/to/your/project && claude -p "Run /archive-stale-deals — show me the stale deals" --allowedTools 'Bash(*)' 'Read(*)'

Note: Even on a schedule, the agent will find stale deals and report them — but the archive step still requires your confirmation in the next interactive session.

Option B: GitHub Actions + Claude

name: Weekly Stale Deal Review
on:
  schedule:
    - cron: '0 14 * * 5'  # 9 AM ET = 2 PM UTC, Fridays
  workflow_dispatch: {}
jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: anthropics/claude-code-action@v1
        with:
          prompt: "Run /archive-stale-deals — find stale deals and post the summary to Slack. Do not archive without confirmation."
          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 }}

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).

GitHub Actions cron uses UTC

0 14 * * 5 runs at 2 PM UTC (9 AM ET) on Fridays. GitHub Actions cron may also have up to 15 minutes of delay.

Troubleshooting

When to use this approach

  • You want human review before any deal is closed — the agent always asks for confirmation
  • You run pipeline cleanup as part of a weekly review meeting — invoke the skill, discuss the list, decide together
  • You want to selectively archive — keep some deals, close others based on context only you know
  • You want conversational flexibility — filter by owner, stage, amount, or threshold on the fly
  • You're already using Claude Code and want pipeline cleanup as a quick command
  • You want to run tasks in the background via Claude Cowork while focusing on other work

When to switch approaches

  • You trust the 60-day threshold enough to run it without manual review → use n8n or the code approach
  • You want a no-code setup with a visual builder → use Make
  • You need the full warn-then-close pattern with a 48-hour grace period running unattended → use n8n
Combine approaches

Start with this skill to build confidence in the thresholds. Once you trust that 60 days is the right cutoff and the Slack warnings give reps enough notice, switch to the n8n or code approach for full automation.

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 stale thresholds, stage-specific filtering, owner-based selection. 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 the number of stale deals. The HubSpot and Slack APIs themselves are free.

Can I change the stale threshold?

Yes. The default is 60 days, defined in the SKILL.md. You can either edit the default or just tell the agent "use 90 days" when you invoke the skill — it adjusts the search filter automatically.

Is there a risk of accidentally closing deals?

No. The three-phase workflow in SKILL.md requires the agent to show you every deal and wait for your explicit confirmation before closing anything. The disable-model-invocation: true setting also means only you can trigger the skill.

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.