# Build an SDR agent with its own follow-up inbox

> Source: <https://dev.to/mqasimca/build-an-sdr-agent-with-its-own-follow-up-inbox-1ahj>
> Published: 2026-07-10 01:27:45+00:00

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.

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.

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*:

`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.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.

You'll need:

`NYLAS_API_KEY`

.`*.nylas.email`

trial subdomain. `nylas init`

once to store your key.:::info

**New to Agent Accounts?** Start with [What are Agent Accounts](https://developer.nylas.com/docs/v3/agent-accounts/) for the product overview, then come back here.

:::

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.

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.

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.

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.

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.`id`

.`id`

`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:

`next_send_at`

for the date they named ("circle back in Q3").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.

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:

`thread_id T`

. Look `T`

up in your sequence state.`paused`

so no future step sends.`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"
```

The CLI mirrors it:

```
nylas email scheduled cancel "$SCHEDULE_ID" "$GRANT_ID"
```

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*.

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"'"
  }'
```

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?"
```

(If you'd rather drive the reply through `email send`

directly, it exposes the same threading via the `--reply-to <message-id>`

flag.)

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.

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" }'
```

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"] }'
```

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
```

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
```

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.* fields** —

`from.address`

, `from.domain`

, `from.tld`

— they cannot match on subject or message content. So "suppress anyone whose reply contains the word **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.

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:

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

:
