Skip to main content
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.

Project Structure

sdr-agent/
├── main.ts
├── agent.ts
├── tools/
│   ├── qualify-lead.ts
│   └── close-conversation.ts
├── .runflow/
│   └── rf.json
├── package.json
└── tsconfig.json

How It Works

Two scenarios, same agent:
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 conversation

SCENARIO 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

Session Lifecycle

The agent controls the session status through tools. The developer never calls Memory.setStatus() manually — the tools do it:
New conversation            → status: null (in progress)
Agent qualifies (BANT >= 7) → tool qualify-lead sets "qualified"
Agent qualifies (BANT < 7)  → tool qualify-lead sets "nurturing"
Lead says "not interested"  → tool close-conversation sets "closed"
Lead asks for callback      → create_schedule (status stays null)
CRON checks inactive leads  → Memory.list() skips "qualified" and "closed"

Step 1: The Agent

agent.ts
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.',
  },
});

Step 2: Tools That Control Session Status

The tools set the session status automatically — the LLM decides when to call them based on the conversation.

Qualify Lead

tools/qualify-lead.ts
import { createTool } from '@runflow-ai/sdk';
import { Memory } from '@runflow-ai/sdk';
import { track } from '@runflow-ai/sdk/observability';
import { z } from 'zod';

export const qualifyLeadTool = createTool({
  id: 'qualify-lead',
  description: 'Record lead qualification score based on BANT criteria. Use when you have enough info to score (at least 3 of 4 criteria answered). This marks the session status.',
  inputSchema: z.object({
    leadName: z.string(),
    budget: z.number().min(0).max(10),
    authority: z.number().min(0).max(10),
    need: z.number().min(0).max(10),
    timeline: z.number().min(0).max(10),
    notes: z.string().optional(),
  }),
  execute: async ({ context }) => {
    const score = (context.budget + context.authority + context.need + context.timeline) / 4;
    const qualified = score >= 7;
    const status = qualified ? 'qualified' : 'nurturing';

    // Set session status — this is how Memory.list() knows to skip this lead
    await Memory.setStatus(status);

    track('lead_qualified', {
      leadName: context.leadName,
      score,
      qualified,
    });

    return {
      score: Math.round(score * 10) / 10,
      qualified,
      status,
      recommendation: qualified
        ? 'Lead qualificado. Agendar demo com AE.'
        : 'Lead morno. Continuar nurturing.',
    };
  },
});

Close Conversation

tools/close-conversation.ts
import { createTool } from '@runflow-ai/sdk';
import { Memory } from '@runflow-ai/sdk';
import { track } from '@runflow-ai/sdk/observability';
import { z } from 'zod';

export const closeConversationTool = createTool({
  id: 'close-conversation',
  description: 'Close this conversation permanently. Use when: lead says they are not interested, asks to stop receiving messages, is a wrong contact, or the conversation is finished.',
  inputSchema: z.object({
    reason: z.enum(['not_interested', 'wrong_contact', 'already_customer', 'completed', 'other']),
    notes: z.string().optional(),
  }),
  execute: async ({ context }) => {
    await Memory.setStatus('closed');

    track('conversation_closed', { reason: context.reason });

    return { closed: true, reason: context.reason };
  },
});

Step 3: The Entry Point

main.ts handles all entry points — regular messages, scheduled callbacks, and CRON follow-ups:
main.ts
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);
}

Step 4: Scheduled Callback (Automatic)

When a lead says “me liga amanha meio dia”, the LLM calls create_schedule. Here’s what happens behind the scenes:
1. LLM calls create_schedule with { name, type: 'daily', time: '12:00', message, maxExecutions: 1 }
2. SDK automatically captures the current identify() state:
   { entityType: 'phone', entityValue: '+5511999999999', sessionId: '...' }
3. Sends to backend as executionContext (stored in trigger metadata)
4. Tomorrow at 12:00, trigger fires with:
   {
     message: "Retomar conversa de qualificacao",
     entityType: "phone",           ← injected from executionContext
     entityValue: "+5511999999999",  ← injected from executionContext
     metadata: { isScheduled: true, ... }
   }
5. main() calls identify('+5511999999999')
6. agent.process() → memoryId = 'phone:+5511999999999' → loads full history
7. Agent: "Oi Joao! Conforme combinamos, estou retornando..."
8. Schedule auto-deactivates (maxExecutions: 1 reached)
The developer writes zero extra code for this. The context capture and restoration is handled by the SDK and trigger engine.

Step 5: Inactive Lead Follow-up

For leads that stopped responding, create a CRON trigger in the portal that runs every 2 hours during business hours:
Name: "Check Inactive Leads"
Type: SCHEDULER
Schedule: CRON → 0 */2 9-18 * * (every 2h, 9am-6pm)
Message: "Check inactive leads"
Then handle it in main.ts:
main.ts
// ... 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.

Key Concepts

Tools Control Status, Not the Developer

The developer never calls Memory.setStatus() directly. The tools do it:
ToolWhen the LLM calls itStatus set
qualify-leadLead scored BANT >= 7qualified
qualify-leadLead scored BANT < 7nurturing
close-conversationLead not interested / wrong contactclosed
(no tool)Lead still in conversationnull (in progress)
Memory.list() then filters by status to find only active leads.

Memory Drives Everything

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.
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.

Same Agent, Multiple Entry Points

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 PointHow context flows
WhatsApp messageidentify(input.metadata.phone) — sets global state
Scheduled callbackidentify(input.entityValue) — injected from trigger’s executionContext
CRON follow-upentityType/entityValue passed directly in agent.process() input

Next Steps

Schedule

Schedule tools reference and security guide

Memory

How memory and identify() work

Observability

Track lead qualification metrics

Multi-Agent

Supervisor pattern for complex routing