tomgranot/hubspot-admin-skills/reassign-deactivated-owners

Claude Code and agent skill by tomgranot/hubspot-admin-skills

Trust tier 2
  • community
LLM review pending
Source check pending

Checking that the SKILL.md file is still in the repository.

Reassign contacts and companies from deactivated team members to active owners. Fully automated via the HubSpot Owners API and Batch Update API. Includes territory analysis for informed reassignment decisions.

Install

npx vskill@latest install tomgranot/hubspot-admin-skills/reassign-deactivated-owners

SKILL.md

Reassign Contacts From Deactivated Owners

Purpose

When team members leave an organization, their HubSpot-owned contacts and companies become orphaned. Leads are not being worked, accounts are not being managed, and any automation that relies on Contact owner or Company owner routes to dead ends. This skill identifies deactivated owners, analyzes their contact portfolios, and batch-reassigns ownership via the API.

Prerequisites

  • A HubSpot service key (HUBSPOT_ACCESS_TOKEN in .env; an existing legacy private app token also works) with crm.objects.contacts.read, crm.objects.contacts.write, crm.objects.companies.read, crm.objects.companies.write, and crm.objects.owners.read scopes
  • Python 3.10+ with uv for package management
  • A .env file containing HUBSPOT_ACCESS_TOKEN
  • A decision from team leadership on the reassignment strategy (see Plan stage)

Scripts

Script paths are relative to this skill's directory. Run them from your project directory so .env, data/, and reports/ resolve there: prefix each path with wherever this skill is installed (skills/reassign-deactivated-owners/ in a clone of the repo).

StageScriptRun with
Before`scripts/before.py`uv run scripts/before.py
Execute`scripts/execute.py`uv run scripts/execute.py
After`scripts/after.py`uv run scripts/after.py

before.py lists deactivated owners and counts their records, exporting the CSV baseline. execute.py batch-reassigns to the configured target owner. after.py verifies zero records remain with deactivated owners.

CRITICAL: Retrieving Deactivated Owners

The Owners API requires the archived=true parameter to retrieve deactivated users. The default endpoint only returns active owners. This is the most commonly missed detail.

python
# CORRECT - returns deactivated/archived owners
requests.get(f"{BASE}/crm/v3/owners", headers=headers,
params={"limit": 500, "archived": "true"})
# DEFAULT - returns only active owners
requests.get(f"{BASE}/crm/v3/owners", headers=headers,
params={"limit": 500})

Deactivated owners have "archived": true in their response object.

Interview: Gather Requirements

Before executing, collect the following information from the user:

Q1: Who should contacts from [deactivated user X] be reassigned to?

  • Run the Before State script first to identify deactivated users and their contact counts, then ask this question for each deactivated user found
  • Examples: "Assign Jane Doe's 500 contacts to John Smith", "Distribute evenly across the sales team", "Assign all to the Marketing Team user"
  • Default: No default -- this requires an explicit business decision for each deactivated user

Q2: Should unowned contacts be assigned to a default user? If so, who?

  • Examples: "Yes, assign to our Integration User", "Yes, round-robin across the sales team", "No, leave them unowned for now"
  • Default: No -- unowned contacts are a separate strategic decision from deactivated-owner contacts

Execution Pattern

This skill follows a 4-stage execution pattern: Plan -> Before -> Execute -> After.

Stage 1: Plan

Before writing any code, confirm with the user:

  1. Reassignment strategy -- one of three options:
  • Option A (Unassign): Set owner to blank. Best if no one is actively working these contacts and the team wants to start fresh with new assignment rules.
  • Option B (Distribute to active team members): Round-robin across current reps. Best if there are active reps who should work these leads.
  • Option C (Assign to shared owner): Assign to a single placeholder user (e.g., "Integration User" or "Marketing Team"). Good middle ground.
  1. Territory analysis: Before reassigning, the user may want to analyze the portfolio of each deactivated owner (customer %, lifecycle distribution, industry breakdown) to make informed decisions about who gets which contacts.
  1. Unassigned contacts: Ask whether the user also wants to address contacts that have no owner at all. This is a separate strategic decision.
  1. Preserve historical ownership: Ask whether to create a "Previous Owner" custom property to retain a record of the original assignment.
Stage 2: Before

Discover all deactivated owners and analyze their contact/company portfolios.

