Module 15 · business · intermediate
Lesson 15 · Stale Deal Nudger
Goal
Deals rarely die because a buyer says no; they die because nobody followed up. Build a daily routine that finds deals that went quiet, ranks them by what's at stake, and hands each salesperson a short list with a follow-up draft ready to go.
Prerequisites
- Modules 02 (schedule + API twin triggers), 10 (LLM chain + structured output), 11 (normalizing sources with Code)
- For the live part: a HubSpot account (free CRM works) and optionally an OpenAI key
Concepts
- Normalize the source, not the logic. HubSpot deals and API-posted deals are mapped to one shape (
id, name, stage, amount, owner, contact, last_activity, url). Find/rank/draft/group never care where deals came from, so swapping HubSpot for Pipedrive or GoHighLevel is one mapping node. - Per-stage rules. "Stale" depends on the stage: 3 quiet days after sending a contract is alarming; 7 days while qualifying is normal. Keep the limits in Settings as data, not in IF nodes.
- Prioritize, don't just list.
urgency = overdue days × (1 + log10(amount / 1000)), so a $24k contract a week late outranks a $500 lead two weeks late. A digest that starts with the right deal gets acted on. - Deterministic tests with an injected clock. The API accepts
now, so "days since activity" is the same on every run. Time-dependent workflows are otherwise untestable. - Templates first, AI optional. The template drafts work with zero credentials; AI drafts (
ai: trueoruse_aiin Settings) are an upgrade, not a dependency. - Empty is a valid answer. When nothing is stale, the API still responds (
stale_count: 0) becauseFind Stale Dealshas Always Output Data on, followed by anAny Stale?check.
Build it
Step 1: Triggers and settings
- Schedule Trigger
Weekdays 8 AM: cron0 8 * * 1-5(uses the instance timezone). - Webhook
Stale Check API: POSTdeals/stale-check, respond via node. - Edit Fields
Settings (edit me):stale_daysobject (per HubSpot stage id +default),closed_stages,owners(HubSpot owner id → name),crm_deal_urlwith{id},use_ai.
HubSpot's default pipeline stage ids are appointmentscheduled, qualifiedtobuy, presentationscheduled, decisionmakerboughtin, contractsent, closedwon, closedlost. Custom pipelines have their own ids (HubSpot → Settings → Objects → Deals → Pipelines).
Step 2: Two sources, one shape
IF Deals from API? ({{ $('Stale Check API').isExecuted }}):
- API: IF
Has deals[]?→ 400 if missing; else CodeDeals from Requestmapsbody.dealsto the common shape. - Schedule: HubSpot
Get HubSpot Deals(credential HubSpot Service Key: create a private app in HubSpot → Settings → Integrations → Private Apps with thecrm.objects.deals.readscope; the node's credential list shows which scopes it needs), Resource Deal, Operation Get Many, Return All, Options → Deal Properties:dealname, dealstage, amount, hubspot_owner_id, notes_last_updated, hs_lastmodifieddate. Then CodeMap HubSpot Deals, which reads each property as either{ value }or a plain value and prefersnotes_last_updated(last logged activity) overhs_lastmodifieddate.
Step 3: Find stale deals
Code Find Stale Deals (workflow/code/find-stale.js), with node Settings → Always Output Data on:
now=body.now(tests) or the current time- skip closed stages;
limit= stage limit or default;idle= whole days sincelast_activity(no date → treated as unknown) - keep
idle > limit, computeurgency, sort descending
IF Any Stale? ({{ $json.id }} exists) → false: if called via API, Respond { stale_count: 0, pipeline_at_risk: 0, owners: [] }.
Step 4: Drafts
IF Use AI? (body.ai === true for the API, use_ai for the schedule):
- no: Code
Template Drafts: one stage-aware opener per stage. - yes: Basic LLM Chain
Draft Follow-up (AI)(rules: ≤ 80 words, one clear ask, no guilt-tripping, no invented facts) + OpenAIgpt-5-mini+ Structured Output Parser{ subject, body }→ CodeAttach AI Drafts, which pairs each draft with its deal by index.
Step 5: One digest per owner
Code Group by Owner (group.js): group, sum pipeline at risk, and write a message:
👋 Alex, 2 deals ($24,500) went quiet:
1. Acme website redesign ($24,000, contractsent): 9 days quiet (limit 3)
https://app.hubspot.com/contacts/123/deal/d1
Draft: "Following up: Acme website redesign"
2. Epsilon landing page ($500, qualifiedtobuy): no activity recorded
Draft: "Following up: Epsilon landing page"
(Actual output for assets/sample-deals.json.)
IF Called via API? → Aggregate Collect Owners → Respond with totals; otherwise → Send Nudges (Exercise 1).
Test it
./test/test.sh # 15 checks on assets/sample-deals.json (fixed "now")
WITH_CREDS=1 ./test/test.sh # + AI-written drafts
To run against HubSpot: add the credential, open the workflow, and click Execute workflow on the Schedule Trigger.
Common problems
- Every deal is stale:
last_activityis empty (HubSpot property not requested, or the mapping reads the wrong shape). CheckMap HubSpot Dealsoutput. - Owners show as "Owner 123456": add the owner ids to
ownersin Settings (HubSpot → Settings → Users & Teams, or the owners API). - Stage limits ignored: your pipeline uses custom stage ids; add them to
stale_days. - No response from the API when nothing is stale: Always Output Data is off on
Find Stale Deals. - AI drafts mixed up between deals: keep
Attach AI Draftspairing by index, and don't add filters betweenFind Stale Dealsand the chain.