Skip to content

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: true or use_ai in Settings) are an upgrade, not a dependency.
  • Empty is a valid answer. When nothing is stale, the API still responds (stale_count: 0) because Find Stale Deals has Always Output Data on, followed by an Any Stale? check.

Build it

Step 1: Triggers and settings

  • Schedule Trigger Weekdays 8 AM: cron 0 8 * * 1-5 (uses the instance timezone).
  • Webhook Stale Check API: POST deals/stale-check, respond via node.
  • Edit Fields Settings (edit me): stale_days object (per HubSpot stage id + default), closed_stages, owners (HubSpot owner id → name), crm_deal_url with {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 Code Deals from Request maps body.deals to the common shape.
  • Schedule: HubSpot Get HubSpot Deals (credential HubSpot Service Key: create a private app in HubSpot → Settings → Integrations → Private Apps with the crm.objects.deals.read scope; 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 Code Map HubSpot Deals, which reads each property as either { value } or a plain value and prefers notes_last_updated (last logged activity) over hs_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 since last_activity (no date → treated as unknown)
  • keep idle > limit, compute urgency, 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) + OpenAI gpt-5-mini + Structured Output Parser { subject, body } → Code Attach 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_activity is empty (HubSpot property not requested, or the mapping reads the wrong shape). Check Map HubSpot Deals output.
  • Owners show as "Owner 123456": add the owner ids to owners in 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 Drafts pairing by index, and don't add filters between Find Stale Deals and the chain.