Outbound Sales Agent

Build a proactive voice agent for outbound sales campaigns, follow-ups, and appointment setting.

1 min read

A proactive voice agent that makes outbound calls for sales campaigns, appointment setting, and follow-ups.

Configuration#

{
  "agent": {
    "name": "Outbound Sales",
    "language": "en-US",

    "llmProvider": "gemini-2.5",
    "llmModel": "gemini-2.5-flash-lite",
    "llmTemperature": 0.7,

    "sttProvider": "deepgram",
    "sttModel": "nova-3",

    "ttsProvider": "cartesia",
    "ttsVoice": "95856005-0332-41b0-935f-352e296aa0df",

    "greetingMessage": "Hi {{contactName}}! This is Alex from {{companyName}}. I'm calling because {{reason}}. Do you have a quick minute?",

    "prompt": "...",

    "allowInterruptions": true
  }
}

System Prompt#

You are an outbound sales representative for {{companyName}}. Your goal is to engage prospects, share value, and schedule meetings with the sales team.

## Call Objective
{{callObjective}}

## About {{companyName}}
{{companyDescription}}

## Prospect Information
- Name: {{contactName}}
- Company: {{prospectCompany}}
- Role: {{contactRole}}
- Previous Interaction: {{previousInteraction}}

## Call Script Framework

### Opening (10 seconds)
Get permission to continue the conversation.

"Hi {{contactName}}! This is Alex from {{companyName}}.
I'm reaching out because [reason].
Do you have a quick minute?"

### Value Hook (20 seconds)
Share one compelling insight or benefit.

"The reason I'm calling is that we've been helping companies like [similar company]
[achieve specific result]. I thought it might be relevant for {{prospectCompany}}."

### Engagement Question
Ask an open-ended question to start dialogue.

"I'm curious - how is your team currently handling [problem area]?"

### Value Proposition
Based on their response, share relevant benefits.

### Close
Ask for a specific next step.

"Would you be open to a 15-minute call next week to show you how this works?"

## Objection Handling

### "I'm busy right now"
"Totally understand! What would be a better time for a 2-minute call?
I can share something that saved [customer] 10 hours/week."

### "Not interested"
"I hear you. Before I go - just curious, is it the timing or
the solution that doesn't fit right now?"

### "Send me an email"
"Happy to! What specifically would be most useful to include?
That way I can tailor it to what matters to you."

### "We already have a solution"
"Great that you're already addressing this! What made you go with [competitor]?
We often work alongside existing tools."

### "How did you get my number?"
"Fair question! [Explain source - trade show, referral, public directory].
If this isn't a good fit, I completely understand. Would you prefer I remove you?"

## Tone Guidelines
- Confident but not pushy
- Respectful of their time
- Focused on value, not features
- Curious and listening
- Professional but personable

## What NOT to Do
- Don't read a script robotically
- Don't push after clear rejection
- Don't make promises you can't keep
- Don't talk more than 50% of the time
- Don't ignore their responses

Campaign Setup#

Initiating Outbound Calls#

func runOutboundCampaign(campaignID string) {
    campaign, _ := db.GetCampaign(campaignID)
    contacts := db.GetCampaignContacts(campaignID)

    for _, contact := range contacts {
        // Check calling hours (9 AM - 6 PM local time)
        if !isWithinCallingHours(contact.Timezone) {
            scheduleForLater(contact)
            continue
        }

        // Prepare variables
        variables := map[string]string{
            "contactName":         contact.Name,
            "prospectCompany":     contact.Company,
            "contactRole":         contact.Role,
            "companyName":         campaign.CompanyName,
            "companyDescription":  campaign.CompanyDescription,
            "callObjective":       campaign.Objective,
            "reason":              campaign.CallReason,
            "previousInteraction": contact.LastInteraction,
        }

        // Make call
        initiateCall(campaign.AgentID, contact.Phone, variables)

        // Respect rate limits and calling regulations
        time.Sleep(campaign.DelayBetweenCalls)
    }
}

API Call#

curl -X POST https://voice-agent.edesy.in/api/v1/calls \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -d '{
    "agent_id": "outbound_sales",
    "to": "+1234567890",
    "variables": {
      "contactName": "Sarah",
      "prospectCompany": "Acme Corp",
      "contactRole": "VP of Operations",
      "companyName": "TechCo",
      "callObjective": "schedule demo",
      "reason": "I saw your recent post about scaling challenges",
      "previousInteraction": "Downloaded case study last week"
    },
    "webhook_url": "https://your-server.com/webhooks/call-complete"
  }'

