> ## Documentation Index
> Fetch the complete documentation index at: https://docs.runflow.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# SDR Agent with Scheduled Follow-ups

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

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

```typescript agent.ts theme={null}
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

```typescript tools/qualify-lead.ts theme={null}
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 (params) => {
    const score = (params.budget + params.authority + params.need + params.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: params.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

```typescript tools/close-conversation.ts theme={null}
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 (params) => {
    await Memory.setStatus('closed');

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

    return { closed: true, reason: params.reason };
  },
});
```

## Step 3: The Entry Point

`main.ts` handles all entry points — regular messages, scheduled callbacks, and CRON follow-ups:

```typescript main.ts theme={null}
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`:

```typescript main.ts theme={null}
// ... 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` };
}
```

<Note>
  **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.
</Note>

## Key Concepts

### Tools Control Status, Not the Developer

The developer never calls `Memory.setStatus()` directly. The tools do it:

| Tool                 | When the LLM calls it               | Status set           |
| -------------------- | ----------------------------------- | -------------------- |
| `qualify-lead`       | Lead scored BANT >= 7               | `qualified`          |
| `qualify-lead`       | Lead scored BANT \< 7               | `nurturing`          |
| `close-conversation` | Lead not interested / wrong contact | `closed`             |
| (no tool)            | Lead still in conversation          | `null` (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.

```typescript theme={null}
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 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    |

## Next Steps

<CardGroup cols={2}>
  <Card title="Schedule" icon="calendar" href="/core-concepts/schedule">
    Schedule tools reference and security guide
  </Card>

  <Card title="Memory" icon="brain" href="/core-concepts/memory">
    How memory and identify() work
  </Card>

  <Card title="Observability" icon="chart-mixed" href="/core-concepts/observability">
    Track lead qualification metrics
  </Card>

  <Card title="Multi-Agent" icon="users" href="/use-cases/multi-agent-system">
    Supervisor pattern for complex routing
  </Card>
</CardGroup>
