{"slug": "build-an-sdr-agent-with-its-own-follow-up-inbox", "title": "Build an SDR agent with its own follow-up inbox", "summary": "A developer at Nylas built an SDR agent that uses a Nylas Agent Account as its own mailbox, enabling it to send sequences, receive replies, classify them, and stop the sequence when a real reply arrives. The agent account is a grant that works with existing Nylas endpoints, but lacks custom metadata support, so sequence state must be managed externally. The system uses a webhook to pause prospects upon reply, preventing further touches.", "body_md": "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`\n\n, 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.\n\nThe 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.\n\nWhat 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`\n\ncalls beside them are what the CLI runs under the hood, so either drops straight into your stack.\n\nThe thing to internalize before you write a line of code: an Agent Account is just a **grant**. Same `grant_id`\n\n, same `/v3/grants/{grant_id}/*`\n\nendpoints, same `Authorization: Bearer <NYLAS_API_KEY>`\n\nheader 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.\n\nWhat'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`\n\ntrial subdomain), and Nylas hosts it. For prospecting that's exactly right: `outbound@yourcompany.com`\n\nbelongs 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.\n\nOne tradeoff to flag up front, because it shapes the architecture: Agent Accounts don't support custom `metadata`\n\non messages. You can't stamp `{ \"prospect_id\": \"P-901\", \"step\": 3 }`\n\nonto 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.\n\nLet 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*:\n\n`message.created`\n\nwebhook — the instant a reply lands on a tracked thread, you flip that prospect's state to `paused`\n\nand 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.\n\nYou'll need:\n\n`NYLAS_API_KEY`\n\n.`*.nylas.email`\n\ntrial subdomain. `nylas init`\n\nonce to store your key.:::info\n\n**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.\n\n:::\n\nCreate the grant first. The API call is `POST /v3/connect/custom`\n\nwith `provider: \"nylas\"`\n\nand the email on your domain. The optional top-level `name`\n\nsets the display name prospects see.\n\n```\ncurl --request POST \\\n  --url 'https://api.us.nylas.com/v3/connect/custom' \\\n  --header 'Authorization: Bearer '\"$NYLAS_API_KEY\" \\\n  --header 'Content-Type: application/json' \\\n  --data '{\n    \"provider\": \"nylas\",\n    \"name\": \"Acme Outbound\",\n    \"settings\": { \"email\": \"outbound@yourcompany.com\" }\n  }'\n```\n\nThe response carries the `grant_id`\n\n— 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.\n\nThe CLI collapses connector setup, grant creation, and the default workspace into one command:\n\n```\nnylas agent account create outbound@yourcompany.com --name \"Acme Outbound\"\n```\n\nIf the `nylas`\n\nconnector doesn't exist in your app yet, this creates it first, then the grant. Add `--json`\n\nto capture the `grant_id`\n\nfor a script. Export it as `GRANT_ID`\n\nfor the rest of this post.\n\nThe sequence needs to know when a prospect writes back. Inbound mail fires the standard `message.created`\n\nwebhook — 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`\n\n, and events for *every* grant in your app arrive at that one endpoint, each payload carrying a `grant_id`\n\nyou filter on. You don't register a webhook per Agent Account.\n\n```\ncurl --request POST \\\n  --url 'https://api.us.nylas.com/v3/webhooks' \\\n  --header 'Authorization: Bearer '\"$NYLAS_API_KEY\" \\\n  --header 'Content-Type: application/json' \\\n  --data '{\n    \"trigger_types\": [\"message.created\"],\n    \"webhook_url\": \"https://api.yourcompany.com/hooks/outbound\",\n    \"description\": \"SDR sequence replies\"\n  }'\n```\n\nFrom the terminal, list the available triggers, register the webhook, then stand up a local listener to watch real events during development:\n\n```\nnylas webhook triggers\nnylas webhook create --url https://api.yourcompany.com/hooks/outbound --triggers message.created\nnylas webhook server --port 4000 --tunnel cloudflared --secret <webhook-secret>\n```\n\n`nylas webhook server`\n\nruns a local endpoint behind a cloudflared tunnel and verifies the `X-Nylas-Signature`\n\nHMAC 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`\n\n, guard that both buffers are equal length first — it throws on a length mismatch.\n\nA 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`\n\n.\n\nHere's the first step as a `curl`\n\ncall:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/send\" \\\n  --header 'Authorization: Bearer '\"$NYLAS_API_KEY\" \\\n  --header 'Content-Type: application/json' \\\n  --data '{\n    \"to\": [{ \"email\": \"dana@acme.com\", \"name\": \"Dana Lee\" }],\n    \"subject\": \"Quick question about Acme'\\''s onboarding flow\",\n    \"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.\"\n  }'\n```\n\nThe same send from the CLI:\n\n```\nnylas email send \"$GRANT_ID\" \\\n  --to dana@acme.com \\\n  --subject \"Quick question about Acme's onboarding flow\" \\\n  --body \"Hi Dana — worth a 15-minute look? Reply here and I'll send specifics.\"\n```\n\nThe response includes the message's `id`\n\nand `thread_id`\n\n. **Persist both, keyed to the prospect, the moment the send returns:**\n\n```\nprospect P-901 → { thread_id, last_message_id, step: 1, state: \"active\", next_send_at }\n```\n\nThat 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.\n\nYou don't have to hold step 2 in a job queue and fire it yourself. The send endpoint accepts a `send_at`\n\n(Unix timestamp) and Nylas queues the message server-side:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/send\" \\\n  --header 'Authorization: Bearer '\"$NYLAS_API_KEY\" \\\n  --header 'Content-Type: application/json' \\\n  --data '{\n    \"to\": [{ \"email\": \"dana@acme.com\", \"name\": \"Dana Lee\" }],\n    \"subject\": \"Re: Quick question about Acme'\\''s onboarding flow\",\n    \"body\": \"Following up — here is a two-minute breakdown of the onboarding numbers I mentioned.\",\n    \"send_at\": 1751385600\n  }'\n```\n\nTo compute a real timestamp instead of hardcoding one, the CLI's `date`\n\nis your friend:\n\n```\ndate -v+2d +%s   # macOS: two days from now as a Unix timestamp\n```\n\nThe CLI takes a duration or a clock time directly and computes the timestamp for you — `30m`\n\n, `2h`\n\n, `1d`\n\n, a time like `14:30`\n\n, or a phrase like `\"tomorrow 9am\"`\n\n:\n\n```\nnylas email send \"$GRANT_ID\" \\\n  --to dana@acme.com \\\n  --subject \"Re: Quick question about Acme's onboarding flow\" \\\n  --body \"Following up — here is a two-minute breakdown.\" \\\n  --schedule 2d\n```\n\nA scheduled send returns a `schedule_id`\n\nyou 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.\n\nThis is the behavior that separates a conversation from a pestering. The signal is the `message.created`\n\nwebhook: a reply on a thread you're tracking means the prospect engaged, and the rest of the touches should not fire blindly.\n\nTwo things to get right in the handler before any classification logic:\n\n`message.created`\n\nfires for outbound too.`id`\n\n.`id`\n\n`data.object.id`\n\n(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}`\n\n, and branch on `message.created.truncated`\n\n(the type Nylas sends when a message exceeds ~1 MB and the body is omitted). Fetch-by-id is the safe framing every time.\n\n```\ncurl --request GET \\\n  --url \"https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/$MESSAGE_ID\" \\\n  --header 'Authorization: Bearer '\"$NYLAS_API_KEY\"\n```\n\nFrom the CLI, list what landed and read the full body of the one you care about:\n\n```\nnylas email list \"$GRANT_ID\" --limit 10\nnylas email read \"$MESSAGE_ID\" \"$GRANT_ID\"\n```\n\nTo see the whole back-and-forth in one object — every message in the conversation — read the thread directly. Use `threads show`\n\n, not a list filter:\n\n```\nnylas email threads show \"$THREAD_ID\" \"$GRANT_ID\"\n```\n\nNow 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:\n\n`next_send_at`\n\nfor 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.\n\nWhatever 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:\n\n`thread_id T`\n\n. Look `T`\n\nup in your sequence state.`paused`\n\nso no future step sends.`send_at`\n\n, cancel the queued message by the `schedule_id`\n\nyou stored so it never goes out.Canceling the queued send is one call. The API is a `DELETE`\n\nagainst the schedule:\n\n```\ncurl --request DELETE \\\n  --url \"https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/schedules/$SCHEDULE_ID\" \\\n  --header 'Authorization: Bearer '\"$NYLAS_API_KEY\"\n```\n\nThe CLI mirrors it:\n\n```\nnylas email scheduled cancel \"$SCHEDULE_ID\" \"$GRANT_ID\"\n```\n\nThat's the whole stop mechanism: inbound `message.created`\n\n→ look up the thread → set `paused`\n\n→ cancel the queued send. The classification then decides what happens *next*.\n\nWhen the bucket is interest, reply on the same thread. The API reply is another send with `reply_to_message_id`\n\nset to the prospect's message, which preserves threading in their client:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/send\" \\\n  --header 'Authorization: Bearer '\"$NYLAS_API_KEY\" \\\n  --header 'Content-Type: application/json' \\\n  --data '{\n    \"to\": [{ \"email\": \"dana@acme.com\", \"name\": \"Dana Lee\" }],\n    \"subject\": \"Re: Quick question about Acme'\\''s onboarding flow\",\n    \"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?\",\n    \"reply_to_message_id\": \"'\"$MESSAGE_ID\"'\"\n  }'\n```\n\nThe CLI wraps all of that — it fetches the original to populate the recipient and subject, and threads via `reply_to_message_id`\n\nfor you:\n\n```\nnylas email reply \"$MESSAGE_ID\" \"$GRANT_ID\" \\\n  --body \"Happy to — here is our pricing overview and a short case study. Want 15 minutes to walk through it?\"\n```\n\n(If you'd rather drive the reply through `email send`\n\ndirectly, it exposes the same threading via the `--reply-to <message-id>`\n\nflag.)\n\nWhen the prospect says \"circle back in Q3,\" you don't reply — you reschedule. Set the sequence state's `next_send_at`\n\nto 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.\n\nCold 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.\n\n**Unsubscribes and bounces belong on a suppression list.** Agent Account rules support an `in_list`\n\noperator 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`\n\nmatches.\n\nStart by creating the list. It's an `address`\n\n-typed list (the type is immutable, and it's what `from.address`\n\nmatches against). Create it via `POST /v3/lists`\n\n:\n\n```\ncurl --request POST \\\n  --url 'https://api.us.nylas.com/v3/lists' \\\n  --header 'Authorization: Bearer '\"$NYLAS_API_KEY\" \\\n  --header 'Content-Type: application/json' \\\n  --data '{ \"name\": \"Suppressed addresses\", \"type\": \"address\" }'\n```\n\nWhen a prospect unsubscribes or an address hard-bounces, add it to the list. The API appends items to the existing list:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/lists/$LIST_ID/items\" \\\n  --header 'Authorization: Bearer '\"$NYLAS_API_KEY\" \\\n  --header 'Content-Type: application/json' \\\n  --data '{ \"items\": [\"dana@acme.com\"] }'\n```\n\nThe CLI does both in two commands — create the list (optionally seeding it inline with `--item`\n\n), then append more addresses as they come in:\n\n```\nnylas agent list create --name \"Suppressed addresses\" --type address\nnylas agent list add \"$LIST_ID\" dana@acme.com\n```\n\nNow 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`\n\nbuilds the rule *and* attaches it to that default workspace in one step:\n\n```\nnylas agent rule create \\\n  --name \"Suppress unsubscribed\" \\\n  --condition from.address,in_list,\"$LIST_ID\" \\\n  --action block\n```\n\nThe equivalent API path is creating the rule via `POST /v3/rules`\n\n, then activating it by attaching it through the workspace's `rule_ids`\n\n(`PATCH /v3/workspaces/{workspace_id}`\n\nwith `{ \"rule_ids\": [\"<rule-id>\"] }`\n\n, or `nylas workspace update <workspace-id> --rules-ids <rule-id>`\n\n). Skip that attach step and the rule does nothing. One honest limitation to design around: **inbound rules match only on from.* fields** —\n\n`from.address`\n\n, `from.domain`\n\n, `from.tld`\n\n— 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`\n\n: `message.delivered`\n\n, `message.bounced`\n\n, `message.complaint`\n\n, and `message.rejected`\n\n(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`\n\n(or `POST /v3/lists/{list_id}/items`\n\n). One transparency note from working on the CLI: these deliverability triggers are wired through the webhooks API — subscribe to them in your `trigger_types`\n\narray — 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.\n\nYou've got the full loop: provision a replyable outbound mailbox, send a scheduled multi-step sequence, catch each reply via `message.created`\n\n, 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:\n\nWhen this post is published, link AI agents and crawlers to the retrieval-ready version on `cli.nylas.com`\n\n:", "url": "https://wpnews.pro/news/build-an-sdr-agent-with-its-own-follow-up-inbox", "canonical_source": "https://dev.to/mqasimca/build-an-sdr-agent-with-its-own-follow-up-inbox-1ahj", "published_at": "2026-07-10 01:27:45+00:00", "updated_at": "2026-07-10 01:35:42.704077+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "artificial-intelligence", "large-language-models", "natural-language-processing"], "entities": ["Nylas", "Nylas CLI", "Nylas Agent Account"], "alternates": {"html": "https://wpnews.pro/news/build-an-sdr-agent-with-its-own-follow-up-inbox", "markdown": "https://wpnews.pro/news/build-an-sdr-agent-with-its-own-follow-up-inbox.md", "text": "https://wpnews.pro/news/build-an-sdr-agent-with-its-own-follow-up-inbox.txt", "jsonld": "https://wpnews.pro/news/build-an-sdr-agent-with-its-own-follow-up-inbox.jsonld"}}