Route HubSpot leads by territory and company size using an agent skill

low complexityCost: Usage-based

Prerequisites

Prerequisites
  • Claude Code or another agent that supports the Agent Skills standard
  • HubSpot private app token stored as HUBSPOT_TOKEN environment variable
  • Slack Bot Token stored as SLACK_BOT_TOKEN environment variable

Overview

This approach creates an agent skill that applies or updates territory assignments in bulk. It's useful for initial territory setup, re-routing after territory changes, or periodic cleanup of misrouted leads.

Step 1: Create the skill

Create .claude/skills/territory-routing/SKILL.md:

---
name: territory-routing
description: Route or re-route HubSpot contacts by territory and company size
disable-model-invocation: true
allowed-tools: Bash(python *)
---
 
Apply territory and company size routing rules to HubSpot contacts.
 
Usage:
- `/territory-routing` — route all unassigned contacts
- `/territory-routing --all` — re-route all contacts (use after territory changes)
- `/territory-routing --dry-run` — show what would change without writing
 
Run: `python $SKILL_DIR/scripts/route_territories.py $@`

Step 2: Write the script

Create .claude/skills/territory-routing/scripts/route_territories.py:

#!/usr/bin/env python3
"""Route HubSpot contacts by territory and company size."""
import os, sys, requests
from slack_sdk import WebClient
 
HUBSPOT_TOKEN = os.environ.get("HUBSPOT_TOKEN")
SLACK_TOKEN = os.environ.get("SLACK_BOT_TOKEN")
if not HUBSPOT_TOKEN:
    print("ERROR: Set HUBSPOT_TOKEN"); sys.exit(1)
 
HEADERS = {"Authorization": f"Bearer {HUBSPOT_TOKEN}", "Content-Type": "application/json"}
slack = WebClient(token=SLACK_TOKEN) if SLACK_TOKEN else None
 
# --- Territory config (edit this) ---
TERRITORY_CONFIG = {
    "enterprise_threshold": 1000,
    "enterprise_rep": {"owner_id": "444444", "slack_id": "U04DDDD", "name": "Dave"},
    "territories": {
        "NY": {"owner_id": "111111", "slack_id": "U01AAAA", "name": "Alice"},
        "MA": {"owner_id": "111111", "slack_id": "U01AAAA", "name": "Alice"},
        "CA": {"owner_id": "222222", "slack_id": "U02BBBB", "name": "Bob"},
        "WA": {"owner_id": "222222", "slack_id": "U02BBBB", "name": "Bob"},
        "FL": {"owner_id": "333333", "slack_id": "U03CCCC", "name": "Carol"},
        "TX": {"owner_id": "333333", "slack_id": "U03CCCC", "name": "Carol"},
    },
    "default_rep": {"owner_id": "555555", "slack_id": "U05EEEE", "name": "Eve"},
}
 
dry_run = "--dry-run" in sys.argv
route_all = "--all" in sys.argv
 
# Fetch contacts
filters = [] if route_all else [
    {"propertyName": "hubspot_owner_id", "operator": "NOT_HAS_PROPERTY"}
]
contacts = []
after = None
while True:
    body = {
        "properties": ["firstname","lastname","email","company","state","country","numberofemployees","hubspot_owner_id"],
        "limit": 100,
    }
    if filters:
        body["filterGroups"] = [{"filters": filters}]
    if after:
        body["after"] = after
    resp = requests.post("https://api.hubapi.com/crm/v3/objects/contacts/search", headers=HEADERS, json=body)
    resp.raise_for_status()
    data = resp.json()
    contacts.extend(data.get("results", []))
    after = data.get("paging", {}).get("next", {}).get("after")
    if not after:
        break
 
if not contacts:
    print("No contacts to route"); sys.exit(0)
 
print(f"{'[DRY RUN] ' if dry_run else ''}Routing {len(contacts)} contacts...\n")
 
# Route each contact
results = {"enterprise": 0, "territory": 0, "fallback": 0}
for contact in contacts:
    props = contact["properties"]
    state = (props.get("state") or "").upper()
    employees = int(props.get("numberofemployees") or 0)
    name = f"{props.get('firstname','')} {props.get('lastname','')}".strip()
 
    if employees >= TERRITORY_CONFIG["enterprise_threshold"]:
        rep = TERRITORY_CONFIG["enterprise_rep"]
        reason = f"Enterprise ({employees} emp)"
        results["enterprise"] += 1
    elif state in TERRITORY_CONFIG["territories"]:
        rep = TERRITORY_CONFIG["territories"][state]
        reason = f"Territory: {state}"
        results["territory"] += 1
    else:
        rep = TERRITORY_CONFIG["default_rep"]
        reason = f"Fallback (state: {state or '?'})"
        results["fallback"] += 1
 
    print(f"  {name} -> {rep['name']} ({reason})")
 
    if not dry_run:
        requests.patch(
            f"https://api.hubapi.com/crm/v3/objects/contacts/{contact['id']}",
            headers=HEADERS,
            json={"properties": {"hubspot_owner_id": rep["owner_id"]}}
        ).raise_for_status()
 
print(f"\nSummary: {results['enterprise']} enterprise, {results['territory']} territory, {results['fallback']} fallback")
if dry_run:
    print("(Dry run — no changes written)")

Step 3: Run it

# Preview routing decisions without writing
/territory-routing --dry-run
 
# Route all unassigned contacts
/territory-routing
 
# Re-route all contacts after territory changes
/territory-routing --all

When to use this approach

  • You're setting up territories for the first time and need to route existing contacts in bulk
  • A territory change happened (new rep, region reassignment) and you need to re-route
  • You want to audit routing with --dry-run before making changes

Need help implementing this?

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