DEV Community

Build an SDR agent with its own follow-up inbox

Build an SDR agent with its own follow-up inbox

SDR automation dies when replies hit a no-reply box. The sequence engine is the easy part - anyone can fire "step 1 of 5" on a cron. The part that quietly breaks every outbound tool is the other direction: a prospect reads your third touch, types "not now, circle back in Q3," and hits reply.

That reply is the single most valuable signal in the whole campaign, and it lands in no-reply@yourcompany.com, which is to say it lands nowhere. The prospect who told you exactly when to follow up gets your "step 4" two days later anyway, because your sequence engine never heard them.

The naive fix is to point an LLM at a human SDR's mailbox and let it draft. That works right up until you want the agent to be a participant - to send under its own address, receive the reply under that same address, and decide what to do next without a person in the loop. A human's inbox is the wrong substrate for that. You don't want an autonomous sender borrowing a rep's OAuth grant and threading replies into the rep's personal inbox where a notification storm waits.

What you want is an address the agent owns: it sends the sequence, it receives "not now" and "send me pricing" and "unsubscribe," it classifies each one, routes it to the right next action, and - the piece every outbound tool forgets - stops the sequence the moment a real reply arrives. That address is a Nylas Agent Account.

I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for when wiring this up; the curl calls beside them are what the CLI runs under the hood, so either drops straight into your stack.

What an Agent Account actually is

