Finds customers who haven't been contacted in X days (or ever) and bulk-creates follow-up tasks so they don't slip through the cracks. You filter contacts by `notes_last_contacted` or `hs_last_sales_activity_date`, pipe the results through jq to build task payloads, then associate each task back to its contact. The associations are the annoying part since there's no batch endpoint, so you're running one CLI call per pair. It leans hard on the bulk-operations patterns for pagination and dry-run digests. If you're staring at a renewals report and need to spin up 200 check-in tasks before end of quarter, this is the playbook.
npx -y skills add hubspot/agent-cli-skills --skill customer-retention --agent claude-codeInstalls into .claude/skills of the current project.
| File | When to use |
|---|---|
resources/customer-health-signals.md | Filter cookbook of churn signals — --filter expressions for notes_last_contacted, hs_last_sales_activity_date, hs_email_optout, stale tickets, subscription status. |
Read bulk-operations/SKILL.md first — every read/write below uses its JSONL pipe, pagination, and dry-run/digest patterns. Activity-property tables and association rules live in sales-execution/SKILL.md.
Schema is portal-specific. Verify each property before filtering — e.g. hubspot properties get --type contacts notes_last_contacted, ... hs_last_sales_activity_date, ... --type subscriptions hs_subscription_status. If subscriptions returns 403, your token lacks subscriptions-read — use a private-app token with that scope.
CUTOFF=$(date -v-60d +%Y-%m-%d 2>/dev/null || date -d '60 days ago' +%Y-%m-%d)
# No outreach in 60d (calls/notes/meetings update notes_last_contacted)
hubspot objects search --type contacts \
--filter "lifecyclestage=customer AND notes_last_contacted<$CUTOFF" \
--properties email,firstname,notes_last_contacted,hubspot_owner_id
# No sales activity in 60d (broader — also catches emails/tasks)
hubspot objects search --type contacts \
--filter "lifecyclestage=customer AND hs_last_sales_activity_date<$CUTOFF" \
--properties email,firstname,hs_last_sales_activity_date
# Never contacted
hubspot objects search --type contacts \
--filter "lifecyclestage=customer AND !notes_last_contacted" \
--properties email,firstname
For more signals (email opt-out, stale tickets, no open deals) see resources/customer-health-signals.md. For >100 hits, use the pagination loop from bulk-operations.
subscriptions is a standard object (hubspot objects types confirms). Enum values for hs_subscription_status are portal-specific — verify before filtering, then plug the exact value in:
hubspot properties get --type subscriptions hs_subscription_status # lists allowed values
# Past-due — revenue at immediate risk (substitute your verified value)
hubspot objects search --type subscriptions \
--filter "hs_subscription_status=past_due" \
--properties hs_recurring_billing_total,hs_subscription_status
# Map an at-risk subscription to its contact for outreach
hubspot associations list --from subscriptions:<sub_id> --to contacts --format jsonl
Activity creation lives in sales-execution (full property tables, note + meeting flows). One anchor example — unassociated tasks are invisible in the CRM UI, so always associate:
task_id=$(hubspot objects create --type tasks \
--property hs_task_subject="Q1 retention check-in" \
--property hs_task_priority=HIGH --property hs_task_status=NOT_STARTED \
--property hs_task_type=CALL --property hs_timestamp=$(date +%s)000 \
--format json | jq -r '.id')
hubspot associations create --from tasks:$task_id --to contacts:<contact_id>
Pipe a search through jq into one objects create call, then associate. Preview with --dry-run first (bulk-operations covers digest/confirm for >100 rows).
DUE_MS=$(( ($(date +%s) + 2*86400) * 1000 )) # due in 2 days
# 1. Capture the cohort (same file feeds both create + associate)
hubspot objects search --type contacts \
--filter "lifecyclestage=customer AND notes_last_contacted<$CUTOFF" \
--properties firstname > /tmp/inactive.jsonl
# 2. Build task payloads — one per contact
jq -c --arg due "$DUE_MS" '{
contact_id: .id,
properties: {
hs_task_subject: ("Re-engage: " + (.properties.firstname // "customer")),
hs_task_priority: "HIGH", hs_task_status: "NOT_STARTED",
hs_task_type: "CALL", hs_timestamp: $due
}
}' /tmp/inactive.jsonl > /tmp/task_payloads.jsonl
# 3. Dry-run, then create (drop contact_id before piping)
jq -c '{properties}' /tmp/task_payloads.jsonl | hubspot objects create --type tasks --dry-run | head
jq -c '{properties}' /tmp/task_payloads.jsonl | hubspot objects create --type tasks > /tmp/created.jsonl
# 4. Associate each new task to its contact (paste preserves order)
paste <(jq -r '.id' /tmp/created.jsonl) <(jq -r '.contact_id' /tmp/task_payloads.jsonl) \
| while read task_id contact_id; do
hubspot associations create --from tasks:$task_id --to contacts:$contact_id
done
One CLI call for the search, one for the create, then N for associations — no xargs -I{} per record. The output-order guarantee of objects create (one result per stdin line, in order — see bulk-operations "Output shape") is what makes the paste correct.
hubspot associations create does not batch — one CLI call per pair.sickn33/antigravity-awesome-skills
moizibnyousaf/ai-agent-skills
github/awesome-copilot