AI Lead Qualifier and Booking Agent
Module 25 · ai · intermediate
decide.js
// Decide the next step: ask the next question, offer slots, book a slot, or close politely.
const s = $('Settings (edit me)').first().json;
const x = $input.first().json;
if (!x.ok) return [{ json: x }];
const db = $getWorkflowStaticData('global');
const lead = db.leads[x.lead_id];
const now = DateTime.fromISO(x.now);
const first = (lead.name || '').split(' ')[0] || 'there';
// Merge answers (budget from a form may be text like "$5k-10k")
const num = (v) => { if (typeof v === 'number') return v; const m = [...String(v ?? '').toLowerCase().replace(/,/g, '').matchAll(/(\d+(?:\.\d+)?)\s*(k)?/g)].map((g) => Number(g[1]) * (g[2] ? 1000 : 1)); return m.length ? Math.max(...m) : null; };
const a = lead.answers;
if (!['offered', 'booked'].includes(lead.status)) Object.assign(a, x.extracted); // answers are frozen once slots are offered
const toWeeks = (v) => {
if (typeof v === 'number') return v;
const t = String(v ?? '').toLowerCase();
for (const [re, w] of [[/asap|right away|immediately|this week|urgent/, 1], [/next week/, 2], [/this month/, 3], [/next month/, 6], [/quarter/, 12], [/next year|just (researching|looking|exploring)|no rush/, 52]]) if (re.test(t)) return w;
const m = t.match(/(\d+)\s*(day|week|month)/); if (m) return Math.ceil(Number(m[1]) * { day: 1 / 7, week: 1, month: 4 }[m[2]]);
return null;
};
if (a.budget !== undefined) a.budget = num(a.budget);
if (a.timeline !== undefined && (a.timeline_weeks === undefined || a.timeline_weeks === null)) a.timeline_weeks = toWeeks(a.timeline);
if (typeof a.decision_maker === 'string') a.decision_maker = /^(y|yes|true|1)/i.test(a.decision_maker);
// Score with reasons
const reasons = [];
let score = 0;
if (a.need) { score += 30; reasons.push(`need: ${a.need}`); }
if (a.budget != null) { if (a.budget >= s.min_budget) { score += 30; reasons.push(`budget $${a.budget} ≥ $${s.min_budget}`); } else reasons.push(`budget $${a.budget} below $${s.min_budget}`); }
if (a.timeline_weeks != null) { if (a.timeline_weeks <= s.max_timeline_weeks) { score += 20; reasons.push(`timeline ${a.timeline_weeks} wk`); } else reasons.push(`timeline ${a.timeline_weeks} wk is later than ${s.max_timeline_weeks}`); }
if (a.decision_maker === true) { score += 20; reasons.push('decision maker'); } else if (a.decision_maker === false) reasons.push('not the decision maker (invite them to the call)');
lead.score = score; lead.reasons = reasons;
// --- slots
const zone = s.timezone;
const fmt = (iso) => DateTime.fromISO(iso).setZone(zone).toFormat("ccc LLL d, h:mm a") + ` (${DateTime.fromISO(iso).setZone(zone).toFormat('ZZZZ')})`;
const overlaps = (st, en, b) => st < DateTime.fromISO(b.end).plus({ minutes: s.buffer_minutes }) && en > DateTime.fromISO(b.start).minus({ minutes: s.buffer_minutes });
for (const [k, h] of Object.entries(db.holds)) if (DateTime.fromISO(h.until) < now) delete db.holds[k]; // expired holds
function findSlots() {
// Bookings and other leads' holds block time just like calendar events (buffer included)
const asRange = (k) => ({ start: k, end: DateTime.fromISO(k).plus({ minutes: s.meeting_minutes }).toISO() });
const blocked = [...x.busy, ...Object.keys(db.bookings).map(asRange),
...Object.entries(db.holds).filter(([, h]) => h.lead_id !== lead.id).map(([k]) => asRange(k))];
const earliest = now.plus({ hours: s.min_notice_hours });
const out = [];
for (let d = 0; d <= s.horizon_days && out.length < 3; d++) {
const day = now.setZone(zone).startOf('day').plus({ days: d });
const hrs = s.hours[day.toFormat('ccc').toLowerCase()];
if (!hrs) continue;
for (let t = day.set({ hour: hrs.open }); t.plus({ minutes: s.meeting_minutes }) <= day.set({ hour: hrs.close }); t = t.plus({ minutes: 30 })) {
const en = t.plus({ minutes: s.meeting_minutes });
if (t < earliest) continue;
const key = t.toUTC().toISO({ suppressMilliseconds: true });
if (blocked.some((b) => overlaps(t, en, b))) continue;
out.push(key);
break; // one slot per day, so the options are spread out
}
}
return out.map((start, i) => ({ id: `S${i + 1}`, start, label: fmt(start) }));
}
function hold(slots) {
for (const [k, h] of Object.entries(db.holds)) if (h.lead_id === lead.id) delete db.holds[k];
for (const sl of slots) db.holds[sl.start] = { lead_id: lead.id, until: now.plus({ minutes: s.hold_minutes }).toISO() };
}
// --- what did they pick?
function pickSlot() {
if (!lead.offered.length) return null;
if (x.slot_id) return lead.offered.find((o) => o.id === x.slot_id) ?? 'invalid';
const t = x.text.toLowerCase();
const n = t.match(/^\s*(?:option\s*|slot\s*|s)?([1-3])\b/) ?? (x.slot_hint ? [null, String(x.slot_hint).replace(/\D/g, '')] : null);
if (n && lead.offered[Number(n[1]) - 1]) return lead.offered[Number(n[1]) - 1];
const hit = lead.offered.filter((o) => { const dt = DateTime.fromISO(o.start).setZone(zone);
return t.includes(dt.toFormat('cccc').toLowerCase()) || t.includes(dt.toFormat('ccc').toLowerCase() + ' ') || t.includes(dt.toFormat('h:mm a').toLowerCase()) || t.includes(dt.toFormat('ha').toLowerCase()); });
return hit.length === 1 ? hit[0] : null;
}
const order = ['need', 'budget', 'timeline_weeks', 'decision_maker'];
const Q = {
need: `Thanks${first !== 'there' ? ', ' + first : ''}! What would you like help with: ${s.services.map((v) => v.name).join(', ')}?`,
budget: `Got it. Roughly what budget do you have in mind for this?`,
timeline_weeks: `And when would you like to have this up and running?`,
decision_maker: `Last one: are you the person who decides on this, or should someone else join the call?`,
};
let action, reply, slots = [], booking = null;
if (x.wants_human) {
lead.status = 'handoff'; action = 'handoff';
reply = `Of course. I've passed your details to ${s.owner_name}, who will get back to you within one business day.`;
} else if (lead.status === 'booked') {
action = 'already_booked'; reply = `You're booked for ${lead.booking.label}. See you then! Reply here if you need to change it.`;
} else if (lead.status === 'offered' && (x.which === 'book' || pickSlot())) {
const sl = pickSlot();
if (!sl || sl === 'invalid') { action = 'error'; reply = 'That time is no longer available.'; x.code = 409; }
else if (db.bookings[sl.start] && db.bookings[sl.start] !== lead.id) { action = 'error'; reply = `Sorry, ${sl.label} was just taken. Here are the other times: ${lead.offered.filter((o) => o.id !== sl.id).map((o) => o.id + ': ' + o.label).join(' · ')}`; x.code = 409; }
else { action = 'book'; booking = sl; reply = `Booked: ${sl.label}. You'll get a ${x.calendar_mode === 'internal' ? 'confirmation email' : 'calendar invite'} at ${lead.email}.`; }
} else {
const missing = order.find((k) => a[k] === undefined || a[k] === null);
if (a.budget != null && a.budget < s.min_budget) {
lead.status = 'not_a_fit'; action = 'close';
reply = `Thanks for the details. Our projects usually start at $${s.min_budget}, so we may not be the best fit right now. These free resources might help: ${s.resources_url}`;
} else if (a.timeline_weeks != null && a.timeline_weeks > s.max_timeline_weeks) {
lead.status = 'nurture'; action = 'close'; lead.follow_up_on = now.plus({ weeks: Math.max(1, a.timeline_weeks - s.max_timeline_weeks) }).toISODate();
reply = `Thanks! Since you're planning a bit further out, I'll check back in closer to the time. Meanwhile, here are some resources: ${s.resources_url}`;
} else if (missing) {
lead.asked = missing; action = 'ask'; reply = Q[missing];
} else if (x.calendar_error) {
lead.status = 'qualified'; action = 'manual_scheduling';
reply = `Great, you're a good fit! ${s.owner_name} will email you a few times for a ${s.meeting_minutes}-minute call shortly.`;
} else {
slots = findSlots();
if (!slots.length) { lead.status = 'qualified'; action = 'manual_scheduling'; reply = `Great, you're a good fit! Our calendar is full this week; ${s.owner_name} will email you some times.`; }
else {
lead.status = 'offered'; lead.offered = slots; hold(slots); action = 'offer';
reply = `Great, you're a good fit! Pick a time for a ${s.meeting_minutes}-minute call with ${s.owner_name}:\n${slots.map((o) => `${o.id.slice(1)}. ${o.label}`).join('\n')}\nReply with 1, 2 or 3.`;
}
}
}
lead.messages.push({ from: 'bot', text: reply, at: x.now });
const summary = `${lead.name || lead.email}${lead.company ? ' (' + lead.company + ')' : ''} · score ${score}: ${reasons.join('; ')}`;
return [{ json: {
ok: action !== 'error', code: x.code ?? 200, lead_id: lead.id, action, reply, status: lead.status, score, reasons, answers: a,
slots, booking, calendar_mode: x.calendar_mode, busy_source: x.busy_source, calendar_error: x.calendar_error,
extraction_source: x.extraction_source, ai_error: x.ai_error, email: lead.email, name: lead.name, summary,
alert_owner: ['handoff', 'manual_scheduling', 'book'].includes(action),
event: booking ? { start: booking.start, end: DateTime.fromISO(booking.start).plus({ minutes: s.meeting_minutes }).toUTC().toISO(),
summary: `Intro call: ${lead.name || lead.email}${lead.company ? ' (' + lead.company + ')' : ''}`, description: `${summary}\n\nConversation:\n${lead.messages.slice(-8).map((m) => m.from + ': ' + m.text).join('\n')}` } : null,
} }];