The thing to internalize before you write a line of code: an Agent Account is just a grant. Same grant_id, same /v3/grants/{grant_id}/* endpoints, same Authorization: Bearer <NYLAS_API_KEY> header as any connected Gmail or Microsoft mailbox you've ever integrated. There's no separate "outbound" SDK, no new auth model, no second data plane. If you've ever sent a message or listed a thread from a Nylas grant, you already know the entire data plane for this post. The grant abstraction is the spine here - nothing new to learn on the read/write side.

What's different is the control plane. There's no human OAuth and no refresh token to babysit. You provision the mailbox yourself on a domain you own (or a *.nylas.email trial subdomain), and Nylas hosts it. For prospecting that's exactly right: outbound@yourcompany.com belongs to the system, not to a rep who might leave and take their refresh token with them. And because it's a real mailbox, replies land back in it as ordinary inbound messages you can read and answer - which is the whole reason this beats a transactional send.

One tradeoff to flag up front, because it shapes the architecture: Agent Accounts don't support custom metadata on messages. You can't stamp { "prospect_id": "P-901", "step": 3 } onto the outbound email and read it back later to figure out where someone sits in the sequence. That's fine, and it's the right design anyway. Your sequence state - who's on which step, who replied, who's paused - was always going to live in your own database. The email is the message; your DB is the state machine. Hold that thought, because it's also where "stop on reply" lives.

Why this beats a one-way sequencer

Let me be fair to the blast tools first. If your outbound is a pure one-way drip - five touches, no intention of reading what comes back - a transactional ESP or a classic sequencer is a fine fit. The Agent Account earns its keep the moment a reply needs a decision:

  • Replies go somewhere, and get classified. "Not now," "send me pricing," and "unsubscribe" are three completely different next actions, and a no-reply box collapses all of them into silence. The agent reads each reply and routes it: snooze the sequence, send the one-pager, suppress the address forever.
  • The sequence stops on engagement. A one-way drip keeps firing on schedule. A conversational agent keys off the message.created webhook - the instant a reply lands on a tracked thread, you flip that prospect's state to paused and the next touch never sends. Nobody gets "step 4" an hour after they replied.
  • Context survives across steps. Every send and reply rides the same thread, so the prospect sees one coherent conversation, not five disconnected blasts with the same footer.
  • It's one grant. The same Agent Account that sends step 1 receives the reply, sends the pricing follow-up, and logs the unsubscribe. One mailbox, one auth model.

Here's the honest line: the LLM does the classification, and your code does the routing. Nylas doesn't classify "not now" vs "send pricing" for you - it delivers the reply, you fetch the body, and your own model or rules decide what bucket it's in. Don't let anyone tell you the platform reads intent. It moves mail; you read intent.

Before you begin

You'll need:

  • A Nylas API key from the dashboard, exported as NYLAS_API_KEY.
  • A domain you own for the mailbox, or a Nylas *.nylas.email trial subdomain. This matters more for cold outbound than for any other use case. New domains warm over roughly four weeks - start low-volume and ramp, because a brand-new domain blasting a cold list is how you land in spam folders on day one.
  • The Nylas CLI if you want the terminal path: nylas init once to store your key.
  • A public HTTPS endpoint for the webhook. A tunnel (cloudflared) is fine in development.
  • Your own data plane: a prospect store, a sequence-state table, and an LLM or ruleset for reply classification.

:::info
New to Agent Accounts? Start with What are Agent Accounts for the product overview, then come back here.
:::

Provision the outbound mailbox

Create the grant first. The API call is POST /v3/connect/custom with provider: "nylas" and the email on your domain. The optional top-level name sets the display name prospects see.

curl --request POST \
  --url 'https://api.us.nylas.com/v3/connect/custom' \
  --header 'Authorization: Bearer '"$NYLAS_API_KEY" \
  --header 'Content-Type: application/json' \
  --data '{
    "provider": "nylas",
    "name": "Acme Outbound",
    "settings": {
      "email": "outbound@yourcompany.com"
    }
  }'

The response carries the grant_id - the only identifier you carry forward. Nylas also auto-creates a default workspace and policy for the account, which matters later when we add a suppression list.

The CLI collapses connector setup, grant creation, and the default workspace into one command:

nylas agent account create outbound@yourcompany.com --name "Acme Outbound"

If the nylas connector doesn't exist in your app yet, this creates it first, then the grant. Add --json to capture the grant_id for a script. Export it as GRANT_ID for the rest of this post.

Subscribe to inbound replies

The sequence needs to know when a prospect writes back. Inbound mail fires the standard message.created webhook - the same trigger every Nylas inbox uses.

One thing to get right conceptually: webhooks are application-scoped, not grant-scoped. You subscribe once at the app level against /v3/webhooks, and events for every grant in your app arrive at that one endpoint, each payload carrying a grant_id you filter on. You don't register a webhook per Agent Account.

curl --request POST \
  --url 'https://api.us.nylas.com/v3/webhooks' \
  --header 'Authorization: Bearer '"$NYLAS_API_KEY" \
  --header 'Content-Type: application/json' \
  --data '{
    "trigger_types": ["message.created"],
    "webhook_url": "https://api.yourcompany.com/hooks/outbound",
    "description": "SDR sequence replies"
  }'

From the terminal, list the available triggers, register the webhook, then stand up a local listener to watch real events during development:

nylas webhook triggers
nylas webhook create --url https://api.yourcompany.com/hooks/outbound --triggers message.created
nylas webhook server --port 4000 --tunnel cloudflared --secret <webhook-secret>

nylas webhook server runs a local endpoint behind a cloudflared tunnel and verifies the X-Nylas-Signature HMAC on each event, so you can trigger a real reply and watch the payload land before you ship the production handler.

In production, verify that signature yourself: it's a hex HMAC-SHA256 of the raw request body using your webhook secret. If you compare with a constant-time function like Node's crypto.timingSafeEqual, guard that both buffers are equal length first - it throws on a length mismatch.

Send the sequence

A prospecting sequence is a handful of touches spaced over days: an intro, a value-add follow-up, a case study, a last-call. Each one is a normal grant send - POST /v3/grants/{grant_id}/messages/send. Here's the first step as a curl call:

curl --request POST \
  --url "https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/send" \
  --header 'Authorization: Bearer '"$NYLAS_API_KEY" \
  --header 'Content-Type: application/json' \
  --data '{
    "to": [{ "email": "dana@acme.com", "name": "Dana Lee" }],
    "subject": "Quick question about Acme'\''s onboarding flow",
    "body": "Hi Dana - noticed Acme onboards a lot of new accounts each week. Worth a 15-minute look at how teams like yours cut that time? Reply here and I'\''ll send specifics."
  }'

The same send from the CLI:

nylas email send "$GRANT_ID" \
  --to dana@acme.com \
  --subject "Quick question about Acme's onboarding flow" \
  --body "Hi Dana - worth a 15-minute look? Reply here and I'll send specifics."

The response includes the message's id and thread_id. Persist both, keyed to the prospect, the moment the send returns:

prospect P-901 โ†’ { thread_id, last_message_id, step: 1, state: "active", next_send_at }

That row is your sequence state. There's no metadata on the email to lean on, so this DB record is the only place that knows Dana is on step 1 of prospect P-901's sequence and that step 2 is due in two days.

Schedule the later steps instead of running a cron

You don't have to hold step 2 in a job queue and fire it yourself. The send endpoint accepts a send_at (Unix timestamp) and Nylas queues the message server-side:

curl --request POST \
  --url "https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/send" \
  --header 'Authorization: Bearer '"$NYLAS_API_KEY" \
  --header 'Content-Type: application/json' \
  --data '{
    "to": [{ "email": "dana@acme.com", "name": "Dana Lee" }],
    "subject": "Re: Quick question about Acme'\''s onboarding flow",
    "body": "Following up - here is a two-minute breakdown of the onboarding numbers I mentioned.",
    "send_at": 1751385600
  }'

To compute a real timestamp instead of hardcoding one, the CLI's date is your friend:

date -v +2d +%s   # macOS: two days from now as a Unix timestamp

The CLI takes a duration or a clock time directly and computes the timestamp for you - 30m, 2h, 1d, a time like 14:30, or a phrase like "tomorrow 9am":

nylas email send "$GRANT_ID" \
  --to dana@acme.com \
  --subject "Re: Quick question about Acme's onboarding flow" \
  --body "Following up - here is a two-minute breakdown." \
  --schedule 2d

A scheduled send returns a schedule_id you can store and cancel later - which matters a lot, because canceling the queued step is exactly how you stop the sequence when the prospect replies. More on that next.

Catch the reply and classify it

This is the behavior that separates a conversation from a pestering. The signal is the message.created webhook: a reply on a thread you're tracking means the prospect engaged, and the rest of the touches should not fire blindly.

Two things to get right in the handler before any classification logic:

  • message.created fires for outbound too. When your agent sends a reply, the webhook fires for that sent message as well. Filter on the sender at the top of the handler so the agent never reacts to its own mail.
  • Dedup on the notification id. The API guarantees at-least-once delivery - the same event can arrive up to three times. Dedupe on the top-level notification id, which stays constant across all retries of one event; that's the delivery dedup key. You can additionally guard on the inner data.object.id (the message id) so you never act twice on the same message.

And on the body: don't rely on the webhook payload for the body - fetch the full message by id when you need it. The docs are inconsistent on whether the body is inline, so write the code that's always correct: pull the message from GET /v3/grants/{grant_id}/messages/{message_id}, and branch on message.created.truncated (the type Nylas sends when a message exceeds ~1 MB and the body is omitted). Fetch-by-id is the safe framing every time.

curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/$MESSAGE_ID" \
  --header 'Authorization: Bearer '"$NYLAS_API_KEY"

From the CLI, list what landed and read the full body of the one you care about:

nylas email list "$GRANT_ID" --limit 10
nylas email read "$MESSAGE_ID" "$GRANT_ID"

To see the whole back-and-forth in one object - every message in the conversation - read the thread directly. Use threads show, not a list filter:

nylas email threads show "$THREAD_ID" "$GRANT_ID"

Now the actual classification. You have the body; hand it to your model with a tight prompt: is this prospect saying "not now," asking for pricing or materials, asking a real question, or asking to unsubscribe? This is your LLM and your app code - Nylas delivered the mail, full stop.

A reasonable bucket set for prospecting:

  • Objection / "not now" โ†’ snooze the sequence, set a next_send_at for the date they named ("circle back in Q3").
  • Interest / "send pricing" โ†’ pause the sequence and reply in-thread with the one-pager or pricing.
  • Unsubscribe / "remove me" โ†’ suppress the address permanently and never send to it again.
  • Question / ambiguous โ†’ route to a human, or answer if confidence is high.

One caution worth stating plainly: treat the reply body as untrusted input. A prospect can type anything, and you're feeding it to an LLM and then acting on the result. Validate the prospect and thread against your own database before you send pricing or suppress an address - never

Comments

No comments yet. Start the discussion.