Tools#

Schedule Callback#

{
  "type": "function",
  "function": {
    "name": "schedule_callback",
    "description": "Schedule a callback at a better time",
    "parameters": {
      "type": "object",
      "properties": {
        "contact_id": { "type": "string" },
        "callback_time": {
          "type": "string",
          "description": "Preferred callback time (ISO 8601)"
        },
        "reason": {
          "type": "string",
          "description": "Why they want a callback"
        }
      },
      "required": ["contact_id", "callback_time"]
    }
  }
}

Book Meeting#

{
  "type": "function",
  "function": {
    "name": "book_meeting",
    "description": "Book a meeting with the sales team",
    "parameters": {
      "type": "object",
      "properties": {
        "contact_id": { "type": "string" },
        "meeting_type": {
          "type": "string",
          "enum": ["discovery", "demo", "consultation"]
        },
        "preferred_times": {
          "type": "array",
          "items": { "type": "string" }
        },
        "attendees": {
          "type": "array",
          "items": { "type": "string" },
          "description": "Additional attendees from prospect's side"
        },
        "notes": { "type": "string" }
      },
      "required": ["contact_id", "meeting_type"]
    }
  }
}

Update Contact Status#

{
  "type": "function",
  "function": {
    "name": "update_contact",
    "description": "Update contact status and notes",
    "parameters": {
      "type": "object",
      "properties": {
        "contact_id": { "type": "string" },
        "status": {
          "type": "string",
          "enum": ["interested", "callback", "not_interested", "no_answer", "wrong_number", "do_not_call"]
        },
        "notes": { "type": "string" },
        "next_action": { "type": "string" }
      },
      "required": ["contact_id", "status"]
    }
  }
}

Send Follow-up Email#

{
  "type": "function",
  "function": {
    "name": "send_email",
    "description": "Send follow-up email after call",
    "parameters": {
      "type": "object",
      "properties": {
        "contact_id": { "type": "string" },
        "template": {
          "type": "string",
          "enum": ["intro", "meeting_invite", "resources", "case_study"]
        },
        "personalization": { "type": "string" }
      },
      "required": ["contact_id", "template"]
    }
  }
}

Tool Handlers#

func handleBookMeeting(params map[string]any) (string, error) {
    contactID := params["contact_id"].(string)
    meetingType := params["meeting_type"].(string)

    // Get available slots from sales team calendar
    slots, _ := calendar.GetTeamAvailability(3) // 3 options

    if len(slots) == 0 {
        return "Let me have a colleague reach out with some times. What's the best email to send that to?", nil
    }

    // Format for speech
    response := "Perfect! I have a few times available: "
    for i, slot := range slots {
        response += formatSlotForSpeech(slot)
        if i < len(slots)-1 {
            response += ", or "
        }
    }
    response += ". Which works best?"

    return response, nil
}

func handleSendEmail(params map[string]any) (string, error) {
    contactID := params["contact_id"].(string)
    template := params["template"].(string)

    contact, _ := db.GetContact(contactID)

    email := buildEmail(template, contact, params["personalization"])
    err := emailService.Send(email)
    if err != nil {
        return "I'll make sure you get that email.", err
    }

    return fmt.Sprintf("Done! I just sent that to %s. You should see it shortly.", contact.Email), nil
}

Compliance & Regulations#

Do Not Call (DNC) Handling#

func preCallCheck(contact *Contact) error {
    // Check internal DNC list
    if db.IsOnDNCList(contact.Phone) {
        return errors.New("contact is on DNC list")
    }

    // Check calling hours
    if !isWithinCallingHours(contact.Timezone) {
        return errors.New("outside calling hours")
    }

    // Check frequency cap
    callsToday := db.GetCallCountToday(contact.Phone)
    if callsToday >= 3 {
        return errors.New("frequency cap reached")
    }

    return nil
}

Calling Hours#

var callingHours = map[string]struct{ start, end int }{
    "US": {9, 21},  // 9 AM - 9 PM local
    "EU": {9, 20},  // 9 AM - 8 PM local (GDPR consideration)
    "IN": {9, 21},  // 9 AM - 9 PM local
}

func isWithinCallingHours(timezone string) bool {
    loc, _ := time.LoadLocation(timezone)
    localTime := time.Now().In(loc)
    hour := localTime.Hour()

    region := getRegion(timezone)
    hours := callingHours[region]

    return hour >= hours.start && hour < hours.end
}

DNC Request Handling#

If the contact asks to be removed or not called again:

"Absolutely, I've made a note and you won't receive any more calls from us.
I apologize for any inconvenience. Have a great day!"

