Route HubSpot leads by territory and company size using a Claude Code skill
Install this skill
Download the skill archive and extract it into your .claude/skills/ directory.
territory-routing.skill.zipPrerequisites
This skill works with any agent that supports the Claude Code skills standard, including Claude Code, Claude Cowork, OpenAI Codex, and Google Antigravity.
- One of the agents listed above
- HubSpot private app with
crm.objects.contacts.readandcrm.objects.contacts.writescopes - Slack bot with
chat:writepermission, added to the target channel (optional, for routing notifications)
Why a Claude Code skill?
The other approaches in this guide route leads in real time — one at a time, as they arrive. An Claude Code skill is different. It's built for bulk operations: routing all unassigned contacts at once, rerouting everyone after a territory change, or auditing what would happen before making changes.
That means you can say:
- "Route all unassigned contacts by territory"
- "Re-route all contacts — we restructured territories last week"
- "Dry-run: show me where each contact would go without writing anything"
- "Route unassigned contacts in California and Texas only"
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 reports results. If you ask for something different next time — a new territory map, a different enterprise threshold, routing by country instead of state — the agent adapts without you touching any code.
How it works
The skill directory has three parts:
SKILL.md— workflow guidelines telling the agent what steps to follow, which env vars to use, and what pitfalls to avoidreferences/— HubSpot Contacts API patterns (endpoints, request shapes, response formats) so the agent calls the right APIs with the right parameterstemplates/— a Slack notification template so routing summaries 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 routed. 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., /territory-routing), 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/territory-routing/{templates,references}This creates the layout:
.claude/skills/territory-routing/
├── SKILL.md # workflow guidelines + config
├── templates/
│ └── slack-routing-summary.md # Slack notification template
└── references/
└── hubspot-contacts-api.md # HubSpot Contacts API patternsStep 2: Write the SKILL.md
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, Read
---
## Goal
Apply territory and company size routing rules to HubSpot contacts in bulk. Support three modes: route only unassigned contacts (default), re-route all contacts after territory changes, and dry-run to preview routing decisions without writing.
## Configuration
Read these environment variables:
- `HUBSPOT_ACCESS_TOKEN` — HubSpot private app token (required)
- `SLACK_BOT_TOKEN` — Slack bot token starting with xoxb- (optional, for posting routing summaries)
- `SLACK_CHANNEL_ID` — Slack channel ID starting with C (optional)
Default territory configuration (the user may override any of these):
- **Enterprise threshold**: 1,000 employees — contacts at companies this size or larger route to the enterprise AE
- **Territory map**: state abbreviations mapped to reps (e.g., NY/MA → Alice, CA/WA → Bob, FL/TX → Carol)
- **Default rep**: catch-all for contacts whose state doesn't match any territory
## Workflow
1. Validate that `HUBSPOT_ACCESS_TOKEN` is set. If missing, print the error and exit.
2. Determine the routing mode from the user's request:
- **Default**: fetch only contacts where `hubspot_owner_id` has no value (unassigned)
- **Re-route all**: fetch all contacts regardless of current owner
- **Dry-run**: run either mode above but skip the PATCH step; print what would change
3. Fetch contacts from HubSpot using the CRM Search API. See `references/hubspot-contacts-api.md` for the endpoint and response format. Request these properties: `firstname`, `lastname`, `email`, `company`, `state`, `country`, `numberofemployees`, `hubspot_owner_id`. Paginate using the `after` cursor if there are more than 100 results.
4. For each contact, apply the three-tier routing hierarchy:
- **Enterprise override**: if `numberofemployees` >= enterprise threshold, assign to enterprise rep
- **Territory match**: if the contact's `state` (uppercased) matches a territory, assign to that territory's rep
- **Fallback**: assign to the default rep
5. Unless in dry-run mode, update each contact's `hubspot_owner_id` via PATCH. See `references/hubspot-contacts-api.md` for the request format.
6. Print a summary of routing decisions: how many enterprise, territory-matched, and fallback assignments.
7. If `SLACK_BOT_TOKEN` and `SLACK_CHANNEL_ID` are set, post a summary to Slack using the template in `templates/slack-routing-summary.md`.
## Important notes
- The `state` property in HubSpot may contain full state names ("California"), abbreviations ("CA"), or lowercase ("ca"). Normalize to uppercase abbreviations before matching. If full names are common, add a mapping (e.g., "CALIFORNIA" → "CA").
- `numberofemployees` may be stored as a string, sometimes with commas ("1,500") or ranges ("1001-5000"). Parse defensively — strip commas, take the first number from ranges, default to 0 on failure.
- HubSpot's CRM Search API returns max 100 results per page. Use the `after` cursor from `paging.next.after` to get all contacts.
- When filtering for unassigned contacts, use the `NOT_HAS_PROPERTY` operator on `hubspot_owner_id`.
- HubSpot rate limit is 100 requests per 10 seconds for private apps. Add a small delay between PATCH calls if routing more than 80 contacts.
- `SLACK_CHANNEL_ID` must be the channel ID (starts with `C`), not the channel name.
- 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:
| Section | Purpose |
|---|---|
| Goal | Tells the agent what outcome to produce |
| Configuration | Which env vars to read, default territory rules, and what the user can override |
| Workflow | Numbered steps with pointers to reference files |
| Important notes | Non-obvious context that prevents common mistakes (state normalization, employee parsing, rate limits) |
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-routing-summary.md
Create .claude/skills/territory-routing/templates/slack-routing-summary.md:
# Slack Routing Summary Template
Use this Block Kit structure for the routing summary message posted after a bulk routing run.
## Block Kit JSON
```json
{
"channel": "<SLACK_CHANNEL_ID>",
"text": "Territory routing complete: <total> contacts routed",
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "Territory Routing Summary"
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*<total> contacts routed*\n\n• Enterprise (1,000+ employees): <count> → <rep name>\n• Territory matches: <count>\n - NY/MA: <count> → Alice\n - CA/WA: <count> → Bob\n - FL/TX: <count> → Carol\n• Fallback: <count> → <default rep name>"
}
},
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": "Mode: <default | re-route all | dry-run> • Run at <timestamp>"
}
]
}
]
}
```
## Notes
- The top-level `text` field is required by the Slack API as a fallback for notifications.
- Group counts by routing reason (enterprise, each territory, fallback).
- Include the mode (default, re-route all, dry-run) and timestamp in the context block.
- For dry-run mode, prefix the header with "[DRY RUN]" so it's obvious no changes were made.references/hubspot-contacts-api.md
Create .claude/skills/territory-routing/references/hubspot-contacts-api.md:
# HubSpot Contacts API Reference
## Search for contacts
Use the CRM Search API to find contacts matching specific criteria.
**Request:**
```
POST https://api.hubapi.com/crm/v3/objects/contacts/search
Authorization: Bearer <HUBSPOT_ACCESS_TOKEN>
Content-Type: application/json
```
**Body (unassigned contacts only):**
```json
{
"filterGroups": [
{
"filters": [
{
"propertyName": "hubspot_owner_id",
"operator": "NOT_HAS_PROPERTY"
}
]
}
],
"properties": ["firstname", "lastname", "email", "company", "state", "country", "numberofemployees", "hubspot_owner_id"],
"limit": 100
}
```
**Body (all contacts — no filter):**
```json
{
"properties": ["firstname", "lastname", "email", "company", "state", "country", "numberofemployees", "hubspot_owner_id"],
"limit": 100
}
```
- `limit` max is 100. If there are more results, use the `after` cursor from `paging.next.after` to paginate.
- Pass the `after` value as a top-level field in the request body: `"after": "100"`.
**Response shape:**
```json
{
"total": 47,
"results": [
{
"id": "12345",
"properties": {
"firstname": "Rachel",
"lastname": "Kim",
"email": "rachel@contoso.com",
"company": "Contoso Ltd",
"state": "CA",
"country": "US",
"numberofemployees": "2400",
"hubspot_owner_id": null
}
}
],
"paging": {
"next": {
"after": "100"
}
}
}
```
## Update a contact's owner
**Request:**
```
PATCH https://api.hubapi.com/crm/v3/objects/contacts/<contact_id>
Authorization: Bearer <HUBSPOT_ACCESS_TOKEN>
Content-Type: application/json
```
**Body:**
```json
{
"properties": {
"hubspot_owner_id": "<owner_id>"
}
}
```
- `owner_id` is a numeric string (e.g., "111111"). Find owner IDs via HubSpot Settings > Users & Teams, or `GET /crm/v3/owners`.
- Returns the updated contact object on success (HTTP 200).
## Rate limits
- Private apps: 100 requests per 10 seconds.
- If routing more than ~80 contacts, add a 100ms delay between PATCH calls to stay within limits.
- Search API counts as 1 request per page regardless of how many results are returned.Step 4: Test the skill
Invoke the skill conversationally:
/territory-routingClaude will read the SKILL.md, check the reference files, write a script, run it, and report the results. A typical run looks like:
Routing unassigned contacts by territory...
Fetched 47 unassigned contacts (1 page)
Enterprise (1000+ emp): 8 → Dave
Territory match: 31
NY/MA: 12 → Alice
CA/WA: 11 → Bob
FL/TX: 8 → Carol
Fallback: 8 → Eve
Updated 47 contacts in HubSpot
Posted summary to #sales-ops
Done. Routed 47 contacts.Because the agent generates code on the fly, you can also make ad hoc requests:
- "Dry-run: show me where each contact would go" — the agent runs the full routing logic but skips the PATCH step
- "Re-route all contacts — we moved Texas from Carol to Bob" — the agent fetches everyone and applies updated rules
- "Route only contacts in CA and NY" — the agent adds state filters
- "Use 500 as the enterprise threshold instead of 1,000" — the agent adjusts the cutoff
When re-routing all contacts (not just unassigned), run with dry-run mode first. This shows every routing decision without writing changes, so you can audit the results before committing.
Step 5: Schedule it (optional)
Option A: Cron + Claude CLI
# Route unassigned contacts every weekday at 9am
0 9 * * 1-5 cd /path/to/your/project && claude -p "Run /territory-routing" --allowedTools 'Bash(*)' 'Read(*)'Option B: GitHub Actions + Claude
name: Territory Routing
on:
schedule:
- cron: '0 14 * * 1-5' # Weekdays at 9am ET
workflow_dispatch: {} # Manual trigger for testing
jobs:
route:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: anthropics/claude-code-action@v1
with:
prompt: "Run /territory-routing"
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).
0 14 * * 1-5 runs at 2pm UTC, which is 9am ET. GitHub Actions cron may also have up to 15 minutes of delay. For time-sensitive routing, use cron on your own server.
Troubleshooting
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
- You want conversational flexibility — different thresholds, state filters, or territory maps without editing code
When to switch approaches
- You need real-time routing as each lead arrives → use n8n, Zapier, or the Code approach
- You want a no-code visual builder → use Make or Zapier
- You need zero LLM cost per execution → use n8n (free self-hosted) or Code + Cron
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 enterprise thresholds, state filters, territory maps, dry-run mode, routing only specific regions. The reference files ensure it calls the right APIs even when improvising, so you get flexibility without sacrificing reliability. If your territories change quarterly, the agent handles the new mapping without you editing code.
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 contacts are processed and how much the agent needs to read. The HubSpot and Slack APIs themselves are free.
Can this handle real-time routing as leads arrive?
No. This skill is designed for bulk operations — routing a backlog of unassigned contacts or rerouting after territory changes. For real-time per-lead routing, use n8n (HubSpot Trigger), Zapier (New Contact trigger), or the Code approach (webhook handler). You can combine both: real-time routing for new leads + this skill for periodic cleanup of anything that slipped through.
Can I use this skill with a different CRM?
The SKILL.md and reference files are HubSpot-specific, but the pattern is portable. To adapt for Salesforce, replace references/hubspot-contacts-api.md with Salesforce REST API patterns and update the SKILL.md workflow steps accordingly. The agent will generate Salesforce-compatible code from the new references.
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.