python
"""
Before State: Identify deactivated owners and their contact/company counts.
"""
import os
import csv
import time
import requests
from dotenv import load_dotenv
load_dotenv()
TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
BASE = "https://api.hubapi.com"
headers = {
"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json",
}
# --- Step 1: Get all owners (active + deactivated) ---
print("Fetching owners...")
# Active owners
resp_active = requests.get(
f"{BASE}/crm/v3/owners", headers=headers, params={"limit": 500},
)
resp_active.raise_for_status()
active_owners = resp_active.json().get("results", [])
# CRITICAL: Use archived=true for deactivated owners
resp_archived = requests.get(
f"{BASE}/crm/v3/owners", headers=headers,
params={"limit": 500, "archived": "true"},
)
resp_archived.raise_for_status()
deactivated_owners = resp_archived.json().get("results", [])
print(f"Active owners: {len(active_owners)}")
print(f"Deactivated owners: {len(deactivated_owners)}")
print("\nActive owners:")
for o in active_owners:
print(f" {o.get('firstName', '')} {o.get('lastName', '')} "
f"({o.get('email', '')}) -- ID: {o['id']}")
print("\nDeactivated owners:")
for o in deactivated_owners:
print(f" {o.get('firstName', '')} {o.get('lastName', '')} "
f"({o.get('email', '')}) -- ID: {o['id']}")
# --- Step 2: Count contacts per deactivated owner ---
print("\nCounting contacts per deactivated owner...")
url = f"{BASE}/crm/v3/objects/contacts/search"
deactivated_contact_data = {}
total_deactivated_contacts = 0
for o in deactivated_owners:
owner_id = o["id"]
name = f"{o.get('firstName', '')} {o.get('lastName', '')}"
resp = requests.post(url, headers=headers, json={
"filterGroups": [{"filters": [
{"propertyName": "hubspot_owner_id", "operator": "EQ",
"value": str(owner_id)},
]}],
"limit": 1,
})
if resp.status_code == 200:
count = resp.json().get("total", 0)
deactivated_contact_data[name] = {
"id": owner_id, "email": o.get("email", ""), "contacts": count,
}
total_deactivated_contacts += count
if count > 0:
print(f" {name}: {count} contacts")
time.sleep(0.1)
print(f"\nTotal contacts owned by deactivated users: {total_deactivated_contacts}")
# --- Step 3: Count unassigned contacts ---
print("\nCounting unassigned contacts (no owner)...")
resp_no_owner = requests.post(url, headers=headers, json={
"filterGroups": [{"filters": [
{"propertyName": "hubspot_owner_id", "operator": "NOT_HAS_PROPERTY"},
]}],
"limit": 1,
})
resp_no_owner.raise_for_status()
unassigned_contacts = resp_no_owner.json().get("total", 0)
print(f"Unassigned contacts: {unassigned_contacts}")
# --- Step 4: Count companies per deactivated owner ---
print("\nCounting companies per deactivated owner...")
comp_url = f"{BASE}/crm/v3/objects/companies/search"
total_deactivated_companies = 0
for o in deactivated_owners:
owner_id = o["id"]
name = f"{o.get('firstName', '')} {o.get('lastName', '')}"
resp = requests.post(comp_url, headers=headers, json={
"filterGroups": [{"filters": [
{"propertyName": "hubspot_owner_id", "operator": "EQ",
"value": str(owner_id)},
]}],
"limit": 1,
})
if resp.status_code == 200:
count = resp.json().get("total", 0)
total_deactivated_companies += count
if count > 0:
print(f" {name}: {count} companies")
time.sleep(0.1)
print(f"\nTotal companies owned by deactivated users: {total_deactivated_companies}")
# --- Step 5: Save CSV audit log ---
os.makedirs("data/audit-logs", exist_ok=True)
csv_path = "data/audit-logs/ownership-summary.csv"
with open(csv_path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=[
"owner_name", "owner_id", "email", "status", "contacts", "companies",
])
writer.writeheader()
for o in deactivated_owners:
name = f"{o.get('firstName', '')} {o.get('lastName', '')}"
data = deactivated_contact_data.get(name, {})
writer.writerow({
"owner_name": name,
"owner_id": o["id"],
"email": o.get("email", ""),
"status": "deactivated",
"contacts": data.get("contacts", 0),
"companies": 0, # Would need per-owner company count
})
for o in active_owners:
name = f"{o.get('firstName', '')} {o.get('lastName', '')}"
writer.writerow({
"owner_name": name,
"owner_id": o["id"],
"email": o.get("email", ""),
"status": "active",
"contacts": "",
"companies": "",
})
print(f"\nAudit CSV saved: {csv_path}")

