Outbound GTM / SDR AI agent
Build an SDR agent on Vercel Eve and AgentMail: research prospects, send personalized outreach, follow up, classify replies, and hand hot leads to a human.
Give an agent the SDR job. It pulls in your leads, researches each one, sends personal first touches from its own inbox, follows up on silence, and reads every reply. You only see the hot leads, with the full thread and research attached.
This cookbook builds it as a Vercel Eve agent with AgentMail as the email layer.
What it needs to do
- Pull in leads and research every prospect
- Set up the agent’s email address so the mail lands
- Send a personalized email to every prospect, staying under about 25 sends per inbox per day
- Follow up, classify replies with a small model, and put hot leads in front of a human
Before you start
- Node.js 24 or newer, and an AI Gateway key (or any model provider key) for Eve.
- An AgentMail API key on the Developer plan or above (custom domains are not on the Free plan).
- A registered outreach domain that is not your main domain. Let it age about two weeks before sending.
- A lead list with validated addresses. Bad addresses bounce, and high bounce rates damage the domain’s reputation.
Scaffold the agent and add the two SDKs:
npx eve@latest init sdr-agent --model anthropic/claude-sonnet-5
cd sdr-agent
npm install agentmail svix
export AGENTMAIL_API_KEY="..."Eve builds the agent from the files under agent/: instructions.md is the system prompt, each file in agent/tools/ is a tool, and agent/channels/ holds HTTP entry points. npm run dev gives you an interactive session with the agent at any point below.
Two small shared files the tools import:
import { AgentMailClient } from "agentmail";
export const agentmail = new AgentMailClient(); // reads AGENTMAIL_API_KEY
export const INBOX = process.env.SDR_INBOX!; // set after part 2, e.g. [email protected]import { DatabaseSync } from "node:sqlite";
export const db = new DatabaseSync("pipeline.db");
db.exec(`CREATE TABLE IF NOT EXISTS leads (
email TEXT PRIMARY KEY,
name TEXT, company TEXT, notes TEXT,
state TEXT DEFAULT 'new', -- new, contacted, replied, handed_off, closed, suppressed
thread_id TEXT,
followup_draft_id TEXT,
touches INTEGER DEFAULT 0,
contacted_at TEXT
)`);Swap SQLite for Postgres or your CRM if that is where your pipeline already lives.
1. Get leads in and research them
The easiest way is a CSV file with email, name, and company columns, loaded into the leads table. If your pipeline lives in a CRM like Attio or HubSpot, pull from there instead, since that is also where the results should land later.
The agent does the research itself through a tool. This one fetches the prospect’s site and runs a news search (Exa here, but any search API works):
import { defineTool } from "eve/tools";
import { z } from "zod";
export default defineTool({
description:
"Research a prospect before drafting. Fetches their website and searches recent news. " +
"Returns raw findings; only claims backed by these findings may appear in the email.",
inputSchema: z.object({ company: z.string(), domain: z.string() }),
async execute({ company, domain }) {
const site = await fetch(`https://${domain}`)
.then((r) => r.text())
.catch(() => "");
const news = await fetch("https://api.exa.ai/search", {
method: "POST",
headers: { "Content-Type": "application/json", "X-API-Key": process.env.EXA_API_KEY! },
body: JSON.stringify({
query: `${company} funding hiring product launch`,
numResults: 5,
contents: { text: { maxCharacters: 1500 } },
}),
}).then((r) => r.json());
return { site: site.slice(0, 5000), news: news.results };
},
});Add two small tools the same way: get_next_leads (reads new leads from the table) and update_lead (writes state, notes, and thread ids back). The instructions in part 3 tell the agent when to call each one.
2. Set up the agent’s email address
Cold email from a brand-new address on your main domain gets filtered and puts the domain at risk. Use a dedicated outreach domain, verified DNS, an inbox named for a person, and a few weeks of warmup.
Add the domain
In the Console, open Domains and click Add Domain, or use any of these interfaces:
agentmail domains create --domain tryacme.comThe response includes the SPF, DKIM, DMARC, and MX records to publish at your registrar. Publish them exactly as returned. If the domain already has an SPF record, merge the new value into it instead of adding a second record. Then verify with agentmail.domains.verify(domain.domainId) and wait until status is VERIFIED (or subscribe to the domain.verified webhook event) before creating inboxes on it.
Create the inbox
Use a person’s name rather than a role account. [email protected] gets more replies than [email protected].
const inbox = await agentmail.inboxes.create({
username: "mara",
domain: "tryacme.com",
displayName: "Mara Ellison",
});
console.log(inbox.email); // [email protected]The response’s email field is the agent’s address. Set it as SDR_INBOX in the agent’s environment.
Warm up the inbox
A new inbox needs a few weeks of warmup traffic before cold outreach. Connect it to a warmup tool (Instantly, Smartlead) through the tool’s custom provider option:
| Setting | IMAP (read) | SMTP (send) |
|---|---|---|
| Host | imap.agentmail.to | smtp.agentmail.to |
| Port | 993 | 465 |
| Security | SSL/TLS | SSL/TLS |
| Username | [email protected] | [email protected] |
| Password | your API key | your API key |
Ramp schedules are in the deliverability guide. While the inbox warms, build the rest of the pipeline. Everything below works against a test inbox on agentmail.to immediately.
3. Send a personalized email to every prospect
The drafting rules live in the agent’s system prompt. Add the outbound section:
You are Mara, an SDR for Acme. Work through leads with get_next_leads.
For each lead: call research_prospect first. Write a plain-text email under
120 words with one reason to reply. Every claim about the prospect must come
from the research findings. If the research has nothing useful, mark the lead
closed with update_lead and move on. Close with a low-pressure question, not
a calendar link.
Send with send_first_touch, then schedule the follow-up with
schedule_followup. Never contact a lead in state suppressed or closed, and
never send more than 3 emails to one person.The send tool enforces the sending constraints in code. It carries an idempotency key (a crash and retry sends one email, not two), refuses past the daily cap, and is gated on human approval until you loosen it:
import { defineTool } from "eve/tools";
import { always } from "eve/tools/approval";
import { z } from "zod";
import { agentmail, INBOX } from "../lib/agentmail";
import { db } from "../lib/leads";
const DAILY_CAP = 25; // deliverability ceiling for one warmed inbox
export default defineTool({
description: "Send the first outreach email to a prospect. Refuses past the daily cap.",
inputSchema: z.object({ to: z.string(), subject: z.string(), text: z.string() }),
approval: always(), // a person reviews every send. Relax per category later
async execute({ to, subject, text }) {
const today = db.prepare(
"SELECT COUNT(*) AS n FROM leads WHERE date(contacted_at) = date('now')",
).get() as { n: number };
if (today.n >= DAILY_CAP) return { sent: false, reason: "daily cap reached" };
const sent = await agentmail.inboxes.messages.send(
INBOX,
{ to: [to], subject, text },
{ headers: { "Idempotency-Key": `first-${to}` } },
);
db.prepare(
"UPDATE leads SET state='contacted', thread_id=?, touches=touches+1, contacted_at=datetime('now') WHERE email=?",
).run(sent.threadId, to);
return { sent: true, messageId: sent.messageId, threadId: sent.threadId };
},
});The follow-up is scheduled the moment the first touch goes out, as a reply draft. inReplyTo points at the first touch, so recipients, subject, and threading derive from it, and sendAt sets the date. Because AgentMail stores it as a scheduled draft, canceling is one delete call, with no separate scheduler to race:
import { defineTool } from "eve/tools";
import { z } from "zod";
import { agentmail, INBOX } from "../lib/agentmail";
import { db } from "../lib/leads";
export default defineTool({
description: "Schedule a follow-up 4 days out, threaded under the first touch.",
inputSchema: z.object({ email: z.string(), messageId: z.string(), text: z.string() }),
async execute({ email, messageId, text }) {
const draft = await agentmail.inboxes.drafts.create(INBOX, {
inReplyTo: messageId,
text,
sendAt: new Date(Date.now() + 4 * 86_400_000).toISOString(),
});
db.prepare("UPDATE leads SET followup_draft_id=? WHERE email=?").run(draft.draftId, email);
return { draftId: draft.draftId };
},
});A matching cancel_followup tool calls agentmail.inboxes.drafts.delete(INBOX, draftId) and clears the column. Run npm run dev at this point: give the agent a few test leads and watch it research, draft, ask for send approval, and schedule follow-ups.
4. Handle replies and surface hot leads
Replies, bounces, and complaints arrive on one webhook. Register it once (the response contains the Svix secret, shown once, save it as AGENTMAIL_WEBHOOK_SECRET):
const webhook = await agentmail.webhooks.create({
url: "https://sdr-agent.example.com/webhooks/agentmail",
eventTypes: ["message.received", "message.bounced", "message.complained"],
inboxIds: [INBOX],
clientId: "sdr-agent-v1",
});In Eve, the receiving end is a channel. It verifies the signature, dedupes (webhook delivery is at least once), handles bounces and complaints in code, and turns each genuine reply into an agent turn. from(threadId) maps each email thread to its own durable session, which retains the conversation context across turns:
import { defineChannel, POST } from "eve/channels";
import { Webhook } from "svix";
import { suppress, claim } from "../lib/leads";
const verifier = new Webhook(process.env.AGENTMAIL_WEBHOOK_SECRET!);
export default defineChannel({
turnPolicy: "queue",
routes: [
POST("/webhooks/agentmail", async (request, { from }) => {
const payload = await request.text();
let event: any;
try {
event = verifier.verify(payload, {
"svix-id": request.headers.get("svix-id") ?? "",
"svix-timestamp": request.headers.get("svix-timestamp") ?? "",
"svix-signature": request.headers.get("svix-signature") ?? "",
});
} catch {
return new Response(null, { status: 400 });
}
const message = event.message;
if (!claim(message.message_id)) return new Response(null, { status: 200 });
if (event.event_type !== "message.received") {
// suppress() also cancels the lead's pending follow-up draft
suppress(message.to[0]);
return new Response(null, { status: 200 });
}
await from(message.thread_id).send(
`New reply from ${message.from} in thread ${message.thread_id}. ` +
`Classify it with the classifier subagent, then act per the playbook.`,
{ auth: null },
);
return new Response(null, { status: 200 });
}),
],
});Classification runs on a small model. In Eve that is a subagent with its own model, two files:
import { defineAgent } from "eve";
export default defineAgent({
description: "Classifies one inbound reply to a cold email.",
model: "anthropic/claude-haiku-4.5",
});Classify the reply into one of: interested, question, not_now, not_interested,
out_of_office, referral, unsubscribe, auto_reply. Return JSON:
{category, summary (one line), return_date (out_of_office only),
referred_contact (referral only)}.
"Interesting, but ask me next quarter" is not_now. "Take me off your list" is
unsubscribe even without the word unsubscribe. Read only what the prospect
wrote, not the quoted history.The reply half of the playbook tells the root agent what to do with each category:
When a reply comes in: cancel the pending follow-up with cancel_followup,
fetch the message, and pass its extracted_text to the classifier.
- interested: send a short acknowledgment with reply_in_thread (under five
sentences, first line reacting to what they said, propose two concrete
times, calendar link as fallback, no pitching). Then forward_to_human with
the research notes and this ask: "Reply to this, or CC yourself into the
thread and take it from here." Label the thread handed-off.
- unsubscribe or not_interested: block_sender, mark the lead suppressed.
- out_of_office: reschedule the follow-up for after the return date.
- auto_reply: do nothing.
- anything else (question, not_now, referral): forward_to_human with the
classifier's summary.
Never reply in a thread labeled handed-off. Never reply to a message whose
Auto-Submitted header is set.The three tools referenced are thin wrappers, one AgentMail call each: reply_in_thread posts to the reply endpoint with an idempotency key, forward_to_human calls the forward endpoint plus agentmail.threads.update(threadId, { addLabels: ["handed-off"] }), and block_sender calls agentmail.lists.create("send", "block", { entry }). The block list is org-level, so an opt-out here binds every future campaign even if a fresh lead list re-imports the address. On the interested path the agent answers in seconds, and that matters: replies sent within 30 minutes book meetings at several times the rate of next-day replies.
If your team lives in Slack, add a webhook post to forward_to_human.
Keep the guardrails in code
The tools enforce what the prompt only asks for: the daily cap and idempotency keys in send_first_touch, approval: always() on every outbound send until you relax it per category, the touch counter in the lead table, and the handed-off label check. Underneath all of that, sends to previously bounced, complained, or unsubscribed addresses fail with 403 MessageRejectedError before anything leaves. Watch message.bounced and message.complained rates per campaign and stop the ramp when they move. Practitioner ceilings: bounces under 2 percent, complaints under 0.3 percent.
Extend the pipeline
Common next steps: enrichment feeding the research notes (Clay, Apollo), more inboxes once volume outgrows one address, A/B testing subject lines, meeting booking beyond a calendar link, and syncing state back to the CRM. To add inboxes, add more INBOX values. The channel, classifier, and lead table stay the same.