myagentmail

2026-04-12 · tutorial cold-outreach sales automation ai-agents

Building a Cold Outreach Agent with myagentmail

Cold outreach is one of the most natural use cases for AI agents. The workflow is well-defined: find prospects, write personalized emails, send them, wait for replies, and follow up. Each step is automatable, and the combination of personalization at scale is exactly where AI agents excel.

This tutorial walks through building a complete outreach agent — from lead sourcing to automated follow-ups — using myagentmail for the email layer.

Architecture Overview

The agent operates on a simple loop:

  1. Source leads from a CSV, CRM, or enrichment API
  2. Personalize the opening email per prospect
  3. Send via myagentmail
  4. Listen for replies in real time
  5. Classify replies (interested, not interested, out of office, bounce)
  6. Follow up automatically if no reply within N days
┌─────────────┐    ┌──────────────┐    ┌─────────────┐
│ Lead Source  │───▶│ Personalize  │───▶│    Send      │
└─────────────┘    └──────────────┘    └──────┬──────┘
                                              │
                   ┌──────────────┐    ┌──────▼──────┐
                   │  Follow Up   │◀───│   Listen    │
                   └──────────────┘    └─────────────┘

Step 1: Set Up the Outreach Inbox

Use a dedicated domain for outreach — don't risk your primary domain's reputation on cold email.

const OUTREACH_API_KEY = process.env.MYAGENTMAIL_API_KEY;
const BASE_URL = "https://myagentmail.com/v1";

async function api(path: string, options: RequestInit = {}) {
  const res = await fetch(`${BASE_URL}${path}`, {
    ...options,
    headers: {
      "X-API-Key": OUTREACH_API_KEY,
      "Content-Type": "application/json",
      ...options.headers,
    },
  });
  if (!res.ok) throw new Error(`API error: ${res.status} ${await res.text()}`);
  return res.json();
}

// Create the outreach inbox
const inbox = await api("/inboxes", {
  method: "POST",
  body: JSON.stringify({
    username: "sarah",
    domain: "outreach.yourcompany.com",
    displayName: "Sarah Kim",
  }),
});

console.log(`Outreach inbox: ${inbox.email}`);
// sarah@outreach.yourcompany.com

Step 2: Load and Enrich Leads

Your leads can come from anywhere — a CSV export, a CRM API, or a lead enrichment service. The key fields you need for personalization are: name, email, company, role, and something specific to personalize on (recent post, company news, shared connection).

interface Lead {
  name: string;
  email: string;
  company: string;
  role: string;
  personalNote: string; // Something specific for personalization
  status: "pending" | "sent" | "replied" | "bounced" | "followed_up";
  messageId?: string;
  sentAt?: Date;
}

// Load leads from a CSV or database
async function loadLeads(): Promise<Lead[]> {
  // In production, pull from your CRM or lead source
  return [
    {
      name: "Jordan Rivera",
      email: "jordan@techstartup.com",
      company: "TechStartup",
      role: "Head of Engineering",
      personalNote: "Recently posted about scaling their ML pipeline",
      status: "pending",
    },
    {
      name: "Alex Chen",
      email: "alex@growthco.com",
      company: "GrowthCo",
      role: "VP of Sales",
      personalNote: "Their team just doubled in size per LinkedIn",
      status: "pending",
    },
    // ... more leads
  ];
}

Step 3: Personalize and Send

The personalization layer is where your AI model comes in. Use your LLM to generate a short, personal opening based on the prospect's context.

async function generateEmail(lead: Lead): Promise<{ subject: string; body: string }> {
  // Use your preferred LLM here (OpenAI, Anthropic, etc.)
  // This is pseudocode — replace with your actual LLM call
  const prompt = `Write a cold outreach email to ${lead.name}, ${lead.role} at ${lead.company}.
Context: ${lead.personalNote}.
Keep it under 100 words. No fluff. One clear ask — a 15-minute call.
Sign off as Sarah Kim.`;

  const response = await callLLM(prompt);
  return {
    subject: response.subject,
    body: response.body,
  };
}

async function sendOutreach(lead: Lead, inboxId: string): Promise<string> {
  const email = await generateEmail(lead);

  const message = await api(`/inboxes/${inboxId}/messages`, {
    method: "POST",
    body: JSON.stringify({
      to: lead.email,
      subject: email.subject,
      text: email.body,
    }),
  });

  return message.id;
}

Sending with Rate Limiting

Don't blast all your leads at once. Space out sends to look natural and protect your sender reputation.

async function sendBatch(leads: Lead[], inboxId: string) {
  const pendingLeads = leads.filter((l) => l.status === "pending");

  for (const lead of pendingLeads) {
    try {
      const messageId = await sendOutreach(lead, inboxId);
      lead.status = "sent";
      lead.messageId = messageId;
      lead.sentAt = new Date();
      console.log(`Sent to ${lead.email} (${messageId})`);
    } catch (err) {
      console.error(`Failed to send to ${lead.email}: ${err.message}`);
    }

    // Wait 30-90 seconds between sends (randomized)
    const delay = 30000 + Math.random() * 60000;
    await new Promise((r) => setTimeout(r, delay));
  }
}

Step 4: Listen for Replies

Set up a WebSocket connection to receive replies in real time. When a reply comes in, classify it and route accordingly.

import WebSocket from "ws";

