Higher-Ed Dial Queue Builder (SDR Pod Lead Engine v1)
Stage 0: Load vertical config
Read reference/verticals/higher_ed.md — the single source for lifecycle internal values, DIALABLE set, AE_SUPPRESS_SET, deal-suppression rules, TIER1_RECENCY_DAYS, the inventory floor formula, ATL/BTL, and CMS signals. Keep values there, not here.
Stage 1: Query the HE callable pool (HubSpot)
MCP tool: search_crm_objects (objectType contacts).
Properties: firstname, lastname, email, phone, mobilephone, jobtitle, company, lifecyclestage, hs_lead_status, notes_last_contacted, hubspot_owner_id, industry, num_associated_deals, hs_lastmodifieddate.
Filter — OR filterGroups, each AND'd with the DIALABLE lifecycle set so the result stays under HubSpot's 10k search ceiling:
- Phone present: include a group for
phone HAS_PROPERTY AND a group for mobilephone HAS_PROPERTY (a contact with only a mobile must still appear).
- HE membership:
email CONTAINS_TOKEN "edu" OR industry CONTAINS_TOKEN "education" (also "higher"/"university"/"college").
- Lifecycle:
lifecyclestage IN [opportunity, salesqualifiedlead, 970124947, marketingqualifiedlead, 1217869329, lead, 1028712882] — the DIALABLE set. This EXCLUDES customer/Declined/Junk/Nurture at the query layer, cutting ~11.5k raw down to ~3.5k dialable (well under the 10k cap).
Pagination: page with the cursor/offset the API returns until total is covered. Guard: if total ≥ 9,900, the 10k search ceiling is in play — partition the query (e.g. by hs_lastmodifieddate ranges or per-lifecycle-bucket) and union the pages; log a warning and set sidecar status:partial if the cap is hit.
Caveat: industry is free-text and there is no email domain-suffix operator; "*.edu" over-matches (~67k) — use the bare "edu" token, then refine in Stage 2.
Stage 2: Refine + per-contact suppression
Drop a contact if any:
- Not truly HE: email does NOT contain
.edu (incl. .edu.<cc>) AND industry has no education token. (Keep international .edu.au etc. Do NOT drop solely because industry is blank when the email is .edu.) Track the drop count; if > 25% of the pool is dropped here, set status:partial and log the delta vs the warehouse's ~5,312.
- AE-owned:
hubspot_owner_id ∈ AE_SUPPRESS_SET = {Lex 82625923, Ron 423155215, Phil 190030668, Anthony 165812850, Anh 165815985} AND ownership/last-touch < 90 days. KEEP (dialable, do NOT drop): Robert 158872429 (works new leads), Tim 87486452 (BDR).
- Bad lead status:
hs_lead_status ∈ {UNQUALIFIED, BAD_TIMING}.
- (Lifecycle skip/route-out already handled at the query layer; Nurture
970063423 if present → email-only.)
- Golden Rules (Customer-domain / Channel Partner / Device Owner): apply
phone-verification-waterfall-skill/golden-rules-filter.md.
- Suppressed (Stage S gate):
bdr_suppression_until IS SET AND > TODAY → drop. INCLUDE if not set or < TODAY (cooling expired). HubSpot filter: propertyName: "bdr_suppression_until", operator: "NOT_HAS_PROPERTY" OR operator: "LT", value: TODAY_ISO. Template: phone-verification-waterfall-skill "Stage S — Suppression Gate".
Stage 2b: Deal-association suppression (efficient set-based)
Build an EXCLUDE-by-deal contact-ID set, then remove those contacts from the pool:
- Open deals:
search_crm_objects objectType deals, filter dealstage NOT_IN [<closed stages>] (closed stages are pipeline-specific — fetch via get_properties on dealstage once; typically closedwon/closedlost). Collect associated contact IDs (deals→contacts associations).
- Recently-closed deals:
deals with closedate ≥ today − DEAL_COOLDOWN_MONTHS (default 12). Collect associated contact IDs.
- Exclude any queue contact whose ID is in either set.
Rationale (per review): an associated open/recent deal is a more precise "hands-off" signal than owner alone. Set-based (query deals once) avoids per-contact lookups. num_associated_deals from Stage 1 is a cheap pre-filter (only contacts with >0 need checking).
Stage 3: Tier classification
Buckets (config): HOT = {opportunity, salesqualifiedlead, 970124947, marketingqualifiedlead}; WARM = {1217869329, lead, 1028712882}.
recently_touched = notes_last_contacted set AND (today - notes_last_contacted) <= TIER1_RECENCY_DAYS # default 30
IF bucket == HOT AND recently_touched: tier = 1 # ~150-200
ELIF bucket == HOT: tier = 2 # ~1,000-1,100 (incl. never-touched)
ELSE (bucket == WARM): tier = 3 # ~2,300
notes_last_contacted is a proxy (may be sequence-stamped, not a human dial). Default window 30d limits inflation. Prefer a true-dial signal (Nooks/Clari) if available.
Stage 4: Order, assign SDRs, daily cut
- Sort: tier ASC, then
notes_last_contacted DESC within tier, then hs_lastmodifieddate DESC.
- Daily target: default 240 (4 SDRs × 60), configurable. Top
target rows = today's dials; rest stay queued for fall-through.
- SDR assignment — HARD PRECONDITION: round-robin the top
target across the confirmed pod roster. ⚠️ Roster names are not yet confirmed (placeholder SDR-1..4). Do NOT emit a production sheet with placeholder owners — block the full run until the roster is set (dry-run/sample is fine with placeholders).
Stage 5: Emit the Nooks dial-queue Google Sheet
MCP tool: mcp__claude_ai_Google_Drive__create_file.
- Build CSV text: header
contact_id,name,phone,email,tier,last_touch_date,lifecycle_stage,sdr_assignment + one row per queued contact, ordered Tier 1→2→3. phone prefers phone, falls back to mobilephone. lifecycle_stage = human label. Escape commas/quotes per CSV.
- Call:
title="HE Dial Queue — <YYYY-MM-DD>", textContent=<CSV>, contentMimeType="text/csv". Per the tool, text/csv auto-converts to a Google Sheet (application/vnd.google-apps.spreadsheet) — do NOT set disableConversionToGoogleType. Optionally set parentId to the pod's Drive folder if known.
- Capture the returned File object's id/URL → use in the sidecar and the Slack message. Nooks imports this Sheet (or a HubSpot view).
Stage 6 (GATED): Stamp HubSpot tier property
Only if he_dial_tier (1|2|3) + he_dial_date contact properties EXIST (MCP cannot create them). manage_crm_objects batch-update the top target contacts. If absent: skip silently, log "tier-stamp skipped (property not provisioned)".
Stage 7: Inventory monitor (greenfield top-up gate)
tier12 = count(tier in {1,2})
floor = TIER12_DAILY_DIALS * 3 # default 67*3 ≈ 200 (Tier 1+2 consumption rate, NOT the 240 total target)
IF tier12 < floor:
top_up_needed = true
slack_send_message: "⚠️ HE Tier 1+2 low: <tier12> (< <floor>). Triggering capped Apollo top-up (≤50/day)."
ELSE:
top_up_needed = false # zero credits
Slack via slack_send_message (pod channel / Tim DM U0AAJUZH2PK). The ≤50/day Apollo top-up (no Clay waterfall) runs as a guarded sub-step or the inventory-monitor skill.
Stage 8: Emit outcome sidecar
Write ~/.claude/skill-analytics/last-outcome-he-dial-queue.json:
{"ts":"[UTC ISO8601]","skill":"he-dial-queue","version":"1.1.0","variant":"default",
"status":"[success|partial|error]","runtime_ms":[est ms],
"metrics":{"pool_dialable":[n],"tier1":[n],"tier2":[n],"tier3":[n],"suppressed_ae":[n],"suppressed_deal":[n],
"daily_target":[n],"tier12":[n],"floor":[n],"top_up_flagged":[bool],"sheet_url":"[url]","credits_used":0},
"error":null,"session_id":"[YYYY-MM-DD]"}
Use "partial" if the 10k cap was hit, the HE delta exceeded threshold, or a stage degraded; "error" only if no queue was produced.
Validation protocol (first runs)
- Read-only dry run: Stages 1–4 only. Print tier counts + 10-row sample. Reconcile total vs the warehouse's 5,312; sanity-check (T1 ~150-200, T2 ~1,000+, T3 ~2,300).
- Small write test: emit the Sheet for the top 25 rows; eyeball columns + Tier ordering + the returned URL.
- Full run: full queue → Sheet; Stage 6 only if the property exists; only after the SDR roster is confirmed.
Never run Stage 7's top-up unless the floor is genuinely breached.
Hard preconditions (block a full production run)
- SDR roster (4 names) for
sdr_assignment — SDR-1 = Edgar Marroquin 93367782 (recorded). ⚠️ HOLD: do NOT build/assign a list for Edgar yet (per Tim 2026-06-09). SDR-2..4 TBD.
- AE_SUPPRESS_SET — CONFIRMED: suppress Lex/Ron/Phil/Anthony/Anh; KEEP (dial) Robert
158872429 + Tim 87486452. (No longer a blocker.)
he_dial_tier/he_dial_date HubSpot properties (only if Stage 6 stamping wanted).
- Nooks↔HubSpot activity logging (for the downstream disposition loop).
Known gaps / scoping-call items
- Closed
dealstage values are pipeline-specific — fetch once via get_properties on dealstage.
- DEAL_COOLDOWN_MONTHS (6–12), TIER1_RECENCY_DAYS (≤90), daily target (200–240), TIER12_DAILY_DIALS — confirm at scoping.
- True-dial signal (vs sequence-stamped
notes_last_contacted) — needs Nooks/Clari engagement read (disposition loop, separate component).
Skill metadata
Version: 1.1 · Status: v1 (Higher-Ed, follow-up-first) · Author: Tim Kipper
Integration: HubSpot (21530819) + Google Drive + Slack; Apollo on top-up only; Nooks dialer (HubSpot/Sheet-native)
Tier: P1 (Core BDR Automation) · Triggers: Scheduled (M-F 6:45 AM ET) + Manual
Feeds: morning-brief (7:30 AM), the disposition feedback loop, and the daily pod digest.
1---2name: he-dial-queue3description: Use when building or refreshing the Higher-Ed SDR pod's daily dial queue, tiering callable leads into Tier 1/2/3, preparing a Nooks dial list from HubSpot, or checking Higher-Ed callable dial inventory.4---5
6# Higher-Ed Dial Queue Builder (SDR Pod Lead Engine v1)
7
8<objective>
9Build the 4-SDR pod's daily tiered dial queue for the Higher-Ed vertical from the EXISTING HubSpot pool (follow-up-first). Apply Golden Rules + AE-ownership + deal-association suppression, classify into Tier 1 (hot + recently touched) → Tier 2 (hot, not recently touched) → Tier 3 (warm), order, assign SDRs, and emit a Nooks-ready Google Sheet. Monitor inventory and flag a capped Apollo greenfield top-up only when Tier 1+2 falls below the floor. Credit burn is near zero at steady state.
10</objective>
11
12<quick_start>
13**Trigger:** M-F 6:45 AM ET (before morning-brief 7:30, before pod dials).
14**Manual:** "Build HE dial queue" / "Refresh pod dial list".
15**Config:** `reference/verticals/higher_ed.md` — lifecycle values, DIALABLE set, suppression (AE IDs, deal rules), tiering, floors, ATL/BTL, CMS signals. All knobs live there.
16**Dependencies:** HubSpot (portal 21530819) read; Google Drive (Sheet); Slack (alert). Apollo only on top-up.
17**Output:** Google Sheet `HE Dial Queue — <YYYY-MM-DD>` (Tier 1→2→3) + inventory status + sidecar JSON.
18**Dialer:** Nooks (imports the Sheet / HubSpot natively). NOT Salesfinity.
19</quick_start>
20
21<success_criteria>
22- [ ] Pull HE callable pool from HubSpot, filtered to the DIALABLE lifecycle set (keeps result < 10k cap), paginated to completion
23- [ ] Apply suppression: AE-ownership (<90d), open-deal assoc, recently-closed-deal assoc, bad lead_status, Golden Rules
24- [ ] Classify Tier 1/2/3 using REAL lifecyclestage values + `notes_last_contacted` recency (TIER1_RECENCY_DAYS)
25- [ ] Order Tier 1 → Tier 2 → Tier 3; assign SDRs round-robin across the daily target (BLOCKED until roster confirmed)
26- [ ] Emit Google Sheet (text/csv → auto-converts to Sheet) with contract columns; capture returned file URL
27- [ ] Inventory check: if Tier 1+2 < floor, Slack-alert + flag capped Apollo top-up (≤50/day)
28- [ ] (Gated) Stamp he_dial_tier + he_dial_date if the HubSpot property exists
29- [ ] Emit sidecar JSON; consume ZERO Apollo/Clay credits unless top-up fired
30</success_criteria>
31
32<workflow>
33
34## Stage 0: Load vertical config
35Read `reference/verticals/higher_ed.md` — the single source for lifecycle internal values, DIALABLE set, AE_SUPPRESS_SET, deal-suppression rules, TIER1_RECENCY_DAYS, the inventory floor formula, ATL/BTL, and CMS signals. Keep values there, not here.
36
37## Stage 1: Query the HE callable pool (HubSpot)
38**MCP tool:** `search_crm_objects` (objectType `contacts`).
39**Properties:** `firstname, lastname, email, phone, mobilephone, jobtitle, company, lifecyclestage, hs_lead_status, notes_last_contacted, hubspot_owner_id, industry, num_associated_deals, hs_lastmodifieddate`.
40
41**Filter — OR filterGroups, each AND'd with the DIALABLE lifecycle set so the result stays under HubSpot's 10k search ceiling:**
42- Phone present: include a group for `phone HAS_PROPERTY` AND a group for `mobilephone HAS_PROPERTY` (a contact with only a mobile must still appear).
43- HE membership: `email CONTAINS_TOKEN "edu"` OR `industry CONTAINS_TOKEN "education"` (also "higher"/"university"/"college").
44- Lifecycle: `lifecyclestage IN [opportunity, salesqualifiedlead, 970124947, marketingqualifiedlead, 1217869329, lead, 1028712882]` — the DIALABLE set. This EXCLUDES customer/Declined/Junk/Nurture at the query layer, cutting ~11.5k raw down to ~3.5k dialable (well under the 10k cap).
45
46**Pagination:** page with the cursor/`offset` the API returns until `total` is covered. **Guard:** if `total` ≥ 9,900, the 10k search ceiling is in play — partition the query (e.g. by `hs_lastmodifieddate` ranges or per-lifecycle-bucket) and union the pages; log a warning and set sidecar `status:partial` if the cap is hit.
47
48> Caveat: `industry` is free-text and there is no email domain-suffix operator; `"*.edu"` over-matches (~67k) — use the bare `"edu"` token, then refine in Stage 2.
49
50## Stage 2: Refine + per-contact suppression
51Drop a contact if any:
52- **Not truly HE:** email does NOT contain `.edu` (incl. `.edu.<cc>`) AND `industry` has no education token. (Keep international `.edu.au` etc. Do NOT drop solely because `industry` is blank when the email is `.edu`.) Track the drop count; if > 25% of the pool is dropped here, set `status:partial` and log the delta vs the warehouse's ~5,312.
53- **AE-owned:** `hubspot_owner_id` ∈ AE_SUPPRESS_SET = {Lex `82625923`, Ron `423155215`, Phil `190030668`, Anthony `165812850`, Anh `165815985`} AND ownership/last-touch < 90 days. **KEEP (dialable, do NOT drop):** Robert `158872429` (works new leads), Tim `87486452` (BDR).
54- **Bad lead status:** `hs_lead_status` ∈ {`UNQUALIFIED`, `BAD_TIMING`}.
55- (Lifecycle skip/route-out already handled at the query layer; Nurture `970063423` if present → email-only.)
56- Golden Rules (Customer-domain / Channel Partner / Device Owner): apply `phone-verification-waterfall-skill/golden-rules-filter.md`.
57- **Suppressed (Stage S gate):** `bdr_suppression_until` IS SET AND > TODAY → drop. INCLUDE if not set or < TODAY (cooling expired). HubSpot filter: `propertyName: "bdr_suppression_until", operator: "NOT_HAS_PROPERTY"` OR `operator: "LT", value: TODAY_ISO`. Template: phone-verification-waterfall-skill "Stage S — Suppression Gate".
58
59## Stage 2b: Deal-association suppression (efficient set-based)
60Build an EXCLUDE-by-deal contact-ID set, then remove those contacts from the pool:
611. **Open deals:** `search_crm_objects` objectType `deals`, filter `dealstage NOT_IN [<closed stages>]` (closed stages are pipeline-specific — fetch via `get_properties` on `dealstage` once; typically `closedwon`/`closedlost`). Collect associated contact IDs (deals→contacts associations).
622. **Recently-closed deals:** `deals` with `closedate` ≥ today − **DEAL_COOLDOWN_MONTHS** (default 12). Collect associated contact IDs.
633. Exclude any queue contact whose ID is in either set.
64
65> Rationale (per review): an associated open/recent deal is a more precise "hands-off" signal than owner alone. Set-based (query deals once) avoids per-contact lookups. `num_associated_deals` from Stage 1 is a cheap pre-filter (only contacts with >0 need checking).
66
67## Stage 3: Tier classification
68Buckets (config): **HOT** = {`opportunity`, `salesqualifiedlead`, `970124947`, `marketingqualifiedlead`}; **WARM** = {`1217869329`, `lead`, `1028712882`}.
69```
70recently_touched = notes_last_contacted set AND (today - notes_last_contacted) <= TIER1_RECENCY_DAYS # default 30
71IF bucket == HOT AND recently_touched: tier = 1 # ~150-200
72ELIF bucket == HOT: tier = 2 # ~1,000-1,100 (incl. never-touched)
73ELSE (bucket == WARM): tier = 3 # ~2,300
74```
75> `notes_last_contacted` is a proxy (may be sequence-stamped, not a human dial). Default window 30d limits inflation. Prefer a true-dial signal (Nooks/Clari) if available.
76
77## Stage 4: Order, assign SDRs, daily cut
781. Sort: tier ASC, then `notes_last_contacted` DESC within tier, then `hs_lastmodifieddate` DESC.
792. **Daily target:** default **240** (4 SDRs × 60), configurable. Top `target` rows = today's dials; rest stay queued for fall-through.
803. **SDR assignment — HARD PRECONDITION:** round-robin the top `target` across the confirmed pod roster. ⚠️ Roster names are **not yet confirmed** (placeholder `SDR-1..4`). Do NOT emit a production sheet with placeholder owners — block the full run until the roster is set (dry-run/sample is fine with placeholders).
81
82## Stage 5: Emit the Nooks dial-queue Google Sheet
83**MCP tool:** `mcp__claude_ai_Google_Drive__create_file`.
841. Build CSV text: header `contact_id,name,phone,email,tier,last_touch_date,lifecycle_stage,sdr_assignment` + one row per queued contact, ordered Tier 1→2→3. `phone` prefers `phone`, falls back to `mobilephone`. `lifecycle_stage` = human label. Escape commas/quotes per CSV.
852. Call: `title="HE Dial Queue — <YYYY-MM-DD>"`, `textContent=<CSV>`, `contentMimeType="text/csv"`. Per the tool, `text/csv` **auto-converts to a Google Sheet** (`application/vnd.google-apps.spreadsheet`) — do NOT set `disableConversionToGoogleType`. Optionally set `parentId` to the pod's Drive folder if known.
863. **Capture the returned File object's id/URL** → use in the sidecar and the Slack message. Nooks imports this Sheet (or a HubSpot view).
87
88## Stage 6 (GATED): Stamp HubSpot tier property
89Only if `he_dial_tier` (1|2|3) + `he_dial_date` contact properties EXIST (MCP cannot create them). `manage_crm_objects` batch-update the top `target` contacts. If absent: skip silently, log "tier-stamp skipped (property not provisioned)".
90
91## Stage 7: Inventory monitor (greenfield top-up gate)
92```
93tier12 = count(tier in {1,2})
94floor = TIER12_DAILY_DIALS * 3 # default 67*3 ≈ 200 (Tier 1+2 consumption rate, NOT the 240 total target)
95IF tier12 < floor:
96 top_up_needed = true
97 slack_send_message: "⚠️ HE Tier 1+2 low: <tier12> (< <floor>). Triggering capped Apollo top-up (≤50/day)."
98ELSE:
99 top_up_needed = false # zero credits
100```
101Slack via `slack_send_message` (pod channel / Tim DM `U0AAJUZH2PK`). The ≤50/day Apollo top-up (no Clay waterfall) runs as a guarded sub-step or the inventory-monitor skill.
102
103## Stage 8: Emit outcome sidecar
104Write `~/.claude/skill-analytics/last-outcome-he-dial-queue.json`:
105```json
106{"ts":"[UTC ISO8601]","skill":"he-dial-queue","version":"1.1.0","variant":"default",
107 "status":"[success|partial|error]","runtime_ms":[est ms],
108 "metrics":{"pool_dialable":[n],"tier1":[n],"tier2":[n],"tier3":[n],"suppressed_ae":[n],"suppressed_deal":[n],
109 "daily_target":[n],"tier12":[n],"floor":[n],"top_up_flagged":[bool],"sheet_url":"[url]","credits_used":0},
110 "error":null,"session_id":"[YYYY-MM-DD]"}
111```
112Use "partial" if the 10k cap was hit, the HE delta exceeded threshold, or a stage degraded; "error" only if no queue was produced.
113
114</workflow>
115
116---
117
118## Validation protocol (first runs)
1191. **Read-only dry run:** Stages 1–4 only. Print tier counts + 10-row sample. Reconcile total vs the warehouse's 5,312; sanity-check (T1 ~150-200, T2 ~1,000+, T3 ~2,300).
1202. **Small write test:** emit the Sheet for the top 25 rows; eyeball columns + Tier ordering + the returned URL.
1213. **Full run:** full queue → Sheet; Stage 6 only if the property exists; only after the SDR roster is confirmed.
122Never run Stage 7's top-up unless the floor is genuinely breached.
123
124## Hard preconditions (block a full production run)
125- **SDR roster** (4 names) for `sdr_assignment` — SDR-1 = Edgar Marroquin `93367782` (recorded). ⚠️ **HOLD: do NOT build/assign a list for Edgar yet** (per Tim 2026-06-09). SDR-2..4 TBD.
126- **AE_SUPPRESS_SET** — CONFIRMED: suppress Lex/Ron/Phil/Anthony/Anh; KEEP (dial) Robert `158872429` + Tim `87486452`. (No longer a blocker.)
127- `he_dial_tier`/`he_dial_date` HubSpot properties (only if Stage 6 stamping wanted).
128- Nooks↔HubSpot activity logging (for the downstream disposition loop).
129
130## Known gaps / scoping-call items
131- Closed `dealstage` values are pipeline-specific — fetch once via `get_properties` on `dealstage`.
132- DEAL_COOLDOWN_MONTHS (6–12), TIER1_RECENCY_DAYS (≤90), daily target (200–240), TIER12_DAILY_DIALS — confirm at scoping.
133- True-dial signal (vs sequence-stamped `notes_last_contacted`) — needs Nooks/Clari engagement read (disposition loop, separate component).
134
135---
136
137## Skill metadata
138**Version:** 1.1 · **Status:** v1 (Higher-Ed, follow-up-first) · **Author:** Tim Kipper
139**Integration:** HubSpot (21530819) + Google Drive + Slack; Apollo on top-up only; Nooks dialer (HubSpot/Sheet-native)
140**Tier:** P1 (Core BDR Automation) · **Triggers:** Scheduled (M-F 6:45 AM ET) + Manual
141**Feeds:** morning-brief (7:30 AM), the disposition feedback loop, and the daily pod digest.