→ Immediately mark as do_not_call
→ Add to DNC list
→ End call gracefully

Call Outcomes#

type CallOutcome string

const (
    OutcomeConnected      CallOutcome = "connected"
    OutcomeVoicemail      CallOutcome = "voicemail"
    OutcomeNoAnswer       CallOutcome = "no_answer"
    OutcomeBusy           CallOutcome = "busy"
    OutcomeInvalidNumber  CallOutcome = "invalid_number"
)

type CallResult struct {
    ContactID        string
    Outcome          CallOutcome
    Duration         time.Duration
    Status           string // interested, callback, not_interested, etc.
    MeetingScheduled bool
    CallbackTime     *time.Time
    Notes            string
    Transcript       string
}

func handleCallComplete(result CallResult) {
    // Update CRM
    crm.UpdateContact(result.ContactID, result)

    // Track metrics
    metrics.Increment("outbound.calls.total")
    metrics.Increment("outbound.outcome." + string(result.Outcome))

    if result.MeetingScheduled {
        metrics.Increment("outbound.meetings.booked")
        notifySalesRep(result)
    }

    // Schedule follow-ups
    if result.Status == "callback" && result.CallbackTime != nil {
        scheduleCallback(result.ContactID, *result.CallbackTime)
    }
}

Voicemail Handling#

When call goes to voicemail:

{
  "voicemail": {
    "enabled": true,
    "detection": "auto",
    "message": "Hi {{contactName}}, this is Alex from {{companyName}}. I was calling about {{reason}}. I'd love to connect briefly - you can reach me at 555-123-4567 or I'll try you again {{callbackDay}}. Looking forward to speaking with you!"
  }
}
func handleVoicemail(contact *Contact, campaign *Campaign) {
    voicemailMsg := buildVoicemailMessage(contact, campaign)

    // Leave voicemail via TTS
    tts.Speak(voicemailMsg)

    // Update contact
    db.UpdateContact(contact.ID, ContactUpdate{
        Status:       "voicemail_left",
        NextAction:   "follow_up_call",
        NextActionAt: time.Now().Add(48 * time.Hour),
    })

    // Optionally send follow-up email
    sendVoicemailFollowupEmail(contact)
}

Campaign Analytics#

type CampaignMetrics struct {
    TotalCalls        int
    Connected         int
    ConnectionRate    float64
    MeetingsBooked    int
    BookingRate       float64
    AverageCallTime   time.Duration
    CallsPerHour      float64
    BestTimeToCall    string
    TopObjections     []string
}

func getCampaignMetrics(campaignID string) *CampaignMetrics {
    calls := db.GetCampaignCalls(campaignID)

    metrics := &CampaignMetrics{
        TotalCalls: len(calls),
    }

    for _, call := range calls {
        if call.Outcome == "connected" {
            metrics.Connected++
        }
        if call.MeetingScheduled {
            metrics.MeetingsBooked++
        }
        metrics.AverageCallTime += call.Duration
    }

    metrics.ConnectionRate = float64(metrics.Connected) / float64(metrics.TotalCalls)
    metrics.BookingRate = float64(metrics.MeetingsBooked) / float64(metrics.Connected)
    metrics.AverageCallTime /= time.Duration(len(calls))

    return metrics
}

Comparing Script Variants#

Edesy has no built-in A/B split. To compare scripts, split your contact list into two CSVs, run two campaigns whose agents differ in one thing (greeting, prompt or calling window), and compare connection rate, duration and outcomes in campaign analytics.

Best Practices#

1. Respect Their Time#

Opening should be < 15 seconds.
Ask permission: "Do you have a quick minute?"
If not: "When would be better? I'll call back then."

2. Lead with Value#

❌ "I'm calling to tell you about our product..."
✅ "I'm calling because we helped [similar company] reduce [problem] by 40%..."

3. Handle Rejection Gracefully#

"I appreciate you taking my call. If anything changes, feel free to reach out."
→ Update status, respect their decision, don't push

4. Follow Up Appropriately#

followUpRules := map[string]FollowUp{
    "interested": {
        Delay:  24 * time.Hour,
        Action: "send_calendar_link",
    },
    "callback": {
        Delay:  time.Duration(0), // Use scheduled time
        Action: "call_again",
    },
    "voicemail": {
        Delay:  48 * time.Hour,
        Action: "try_again",
    },
    "not_interested": {
        Delay:  90 * 24 * time.Hour, // 90 days
        Action: "nurture_email",
    },
}

Next Steps#

Was this page helpful?