This is what most agent email code looks like today:
// SendGrid / Resend / Postmark — outbound only
await sendgrid.send({
to: "prospect@example.com",
from: "outreach@yourcompany.com",
subject: "Following up on your demo request",
html: "<p>Hi Alice — wanted to follow up on...</p>",
});
// That's it. If Alice replies, the agent never sees it.
The send works fine. The problem is everything after: when Alice replies, that reply bounces, lands at a no-reply nobody reads, or hits a human inbox the agent can't reach programmatically. The agent is talking into a void. Transactional providers were built for receipts and password resets — one-way mail — and an agent that's supposed to hold a conversation needs a receive path those APIs simply don't have.
Agent Accounts (a beta feature from Nylas) close that gap with a full hosted mailbox: send and receive, with threading, webhooks, and folders built in. Here's what the migration actually involves.
Outbound barely changes — it's still an API call. The new parts are everything transactional providers never gave you:
| Concern | Transactional provider | Agent Account |
|---|---|---|
| Outbound | API call | Same — POST /messages/send |
| Inbound | None (or polling a shared inbox) | Replies land automatically, fire message.created |
| Threading | You track Message-ID yourself |
|
| Headers preserved, threads grouped automatically | ||
| Reply detection | Parse forwards, poll | Webhook within seconds of arrival |
| DNS | SPF/DKIM/DMARC for the provider | MX, SPF, DKIM, DMARC for the mailbox host |
One call creates the account; the response includes a grant_id
that identifies it on every later request:
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",
"settings": { "email": "outreach@agents.yourcompany.com" }
}'
(Or nylas agent account create outreach@agents.yourcompany.com
from the CLI.) Custom domains need MX and TXT records pointed at the mail host before inbound works — provisioning docs here — but a *.nylas.email
trial subdomain works instantly for prototyping.
The replacement send is nearly identical to what you had, with one addition that matters: store the thread_id
from the response.
const sent = await nylas.messages.send({
identifier: AGENT_GRANT_ID,
requestBody: {
to: [{ email: "prospect@example.com", name: "Alice" }],
subject: "Following up on your demo request",
body: "<p>Hi Alice — wanted to follow up on...</p>",
},
});
await db.conversations.create({
threadId: sent.data.threadId,
contactEmail: "prospect@example.com",
step: "awaiting_reply",
});
That stored ID is how you'll recognize Alice's reply when it comes back.
Subscribe a webhook to message.created
— this is the entire piece of infrastructure your transactional setup never had:
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://youragent.example.com/webhooks/nylas"
}'
The notification fires within seconds of a reply arriving. The handler then closes the loop that didn't exist before: look up the thread, skip the agent's own outbound, fetch the full body, restore context.
app.post("/webhooks/nylas", async (req, res) => {
res.status(200).end();
const event = req.body;
if (event.type !== "message.created") return;
const msg = event.data.object;
if (msg.grant_id !== AGENT_GRANT_ID) return;
// Skip the agent's own outbound messages.
if (msg.from?.[0]?.email === "outreach@agents.yourcompany.com") return;
// Is this a reply to something we sent?
const conversation = await db.conversations.findByThreadId(msg.thread_id);
if (!conversation) return; // new inbound, not a tracked reply
// The webhook payload is a summary — fetch the full body.
const full = await nylas.messages.find({
identifier: AGENT_GRANT_ID,
messageId: msg.id,
});
// Hand it to the LLM with conversation context restored.
await processReply(full.data, conversation);
});
When the agent answers, pass reply_to_message_id
and the reply threads correctly in the recipient's client — Nylas sets the In-Reply-To
and References
headers, so there's no Message-ID
bookkeeping on your side. And the recipient sees a normal threaded reply: no "sent via" branding, no relay footer. The reply-handling recipe has the full routing logic.
If your company's mail lives on Google Workspace or Microsoft 365, leave those MX records alone. The pattern that works:
yourcompany.com
pointed where it is.agents.yourcompany.com
and point This also isolates the agent's sender reputation from your primary domain — which you'll care about the first time an experiment goes sideways.
Does Nylas manage my conversation state too? No — and the comparison table above is honest about this. The Threads API gives you conversation history for free, but the mapping from threads to your agent's workflow state (awaiting reply, confirmed, closed) is still yours to build. That's the db.conversations
table in the code above.
Do I still need to generate Message-ID values? No. With a transactional provider, threading is your problem: you track
Message-ID
yourself and set In-Reply-To
on every send. With an Agent Account, headers are preserved and messages group into threads automatically — reply_to_message_id
is the only threading concept your code touches.Keep the transactional provider for receipts and password resets if it's working — those flows don't need replies. Move only the conversations where the agent must see what comes back: outreach, support, scheduling, anything multi-turn. Different addresses, different jobs.
The complete walkthrough is in the migration recipe. Next step if you're tempted: provision a trial-subdomain account, re-point one send call at it, and reply to the email it sends you. Watching your own reply arrive as a webhook is the moment the architecture clicks.