DEV Community

Qasim
Qasim

Posted on

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" }
  }'
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
  }'
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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."
  }'
Enter fullscreen mode Exit fullscreen mode

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."
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
  }'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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 act on something you scraped out of an email body without checking it against state you control.

Route each reply to the right next action

Stop the sequence on any reply

Whatever the classification, any real inbound reply pauses the drip. The mechanism lives entirely in your own state — there's no metadata on the email holding the step counter:

  1. The webhook tells you a message arrived on thread_id T. Look T up in your sequence state.
  2. If you find an active sequence, flip its state to paused so no future step sends.
  3. If you scheduled a later step server-side with send_at, cancel the queued message by the schedule_id you stored so it never goes out.

Canceling the queued send is one call. The API is a DELETE against the schedule:

curl --request DELETE \
  --url "https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/schedules/$SCHEDULE_ID" \
  --header 'Authorization: Bearer '"$NYLAS_API_KEY"
Enter fullscreen mode Exit fullscreen mode

The CLI mirrors it:

nylas email scheduled cancel "$SCHEDULE_ID" "$GRANT_ID"
Enter fullscreen mode Exit fullscreen mode

That's the whole stop mechanism: inbound message.created → look up the thread → set paused → cancel the queued send. The classification then decides what happens next.

Answer "send me pricing" in-thread

When the bucket is interest, reply on the same thread. The API reply is another send with reply_to_message_id set to the prospect's message, which preserves threading in their client:

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": "Happy to — here is our pricing overview and a short case study from a team your size. Want me to set up 15 minutes to walk through it?",
    "reply_to_message_id": "'"$MESSAGE_ID"'"
  }'
Enter fullscreen mode Exit fullscreen mode

The CLI wraps all of that — it fetches the original to populate the recipient and subject, and threads via reply_to_message_id for you:

nylas email reply "$MESSAGE_ID" "$GRANT_ID" \
  --body "Happy to — here is our pricing overview and a short case study. Want 15 minutes to walk through it?"
Enter fullscreen mode Exit fullscreen mode

(If you'd rather drive the reply through email send directly, it exposes the same threading via the --reply-to <message-id> flag.)

Snooze on "not now"

When the prospect says "circle back in Q3," you don't reply — you reschedule. Set the sequence state's next_send_at to the date they named and leave the drip paused until then. No API call needed beyond storing the date; when that date arrives, your scheduler resumes the sequence with a fresh send. The win is that you're following up when they told you to, which is worth ten cold touches.

Honor unsubscribes and watch deliverability

Cold outbound lives or dies on deliverability, and the fastest way to torch a domain is to keep mailing people who asked you to stop, or to keep hammering addresses that bounce. Two pieces close that loop.

Unsubscribes and bounces belong on a suppression list. Agent Account rules support an in_list operator that matches a sender address against a managed list — exactly the shape of a suppression list. The pattern is two pieces: a list you populate with addresses to suppress, and a rule that acts on in_list matches.

Start by creating the list. It's an address-typed list (the type is immutable, and it's what from.address matches against). Create it via POST /v3/lists:

curl --request POST \
  --url 'https://api.us.nylas.com/v3/lists' \
  --header 'Authorization: Bearer '"$NYLAS_API_KEY" \
  --header 'Content-Type: application/json' \
  --data '{ "name": "Suppressed addresses", "type": "address" }'
Enter fullscreen mode Exit fullscreen mode

When a prospect unsubscribes or an address hard-bounces, add it to the list. The API appends items to the existing list:

curl --request POST \
  --url "https://api.us.nylas.com/v3/lists/$LIST_ID/items" \
  --header 'Authorization: Bearer '"$NYLAS_API_KEY" \
  --header 'Content-Type: application/json' \
  --data '{ "items": ["dana@acme.com"] }'
Enter fullscreen mode Exit fullscreen mode

The CLI does both in two commands — create the list (optionally seeding it inline with --item), then append more addresses as they come in:

nylas agent list create --name "Suppressed addresses" --type address
nylas agent list add "$LIST_ID" dana@acme.com
Enter fullscreen mode Exit fullscreen mode

Now the rule that enforces the list. A rule is inert until it's attached to a workspace — remember the default workspace Nylas created with the account. From the CLI, agent rule create builds the rule and attaches it to that default workspace in one step:

nylas agent rule create \
  --name "Suppress unsubscribed" \
  --condition from.address,in_list,"$LIST_ID" \
  --action block
Enter fullscreen mode Exit fullscreen mode

The equivalent API path is creating the rule via POST /v3/rules, then activating it by attaching it through the workspace's rule_ids (PATCH /v3/workspaces/{workspace_id} with { "rule_ids": ["<rule-id>"] }, or nylas workspace update <workspace-id> --rules-ids <rule-id>). Skip that attach step and the rule does nothing. One honest limitation to design around: inbound rules match only on from.* fieldsfrom.address, from.domain, from.tld — they cannot match on subject or message content. So "suppress anyone whose reply contains the word unsubscribe" is not a rule. You classify that intent in your app code after the webhook, then add the address to the list. The rule enforces the list; your code populates it.

Watch bounces and complaints. Agent Accounts emit deliverability webhooks beyond message.created: message.delivered, message.bounced, message.complaint, and message.rejected (which fires specifically when an outbound message is rejected because an attachment carried a virus — not as a generic rejection). A hard bounce or a spam complaint is a strong signal to stop mailing that address; in your webhook handler, route both into the same suppression list you built above with one agent list add (or POST /v3/lists/{list_id}/items). One transparency note from working on the CLI: these deliverability triggers are wired through the webhooks API — subscribe to them in your trigger_types array — and there isn't a dedicated CLI shortcut for each, so the API is the path here. I'd rather tell you that than have you hunt for a flag that doesn't exist.

What's next

You've got the full loop: provision a replyable outbound mailbox, send a scheduled multi-step sequence, catch each reply via message.created, classify "not now" vs "send pricing" vs "unsubscribe" in your own code, route each to the right action, stop the sequence on engagement, and protect the domain with a suppression list. From here:

AI-answer pages for agents

When this post is published, link AI agents and crawlers to the retrieval-ready version on cli.nylas.com:

Top comments (0)