Optional: Territory analysis template

For informed reassignment decisions, generate a portfolio breakdown for each deactivated owner:

python
# Territory analysis: lifecycle + industry breakdown per owner
for o in deactivated_owners:
owner_id = o["id"]
name = f"{o.get('firstName', '')} {o.get('lastName', '')}"
print(f"\n--- Portfolio: {name} ---")
# Lifecycle breakdown
for stage in ["customer", "opportunity", "salesqualifiedlead",
"marketingqualifiedlead", "lead", "subscriber"]:
resp = requests.post(url, headers=headers, json={
"filterGroups": [{"filters": [
{"propertyName": "hubspot_owner_id", "operator": "EQ",
"value": str(owner_id)},
{"propertyName": "lifecyclestage", "operator": "EQ",
"value": stage},
]}],
"limit": 1,
})
if resp.status_code == 200:
count = resp.json().get("total", 0)
if count > 0:
print(f" {stage}: {count}")
time.sleep(0.1)

Present findings to the user. Ask for explicit reassignment instructions (which deactivated owner's contacts go to which active owner or shared user).

Stage 3: Execute

Batch-reassign contacts and companies from deactivated owners to the target owner(s).

python
"""
Execute: Batch-reassign contacts from deactivated owners.
"""
import time
import requests
# Target owner ID (get from user after Before State)
TARGET_OWNER_ID = "REPLACE_WITH_TARGET_OWNER_ID"
def get_contact_ids_for_owner(owner_id, limit=100):
"""
Get contact IDs for a given owner using pagination.
HubSpot search API caps at 10K results per query.
For larger sets, the caller should loop until no more
contacts remain for this owner.
"""
contact_ids = []
after = None
while True:
payload = {
"filterGroups": [{"filters": [
{"propertyName": "hubspot_owner_id", "operator": "EQ",
"value": str(owner_id)},
]}],
"properties": ["email"],
"limit": limit,
}
if after:
payload["after"] = after
resp = requests.post(
f"{BASE}/crm/v3/objects/contacts/search",
headers=headers, json=payload,
)
if resp.status_code == 400:
# Hit the 10K search limit -- return what we have
break
resp.raise_for_status()
data = resp.json()
results = data.get("results", [])
if not results:
break
contact_ids.extend(c["id"] for c in results)

This is the start of the file. Read the full SKILL.md on GitHub.

Security

  • Security scanPassed, 100 of 100Pattern checks on the skill's files.
  • Model reviewNot runA model review is scheduled for the next rescan.

Quality evaluation

  • Task evaluationNot runNo evaluation yet. Run one in Skill Studio.

Source

Popularity

Repository stars
78
Repository forks
18
7-day trend score
20

Stars and forks are for the whole GitHub repository, not this skill alone. Not refreshed yet.

Works with

Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.

  • Claude Code
  • Cursor
  • GitHub Copilot
  • Windsurf
  • Codex
  • Gemini CLI
  • Cline
  • Amp
  • Roo Code
  • Goose
  • Continue
  • Kilo Code
  • Junie
  • Trae
  • OpenHands
  • Qwen Code
  • AI Agent
  • Tabnine Chat
  • Sourcegraph Cody
  • Amazon Q
  • Firebase Genkit
  • Sweep
  • Devin
  • Phind
  • Mentat
  • Claude Desktop
  • Aider
  • GPT Pilot
  • Plandex
  • bolt.new
  • v0
  • Replit Agent
  • Anse
  • TypingMind
  • BetterChatGPT
  • LibreChat
  • Warp AI
  • Pieces
  • Fig AI
Embed badge
vskill VERIFIED badge
[![vskill VERIFIED](https://verified-skill.com/api/v1/skills/tomgranot/hubspot-admin-skills/reassign-deactivated-owners/badge)](https://verified-skill.com/skills/tomgranot/hubspot-admin-skills/reassign-deactivated-owners)

Something wrong with this skill or its page? Tell us.