function classifyReply(text: string): "interested" | "not_interested" | "ooo" | "unknown" {
  const lower = text.toLowerCase();

  // Out of office detection
  if (lower.includes("out of office") || lower.includes("currently away") || lower.includes("ooo")) {
    return "ooo";
  }

  // Negative signals
  if (lower.includes("unsubscribe") || lower.includes("not interested") || lower.includes("remove me") || lower.includes("stop emailing")) {
    return "not_interested";
  }

  // Positive signals
  if (lower.includes("sounds good") || lower.includes("let's chat") || lower.includes("interested") || lower.includes("tell me more") || lower.includes("schedule") || lower.includes("calendar")) {
    return "interested";
  }

  return "unknown";
}

function startReplyListener(inboxId: string, leads: Lead[]) {
  const ws = new WebSocket(`wss://myagentmail.com/v1/ws?inboxId=${inboxId}`, {
    headers: { "X-API-Key": OUTREACH_API_KEY },
  });

  ws.on("message", (raw) => {
    const event = JSON.parse(raw.toString());
    if (event.type !== "message.received") return;

    const { from, text, inReplyTo } = event.data;

    // Find the lead this reply is from
    const lead = leads.find((l) => l.email === from);
    if (!lead) return;

    lead.status = "replied";
    const classification = classifyReply(text);

    switch (classification) {
      case "interested":
        console.log(`HOT LEAD: ${lead.name} is interested!`);
        // Notify the human sales rep, create a CRM task, etc.
        notifySalesTeam(lead, text);
        break;
      case "not_interested":
        console.log(`${lead.name} is not interested — removing from sequence`);
        // Respect the opt-out, do not follow up
        break;
      case "ooo":
        console.log(`${lead.name} is OOO — rescheduling follow-up`);
        scheduleFollowUp(lead, 7); // Try again in a week
        break;
      default:
        console.log(`${lead.name} replied — needs human review`);
        notifySalesTeam(lead, text);
    }
  });

  ws.on("close", () => setTimeout(() => startReplyListener(inboxId, leads), 5000));
}

Step 5: Automated Follow-Ups

The follow-up is where most outreach campaigns succeed or fail. Most positive replies come on the second or third touch, not the first.

async function checkAndFollowUp(leads: Lead[], inboxId: string) {
  const now = new Date();

  for (const lead of leads) {
    if (lead.status !== "sent") continue;
    if (!lead.sentAt) continue;

    const daysSinceSend = (now.getTime() - lead.sentAt.getTime()) / (1000 * 60 * 60 * 24);

    // Follow up after 3 days of no reply
    if (daysSinceSend >= 3) {
      const followUp = await generateFollowUp(lead);

      await api(`/inboxes/${inboxId}/messages`, {
        method: "POST",
        body: JSON.stringify({
          to: lead.email,
          subject: `Re: ${followUp.originalSubject}`,
          text: followUp.body,
          inReplyTo: lead.messageId, // Thread with the original
        }),
      });

      lead.status = "followed_up";
      console.log(`Follow-up sent to ${lead.name}`);

      // Rate limit between follow-ups too
      await new Promise((r) => setTimeout(r, 45000));
    }
  }
}

async function generateFollowUp(lead: Lead) {
  const prompt = `Write a brief follow-up email to ${lead.name} at ${lead.company}.
This is a follow-up to a cold email sent 3 days ago.
Keep it to 2-3 sentences. Be direct. Reference the original email.
Sign off as Sarah Kim.`;

  return await callLLM(prompt);
}

Run the follow-up check on a schedule — once or twice per day:

// Run follow-up checks every 12 hours
setInterval(() => checkAndFollowUp(leads, inbox.id), 12 * 60 * 60 * 1000);

Step 6: Putting It All Together

async function main() {
  // Set up inbox
  const inbox = await api("/inboxes", {
    method: "POST",
    body: JSON.stringify({
      username: "sarah",
      domain: "outreach.yourcompany.com",
      displayName: "Sarah Kim",
    }),
  });

  // Load leads
  const leads = await loadLeads();

  // Start listening for replies
  startReplyListener(inbox.id, leads);

  // Send initial batch
  await sendBatch(leads, inbox.id);

  // Start follow-up scheduler
  setInterval(() => checkAndFollowUp(leads, inbox.id), 12 * 60 * 60 * 1000);

  console.log("Outreach agent running");
}

main().catch(console.error);

Best Practices

Respect opt-outs immediately. When someone says "not interested" or "unsubscribe," stop all communication. This isn't just ethical — it protects your domain reputation.

Limit follow-ups to 2-3 per lead. More than that damages your reputation and annoys people. If three touches don't get a response, move on.

Warm your domain first. Don't start with cold outreach on day one. Spend 2-3 weeks sending emails to known contacts who will reply. Build reputation before going cold.

Personalize meaningfully. "I saw your company is doing great things" is not personalization. "I noticed your team shipped a new ML pipeline last month" is. The LLM has to work with real context.

Send during business hours. Queue emails for 9 AM - 5 PM in the recipient's timezone. Emails sent at 3 AM look automated because they are.

Track everything. Log every send, every reply, every classification. This data is how you improve your agent's personalization, timing, and targeting over time.

Cold outreach isn't about volume — it's about relevance and timing. An AI agent with a dedicated email identity, real-time reply handling, and thoughtful follow-up sequences can outperform a human SDR team on both metrics. The email infrastructure just needs to keep up.


← All posts