Post a daily Slack leaderboard of rep activity from HubSpot 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)

Overview

Instead of building a persistent automation, you can create an agent skill — a reusable instruction set that tells your AI coding agent how to fetch yesterday's rep activity from HubSpot, rank reps, and post a leaderboard to Slack. 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 posting the leaderboard on demand, iterating on the format, or testing before committing to a scheduled automation.

Step 1: Create the skill directory

mkdir -p .claude/skills/rep-leaderboard/scripts

Step 2: Write the SKILL.md file

Create .claude/skills/rep-leaderboard/SKILL.md:

---
name: rep-leaderboard
description: Generates a daily rep activity leaderboard from HubSpot (calls, emails, meetings) and posts it to Slack with emoji medals for the top 3 reps.
disable-model-invocation: true
allowed-tools: Bash(python *)
---
 
Generate a daily rep activity leaderboard by running the bundled script:
 
1. Run: `python $SKILL_DIR/scripts/leaderboard.py`
2. Review the output for any errors
3. Confirm the leaderboard was posted to Slack

Step 3: Write the leaderboard script

Create .claude/skills/rep-leaderboard/scripts/leaderboard.py:

#!/usr/bin/env python3
"""
Daily Rep Activity Leaderboard: HubSpot -> Slack
Fetches yesterday's calls, emails, and meetings, ranks reps, posts to Slack.
"""
import os
import sys
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")
 
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"}
 
# --- Date range: yesterday midnight to today midnight (UTC) ---
today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
yesterday = today - timedelta(days=1)
start_ms = str(int(yesterday.timestamp() * 1000))
end_ms = str(int(today.timestamp() * 1000))
 
# --- Fetch owners ---
print("Fetching owners...")
owners_resp = requests.get("https://api.hubapi.com/crm/v3/owners?limit=100", headers=HEADERS)
owners_resp.raise_for_status()
owner_map = {}
for o in owners_resp.json()["results"]:
    owner_map[o["id"]] = f"{o.get('firstName', '')} {o.get('lastName', '')}".strip() or o["email"]
 
# --- Search engagements ---
def search(object_type):
    results = []
    after = "0"
    while True:
        resp = requests.post(
            f"https://api.hubapi.com/crm/v3/objects/{object_type}/search",
            headers=HEADERS,
            json={
                "filterGroups": [{"filters": [
                    {"propertyName": "hs_timestamp", "operator": "GTE", "value": start_ms},
                    {"propertyName": "hs_timestamp", "operator": "LT", "value": end_ms},
                ]}],
                "properties": ["hs_timestamp", "hubspot_owner_id"],
                "limit": 100,
                "after": after,
            },
        )
        resp.raise_for_status()
        data = resp.json()
        results.extend(data["results"])
        if data.get("paging", {}).get("next", {}).get("after"):
            after = data["paging"]["next"]["after"]
        else:
            break
    return results
 
print("Fetching yesterday's activities...")
calls = search("calls")
emails = search("emails")
meetings = search("meetings")
print(f"Found: {len(calls)} calls, {len(emails)} emails, {len(meetings)} meetings")
 
# --- Rank reps ---
reps = {}
for items, activity_type in [(calls, "calls"), (emails, "emails"), (meetings, "meetings")]:
    for item in items:
        oid = item["properties"].get("hubspot_owner_id")
        if not oid:
            continue
        if oid not in reps:
            reps[oid] = {"calls": 0, "emails": 0, "meetings": 0, "total": 0}
        reps[oid][activity_type] += 1
        reps[oid]["total"] += 1
 
ranked = sorted(reps.items(), key=lambda x: x[1]["total"], reverse=True)
medals = ["\U0001F947", "\U0001F948", "\U0001F949"]
 
lines = []
for i, (oid, c) in enumerate(ranked):
    medal = medals[i] if i < 3 else f"{i + 1}."
    name = owner_map.get(oid, f"Owner {oid}")
    lines.append(f"{medal} *{name}* — {c['total']} activities ({c['calls']}C {c['emails']}E {c['meetings']}M)")
 
if not lines:
    print("No activity logged yesterday — skipping.")
    sys.exit(0)
 
# --- Post to Slack ---
date_str = yesterday.strftime("%A, %b %d")
blocks = [
    {"type": "header", "text": {"type": "plain_text", "text": "\U0001F3C6 Rep Activity Leaderboard"}},
    {"type": "context", "elements": [{"type": "mrkdwn", "text": f"Activity for {date_str}"}]},
    {"type": "divider"},
    {"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}},
    {"type": "context", "elements": [{"type": "mrkdwn", "text": "C = Calls | E = Emails | M = Meetings"}]},
]
 
print("Posting to Slack...")
slack = WebClient(token=SLACK_TOKEN)
result = slack.chat_postMessage(channel=SLACK_CHANNEL, text="Rep Activity Leaderboard", blocks=blocks, unfurl_links=False)
print(f"Posted leaderboard: {result['ts']}")

Step 4: Run the skill

# Claude Code
/rep-leaderboard
 
# Or run directly
python .claude/skills/rep-leaderboard/scripts/leaderboard.py

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 /rep-leaderboard to post the daily activity leaderboard"
  4. Set frequency to Daily on weekday mornings
  5. Set the working folder to your project directory
Desktop must be open

Cowork scheduled tasks only run while your computer is awake and the Claude Desktop app is open. If your machine is asleep Monday morning, the task runs when the app next opens.

Option B: Cron + CLI

# crontab -e
0 8 * * 1-5 cd /path/to/project && python .claude/skills/rep-leaderboard/scripts/leaderboard.py

Option C: GitHub Actions

name: Daily Rep Leaderboard
on:
  schedule:
    - cron: '0 13 * * 1-5'
  workflow_dispatch: {}
jobs:
  leaderboard:
    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/rep-leaderboard/scripts/leaderboard.py
        env:
          HUBSPOT_TOKEN: ${{ secrets.HUBSPOT_TOKEN }}
          SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
          SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }}

When to use this approach

  • You want a leaderboard now without setting up n8n, Zapier, or Make
  • You're testing metrics — quickly change which activities count or adjust the ranking formula
  • You want ad-hoc variants — "show me just SDR activity" or "only count this week"
  • You want the logic version-controlled alongside your code

When to graduate to a dedicated tool

  • You need reliable daily scheduling without depending on your machine
  • Multiple non-technical stakeholders need to modify the leaderboard config
  • You want visual execution history and built-in retry logic
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.