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 d โ 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 d
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 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 s 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.d
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 d
โ 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 d 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
: