Build an SDR agent that qualifies leads via WhatsApp and automatically follows up using scheduled callbacks with conversation memory
An SDR agent that qualifies leads via WhatsApp, schedules callbacks when the lead asks (“call me tomorrow at noon”), and automatically follows up on leads that stopped responding. The agent uses identify() + Memory to resume conversations exactly where they left off — no context lost.
SCENARIO 1: Lead asks to be called back────────────────────────────────────────Lead: "Me liga amanha meio dia" → Agent calls create_schedule (SDK captures conversation context automatically) → Tomorrow at noon, trigger fires with entityType/entityValue → main() calls identify() → memory loads → agent resumes conversationSCENARIO 2: Lead stops responding────────────────────────────────────────CRON trigger fires every 2 hours (business hours) → main() calls Memory.list() to find inactive sessions → Filters by lastMessage.role === 'assistant' (agent was the last to speak) → For each lead, calls agent.process() with entityType/entityValue → Memory loads → agent sends natural follow-up
import { Agent, openai, createScheduleTools } from '@runflow-ai/sdk';import { qualifyLeadTool } from './tools/qualify-lead';import { closeConversationTool } from './tools/close-conversation';const scheduleTools = createScheduleTools();export const sdrAgent = new Agent({ name: 'SDR Agent', instructions: `You are an SDR qualifying leads via WhatsApp for ACME Corp.## Qualification (BANT)- Budget: can they afford the solution?- Authority: are they the decision maker?- Need: do they have a real problem we solve?- Timeline: when do they need a solution?Ask one question at a time. Be natural, not robotic.## Tools — when to use each- **qualify-lead**: Use when you have enough info to score the lead (at least 3 of 4 BANT criteria). This marks the conversation as qualified or nurturing.- **close-conversation**: Use when the lead explicitly says they are not interested, asks to stop, or is a wrong contact. This marks the conversation as closed.- **create_schedule**: Use when the lead asks to be called back later. ALWAYS set maxExecutions to 1 for one-time callbacks.## Resuming conversations- When resuming a scheduled callback, greet naturally: "Oi [name]! Conforme combinamos..."- When following up on an inactive lead, be brief and casual. Reference the last topic discussed.- Don't repeat questions they already answered.`, model: openai('gpt-4o'), tools: { create_schedule: scheduleTools.create_schedule, qualifyLead: qualifyLeadTool, closeConversation: closeConversationTool, }, memory: { maxTurns: 30, summarizeAfter: 20, summarizePrompt: 'Summarize: lead name, BANT score so far, topics discussed, next steps, and any objections raised.', },});
main.ts handles all entry points — regular messages, scheduled callbacks, and CRON follow-ups:
main.ts
Copy
import { identify, Memory } from '@runflow-ai/sdk';import { sdrAgent } from './agent';export async function main(input: any) { // Identify the lead — this is what loads the right memory const phone = input.entityValue || input.metadata?.phone; if (phone) { identify(phone); // Auto-detects type as 'phone' } // Check if this is a CRON trigger for inactive leads if (input.metadata?.isScheduled && input.message === 'Check inactive leads') { return handleInactiveLeads(); } // Regular conversation or scheduled callback — same flow return sdrAgent.process(input);}
// ... main() function from above ...async function handleInactiveLeads() { // Find leads inactive for 4+ hours that haven't been qualified or closed const inactive = await Memory.list({ lastInteractionBefore: new Date(Date.now() - 4 * 60 * 60 * 1000), // status: null means "in progress" — skips qualified, closed, nurturing }); let processed = 0; for (const session of inactive) { // Skip sessions that already have a status (qualified, closed, etc.) if (session.status) continue; // Only follow up if the agent was the last to speak (lead didn't respond) if (session.lastMessage?.role !== 'assistant') continue; // Skip if no entity info (can't identify the lead) if (!session.entityType || !session.entityValue) continue; // Pass context directly in the input — no identify() in the loop await sdrAgent.process({ message: 'This lead has not responded in a few hours. Send a brief, natural follow-up referencing the last topic discussed.', entityType: session.entityType, entityValue: session.entityValue, channel: 'whatsapp', }); processed++; } return { processed, message: `Followed up with ${processed} inactive leads` };}
No identify() in the loop. Since identify() sets a global singleton, calling it repeatedly in a loop would cause race conditions. Instead, pass entityType/entityValue directly in the agent.process() input — the agent resolves the memory key from the input fields.
The entire follow-up system relies on identify() setting the right memory key. No external CRM needed for basic context — the conversation history IS the context.
Copy
import { identify } from '@runflow-ai/sdk';// Auto-detects phone → memoryId = 'phone:+5511999999999'identify('+5511999999999');// Whether it's a WhatsApp message, a scheduled callback, or a CRON follow-up// — if identify() is called with the same phone, the agent has full history.
The agent doesn’t need to know if it’s handling a live conversation, a scheduled callback, or a batch follow-up. The main.ts normalizes the input and the agent processes it the same way.
Entry Point
How context flows
WhatsApp message
identify(input.metadata.phone) — sets global state
Scheduled callback
identify(input.entityValue) — injected from trigger’s executionContext
CRON follow-up
entityType/entityValue passed directly in agent.process() input